diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9cca045..7669edb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2732955..01158f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, diff --git a/README.md b/README.md index 94ce4b0..f0947ae 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 | @@ -393,10 +393,10 @@ class _SignUpFormState extends State { 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'), @@ -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 validators) Validator.required({String errorMessage}) Validator.email({String errorMessage}) Validator.url({String errorMessage}) @@ -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. diff --git a/analysis_options.yaml b/analysis_options.yaml index 4ff1074..d8969e1 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,3 +1,5 @@ -include: package:flutter_lints/flutter.yaml +include: package:lints/recommended.yaml -analyzer: \ No newline at end of file +analyzer: + exclude: + - example/** diff --git a/example/lib/main.dart b/example/lib/main.dart index 753cae4..5819e25 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -43,13 +43,18 @@ class _ExampleScreenState extends State { 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), diff --git a/example/pubspec.lock b/example/pubspec.lock index 235159d..3e80178 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -81,7 +81,7 @@ packages: path: ".." relative: true source: path - version: "1.1.0" + version: "1.3.0" leak_tracker: dependency: transitive description: @@ -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: @@ -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: @@ -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: @@ -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" diff --git a/lib/form_validator.dart b/lib/form_validator.dart index 6e2f089..f6f4c0c 100644 --- a/lib/form_validator.dart +++ b/lib/form_validator.dart @@ -1,5 +1,42 @@ -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`. @@ -7,7 +44,10 @@ import 'package:flutter_validators/validators/contains.dart' as contains_fn; /// 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 { @@ -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 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', diff --git a/lib/validators/alpha.dart b/lib/validators/alpha.dart index b644533..ea5bca6 100644 --- a/lib/validators/alpha.dart +++ b/lib/validators/alpha.dart @@ -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); } diff --git a/lib/validators/ascii.dart b/lib/validators/ascii.dart index aa6f807..6671109 100644 --- a/lib/validators/ascii.dart +++ b/lib/validators/ascii.dart @@ -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); } diff --git a/lib/validators/base32.dart b/lib/validators/base32.dart index 3dc32a9..8d130d0 100644 --- a/lib/validators/base32.dart +++ b/lib/validators/base32.dart @@ -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) { diff --git a/lib/validators/base58.dart b/lib/validators/base58.dart index 2f90006..cf7cf2f 100644 --- a/lib/validators/base58.dart +++ b/lib/validators/base58.dart @@ -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); } diff --git a/lib/validators/credit_card.dart b/lib/validators/credit_card.dart index bd522f6..737fab7 100644 --- a/lib/validators/credit_card.dart +++ b/lib/validators/credit_card.dart @@ -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; diff --git a/lib/validators/date.dart b/lib/validators/date.dart index 6f93968..3b6c921 100644 --- a/lib/validators/date.dart +++ b/lib/validators/date.dart @@ -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; } diff --git a/lib/validators/email.dart b/lib/validators/email.dart index 4683c05..27cfb1f 100644 --- a/lib/validators/email.dart +++ b/lib/validators/email.dart @@ -40,8 +40,10 @@ extension EmailX on String { /// - Domain part: can be an IP address or domain name with TLD of at least 2 characters /// /// The regex handles common email formats including quoted strings and subdomains. +final _email = RegExp( + r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$', +); + bool _isEmail(String str) { - return RegExp( - r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$', - ).hasMatch(str); + return _email.hasMatch(str); } diff --git a/lib/validators/hex_color.dart b/lib/validators/hex_color.dart index c5bc5b7..7bf0570 100644 --- a/lib/validators/hex_color.dart +++ b/lib/validators/hex_color.dart @@ -19,6 +19,8 @@ extension HexColorX on String { } } +final _hexColor = RegExp(r'^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$'); + bool _isHexColor(String str) { - return RegExp(r'^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$').hasMatch(str); + return _hexColor.hasMatch(str); } diff --git a/lib/validators/int.dart b/lib/validators/int.dart index 2cc7176..e29d522 100644 --- a/lib/validators/int.dart +++ b/lib/validators/int.dart @@ -1,15 +1,16 @@ -/// Checks if the string represents a valid integer. +/// Checks if the string represents a decimal integer. /// -/// Uses Dart's `int.tryParse` to determine if the string can be parsed as an integer. -/// This supports both positive and negative integers, as well as leading zeros. +/// Accepts an optional sign, leading zeros, and surrounding whitespace. +/// Hex literals such as `0x10` are rejected. /// -/// Returns `true` if the string can be converted to an integer, otherwise returns `false`. +/// Returns `true` if the string is a decimal integer, otherwise returns `false`. /// /// Example: /// ```dart /// isInt('123'); // true /// isInt('-123'); // true /// isInt('01'); // true +/// isInt('0x10'); // false /// isInt('12.34'); // false /// isInt('abc'); // false /// ``` @@ -17,18 +18,19 @@ bool isInt(String str) => _isInt(str); /// Extension providing integer validation methods on [String]. extension IntX on String { - /// Checks if the string represents a valid integer. + /// Checks if the string represents a decimal integer. /// - /// Uses Dart's `int.tryParse` to determine if the string can be parsed as an integer. - /// This supports both positive and negative integers, as well as leading zeros. + /// Accepts an optional sign, leading zeros, and surrounding whitespace. + /// Hex literals such as `0x10` are rejected. /// - /// Returns `true` if the string can be converted to an integer, otherwise returns `false`. + /// Returns `true` if the string is a decimal integer, otherwise returns `false`. /// /// Example: /// ```dart /// '123'.isInt; // true /// '-123'.isInt; // true /// '01'.isInt; // true + /// '0x10'.isInt; // false /// '12.34'.isInt; // false /// 'abc'.isInt; // false /// ``` @@ -37,10 +39,8 @@ extension IntX on String { } } -/// Internal implementation for integer validation. -/// -/// Uses Dart's built-in `int.tryParse` method to attempt parsing the string as an integer. -/// The method returns null if parsing fails, which indicates the string is not a valid integer. +final _decimalInt = RegExp(r'^[+-]?\d+$'); + bool _isInt(String str) { - return int.tryParse(str) != null; + return _decimalInt.hasMatch(str.trim()); } diff --git a/lib/validators/ip.dart b/lib/validators/ip.dart index 9d1b684..05911c7 100644 --- a/lib/validators/ip.dart +++ b/lib/validators/ip.dart @@ -35,9 +35,13 @@ bool _isIP(String str, [int? version]) { return false; } +final _ipv4 = RegExp(r'^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$'); +final _ipv6 = RegExp( + r'^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$', +); + bool _isIPv4(String str) { - final reg = RegExp(r'^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$'); - final match = reg.firstMatch(str); + final match = _ipv4.firstMatch(str); if (match == null) return false; for (int i = 1; i <= 4; i++) { @@ -48,9 +52,5 @@ bool _isIPv4(String str) { } bool _isIPv6(String str) { - // Common robust regex for IPv6 validation - final reg = RegExp( - r'^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$', - ); - return reg.hasMatch(str); + return _ipv6.hasMatch(str); } diff --git a/lib/validators/phone.dart b/lib/validators/phone.dart index dd960d0..829844e 100644 --- a/lib/validators/phone.dart +++ b/lib/validators/phone.dart @@ -70,31 +70,29 @@ bool _isPhone(String str) { return false; } - // Common phone number patterns - final patterns = [ - // International format with + and country code - r'^\+[1-9]\d{1,14}$', - - // International format with spaces/dashes after country code - r'^\+[1-9][\s.-]?\d{1,3}[\s.-]?\d{3,4}[\s.-]?\d{3,4}$', - - // US/North American format - r'^\([0-9]{3}\)\s[0-9]{3}-[0-9]{4}$', - r'^[0-9]{3}-[0-9]{3}-[0-9]{4}$', - - // Format with dots - r'^[0-9]{3}\.[0-9]{3}\.[0-9]{4}$', - - // Simple sequence of digits (at least 7, at most 15) - r'^[0-9]{7,15}$', - ]; - - for (final pattern in patterns) { - final regExp = RegExp(pattern); - if (regExp.hasMatch(str)) { + for (final pattern in _phonePatterns) { + if (pattern.hasMatch(str)) { return true; } } return false; } + +final _phonePatterns = [ + // International format with + and country code + RegExp(r'^\+[1-9]\d{1,14}$'), + + // International format with spaces/dashes after country code + RegExp(r'^\+[1-9][\s.-]?\d{1,3}[\s.-]?\d{3,4}[\s.-]?\d{3,4}$'), + + // US/North American format + RegExp(r'^\([0-9]{3}\)\s[0-9]{3}-[0-9]{4}$'), + RegExp(r'^[0-9]{3}-[0-9]{3}-[0-9]{4}$'), + + // Format with dots + RegExp(r'^[0-9]{3}\.[0-9]{3}\.[0-9]{4}$'), + + // Simple sequence of digits (at least 7, at most 15) + RegExp(r'^[0-9]{7,15}$'), +]; diff --git a/lib/validators/url.dart b/lib/validators/url.dart index 5ea0e1e..8e57f26 100644 --- a/lib/validators/url.dart +++ b/lib/validators/url.dart @@ -1,17 +1,23 @@ -/// Checks if the string is a valid URL. +import 'ip.dart'; + +/// Checks if the string is a valid HTTP or HTTPS URL. /// -/// Returns `true` if the string can be parsed as a valid Uri, otherwise returns `false`. +/// The URL must have a non-empty host. The host must be `localhost`, an IP +/// address, or a name that contains a dot (a TLD). /// /// Example: /// ```dart /// isURL('https://google.com'); // true +/// isURL('http://localhost'); // true +/// isURL('https://'); // false +/// isURL('http://foo'); // false /// isURL('invalid-url'); // false /// ``` bool isURL(String str) => _isURL(str); /// Extension providing URL validation methods on [String]. extension UrlX on String { - /// Checks if the string is a valid URL. + /// Checks if the string is a valid HTTP or HTTPS URL. bool get isURL { return _isURL(this); } @@ -20,7 +26,12 @@ extension UrlX on String { bool _isURL(String str) { if (str.isEmpty) return false; final uri = Uri.tryParse(str); - return uri != null && - uri.hasAuthority && - (uri.scheme == 'http' || uri.scheme == 'https'); + if (uri == null) return false; + if (uri.scheme != 'http' && uri.scheme != 'https') return false; + + final host = uri.host; + if (host.isEmpty || host == '.') return false; + if (host.toLowerCase() == 'localhost') return true; + if (isIP(host)) return true; + return host.contains('.'); } diff --git a/lib/validators/uuid.dart b/lib/validators/uuid.dart index 8457d36..19d8204 100644 --- a/lib/validators/uuid.dart +++ b/lib/validators/uuid.dart @@ -17,8 +17,10 @@ extension UuidX on String { } } +final _uuid = RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1345][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$', +); + bool _isUUID(String str) { - return RegExp( - r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1345][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$', - ).hasMatch(str); + return _uuid.hasMatch(str); } diff --git a/pubspec.lock b/pubspec.lock index 977425b..14623b1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,14 +89,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" - url: "https://pub.dev" - source: hosted - version: "5.0.0" frontend_server_client: dependency: transitive description: @@ -146,7 +138,7 @@ packages: source: hosted version: "0.7.2" lints: - dependency: transitive + dependency: "direct dev" description: name: lints sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 @@ -387,4 +379,3 @@ packages: version: "3.1.0" sdks: dart: ">=3.7.0 <4.0.0" - flutter: ">=1.17.0" diff --git a/pubspec.yaml b/pubspec.yaml index a759457..cea96c5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,13 +1,12 @@ name: flutter_validators description: A pure dart package of String validators and sanitizers. Inspired by validator.js. -version: 1.2.0 +version: 1.3.0 homepage: https://github.com/StacDev/flutter_validators repository: https://github.com/StacDev/flutter_validators environment: sdk: ^3.7.0 - flutter: ">=1.17.0" dev_dependencies: test: ^1.25.15 - flutter_lints: ^5.0.0 \ No newline at end of file + lints: ^5.0.0 diff --git a/test/form_validator_test.dart b/test/form_validator_test.dart index 6b941e8..f283426 100644 --- a/test/form_validator_test.dart +++ b/test/form_validator_test.dart @@ -22,6 +22,18 @@ void main() { expect(email(null), isNull); }); + test('compose runs validators in order and returns the first error', () { + final email = Validator.compose([ + Validator.required(errorMessage: 'Email is required'), + Validator.email(errorMessage: 'Enter a valid email'), + ]); + expect(email(''), 'Email is required'); + expect(email(null), 'Email is required'); + expect(email(' '), 'Email is required'); + expect(email('invalid'), 'Enter a valid email'); + expect(email('user@example.com'), isNull); + }); + test('Length validator', () { final len = Validator.length(3, max: 5, errorMessage: 'Bad len'); expect(len('abc'), isNull); diff --git a/test/validators/date_test.dart b/test/validators/date_test.dart index 47c3266..f1c1ec1 100644 --- a/test/validators/date_test.dart +++ b/test/validators/date_test.dart @@ -7,11 +7,13 @@ void main() { expect('2023-12-01'.isDate, isTrue); expect('2023-12-01 12:00:00'.isDate, isTrue); expect('2023-12-01T12:00:00Z'.isDate, isTrue); - expect('2023-13-01'.isDate, isTrue); // DateTime.tryParse allows rollover + expect('2024-02-29'.isDate, isTrue); // leap year }); test('Invalid Dates', () { expect('invalid date'.isDate, isFalse); + expect('2023-13-01'.isDate, isFalse); + expect('2023-02-29'.isDate, isFalse); expect(''.isDate, isFalse); }); }); diff --git a/test/validators/int_test.dart b/test/validators/int_test.dart index a4cb061..c9ace3f 100644 --- a/test/validators/int_test.dart +++ b/test/validators/int_test.dart @@ -14,6 +14,8 @@ void main() { test('Invalid integers', () { expect('12.34'.isInt, isFalse); expect('abc'.isInt, isFalse); + expect('0x10'.isInt, isFalse); + expect('0X10'.isInt, isFalse); expect(''.isInt, isFalse); }); }); diff --git a/test/validators/url_test.dart b/test/validators/url_test.dart index 2b852f0..f707159 100644 --- a/test/validators/url_test.dart +++ b/test/validators/url_test.dart @@ -7,11 +7,17 @@ void main() { expect('https://google.com'.isURL, isTrue); expect('http://example.com/path?query=1'.isURL, isTrue); expect('https://sub.domain.org'.isURL, isTrue); + expect('http://localhost'.isURL, isTrue); + expect('http://127.0.0.1'.isURL, isTrue); }); test('Invalid URLs', () { expect('invalid-url'.isURL, isFalse); expect('ftp://server.com'.isURL, isFalse); // We only support http/https + expect('javascript:alert(1)'.isURL, isFalse); + expect('https://'.isURL, isFalse); + expect('http://.'.isURL, isFalse); + expect('http://foo'.isURL, isFalse); expect(''.isURL, isFalse); }); });