Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
- uses: dart-lang/setup-dart@v1
- name: Install dependencies
run: dart pub get
run: dart pub get --no-example
- name: Analyze code
run: dart analyze
run: dart analyze lib test
- name: Run tests
run: dart test
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
## 1.3.0

* Added `Validator.compose` to run multiple form validators in order.
* Fixed `isURL` to require a real host (`https://`, `http://.`, and `http://foo`
are rejected; `localhost` and IP hosts remain valid).
* Fixed `isDate` to reject calendar-invalid dates such as `2023-13-01` and
`2023-02-29`.
* Fixed `isInt` to accept decimal integers only (hex literals like `0x10` are
rejected).
* The package is now a pure Dart package: the Flutter SDK is no longer required.

## 1.2.0

* Added 21 new validators inspired by validator.js: `isLowercase`, `isUppercase`,
Expand Down
28 changes: 17 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Add the package to your `pubspec.yaml`:

```yaml
dependencies:
flutter_validators: ^1.2.0
flutter_validators: ^1.3.0
```

Then run:
Expand Down Expand Up @@ -148,7 +148,7 @@ Every validator is available **both** as a top-level function and as a `String`

| Validator | Extension | Description |
|---|---|---|
| `isInt(str)` | `str.isInt` | Integer (positive or negative) |
| `isInt(str)` | `str.isInt` | Decimal integer (positive or negative) |
| `isNumeric(str)` | `str.isNumeric` | Number (integer or float) |
| `isFloat(str, {min, max})` | `str.isFloat({min, max})` | Finite float, optionally within a range |
| `isDecimal(str)` | `str.isDecimal` | Decimal number |
Expand Down Expand Up @@ -393,10 +393,10 @@ class _SignUpFormState extends State<SignUpForm> {
TextFormField(
decoration: const InputDecoration(labelText: 'Email'),
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (value) {
return Validator.required(errorMessage: 'Email is required')(value) ??
Validator.email(errorMessage: 'Enter a valid email')(value);
},
validator: Validator.compose([
Validator.required(errorMessage: 'Email is required'),
Validator.email(errorMessage: 'Enter a valid email'),
]),
),
TextFormField(
decoration: const InputDecoration(labelText: 'Website'),
Expand Down Expand Up @@ -435,6 +435,7 @@ A complete, runnable app is in the [`example/`](example/) directory.
Every method on the `Validator` class returns `String? Function(String?)`:

```dart
Validator.compose(Iterable<String? Function(String?)> validators)
Validator.required({String errorMessage})
Validator.email({String errorMessage})
Validator.url({String errorMessage})
Expand Down Expand Up @@ -482,19 +483,24 @@ Validator.strongPassword({int minLength, int minLowercase, int minUppercase, int

## 💡 Behavior Notes and FAQ

**`Validator` methods treat `null` and empty strings as valid.** This is intentional — it lets you compose validators freely. To make a field mandatory, pair it with `Validator.required()`:
**`Validator` methods treat `null` and empty strings as valid.** This is intentional — it lets you compose validators freely. Individual validators still skip empty values; to make a field mandatory, pair them with `Validator.required()` using `Validator.compose`:

```dart
validator: (value) {
return Validator.required()(value) ?? Validator.email()(value);
}
validator: Validator.compose([
Validator.required(),
Validator.email(),
])
```

**`contains` is a top-level function only.** Dart's `String` already has a built-in `.contains()` method, so the package does not add a conflicting extension. Use `contains(str, seed)` instead of `str.contains(...)` when you need the case-insensitivity or `minOccurrences` options.

**Trimming extensions are named `trimChars` / `ltrimChars` / `rtrimChars`.** Dart's `String` already provides `.trim()`, `.trimLeft()` and `.trimRight()` for whitespace, so the custom-character variants use distinct names to avoid collisions. The top-level functions keep the plain `trim` / `ltrim` / `rtrim` names.

**`isURL` accepts only `http` and `https` schemes.** Other schemes such as `ftp://` are rejected.
**`isURL` accepts only `http` and `https` schemes** and requires a real host. Other schemes such as `ftp://` are rejected. The host must be `localhost`, an IP address, or a name that contains a dot — so `https://` and `http://foo` fail, while `http://localhost` and `https://example.com` pass.

**`isDate` requires a real calendar date.** ISO-8601 strings whose `YYYY-MM-DD` prefix is not a valid calendar date (for example `2023-13-01` or `2023-02-29`) are rejected.

**`isInt` accepts decimal integers only.** Hex literals such as `0x10` are rejected. Leading zeros and surrounding whitespace are still allowed.

**`isBase64` has a `urlSafe` option.** By default it validates the standard Base64 alphabet (with padding); pass `urlSafe: true` to validate the URL- and filename-safe alphabet instead.

Expand Down
6 changes: 4 additions & 2 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
include: package:flutter_lints/flutter.yaml
include: package:lints/recommended.yaml

analyzer:
analyzer:
exclude:
- example/**
9 changes: 7 additions & 2 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,18 @@ class _ExampleScreenState extends State<ExampleScreen> {
children: [
TextFormField(
decoration: const InputDecoration(labelText: 'Email'),
validator: Validator.email(),
validator: Validator.compose([
Validator.required(errorMessage: 'Email is required'),
Validator.email(),
]),
autovalidateMode: AutovalidateMode.onUserInteraction,
),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(labelText: 'URL'),
validator: Validator.url(errorMessage: 'Please provide a valid URL'),
validator: Validator.url(
errorMessage: 'Please provide a valid URL',
),
autovalidateMode: AutovalidateMode.onUserInteraction,
),
const SizedBox(height: 16),
Expand Down
20 changes: 10 additions & 10 deletions example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ packages:
path: ".."
relative: true
source: path
version: "1.1.0"
version: "1.3.0"
leak_tracker:
dependency: transitive
description:
Expand Down Expand Up @@ -118,10 +118,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
Expand All @@ -134,10 +134,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.19.0"
path:
dependency: transitive
description:
Expand Down Expand Up @@ -195,18 +195,18 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.12"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.4.3"
vm_service:
dependency: transitive
description:
Expand All @@ -216,5 +216,5 @@ packages:
source: hosted
version: "15.0.2"
sdks:
dart: ">=3.9.0 <4.0.0"
dart: ">=3.11.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
67 changes: 64 additions & 3 deletions lib/form_validator.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,53 @@
import 'package:flutter_validators/flutter_validators.dart';
import 'package:flutter_validators/validators/contains.dart' as contains_fn;
import 'validators/alpha.dart';
import 'validators/ascii.dart';
import 'validators/base32.dart';
import 'validators/base58.dart';
import 'validators/base64.dart';
import 'validators/boolean.dart';
import 'validators/byte_length.dart';
import 'validators/contains.dart' as contains_fn;
import 'validators/credit_card.dart';
import 'validators/date.dart';
import 'validators/decimal.dart';
import 'validators/email.dart';
import 'validators/equals.dart';
import 'validators/float.dart';
import 'validators/fqdn.dart';
import 'validators/hex_color.dart';
import 'validators/hexadecimal.dart';
import 'validators/in.dart';
import 'validators/int.dart';
import 'validators/ip.dart';
import 'validators/json.dart';
import 'validators/jwt.dart';
import 'validators/lat_long.dart';
import 'validators/length.dart';
import 'validators/lowercase.dart';
import 'validators/mac_address.dart';
import 'validators/matches.dart';
import 'validators/md5.dart';
import 'validators/mongo_id.dart';
import 'validators/numeric.dart';
import 'validators/octal.dart';
import 'validators/phone.dart';
import 'validators/port.dart';
import 'validators/semver.dart';
import 'validators/slug.dart';
import 'validators/strong_password.dart';
import 'validators/uppercase.dart';
import 'validators/url.dart';
import 'validators/uuid.dart';

/// A utility class for Flutter Form validation.
/// Provides methods that return a validator function suitable for `TextFormField`.
///
/// Example:
/// ```dart
/// TextFormField(
/// validator: Validator.email(errorMessage: 'Please enter a valid email'),
/// validator: Validator.compose([
/// Validator.required(errorMessage: 'Email is required'),
/// Validator.email(errorMessage: 'Please enter a valid email'),
/// ]),
/// )
/// ```
class Validator {
Expand All @@ -19,6 +59,27 @@ class Validator {
value == null || value.trim().isEmpty ? errorMessage : null;
}

/// Runs [validators] in order and returns the first error, or `null` if all pass.
///
/// Example:
/// ```dart
/// validator: Validator.compose([
/// Validator.required(errorMessage: 'Email is required'),
/// Validator.email(errorMessage: 'Enter a valid email'),
/// ])
/// ```
static String? Function(String?) compose(
Iterable<String? Function(String?)> validators,
) {
return (String? value) {
for (final validator in validators) {
final error = validator(value);
if (error != null) return error;
}
return null;
};
}

/// Ensures the string is a valid email.
static String? Function(String?) email({
String errorMessage = 'Please enter a valid email address',
Expand Down
7 changes: 5 additions & 2 deletions lib/validators/alpha.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@ extension AlphaX on String {
}
}

final _alpha = RegExp(r'^[a-zA-Z]+$');
final _alphanumeric = RegExp(r'^[a-zA-Z0-9]+$');

bool _isAlpha(String str) {
return RegExp(r'^[a-zA-Z]+$').hasMatch(str);
return _alpha.hasMatch(str);
}

bool _isAlphanumeric(String str) {
return RegExp(r'^[a-zA-Z0-9]+$').hasMatch(str);
return _alphanumeric.hasMatch(str);
}
4 changes: 3 additions & 1 deletion lib/validators/ascii.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ extension AsciiX on String {
///
/// Validates if a string contains only ASCII characters using a regular expression
/// that matches characters in the range \x00-\x7F (code points 0-127).
final _ascii = RegExp(r'^[\x00-\x7F]+$');

bool _isAscii(String str) {
return RegExp(r'^[\x00-\x7F]+$').hasMatch(str);
return _ascii.hasMatch(str);
}
4 changes: 3 additions & 1 deletion lib/validators/base32.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ extension Base32X on String {
/// Validates if a string is base32 encoded using a regular expression
/// that matches uppercase letters A-Z, digits 2-7, and optional padding
/// with '=' characters at the end.
final _base32 = RegExp(r'^[A-Z2-7]+={0,6}$');

bool _isBase32(String str) {
if (str.isEmpty) return false;
if (!RegExp(r'^[A-Z2-7]+={0,6}$').hasMatch(str)) return false;
if (!_base32.hasMatch(str)) return false;

final paddingIdx = str.indexOf('=');
if (paddingIdx != -1) {
Expand Down
4 changes: 3 additions & 1 deletion lib/validators/base58.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ extension Base58X on String {
/// Validates if a string is base58 encoded using a regular expression
/// that matches the base58 character set: A-H, J-N, P-Z, a-k, m-z, and 1-9.
/// The omitted characters are: 0, O, I, and l to avoid visual ambiguity.
final _base58 = RegExp(r'^[A-HJ-NP-Za-km-z1-9]+$');

bool _isBase58(String str) {
return RegExp(r'^[A-HJ-NP-Za-km-z1-9]+$').hasMatch(str);
return _base58.hasMatch(str);
}
7 changes: 5 additions & 2 deletions lib/validators/credit_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ extension CreditCardX on String {
}
}

final _creditCardSeparators = RegExp(r'[\s\-]');
final _creditCardDigits = RegExp(r'^\d{13,19}$');

bool _isCreditCard(String str) {
final sanitized = str.replaceAll(RegExp(r'[\s\-]'), '');
if (!RegExp(r'^\d{13,19}$').hasMatch(sanitized)) return false;
final sanitized = str.replaceAll(_creditCardSeparators, '');
if (!_creditCardDigits.hasMatch(sanitized)) return false;

int sum = 0;
bool alternate = false;
Expand Down
24 changes: 20 additions & 4 deletions lib/validators/date.dart
Original file line number Diff line number Diff line change
@@ -1,22 +1,38 @@
/// Checks if the string represents a valid date.
/// Checks if the string represents a valid calendar date.
///
/// Uses Dart's built-in `DateTime.tryParse` to validate the string formatting.
/// Accepts ISO-8601 strings such as `2023-12-01`, `2023-12-01 12:00:00`, and
/// `2023-12-01T12:00:00Z`. The `YYYY-MM-DD` prefix must be a real calendar
/// date — month/day overflow such as `2023-13-01` or `2023-02-29` is rejected.
///
/// Example:
/// ```dart
/// isDate('2023-12-01'); // true
/// isDate('2023-13-01'); // false
/// isDate('2023-02-29'); // false
/// isDate('invalid date'); // false
/// ```
bool isDate(String str) => _isDate(str);

/// Extension providing date validation methods on [String].
extension DateX on String {
/// Checks if the string represents a valid date.
/// Checks if the string represents a valid calendar date.
bool get isDate {
return _isDate(this);
}
}

final _isoDatePrefix = RegExp(r'^(\d{4})-(\d{2})-(\d{2})');

bool _isDate(String str) {
return DateTime.tryParse(str) != null;
if (DateTime.tryParse(str) == null) return false;
final match = _isoDatePrefix.firstMatch(str);
if (match == null) return false;

final year = int.parse(match.group(1)!);
final month = int.parse(match.group(2)!);
final day = int.parse(match.group(3)!);
final reconstructed = DateTime.utc(year, month, day);
return reconstructed.year == year &&
reconstructed.month == month &&
reconstructed.day == day;
}
Loading
Loading