From 2d7d643c4337e2e9119fae741816f09cb7967b50 Mon Sep 17 00:00:00 2001
From: Divyanshu Bhargava
Date: Mon, 15 Jun 2026 19:09:57 +0530
Subject: [PATCH] Launch flutter_validators 1.3
---
.github/workflows/test.yml | 36 +-
.gitignore | 1 +
.pubignore | 3 +
CHANGELOG.md | 14 +
README.md | 743 +++++++-------
doc/composition.md | 47 +
doc/messages.md | 28 +
doc/migration.md | 32 +
doc/releasing.md | 24 +
doc/strict-validation.md | 18 +
example/README.md | 32 +-
example/lib/main.dart | 409 +++++++-
example/pubspec.lock | 20 +-
example/pubspec.yaml | 74 +-
example/test/widget_test.dart | 138 ++-
lib/field_validator.dart | 125 +++
lib/flutter_validators.dart | 8 +
lib/form_validator.dart | 947 ++++++++++++++----
lib/validation_message.dart | 35 +
lib/validators/base32.dart | 79 +-
lib/validators/credit_card.dart | 78 +-
lib/validators/data_rules.dart | 73 ++
lib/validators/date.dart | 75 +-
lib/validators/email.dart | 145 ++-
lib/validators/finance.dart | 81 ++
lib/validators/fqdn.dart | 96 +-
lib/validators/isbn.dart | 46 +
lib/validators/jwt.dart | 64 +-
lib/validators/number_rules.dart | 51 +
lib/validators/text_rules.dart | 45 +
lib/validators/url.dart | 114 ++-
lib/validators/uuid.dart | 36 +-
pubspec.lock | 3 +-
pubspec.yaml | 15 +-
test/composition_and_messages_test.dart | 136 +++
test/readme_examples_test.dart | 130 +++
test/validators/new_validators_test.dart | 145 +++
.../validators/strict_configuration_test.dart | 85 ++
tool/check_coverage.dart | 36 +
tool/pure_dart_smoke.dart | 10 +
40 files changed, 3265 insertions(+), 1012 deletions(-)
create mode 100644 doc/composition.md
create mode 100644 doc/messages.md
create mode 100644 doc/migration.md
create mode 100644 doc/releasing.md
create mode 100644 doc/strict-validation.md
create mode 100644 lib/field_validator.dart
create mode 100644 lib/validation_message.dart
create mode 100644 lib/validators/data_rules.dart
create mode 100644 lib/validators/finance.dart
create mode 100644 lib/validators/isbn.dart
create mode 100644 lib/validators/number_rules.dart
create mode 100644 lib/validators/text_rules.dart
create mode 100644 test/composition_and_messages_test.dart
create mode 100644 test/readme_examples_test.dart
create mode 100644 test/validators/new_validators_test.dart
create mode 100644 test/validators/strict_configuration_test.dart
create mode 100644 tool/check_coverage.dart
create mode 100644 tool/pure_dart_smoke.dart
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 9cca045..7d6e247 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,22 +1,44 @@
-name: Test and Analyze
+name: Release quality
on:
push:
- branches: [ "main" ]
+ branches: ["main"]
pull_request:
- branches: [ "main" ]
+ branches: ["main"]
jobs:
- build:
+ validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
- channel: 'stable'
+ channel: stable
- name: Install dependencies
run: dart pub get
+ - name: Check formatting
+ run: dart format --output=none --set-exit-if-changed lib test tool example/lib example/test
- name: Analyze code
run: dart analyze
- - name: Run tests
- run: dart test
+ - name: Run pure Dart smoke test
+ run: dart run tool/pure_dart_smoke.dart
+ - name: Run package tests with coverage
+ run: |
+ dart test --coverage=coverage
+ dart run coverage:format_coverage \
+ --lcov \
+ --in=coverage \
+ --out=coverage/lcov.info \
+ --packages=.dart_tool/package_config.json \
+ --report-on=lib
+ dart run tool/check_coverage.dart coverage/lcov.info 95
+ - name: Analyze and test example application
+ working-directory: example
+ run: |
+ flutter pub get
+ flutter analyze
+ flutter test
+ - name: Generate API documentation
+ run: dart doc --output=/tmp/flutter_validators_api
+ - name: Validate pub package
+ run: dart pub publish --dry-run
diff --git a/.gitignore b/.gitignore
index a247422..4b52c81 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,7 @@
.pub-cache/
.pub/
build/
+coverage/
# Android related
**/android/**/gradle-wrapper.jar
diff --git a/.pubignore b/.pubignore
index aef4cb2..b14b27c 100644
--- a/.pubignore
+++ b/.pubignore
@@ -13,3 +13,6 @@ example/.dart_tool/
test_output.log
assets/
build/
+coverage/
+test/
+tool/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2732955..b259f3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,17 @@
+## 1.3.0
+
+* Added generic `FieldValidator` composition with `compose`, `aggregate`,
+ `any`, conditional rules, transforms, and fluent chaining.
+* Added dependency-free `ValidationMessage` resolution with runtime locale
+ switching and explicit-message precedence.
+* Added opt-in strict JWT, Base32, credit-card, URL, email, FQDN, UUID, and
+ ISO-8601 calendar validation without changing 1.2 defaults.
+* Added numeric range rules; date and time rules; text boundaries and word
+ counts; IBAN, BIC, CVC, and card-expiry checks; ISBN-10/13; hash, MIME, and
+ data-URI validation.
+* Expanded form factories, documentation, migration guides, executable
+ examples, adversarial tests, and release-quality CI checks.
+
## 1.2.0
* Added 21 new validators inspired by validator.js: `isLowercase`, `isUppercase`,
diff --git a/README.md b/README.md
index 94ce4b0..ea2a350 100644
--- a/README.md
+++ b/README.md
@@ -2,517 +2,458 @@
-
-
Flutter Validators
-
+# Flutter Validators
-
- The most comprehensive string validation package for Dart & Flutter.
-
+Dependency-free validation, sanitization, generic composition, localized
+messages, and Flutter form helpers for Dart and Flutter.
-
-
-
-
-
-
-
-
- Inspired by validator.js · 40+ validators & sanitizers · Works with Flutter Forms out of the box
-
+[](https://pub.dev/packages/flutter_validators)
+[](https://github.com/StacDev/flutter_validators/actions/workflows/test.yml)
+[](LICENSE)
----
+Version 1.3 keeps all 1.2 validation defaults and adds opt-in strict rules,
+generic `FieldValidator` composition, resolver-backed messages, and
+high-value validators for dates, finance, identifiers, and data formats.
-**Flutter Validators** is a pure Dart package with **40+ string validators** and **13 sanitizers** — from emails and URLs to credit cards, UUIDs, JWTs and strong-password checks. Every validator works three ways:
+## Features
-- as a **top-level function** — `isEmail('foo@bar.com')`
-- as a **`String` extension** — `'foo@bar.com'.isEmail`
-- as a **Flutter form validator** — `Validator.email()` plugs straight into `TextFormField`
+- Pure Dart with zero runtime dependencies.
+- Functions, `String` extensions, and `TextFormField`-ready factories.
+- Generic composition for strings, numbers, booleans, collections, and models.
+- Optional strict validation without breaking permissive 1.2 defaults.
+- Dependency-free localized message resolution.
+- 60+ validators and 13 sanitizers.
-Zero runtime dependencies. Fully tested. Works with both Dart and Flutter.
+## Which API Should I Use?
----
-
-## 📚 Table of Contents
-
-- [✨ Features](#-features)
-- [📦 Installation](#-installation)
-- [🚀 Quick Start](#-quick-start)
-- [🧩 Validators](#-validators)
- - [Contact and Web](#contact-and-web)
- - [Numbers](#numbers)
- - [Text and Format](#text-and-format)
- - [Encoding and Data](#encoding-and-data)
- - [Identifiers and Crypto](#identifiers-and-crypto)
- - [Security](#security)
-- [🧹 Sanitizers](#-sanitizers)
- - [Trimming](#trimming)
- - [HTML Escaping](#html-escaping)
- - [Character Filtering](#character-filtering)
- - [Type Conversion](#type-conversion)
- - [Email Normalization](#email-normalization)
-- [📝 Flutter Form Integration](#-flutter-form-integration)
-- [💡 Behavior Notes and FAQ](#-behavior-notes-and-faq)
-- [🤝 Contributing](#-contributing)
-- [📄 License](#-license)
-
----
-
-## ✨ Features
-
-- **40+ validators** covering email, URL, numbers, encodings, identifiers, crypto hashes and more.
-- **13 sanitizers** for trimming, HTML escaping, character filtering and type conversion.
-- **Three usage styles** — top-level functions, `String` extensions, and Flutter form validators — pick whatever reads best.
-- **First-class Flutter form support** via the `Validator` class, which returns `String? Function(String?)` closures with customizable error messages.
-- **Pure Dart, zero runtime dependencies** — lightweight and safe to add to any project.
-- **Fully tested** — every validator and sanitizer has dedicated test coverage.
+| API | Best for | Example | Return type |
+|---|---|---|---|
+| Function | services, parsing, tests | `isEmail(value)` | `bool` |
+| Extension | concise application code | `value.isEmail` | `bool` |
+| Form factory | Flutter forms and messages | `Validator.email()` | `FieldValidator` |
+| Composition | reusable business rules | `compose([ruleA, ruleB])` | `FieldValidator` |
----
+All non-required form validators accept `null` and `''`. Add
+`Validator.required()` when a value must be present.
-## 📦 Installation
+## Installation
-Add the package to your `pubspec.yaml`:
+Pure Dart:
-```yaml
-dependencies:
- flutter_validators: ^1.2.0
+```sh
+dart pub add flutter_validators
```
-Then run:
+Flutter:
```sh
-dart pub get
+flutter pub add flutter_validators
```
----
-
-## 🚀 Quick Start
-
-Import the package:
+Then import the public library:
```dart
import 'package:flutter_validators/flutter_validators.dart';
```
-**As `String` extensions** — the most concise style:
+## Five-Minute Quick Start
+
+This complete Dart program validates, sanitizes, and composes rules:
```dart
-'foo@bar.com'.isEmail; // true
-'https://google.com'.isURL; // true
-'4111111111111111'.isCreditCard; // true
-'abc123'.isAlphanumeric; // true
-```
+import 'package:flutter_validators/flutter_validators.dart';
-**As top-level functions** — handy when the value isn't a literal:
+void main() {
+ final rawEmail = ' Test.User+news@GMAIL.com ';
+ final cleaned = trim(rawEmail);
+ final normalized = normalizeEmail(cleaned);
-```dart
-isEmail('foo@bar.com'); // true
-isURL('https://google.com'); // true
-isIP('192.168.1.1'); // true
+ final emailRule = compose([
+ Validator.required(),
+ Validator.email(),
+ ]);
+
+ print(normalized); // testuser@gmail.com
+ print(emailRule(normalized)); // null
+ print(isURL('https://dart.dev')); // true
+ print('2024-02-29'.isISO8601Date); // true
+}
```
-**As Flutter form validators** — drop straight into `TextFormField`:
+A complete Flutter field:
```dart
-TextFormField(
- validator: Validator.email(errorMessage: 'Enter a valid email'),
-)
+import 'package:flutter/material.dart';
+import 'package:flutter_validators/flutter_validators.dart';
+
+class EmailField extends StatelessWidget {
+ const EmailField({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return TextFormField(
+ keyboardType: TextInputType.emailAddress,
+ autovalidateMode: AutovalidateMode.onUserInteraction,
+ decoration: const InputDecoration(labelText: 'Email'),
+ validator: Validator.required(errorMessage: 'Email is required')
+ .and(Validator.email(errorMessage: 'Enter a valid email')),
+ );
+ }
+}
```
----
+## Registration Form
-## 🧩 Validators
+The runnable example contains the full screen. This smaller complete form shows
+required fields, composition, password rules, conditional validation, and
+custom messages:
-Every validator is available **both** as a top-level function and as a `String` extension. Parameterized validators accept their options as named/positional arguments.
+```dart
+import 'package:flutter/material.dart';
+import 'package:flutter_validators/flutter_validators.dart';
-### Contact and Web
+class RegistrationForm extends StatefulWidget {
+ const RegistrationForm({super.key});
-| Validator | Extension | Description |
-|---|---|---|
-| `isEmail(str)` | `str.isEmail` | Valid email address |
-| `isURL(str)` | `str.isURL` | Valid HTTP/HTTPS URL |
-| `isFQDN(str)` | `str.isFQDN` | Fully qualified domain name |
-| `isPhone(str)` | `str.isPhone` | Phone number (international & US formats) |
-| `isLatLong(str)` | `str.isLatLong` | `latitude,longitude` coordinate pair |
+ @override
+ State createState() => _RegistrationFormState();
+}
-```dart
-'user@example.com'.isEmail; // true
-'https://dart.dev'.isURL; // true
-'sub.example.co.uk'.isFQDN; // true
-'localhost'.isFQDN; // false (no TLD)
-'(123) 456-7890'.isPhone; // true
-'40.7128,-74.0060'.isLatLong; // true
-```
+class _RegistrationFormState extends State {
+ final key = GlobalKey();
+ bool createTeam = false;
-### Numbers
+ @override
+ Widget build(BuildContext context) {
+ return Form(
+ key: key,
+ child: Column(
+ children: [
+ TextFormField(
+ decoration: const InputDecoration(labelText: 'Email'),
+ validator: Validator.required().and(Validator.email()),
+ ),
+ TextFormField(
+ obscureText: true,
+ decoration: const InputDecoration(labelText: 'Password'),
+ validator: Validator.required().and(
+ Validator.strongPassword(
+ errorMessage: 'Use upper, lower, number, and symbol',
+ ),
+ ),
+ ),
+ SwitchListTile(
+ title: const Text('Create a team'),
+ value: createTeam,
+ onChanged: (value) => setState(() => createTeam = value),
+ ),
+ TextFormField(
+ decoration: const InputDecoration(labelText: 'Team name'),
+ validator: Validator.required().when((_) => createTeam),
+ ),
+ ElevatedButton(
+ onPressed: () => key.currentState!.validate(),
+ child: const Text('Register'),
+ ),
+ ],
+ ),
+ );
+ }
+}
+```
-| Validator | Extension | Description |
-|---|---|---|
-| `isInt(str)` | `str.isInt` | 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 |
-| `isHexadecimal(str)` | `str.isHexadecimal` | Hexadecimal number |
-| `isOctal(str)` | `str.isOctal` | Octal number |
-| `isPort(str)` | `str.isPort` | Port number (0–65535) |
+Run all interactive examples:
-```dart
-'42'.isInt; // true
-'3.14'.isNumeric; // true
-'1.5'.isFloat(); // true
-'5'.isFloat(min: 0, max: 2); // false (out of range)
-'.5'.isDecimal; // true
-'deadBEEF'.isHexadecimal; // true
-'0o17'.isOctal; // true
-'8080'.isPort; // true
-'65536'.isPort; // false (out of range)
+```sh
+cd example
+flutter run
```
-### Text and Format
+## Composition
-| Validator | Extension | Description |
-|---|---|---|
-| `isAlpha(str)` | `str.isAlpha` | Letters only (a–z, A–Z) |
-| `isAlphanumeric(str)` | `str.isAlphanumeric` | Letters and numbers only |
-| `isAscii(str)` | `str.isAscii` | ASCII characters only |
-| `isLowercase(str)` | `str.isLowercase` | Entirely lowercase |
-| `isUppercase(str)` | `str.isUppercase` | Entirely uppercase |
-| `isLength(str, min, [max])` | `str.isLength(min, [max])` | Length within a range |
-| `isByteLength(str, min, [max])` | `str.isByteLength(min, [max])` | UTF-8 byte length within a range |
-| `isSlug(str)` | `str.isSlug` | URL slug (`my-blog-post`) |
-| `isIn(str, values)` | `str.isIn(values)` | One of an allowed set of values |
-| `matches(str, pattern)` | `str.matches(pattern)` | Matches a `Pattern` / `RegExp` |
-| `contains(str, seed, {ignoreCase, minOccurrences})` | — | Contains a substring |
-| `equals(str, comparison)` | `str.equals(comparison)` | Exact (case-sensitive) string match |
+`compose` stops at the first error. `aggregate` returns all errors. `any`
+succeeds when one rule succeeds.
```dart
-'Hello'.isAlpha; // true
-'abc123'.isAlphanumeric; // true
-'héllo'.isAscii; // false
-'hello'.isLowercase; // true
-'abc'.isLength(2, 5); // true
-'é'.isByteLength(2, 2); // true ('é' is 2 bytes in UTF-8)
-'my-blog-post'.isSlug; // true
-'red'.isIn(['red', 'green', 'blue']); // true
-'abc123'.matches(RegExp(r'\d+')); // true
-'foo'.equals('foo'); // true
-
-// `contains` is a top-level function only (see Behavior Notes)
-contains('hello world', 'world'); // true
-contains('Hello World', 'world', ignoreCase: true); // true
-contains('a-a-a', 'a', minOccurrences: 3); // true
+final requiredEmail = compose([
+ Validator.required(),
+ Validator.email(),
+]);
+
+final passwordReport = aggregate([
+ Validator.length(12, errorMessage: 'Use at least 12 characters'),
+ Validator.matches(RegExp(r'\d'), errorMessage: 'Add a number'),
+]);
+
+final contact = any([
+ Validator.email(),
+ Validator.url(),
+], errorMessage: 'Enter an email or URL');
+
+final fluent = Validator.required()
+ .and(Validator.email())
+ .when((value) => value != 'skip')
+ .withMessage('Enter a usable email');
```
-### Encoding and Data
-
-| Validator | Extension | Description |
-|---|---|---|
-| `isBase32(str)` | `str.isBase32` | Base32 encoded |
-| `isBase58(str)` | `str.isBase58` | Base58 encoded |
-| `isBase64(str, {urlSafe})` | `str.isBase64({urlSafe})` | Base64 encoded (standard or URL-safe) |
-| `isJson(str)` | `str.isJson` | Valid JSON |
-| `isHexColor(str)` | `str.isHexColor` | Hex color code (`#fff`, `ff0000`) |
-| `isBoolean(str)` | `str.isBoolean` | Boolean string (`true`/`false`/`1`/`0`) |
-| `isDate(str)` | `str.isDate` | Parseable date string |
+Generic rules work beyond strings:
```dart
-'JBSWY3DP'.isBase32; // true
-'aGVsbG8='.isBase64(); // true
-'a-b_cdef'.isBase64(urlSafe: true); // true
-'{"name":"Dart"}'.isJson; // true
-'#ff0000'.isHexColor; // true
-'true'.isBoolean; // true
-'2024-01-15'.isDate; // true
+final positiveAge = when(
+ (value) => value != null,
+ (value) => value! > 0 ? null : 'Age must be positive',
+);
+
+final evenItemCount = transform, int>(
+ (items) => items?.length,
+ (length) => length == null || length.isEven ? null : 'Use an even count',
+);
```
-### Identifiers and Crypto
+See [Composition recipes](doc/composition.md) for `unless`, `skipWhen`, `.or()`,
+and model validation.
-| Validator | Extension | Description |
-|---|---|---|
-| `isUUID(str)` | `str.isUUID` | UUID (v1, v3, v4, v5) |
-| `isMongoId(str)` | `str.isMongoId` | MongoDB ObjectId (24-char hex) |
-| `isMD5(str)` | `str.isMD5` | MD5 hash |
-| `isJWT(str)` | `str.isJWT` | JSON Web Token |
-| `isCreditCard(str)` | `str.isCreditCard` | Credit card number (Luhn algorithm) |
-| `isMACAddress(str)` | `str.isMACAddress` | MAC address (EUI-48 / EUI-64) |
-| `isSemVer(str)` | `str.isSemVer` | Semantic version |
+## Optional And Required Fields
```dart
-'550e8400-e29b-41d4-a716-446655440000'.isUUID; // true
-'507f1f77bcf86cd799439011'.isMongoId; // true
-'d41d8cd98f00b204e9800998ecf8427e'.isMD5; // true
-'eyJhbGci.eyJzdWIi.SflKxwRJ'.isJWT; // true
-'4111111111111111'.isCreditCard; // true
-'00:1B:44:11:3A:B7'.isMACAddress; // true
-'2.1.0-alpha.1'.isSemVer; // true
-```
-
-### Security
+final optionalWebsite = Validator.url();
+optionalWebsite(null); // null
+optionalWebsite(''); // null
-| Validator | Extension | Description |
-|---|---|---|
-| `isStrongPassword(str, {...})` | `str.isStrongPassword({...})` | Password meets configurable strength rules |
+final requiredWebsite = Validator.required().and(Validator.url());
+requiredWebsite(''); // "This field is required"
+```
-`isStrongPassword` accepts five options, all with sensible defaults:
+## Strict And Configurable Validation
-| Option | Default | Meaning |
-|---|---|---|
-| `minLength` | `8` | Minimum total length |
-| `minLowercase` | `1` | Minimum lowercase letters |
-| `minUppercase` | `1` | Minimum uppercase letters |
-| `minNumbers` | `1` | Minimum digits |
-| `minSymbols` | `1` | Minimum non-alphanumeric symbols |
+Old behavior remains the default. Strictness is explicit:
```dart
-'Abcd1234!'.isStrongPassword(); // true
-'weak'.isStrongPassword(); // false
-
-// Relax the rules — e.g. allow passphrases with no symbols or digits
-'abcdefghij'.isStrongPassword(
- minUppercase: 0,
- minNumbers: 0,
- minSymbols: 0,
-); // true
-```
+isDate('2023-13-01'); // true: DateTime.tryParse rollover
+isISO8601Date('2023-13-01'); // false: real YYYY-MM-DD calendar date
----
+isJWT('aaa.bbb.'); // true: structural compatibility check
+isJWT('aaa.bbb.', strict: true); // false: decoded JSON is required
-## 🧹 Sanitizers
+isBase32('ABC'); // true: compatibility mode
+isBase32('ABC', strict: true); // false: invalid encoded length
-Sanitizers transform or coerce strings. Like validators, they're available as both top-level functions and `String` extensions. Import them via the main library or directly:
-
-```dart
-import 'package:flutter_validators/flutter_validators.dart';
-// or, sanitizers only:
-import 'package:flutter_validators/sanitizers.dart';
+isCreditCard('0000000000000000'); // true: Luhn compatibility mode
+isCreditCard('0000000000000000', strict: true); // false
```
-### Trimming
-
-| Sanitizer | Extension | Description |
-|---|---|---|
-| `trim(str, [chars])` | `str.trimChars(chars)` | Trim whitespace/chars from both ends |
-| `ltrim(str, [chars])` | `str.ltrimChars(chars)` | Trim from the start |
-| `rtrim(str, [chars])` | `str.rtrimChars(chars)` | Trim from the end |
+Configure web and identifier rules:
```dart
-trim(' hello '); // 'hello'
-trim('xxhelloxx', 'x'); // 'hello'
-ltrim('00042', '0'); // '42'
-rtrim('hello!!!', '!'); // 'hello'
+isEmail('δοκιμή@παράδειγμα.δοκιμή', allowUnicode: true);
+isEmail('person@localhost', requireTld: false);
+isURL('ftp://example.com', protocols: const ['ftp'], requireTld: true);
+isFQDN('service_name.example.com', allowUnderscores: true);
+isUUID('550e8400-e29b-41d4-a716-446655440000', 4);
```
-### HTML Escaping
+The same options are available from `Validator.email`, `Validator.url`,
+`Validator.fqdn`, `Validator.uuid`, `Validator.jwt`, `Validator.base32`, and
+`Validator.creditCard`. See [Strict validation](doc/strict-validation.md).
-| Sanitizer | Extension | Description |
-|---|---|---|
-| `escape(str)` | `str.escape()` | Escape HTML-unsafe characters |
-| `unescape(str)` | `str.unescape()` | Reverse of `escape` |
+## New Validator Recipes
-```dart
-escape('');
-// '<script>alert(1)</script>'
+Numeric and date ranges:
-unescape('<b>hi</b>'); // 'hi'
+```dart
+isPositive('12.5');
+isNegative('-2');
+isInRange('18', 13, 120);
+isDivisibleBy('24', 6);
+
+isTime('23:59:59');
+isBefore('2024-12-31', DateTime.utc(2025));
+isAfter('2025-01-02', DateTime.utc(2025));
```
-### Character Filtering
-
-| Sanitizer | Extension | Description |
-|---|---|---|
-| `blacklist(str, chars)` | `str.blacklist(chars)` | Remove the listed characters |
-| `whitelist(str, chars)` | `str.whitelist(chars)` | Keep only the listed characters |
-| `stripLow(str, {keepNewLines})` | `str.stripLow({keepNewLines})` | Remove ASCII control characters |
+Text:
```dart
-blacklist('hello world', 'lo'); // 'he wrd'
-whitelist('a1b2c3', '0123456789'); // '123'
-stripLow('line1\nline2'); // 'line1line2'
-stripLow('line1\nline2', keepNewLines: true); // 'line1\nline2'
+startsWith('Flutter validators', 'flutter', ignoreCase: true);
+endsWith('report.pdf', '.pdf');
+isSingleLine('one line');
+hasWordCount('a short profile', min: 2, max: 20);
```
-### Type Conversion
-
-| Sanitizer | Extension | Returns | Description |
-|---|---|---|---|
-| `toBoolean(str, {strict})` | `str.toBoolean({strict})` | `bool` | Convert to a boolean |
-| `toInt(str, {radix})` | `str.toInt({radix})` | `int?` | Parse to an integer |
-| `toFloat(str)` | `str.toFloat()` | `double?` | Parse to a double |
-| `toDate(str)` | `str.toDate()` | `DateTime?` | Parse to a `DateTime` |
+Finance and identifiers:
```dart
-toBoolean('true'); // true
-toBoolean('0'); // false
-toBoolean('yes', strict: true); // false (strict: only '1'/'true' are true)
-toInt('42'); // 42
-toInt('ff', radix: 16); // 255
-toInt('abc'); // null
-toFloat('3.14'); // 3.14
-toDate('2024-01-15'); // DateTime(2024, 1, 15)
+isIBAN('GB82 WEST 1234 5698 7654 32');
+isBIC('DEUTDEFF');
+isCreditCardCVC('123');
+isCreditCardExpirationDate(
+ '06/30',
+ referenceDate: DateTime(2030, 6),
+);
+isISBN('0-306-40615-2', version: 10);
+isISBN('978-0-306-40615-7', version: 13);
```
-### Email Normalization
-
-| Sanitizer | Extension | Returns | Description |
-|---|---|---|---|
-| `normalizeEmail(str)` | `str.normalizeEmail()` | `String?` | Canonicalize an email address |
+Data and security:
```dart
-normalizeEmail('Test.User+promo@GMAIL.com'); // 'testuser@gmail.com'
-normalizeEmail('User@Example.COM'); // 'User@example.com'
-normalizeEmail('not-an-email'); // null
+isHash(
+ 'e3b0c44298fc1c149afbf4c8996fb924'
+ '27ae41e4649b934ca495991b7852b855',
+ HashAlgorithm.sha256,
+);
+isMimeType('application/json');
+isDataURI('data:text/plain;base64,SGVsbG8=');
```
----
+Each has a form factory such as `Validator.range`, `Validator.before`,
+`Validator.wordCount`, `Validator.iban`, `Validator.isbn`,
+`Validator.mimeType`, and `Validator.dataURI`.
-## 📝 Flutter Form Integration
+## Sanitization
-The `Validator` class returns `String? Function(String?)` closures — exactly the type `TextFormField.validator` expects. A closure returns `null` when the value is valid, or the error message when it isn't. Every method accepts a custom `errorMessage`.
+Validation answers whether input is acceptable. Sanitization changes input.
```dart
-import 'package:flutter/material.dart';
-import 'package:flutter_validators/flutter_validators.dart';
-
-class SignUpForm extends StatefulWidget {
- const SignUpForm({super.key});
-
- @override
- State createState() => _SignUpFormState();
-}
-
-class _SignUpFormState extends State {
- final _formKey = GlobalKey();
+final cleaned = trim(' User@Example.COM ');
+final normalized = normalizeEmail(cleaned); // User@example.com
- @override
- Widget build(BuildContext context) {
- return Form(
- key: _formKey,
- child: Column(
- children: [
- // Combine `required` with `email` to enforce a non-empty, valid email.
- 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);
- },
- ),
- TextFormField(
- decoration: const InputDecoration(labelText: 'Website'),
- autovalidateMode: AutovalidateMode.onUserInteraction,
- validator: Validator.url(),
- ),
- TextFormField(
- decoration: const InputDecoration(labelText: 'Password'),
- obscureText: true,
- autovalidateMode: AutovalidateMode.onUserInteraction,
- validator: Validator.strongPassword(
- errorMessage: 'Use 8+ chars with upper, lower, number & symbol',
- ),
- ),
- ElevatedButton(
- onPressed: () {
- if (_formKey.currentState!.validate()) {
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Form is valid!')),
- );
- }
- },
- child: const Text('Sign Up'),
- ),
- ],
- ),
- );
- }
-}
+final safeText = escape('Hello');
+final digits = whitelist('+1 (415) 555-0100', '0123456789');
+final count = toInt('42');
```
-A complete, runnable app is in the [`example/`](example/) directory.
-
-### Validator API Reference
-
-Every method on the `Validator` class returns `String? Function(String?)`:
+Sanitize first, then validate the transformed value:
```dart
-Validator.required({String errorMessage})
-Validator.email({String errorMessage})
-Validator.url({String errorMessage})
-Validator.ip({int? version, String errorMessage})
-Validator.fqdn({String errorMessage})
-Validator.phone({String errorMessage})
-Validator.latLong({String errorMessage})
-Validator.date({String errorMessage})
-Validator.numeric({String errorMessage})
-Validator.integer({String errorMessage})
-Validator.float({double? min, double? max, String errorMessage})
-Validator.decimal({String errorMessage})
-Validator.hexadecimal({String errorMessage})
-Validator.octal({String errorMessage})
-Validator.port({String errorMessage})
-Validator.alpha({String errorMessage})
-Validator.alphanumeric({String errorMessage})
-Validator.ascii({String errorMessage})
-Validator.lowercase({String errorMessage})
-Validator.uppercase({String errorMessage})
-Validator.slug({String errorMessage})
-Validator.length(int min, {int? max, String errorMessage})
-Validator.byteLength(int min, {int? max, String errorMessage})
-Validator.contains(String seed, {bool ignoreCase, int minOccurrences, String errorMessage})
-Validator.matches(Pattern pattern, {String errorMessage})
-Validator.inList(Iterable allowed, {String errorMessage})
-Validator.equals(String comparison, {String errorMessage})
-Validator.base32({String errorMessage})
-Validator.base58({String errorMessage})
-Validator.base64({bool urlSafe, String errorMessage})
-Validator.json({String errorMessage})
-Validator.hexColor({String errorMessage})
-Validator.boolean({String errorMessage})
-Validator.uuid({String errorMessage})
-Validator.mongoId({String errorMessage})
-Validator.md5({String errorMessage})
-Validator.jwt({String errorMessage})
-Validator.creditCard({String errorMessage})
-Validator.macAddress({String errorMessage})
-Validator.semVer({String errorMessage})
-Validator.strongPassword({int minLength, int minLowercase, int minUppercase, int minNumbers, int minSymbols, String errorMessage})
+final email = normalizeEmail(trim(rawInput));
+final error = Validator.required().and(Validator.email())(email);
```
----
-
-## 💡 Behavior Notes and FAQ
+## Localized Messages
-**`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()`:
+No localization package is required. Set a resolver that maps stable keys and
+parameters into the current locale:
```dart
-validator: (value) {
- return Validator.required()(value) ?? Validator.email()(value);
-}
+var locale = 'en';
+
+Validator.messageResolver = (message) {
+ final translations = {
+ 'es': {
+ 'required': 'Este campo es obligatorio',
+ 'email': 'Introduce un correo válido',
+ },
+ };
+ return translations[locale]?[message.key] ?? message.fallback;
+};
+
+final rule = Validator.required().and(Validator.email());
+locale = 'es';
+rule('bad'); // "Introduce un correo válido"
+
+Validator.email(errorMessage: 'Account email is invalid')('bad');
+// Explicit text always wins.
+
+Validator.resetMessageResolver();
```
-**`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.
+See [Message resolution](doc/messages.md) for parameter interpolation and
+locale lifecycle guidance.
-**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.
+## Behavior Reference
-**`isURL` accepts only `http` and `https` schemes.** Other schemes such as `ftp://` are rejected.
+| Area | Default | Opt-in behavior |
+|---|---|---|
+| Non-required form validators | `null` and `''` are valid | compose with `required()` |
+| Date | `DateTime.tryParse` | `isISO8601Date` |
+| JWT | three valid-looking segments | `strict: true` decodes JSON |
+| Base32 | valid alphabet and compatible padding | strict encoded lengths |
+| Credit card | length plus Luhn | network pattern and repeated-digit rejection |
+| URL | HTTP/HTTPS URI with authority | protocols, TLD, localhost, underscore rules |
+| Email | 1.2 ASCII behavior | Unicode, local domain, IP domain, max length |
+| Messages | English fallback | application resolver or explicit message |
+
+| API kind | Empty string | Invalid input | Valid input |
+|---|---|---|---|
+| Function/extension | validator-specific `false` | `false` | `true` |
+| Form factory except `required` | `null` | error string | `null` |
+| `required` | error string | `null` | `null` |
+| Sanitizer | transformed value | transformed value or nullable conversion | transformed value |
-**`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.
+## Common Mistakes
-**`isBoolean` accepts `'true'`, `'false'`, `'1'` and `'0'`.** Any other value is not a boolean string.
+- `Validator.email()` does not require a value. Compose it with
+ `Validator.required()`.
+- `isDate()` intentionally uses permissive Dart parsing. Use
+ `isISO8601Date()` for calendar input.
+- Sanitizers do not prove validity. Validate the sanitized result.
+- Credit-card validation checks syntax and checksums only. It cannot verify
+ ownership, funds, issuer status, or whether a payment will succeed.
+- Strict JWT validation decodes structure; it does not verify signatures.
-**`isFloat` rejects non-finite values.** `'Infinity'` and `'NaN'` return `false`, even though Dart's `double.tryParse` can parse them.
+## Migration
-**`normalizeEmail` applies Gmail-specific rules.** For `gmail.com` / `googlemail.com` addresses it lowercases the local part, removes dots, and strips any `+tag` suffix. For other providers it only lowercases the domain. It returns `null` if the input isn't a valid email.
+From `flutter_validators 1.2`: no existing public symbol was removed or
+renamed, and permissive defaults remain. Update the constraint and adopt strict
+options gradually.
----
+```yaml
+dependencies:
+ flutter_validators: ^1.3.0
+```
-## 🤝 Contributing
+From the legacy `validators` package, import this package and replace calls
+incrementally. Most common function names are compatible; parameterized and
+form APIs should be migrated explicitly.
+
+See the full [Migration guide](doc/migration.md).
+
+## Public API Index
+
+- Composition: `FieldValidator`, `compose`, `aggregate`, `any`, `conditional`,
+ `when`, `unless`, `skipWhen`, `transform`, `.and()`, `.or()`,
+ `.withMessage()`.
+- Messages: `ValidationMessage`, `ValidationMessageResolver`,
+ `ValidationMessages`, `Validator.messageResolver`.
+- Web/contact: `isEmail`, `isURL`, `isFQDN`, `isPhone`, `isIP`, `isLatLong`.
+- Numbers: `isInt`, `isNumeric`, `isDecimal`, `isFloat`, `isPositive`,
+ `isNegative`, `isInRange`, `isDivisibleBy`, `isPort`.
+- Dates: `isDate`, `isISO8601Date`, `isTime`, `isBefore`, `isAfter`.
+- Text: `isAlpha`, `isAlphanumeric`, `isAscii`, `isLength`, `isByteLength`,
+ `isLowercase`, `isUppercase`, `startsWith`, `endsWith`, `isSingleLine`,
+ `hasWordCount`, `contains`, `matches`, `equals`, `isIn`, `isSlug`.
+- Finance/IDs: `isCreditCard`, `isCreditCardCVC`,
+ `isCreditCardExpirationDate`, `isIBAN`, `isBIC`, `isISBN`, `isUUID`,
+ `isMongoId`, `isMACAddress`.
+- Data/security: `isJson`, `isJWT`, `isBase32`, `isBase58`, `isBase64`,
+ `isHexadecimal`, `isHexColor`, `isMD5`, `isHash`, `HashAlgorithm`,
+ `isMimeType`, `isDataURI`, `isSemVer`, `isStrongPassword`, `isBoolean`,
+ `isOctal`.
+- Sanitizers: `trim`, `ltrim`, `rtrim`, `escape`, `unescape`, `blacklist`,
+ `whitelist`, `stripLow`, `normalizeEmail`, `toBoolean`, `toInt`, `toFloat`,
+ `toDate`.
+- Forms: `Validator` exposes factories for every validator where form input is
+ appropriate.
+
+API reference is generated from source documentation on
+[pub.dev](https://pub.dev/documentation/flutter_validators/latest/).
+
+## Contributing
+
+Run the same checks as CI:
-Contributions, issues, and feature requests are welcome!
-Feel free to check the [issues page](https://github.com/StacDev/flutter_validators/issues).
+```sh
+dart format --output=none --set-exit-if-changed lib test tool
+dart analyze
+dart test
+cd example && flutter test
+```
----
+Issues and pull requests are welcome at the
+[GitHub repository](https://github.com/StacDev/flutter_validators).
+Maintainers should follow the [1.3 release checklist](doc/releasing.md).
-## 📄 License
+## License
-This project is [MIT](LICENSE) licensed.
+MIT. See [LICENSE](LICENSE).
diff --git a/doc/composition.md b/doc/composition.md
new file mode 100644
index 0000000..9ac01d8
--- /dev/null
+++ b/doc/composition.md
@@ -0,0 +1,47 @@
+# Composition Recipes
+
+`FieldValidator` is `String? Function(T? value)`: `null` means valid and a
+string is the validation error.
+
+## First Error Or All Errors
+
+```dart
+final firstError = compose([
+ Validator.required(),
+ Validator.email(),
+]);
+
+final allErrors = aggregate([
+ Validator.length(12),
+ Validator.strongPassword(),
+]);
+```
+
+## Alternatives
+
+```dart
+final contact = any([
+ Validator.email(),
+ Validator.url(),
+], errorMessage: 'Enter an email or URL');
+```
+
+## Conditional Rules
+
+```dart
+final teamName = Validator.required().when((_) => createTeam);
+final nickname = Validator.length(3).unless((value) => value == 'anonymous');
+final optionalCode = Validator.uuid().skipWhen((value) => value == 'later');
+```
+
+## Model And Collection Rules
+
+```dart
+final itemCount = transform, int>(
+ (items) => items?.length,
+ (count) => count != null && count > 10 ? 'Use at most 10 items' : null,
+);
+```
+
+Predicates and transformers may receive `null`; decide explicitly whether to
+skip or reject it.
diff --git a/doc/messages.md b/doc/messages.md
new file mode 100644
index 0000000..a756281
--- /dev/null
+++ b/doc/messages.md
@@ -0,0 +1,28 @@
+# Message Resolution
+
+`ValidationMessage` contains a stable key, an English fallback, and parameters.
+The package does not depend on Flutter localization or `intl`.
+
+```dart
+Validator.messageResolver = (message) {
+ if (message.key == 'range') {
+ return 'Use ${message.parameters['min']} through '
+ '${message.parameters['max']}';
+ }
+ return message.fallback;
+};
+```
+
+Resolution occurs when validation fails, so an existing validator follows the
+application's current locale.
+
+An explicit `errorMessage` always wins:
+
+```dart
+final accountEmail = Validator.email(
+ errorMessage: 'The account email is invalid',
+);
+```
+
+Call `Validator.resetMessageResolver()` during test teardown and when returning
+to fallback English.
diff --git a/doc/migration.md b/doc/migration.md
new file mode 100644
index 0000000..af7a6c0
--- /dev/null
+++ b/doc/migration.md
@@ -0,0 +1,32 @@
+# Migration Guide
+
+## From flutter_validators 1.2
+
+1. Change the dependency constraint to `^1.3.0`.
+2. Run existing tests. Default validators retain 1.2 behavior.
+3. Add strict options field by field where product requirements need them.
+4. Replace local form-composition helpers with `compose` or fluent `.and()`.
+5. Configure `Validator.messageResolver` if the application localizes errors.
+
+No 1.2 public symbol was removed or renamed.
+
+## From validators
+
+Replace the import:
+
+```dart
+import 'package:flutter_validators/flutter_validators.dart';
+```
+
+Common calls such as `isEmail`, `isURL`, `isIP`, `isUUID`, `isJSON`/`isJson`,
+and numeric checks may differ in naming or accepted edge cases. Migrate one
+validator family at a time and keep representative valid and invalid fixtures.
+
+For Flutter fields, replace handwritten closures with:
+
+```dart
+validator: Validator.required().and(Validator.email())
+```
+
+Sanitization remains a separate step. Normalize input before applying form or
+domain validation.
diff --git a/doc/releasing.md b/doc/releasing.md
new file mode 100644
index 0000000..e554da0
--- /dev/null
+++ b/doc/releasing.md
@@ -0,0 +1,24 @@
+# Releasing 1.3
+
+## Release Candidate
+
+1. Confirm formatting, analysis, package tests, example widget tests, 95% line
+ coverage, API docs, pure Dart smoke test, and pub dry-run all pass.
+2. Set the version to `1.3.0-rc.1`.
+3. Publish the candidate and validate it from new pure Dart and Flutter apps.
+4. Announce the candidate with links to the README, strict-validation guide,
+ migration guide, and example application.
+5. Keep the candidate open for seven days. Accept only correctness,
+ compatibility, documentation, and release-blocking fixes.
+
+## Stable Release
+
+1. Set the version to `1.3.0` and rerun every release-quality check.
+2. Publish to pub.dev.
+3. Tag the commit as `v1.3.0`.
+4. Create a GitHub release using the 1.3 changelog, migration notes, strict-mode
+ warnings, and links to the runnable examples.
+5. Monitor issues and documentation feedback for 14 days.
+
+No release should remove a 1.2 symbol, enable strict behavior by default, add a
+runtime dependency, or publish with broken documentation snippets.
diff --git a/doc/strict-validation.md b/doc/strict-validation.md
new file mode 100644
index 0000000..b56c7b2
--- /dev/null
+++ b/doc/strict-validation.md
@@ -0,0 +1,18 @@
+# Strict Validation
+
+Version 1.3 preserves 1.2 defaults. Strict checks are opt-in so an upgrade does
+not unexpectedly reject stored data.
+
+| Validator | Compatibility default | Strict/configurable option |
+|---|---|---|
+| Date | `DateTime.tryParse` | `isISO8601Date` |
+| JWT | segment structure | decoded JSON objects, unsigned-token rule |
+| Base32 | alphabet and padding shape | legal encoded lengths |
+| Credit card | length and Luhn | known network shape, repeated digits |
+| URL | HTTP/HTTPS authority | protocols, TLD, localhost, underscores |
+| Email | historical ASCII regex | Unicode, TLD, IP domain, max length |
+| FQDN | historical labels/TLD | trailing dot, underscores, total length |
+| UUID | versions 1, 3, 4, 5 | select a version |
+
+Strict JWT checks do not verify cryptographic signatures. Credit-card checks do
+not contact an issuer. Use authentication and payment providers for those jobs.
diff --git a/example/README.md b/example/README.md
index 83f7e49..cf96afe 100644
--- a/example/README.md
+++ b/example/README.md
@@ -1,17 +1,27 @@
-# example
+# Flutter Validators Example
-A new Flutter project.
+This application is an executable guide to the 1.3 APIs.
-## Getting Started
+## Run
-This project is a starting point for a Flutter application.
+```sh
+flutter pub get
+flutter run
+```
-A few resources to get you started if this is your first Flutter project:
+## Walkthrough
-- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
-- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
-- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
+- **Registration**: required/optional fields, fluent composition, conditional
+ validation, strong passwords, and custom messages.
+- **Finance**: IBAN, BIC, and ISBN checksum validation.
+- **Strict**: side-by-side results for permissive and strict date, JWT, Base32,
+ and credit-card rules.
+- **Sanitizers**: before-and-after trimming and email normalization.
+- **Messages**: dependency-free locale switching with `ValidationMessage`.
-For help getting started with Flutter development, view the
-[online documentation](https://docs.flutter.dev/), which offers tutorials,
-samples, guidance on mobile development, and a full API reference.
+The source comments in `lib/main.dart` call out empty-value and composition
+behavior. Run the demonstrated flows as widget tests:
+
+```sh
+flutter test
+```
diff --git a/example/lib/main.dart b/example/lib/main.dart
index 753cae4..6710a7e 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -9,75 +9,418 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
- title: 'Flutter Validators Example',
+ title: 'Flutter Validators 1.3',
theme: ThemeData(
- primarySwatch: Colors.blue,
+ colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
),
- home: const ExampleScreen(),
+ home: const DefaultTabController(length: 5, child: ExampleHome()),
);
}
}
-class ExampleScreen extends StatefulWidget {
- const ExampleScreen({super.key});
+class ExampleHome extends StatelessWidget {
+ const ExampleHome({super.key});
@override
- State createState() => _ExampleScreenState();
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Flutter Validators 1.3'),
+ bottom: const TabBar(
+ isScrollable: true,
+ tabs: [
+ Tab(text: 'Registration'),
+ Tab(text: 'Finance'),
+ Tab(text: 'Strict'),
+ Tab(text: 'Sanitizers'),
+ Tab(text: 'Messages'),
+ ],
+ ),
+ ),
+ body: const TabBarView(
+ children: [
+ RegistrationDemo(),
+ FinanceDemo(),
+ StrictDemo(),
+ SanitizerDemo(),
+ MessageDemo(),
+ ],
+ ),
+ );
+ }
}
-class _ExampleScreenState extends State {
+class RegistrationDemo extends StatefulWidget {
+ const RegistrationDemo({super.key});
+
+ @override
+ State createState() => _RegistrationDemoState();
+}
+
+class _RegistrationDemoState extends State {
final _formKey = GlobalKey();
+ bool _createTeam = false;
+ String _result = 'Complete the form to see composition in action.';
@override
Widget build(BuildContext context) {
- return Scaffold(
- appBar: AppBar(title: const Text('Flutter Validators Example')),
- body: SingleChildScrollView(
- padding: const EdgeInsets.all(16.0),
- child: Form(
+ return _DemoPage(
+ title: 'Registration and profile',
+ description:
+ 'Required fields are composed with format rules. Optional validators '
+ 'accept empty values until required is added.',
+ children: [
+ Form(
key: _formKey,
child: Column(
children: [
TextFormField(
+ key: const Key('registration-email'),
decoration: const InputDecoration(labelText: 'Email'),
- validator: Validator.email(),
- autovalidateMode: AutovalidateMode.onUserInteraction,
+ // Composition returns the first useful error.
+ validator: Validator.required(
+ errorMessage: 'Email is required',
+ ).and(Validator.email(errorMessage: 'Enter a valid email')),
),
- const SizedBox(height: 16),
+ const SizedBox(height: 12),
TextFormField(
- decoration: const InputDecoration(labelText: 'URL'),
- validator: Validator.url(errorMessage: 'Please provide a valid URL'),
- autovalidateMode: AutovalidateMode.onUserInteraction,
+ key: const Key('registration-password'),
+ obscureText: true,
+ decoration: const InputDecoration(labelText: 'Password'),
+ validator: Validator.required().and(
+ Validator.strongPassword(
+ errorMessage:
+ 'Use upper, lower, number, symbol, and 8 characters',
+ ),
+ ),
),
- const SizedBox(height: 16),
+ const SizedBox(height: 12),
TextFormField(
- decoration: const InputDecoration(labelText: 'Credit Card'),
- validator: Validator.creditCard(),
- autovalidateMode: AutovalidateMode.onUserInteraction,
+ key: const Key('registration-profile'),
+ decoration: const InputDecoration(
+ labelText: 'Profile summary (optional)',
+ ),
+ // Empty is valid because this field is not required.
+ validator: Validator.wordCount(
+ max: 20,
+ errorMessage: 'Keep the profile to 20 words',
+ ),
+ ),
+ SwitchListTile(
+ contentPadding: EdgeInsets.zero,
+ title: const Text('Create a team'),
+ value: _createTeam,
+ onChanged: (value) => setState(() => _createTeam = value),
+ ),
+ TextFormField(
+ key: const Key('registration-team'),
+ decoration: const InputDecoration(labelText: 'Team name'),
+ validator: Validator.required(
+ errorMessage: 'Team name is required',
+ ).when((_) => _createTeam),
),
const SizedBox(height: 16),
+ FilledButton(
+ key: const Key('registration-submit'),
+ onPressed: () {
+ final valid = _formKey.currentState!.validate();
+ setState(() {
+ _result =
+ valid ? 'Registration is valid' : 'Fix the form errors';
+ });
+ },
+ child: const Text('Validate registration'),
+ ),
+ ],
+ ),
+ ),
+ _ResultText(_result),
+ ],
+ );
+ }
+}
+
+class FinanceDemo extends StatefulWidget {
+ const FinanceDemo({super.key});
+
+ @override
+ State createState() => _FinanceDemoState();
+}
+
+class _FinanceDemoState extends State {
+ final _formKey = GlobalKey();
+ String _result = 'Validate bank and book identifiers together.';
+
+ @override
+ Widget build(BuildContext context) {
+ return _DemoPage(
+ title: 'Finance and identifiers',
+ description: 'IBAN, BIC, card CVC, expiry, and ISBN checksum validation.',
+ children: [
+ Form(
+ key: _formKey,
+ child: Column(
+ children: [
+ TextFormField(
+ key: const Key('finance-iban'),
+ decoration: const InputDecoration(labelText: 'IBAN'),
+ validator: Validator.required().and(Validator.iban()),
+ ),
+ const SizedBox(height: 12),
TextFormField(
- decoration: const InputDecoration(labelText: 'Age (Numeric)'),
- validator: Validator.numeric(),
- autovalidateMode: AutovalidateMode.onUserInteraction,
+ key: const Key('finance-bic'),
+ decoration: const InputDecoration(labelText: 'BIC / SWIFT'),
+ validator: Validator.required().and(Validator.bic()),
),
- const SizedBox(height: 32),
- ElevatedButton(
+ const SizedBox(height: 12),
+ TextFormField(
+ key: const Key('finance-isbn'),
+ decoration: const InputDecoration(labelText: 'ISBN-13'),
+ validator: Validator.required().and(
+ Validator.isbn(version: 13),
+ ),
+ ),
+ const SizedBox(height: 16),
+ FilledButton(
+ key: const Key('finance-submit'),
onPressed: () {
- if (_formKey.currentState!.validate()) {
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Form is valid!')),
- );
- }
+ final valid = _formKey.currentState!.validate();
+ setState(() {
+ _result =
+ valid
+ ? 'Financial identifiers are valid'
+ : 'One or more identifiers are invalid';
+ });
},
- child: const Text('Submit'),
+ child: const Text('Validate identifiers'),
),
],
),
),
+ _ResultText(_result),
+ ],
+ );
+ }
+}
+
+class StrictDemo extends StatefulWidget {
+ const StrictDemo({super.key});
+
+ @override
+ State createState() => _StrictDemoState();
+}
+
+class _StrictDemoState extends State {
+ bool _strict = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final checks = <(String, String, bool)>[
+ (
+ 'Date',
+ '2023-13-01',
+ _strict ? isISO8601Date('2023-13-01') : isDate('2023-13-01'),
+ ),
+ ('JWT', 'aaa.bbb.', isJWT('aaa.bbb.', strict: _strict)),
+ ('Base32', 'ABC', isBase32('ABC', strict: _strict)),
+ (
+ 'Credit card',
+ '0000000000000000',
+ isCreditCard('0000000000000000', strict: _strict),
+ ),
+ ];
+
+ return _DemoPage(
+ title: 'Strict versus permissive',
+ description:
+ 'Compatibility mode preserves 1.2 behavior. Strict checks are opt-in.',
+ children: [
+ SwitchListTile(
+ key: const Key('strict-switch'),
+ contentPadding: EdgeInsets.zero,
+ title: const Text('Strict validation'),
+ value: _strict,
+ onChanged: (value) => setState(() => _strict = value),
+ ),
+ for (final check in checks)
+ ListTile(
+ contentPadding: EdgeInsets.zero,
+ title: Text(check.$1),
+ subtitle: Text(check.$2),
+ trailing: Text(check.$3 ? 'Accepted' : 'Rejected'),
+ ),
+ ],
+ );
+ }
+}
+
+class SanitizerDemo extends StatefulWidget {
+ const SanitizerDemo({super.key});
+
+ @override
+ State createState() => _SanitizerDemoState();
+}
+
+class _SanitizerDemoState extends State {
+ final _controller = TextEditingController(
+ text: ' Test.User+news@GMAIL.com ',
+ );
+ String _before = '';
+ String _after = '';
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return _DemoPage(
+ title: 'Sanitization pipeline',
+ description:
+ 'Sanitizers transform input. Validate the transformed value afterward.',
+ children: [
+ TextField(
+ key: const Key('sanitizer-input'),
+ controller: _controller,
+ decoration: const InputDecoration(labelText: 'Raw email'),
+ ),
+ const SizedBox(height: 16),
+ FilledButton(
+ key: const Key('sanitizer-run'),
+ onPressed: () {
+ final before = _controller.text;
+ final after = normalizeEmail(trim(before)) ?? 'Invalid email';
+ setState(() {
+ _before = before;
+ _after = after;
+ });
+ },
+ child: const Text('Run sanitizer'),
+ ),
+ const SizedBox(height: 16),
+ SelectableText('Before: $_before'),
+ SelectableText('After: $_after'),
+ ],
+ );
+ }
+}
+
+class MessageDemo extends StatefulWidget {
+ const MessageDemo({super.key});
+
+ @override
+ State createState() => _MessageDemoState();
+}
+
+class _MessageDemoState extends State {
+ final _formKey = GlobalKey();
+ bool _spanish = false;
+ String _result = 'Switch locale, then validate the same field.';
+
+ @override
+ void dispose() {
+ Validator.resetMessageResolver();
+ super.dispose();
+ }
+
+ void _configureResolver() {
+ Validator.messageResolver = (message) {
+ if (!_spanish) return message.fallback;
+ const spanish = {
+ 'required': 'Este campo es obligatorio',
+ 'email': 'Introduce un correo válido',
+ };
+ return spanish[message.key] ?? message.fallback;
+ };
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return _DemoPage(
+ title: 'Message resolver',
+ description:
+ 'Applications own localization. Explicit errorMessage values still win.',
+ children: [
+ SwitchListTile(
+ key: const Key('message-locale'),
+ contentPadding: EdgeInsets.zero,
+ title: const Text('Spanish messages'),
+ value: _spanish,
+ onChanged: (value) => setState(() => _spanish = value),
+ ),
+ Form(
+ key: _formKey,
+ child: TextFormField(
+ key: const Key('message-email'),
+ decoration: const InputDecoration(labelText: 'Email'),
+ validator: Validator.required().and(Validator.email()),
+ ),
+ ),
+ const SizedBox(height: 16),
+ FilledButton(
+ key: const Key('message-submit'),
+ onPressed: () {
+ _configureResolver();
+ final valid = _formKey.currentState!.validate();
+ setState(() {
+ _result = valid ? 'Email is valid' : 'Resolver returned an error';
+ });
+ },
+ child: const Text('Validate message'),
+ ),
+ _ResultText(_result),
+ ],
+ );
+ }
+}
+
+class _DemoPage extends StatelessWidget {
+ const _DemoPage({
+ required this.title,
+ required this.description,
+ required this.children,
+ });
+
+ final String title;
+ final String description;
+ final List children;
+
+ @override
+ Widget build(BuildContext context) {
+ return SingleChildScrollView(
+ padding: const EdgeInsets.all(20),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text(title, style: Theme.of(context).textTheme.headlineSmall),
+ const SizedBox(height: 8),
+ Text(description),
+ const SizedBox(height: 20),
+ ...children,
+ ],
+ ),
+ );
+ }
+}
+
+class _ResultText extends StatelessWidget {
+ const _ResultText(this.value);
+
+ final String value;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.only(top: 16),
+ child: Text(
+ value,
+ key: ValueKey(value),
+ style: Theme.of(context).textTheme.titleMedium,
),
);
}
diff --git a/example/pubspec.lock b/example/pubspec.lock
index 235159d..c093c4e 100644
--- a/example/pubspec.lock
+++ b/example/pubspec.lock
@@ -41,14 +41,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
- cupertino_icons:
- dependency: "direct main"
- description:
- name: cupertino_icons
- sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
- url: "https://pub.dev"
- source: hosted
- version: "1.0.9"
fake_async:
dependency: transitive
description:
@@ -81,7 +73,7 @@ packages:
path: ".."
relative: true
source: path
- version: "1.1.0"
+ version: "1.3.0"
leak_tracker:
dependency: transitive
description:
@@ -134,10 +126,10 @@ packages:
dependency: transitive
description:
name: meta
- sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
+ sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
- version: "1.17.0"
+ version: "1.18.0"
path:
dependency: transitive
description:
@@ -195,10 +187,10 @@ packages:
dependency: transitive
description:
name: test_api
- sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
+ sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
- version: "0.7.10"
+ version: "0.7.11"
vector_math:
dependency: transitive
description:
@@ -216,5 +208,5 @@ packages:
source: hosted
version: "15.0.2"
sdks:
- dart: ">=3.9.0 <4.0.0"
+ dart: ">=3.10.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
diff --git a/example/pubspec.yaml b/example/pubspec.yaml
index c4d79bb..fc8e6c6 100644
--- a/example/pubspec.yaml
+++ b/example/pubspec.yaml
@@ -1,91 +1,21 @@
name: example
-description: "A new Flutter project."
-# The following line prevents the package from being accidentally published to
-# pub.dev using `flutter pub publish`. This is preferred for private packages.
-publish_to: 'none' # Remove this line if you wish to publish to pub.dev
-
-# The following defines the version and build number for your application.
-# A version number is three numbers separated by dots, like 1.2.43
-# followed by an optional build number separated by a +.
-# Both the version and the builder number may be overridden in flutter
-# build by specifying --build-name and --build-number, respectively.
-# In Android, build-name is used as versionName while build-number used as versionCode.
-# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
-# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
-# Read more about iOS versioning at
-# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
-# In Windows, build-name is used as the major, minor, and patch parts
-# of the product and file versions while build-number is used as the build suffix.
+description: Interactive examples for flutter_validators 1.3.
+publish_to: none
version: 1.0.0+1
environment:
sdk: ^3.7.0
-# Dependencies specify other packages that your package needs in order to work.
-# To automatically upgrade your package dependencies to the latest versions
-# consider running `flutter pub upgrade --major-versions`. Alternatively,
-# dependencies can be manually updated by changing the version numbers below to
-# the latest version available on pub.dev. To see which dependencies have newer
-# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
-
- # The following adds the Cupertino Icons font to your application.
- # Use with the CupertinoIcons class for iOS style icons.
- cupertino_icons: ^1.0.8
flutter_validators:
path: ..
dev_dependencies:
flutter_test:
sdk: flutter
-
- # The "flutter_lints" package below contains a set of recommended lints to
- # encourage good coding practices. The lint set provided by the package is
- # activated in the `analysis_options.yaml` file located at the root of your
- # package. See that file for information about deactivating specific lint
- # rules and activating additional ones.
flutter_lints: ^6.0.0
-# For information on the generic Dart part of this file, see the
-# following page: https://dart.dev/tools/pub/pubspec
-
-# The following section is specific to Flutter packages.
flutter:
-
- # The following line ensures that the Material Icons font is
- # included with your application, so that you can use the icons in
- # the material Icons class.
uses-material-design: true
-
- # To add assets to your application, add an assets section, like this:
- # assets:
- # - images/a_dot_burr.jpeg
- # - images/a_dot_ham.jpeg
-
- # An image asset can refer to one or more resolution-specific "variants", see
- # https://flutter.dev/to/resolution-aware-images
-
- # For details regarding adding assets from package dependencies, see
- # https://flutter.dev/to/asset-from-package
-
- # To add custom fonts to your application, add a fonts section here,
- # in this "flutter" section. Each entry in this list should have a
- # "family" key with the font family name, and a "fonts" key with a
- # list giving the asset and other descriptors for the font. For
- # example:
- # fonts:
- # - family: Schyler
- # fonts:
- # - asset: fonts/Schyler-Regular.ttf
- # - asset: fonts/Schyler-Italic.ttf
- # style: italic
- # - family: Trajan Pro
- # fonts:
- # - asset: fonts/TrajanPro.ttf
- # - asset: fonts/TrajanPro_Bold.ttf
- # weight: 700
- #
- # For details regarding fonts from package dependencies,
- # see https://flutter.dev/to/font-from-package
diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart
index 4e1978b..c106132 100644
--- a/example/test/widget_test.dart
+++ b/example/test/widget_test.dart
@@ -1,59 +1,95 @@
-// This is a basic Flutter widget test.
-//
-// To perform an interaction with a widget in your test, use the WidgetTester
-// utility in the flutter_test package. For example, you can send tap and scroll
-// gestures. You can also use WidgetTester to find child widgets in the widget
-// tree, read text, and verify that the values of widget properties are correct.
-
+import 'package:example/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
-
-import 'package:example/main.dart';
+import 'package:flutter_validators/flutter_validators.dart';
void main() {
- testWidgets('Form validation smoke test', (WidgetTester tester) async {
- // Build our app and trigger a frame.
+ tearDown(Validator.resetMessageResolver);
+
+ testWidgets('registration demonstrates composition and conditional fields', (
+ tester,
+ ) async {
+ await tester.pumpWidget(const MyApp());
+
+ await tester.ensureVisible(find.byKey(const Key('registration-submit')));
+ await tester.tap(find.byKey(const Key('registration-submit')));
+ await tester.pump();
+ expect(find.text('Email is required'), findsOneWidget);
+
+ await tester.enterText(
+ find.byKey(const Key('registration-email')),
+ 'person@example.com',
+ );
+ await tester.enterText(
+ find.byKey(const Key('registration-password')),
+ 'Strong123!',
+ );
+ await tester.ensureVisible(find.byKey(const Key('registration-submit')));
+ await tester.tap(find.byKey(const Key('registration-submit')));
+ await tester.pump();
+ expect(find.text('Registration is valid'), findsOneWidget);
+ });
+
+ testWidgets('finance validates IBAN, BIC, and ISBN', (tester) async {
+ await tester.pumpWidget(const MyApp());
+ await _openTab(tester, 'Finance');
+
+ await tester.enterText(
+ find.byKey(const Key('finance-iban')),
+ 'GB82 WEST 1234 5698 7654 32',
+ );
+ await tester.enterText(find.byKey(const Key('finance-bic')), 'DEUTDEFF');
+ await tester.enterText(
+ find.byKey(const Key('finance-isbn')),
+ '978-0-306-40615-7',
+ );
+ await tester.ensureVisible(find.byKey(const Key('finance-submit')));
+ await tester.tap(find.byKey(const Key('finance-submit')));
+ await tester.pump();
+ expect(find.text('Financial identifiers are valid'), findsOneWidget);
+ });
+
+ testWidgets('strict tab compares compatibility and strict results', (
+ tester,
+ ) async {
+ await tester.pumpWidget(const MyApp());
+ await _openTab(tester, 'Strict');
+ expect(find.text('Accepted'), findsNWidgets(4));
+
+ await tester.tap(find.byKey(const Key('strict-switch')));
+ await tester.pump();
+ expect(find.text('Rejected'), findsNWidgets(4));
+ });
+
+ testWidgets('sanitizer tab shows before and after output', (tester) async {
await tester.pumpWidget(const MyApp());
+ await _openTab(tester, 'Sanitizers');
- // Locate the TextFormField widgets
- final emailField = find.byType(TextFormField).at(0);
- final urlField = find.byType(TextFormField).at(1);
- final creditCardField = find.byType(TextFormField).at(2);
- final numericField = find.byType(TextFormField).at(3);
-
- // Enter invalid values
- await tester.enterText(emailField, 'invalid-email');
- await tester.enterText(urlField, 'invalid-url');
- await tester.enterText(creditCardField, '1234');
- await tester.enterText(numericField, 'abc');
-
- // Tap the submit button
- await tester.tap(find.text('Submit'));
- await tester.pumpAndSettle();
-
- // Verify that validation errors appear
- expect(find.text('Please enter a valid email address'), findsOneWidget);
- expect(find.text('Please provide a valid URL'), findsOneWidget);
- expect(find.text('Please enter a valid credit card number'), findsOneWidget);
- expect(find.text('Please enter a valid number'), findsOneWidget);
-
- // Enter valid values
- await tester.enterText(emailField, 'test@example.com');
- await tester.enterText(urlField, 'https://example.com');
- await tester.enterText(creditCardField, '4111111111111111');
- await tester.enterText(numericField, '25');
-
- // Tap the submit button again
- await tester.tap(find.text('Submit'));
- await tester.pumpAndSettle();
-
- // Verify that validation errors disappear
- expect(find.text('Please enter a valid email address'), findsNothing);
- expect(find.text('Please provide a valid URL'), findsNothing);
- expect(find.text('Please enter a valid credit card number'), findsNothing);
- expect(find.text('Please enter a valid number'), findsNothing);
-
- // Verify success state (e.g., success SnackBar appears)
- expect(find.text('Form is valid!'), findsOneWidget);
+ await tester.tap(find.byKey(const Key('sanitizer-run')));
+ await tester.pump();
+ expect(find.text('After: testuser@gmail.com'), findsOneWidget);
});
+
+ testWidgets('message tab switches locale through the resolver', (
+ tester,
+ ) async {
+ await tester.pumpWidget(const MyApp());
+ await _openTab(tester, 'Messages');
+
+ await tester.enterText(
+ find.byKey(const Key('message-email')),
+ 'not-an-email',
+ );
+ await tester.tap(find.byKey(const Key('message-locale')));
+ await tester.tap(find.byKey(const Key('message-submit')));
+ await tester.pump();
+ expect(find.text('Introduce un correo válido'), findsOneWidget);
+ });
+}
+
+Future _openTab(WidgetTester tester, String label) async {
+ final tab = find.text(label);
+ await tester.ensureVisible(tab);
+ await tester.tap(tab);
+ await tester.pumpAndSettle();
}
diff --git a/lib/field_validator.dart b/lib/field_validator.dart
new file mode 100644
index 0000000..2319cad
--- /dev/null
+++ b/lib/field_validator.dart
@@ -0,0 +1,125 @@
+/// A synchronous validator for nullable values.
+///
+/// Returning `null` means the value is valid. Returning a string means the
+/// value is invalid and the string should be shown to the user.
+typedef FieldValidator = String? Function(T? value);
+
+/// Runs [validators] in order and returns the first validation error.
+FieldValidator compose(Iterable> validators) {
+ final rules = List>.unmodifiable(validators);
+ return (value) {
+ for (final validator in rules) {
+ final error = validator(value);
+ if (error != null) return error;
+ }
+ return null;
+ };
+}
+
+/// Runs every validator and joins all validation errors.
+FieldValidator aggregate(
+ Iterable> validators, {
+ String separator = '\n',
+}) {
+ final rules = List>.unmodifiable(validators);
+ return (value) {
+ final errors = [];
+ for (final validator in rules) {
+ final error = validator(value);
+ if (error != null) errors.add(error);
+ }
+ return errors.isEmpty ? null : errors.join(separator);
+ };
+}
+
+/// Succeeds when at least one of [validators] succeeds.
+FieldValidator any(
+ Iterable> validators, {
+ String errorMessage = 'Value does not match any allowed format',
+}) {
+ final rules = List>.unmodifiable(validators);
+ if (rules.isEmpty) {
+ throw ArgumentError.value(validators, 'validators', 'Must not be empty');
+ }
+
+ return (value) {
+ for (final validator in rules) {
+ if (validator(value) == null) return null;
+ }
+ return errorMessage;
+ };
+}
+
+/// Runs [validator] only when [predicate] returns `true`.
+FieldValidator conditional(
+ bool Function(T? value) predicate,
+ FieldValidator validator,
+) {
+ return (value) => predicate(value) ? validator(value) : null;
+}
+
+/// Alias for [conditional], useful when reading a validation rule as a sentence.
+FieldValidator when(
+ bool Function(T? value) predicate,
+ FieldValidator validator,
+) {
+ return conditional(predicate, validator);
+}
+
+/// Runs [validator] unless [predicate] returns `true`.
+FieldValidator unless(
+ bool Function(T? value) predicate,
+ FieldValidator validator,
+) {
+ return conditional((value) => !predicate(value), validator);
+}
+
+/// Skips [validator] when [predicate] returns `true`.
+FieldValidator skipWhen(
+ bool Function(T? value) predicate,
+ FieldValidator validator,
+) {
+ return unless(predicate, validator);
+}
+
+/// Transforms a value before passing it to [validator].
+FieldValidator transform(
+ R? Function(T? value) transformer,
+ FieldValidator validator,
+) {
+ return (value) => validator(transformer(value));
+}
+
+/// Fluent composition helpers for any [FieldValidator].
+extension FieldValidatorComposition on FieldValidator {
+ /// Runs this validator, then [other], returning the first error.
+ FieldValidator and(FieldValidator other) => compose([this, other]);
+
+ /// Succeeds when this validator or [other] succeeds.
+ FieldValidator or(
+ FieldValidator other, {
+ String errorMessage = 'Value does not match any allowed format',
+ }) {
+ return any([this, other], errorMessage: errorMessage);
+ }
+
+ /// Runs this validator only when [predicate] returns `true`.
+ FieldValidator when(bool Function(T? value) predicate) {
+ return conditional(predicate, this);
+ }
+
+ /// Runs this validator unless [predicate] returns `true`.
+ FieldValidator unless(bool Function(T? value) predicate) {
+ return conditional((value) => !predicate(value), this);
+ }
+
+ /// Skips this validator when [predicate] returns `true`.
+ FieldValidator skipWhen(bool Function(T? value) predicate) {
+ return unless(predicate);
+ }
+
+ /// Replaces any error from this validator with [errorMessage].
+ FieldValidator withMessage(String errorMessage) {
+ return (value) => this(value) == null ? null : errorMessage;
+ }
+}
diff --git a/lib/flutter_validators.dart b/lib/flutter_validators.dart
index eb74ecb..328afc9 100644
--- a/lib/flutter_validators.dart
+++ b/lib/flutter_validators.dart
@@ -1,5 +1,8 @@
library;
+export 'field_validator.dart';
+export 'validation_message.dart';
+
export 'validators/alpha.dart';
export 'validators/ascii.dart';
export 'validators/base32.dart';
@@ -10,9 +13,11 @@ export 'validators/byte_length.dart';
export 'validators/contains.dart';
export 'validators/credit_card.dart';
export 'validators/date.dart';
+export 'validators/data_rules.dart';
export 'validators/decimal.dart';
export 'validators/email.dart';
export 'validators/equals.dart';
+export 'validators/finance.dart';
export 'validators/float.dart';
export 'validators/fqdn.dart';
export 'validators/hex_color.dart';
@@ -20,6 +25,7 @@ export 'validators/hexadecimal.dart';
export 'validators/in.dart';
export 'validators/int.dart';
export 'validators/ip.dart';
+export 'validators/isbn.dart';
export 'validators/json.dart';
export 'validators/jwt.dart';
export 'validators/lat_long.dart';
@@ -30,12 +36,14 @@ export 'validators/matches.dart';
export 'validators/md5.dart';
export 'validators/mongo_id.dart';
export 'validators/numeric.dart';
+export 'validators/number_rules.dart';
export 'validators/octal.dart';
export 'validators/phone.dart';
export 'validators/port.dart';
export 'validators/semver.dart';
export 'validators/slug.dart';
export 'validators/strong_password.dart';
+export 'validators/text_rules.dart';
export 'validators/uppercase.dart';
export 'validators/url.dart';
export 'validators/uuid.dart';
diff --git a/lib/form_validator.dart b/lib/form_validator.dart
index 6e2f089..0f16e39 100644
--- a/lib/form_validator.dart
+++ b/lib/form_validator.dart
@@ -1,350 +1,915 @@
-import 'package:flutter_validators/flutter_validators.dart';
-import 'package:flutter_validators/validators/contains.dart' as contains_fn;
+import 'package:flutter_validators/field_validator.dart' as fields;
+import 'package:flutter_validators/flutter_validators.dart' as rules;
+import 'package:flutter_validators/validation_message.dart';
-/// A utility class for Flutter Form validation.
-/// Provides methods that return a validator function suitable for `TextFormField`.
+/// Factories and composition helpers for Flutter form validation.
///
-/// Example:
-/// ```dart
-/// TextFormField(
-/// validator: Validator.email(errorMessage: 'Please enter a valid email'),
-/// )
-/// ```
-class Validator {
- /// Ensures the field is not null or empty.
- static String? Function(String?) required({
- String errorMessage = 'This field is required',
+/// Every non-required string validator treats `null` and the empty string as
+/// valid so optional fields compose naturally with [required].
+abstract final class Validator {
+ /// The active dependency-free validation message resolver.
+ static ValidationMessageResolver? get messageResolver {
+ return ValidationMessages.resolver;
+ }
+
+ static set messageResolver(ValidationMessageResolver? value) {
+ ValidationMessages.resolver = value;
+ }
+
+ /// Restores the built-in English messages.
+ static void resetMessageResolver() => ValidationMessages.reset();
+
+ /// Runs validators in order and returns the first error.
+ static fields.FieldValidator compose(
+ Iterable> validators,
+ ) {
+ return fields.compose(validators);
+ }
+
+ /// Runs every validator and joins all errors.
+ static fields.FieldValidator aggregate(
+ Iterable> validators, {
+ String separator = '\n',
+ }) {
+ return fields.aggregate(validators, separator: separator);
+ }
+
+ /// Succeeds when at least one validator succeeds.
+ static fields.FieldValidator any(
+ Iterable> validators, {
+ String? errorMessage,
}) {
- return (String? value) =>
- value == null || value.trim().isEmpty ? errorMessage : null;
+ final rules = List>.unmodifiable(validators);
+ if (rules.isEmpty) {
+ throw ArgumentError.value(validators, 'validators', 'Must not be empty');
+ }
+ return (value) {
+ for (final validator in rules) {
+ if (validator(value) == null) return null;
+ }
+ return _resolve(
+ 'any',
+ 'Value does not match any allowed format',
+ errorMessage,
+ );
+ };
+ }
+
+ /// Runs [validator] when [predicate] is true.
+ static fields.FieldValidator conditional(
+ bool Function(T? value) predicate,
+ fields.FieldValidator validator,
+ ) {
+ return fields.conditional(predicate, validator);
+ }
+
+ /// Alias for [conditional].
+ static fields.FieldValidator when(
+ bool Function(T? value) predicate,
+ fields.FieldValidator validator,
+ ) {
+ return fields.when(predicate, validator);
+ }
+
+ /// Runs [validator] unless [predicate] is true.
+ static fields.FieldValidator unless(
+ bool Function(T? value) predicate,
+ fields.FieldValidator validator,
+ ) {
+ return fields.unless(predicate, validator);
+ }
+
+ /// Skips [validator] when [predicate] is true.
+ static fields.FieldValidator skipWhen(
+ bool Function(T? value) predicate,
+ fields.FieldValidator validator,
+ ) {
+ return fields.skipWhen(predicate, validator);
+ }
+
+ /// Transforms values before validating them.
+ static fields.FieldValidator transform(
+ R? Function(T? value) transformer,
+ fields.FieldValidator validator,
+ ) {
+ return fields.transform(transformer, validator);
+ }
+
+ /// Ensures the field is not null or blank.
+ static fields.FieldValidator required({String? errorMessage}) {
+ return (value) =>
+ value == null || value.trim().isEmpty
+ ? _resolve('required', 'This field is required', errorMessage)
+ : null;
}
/// Ensures the string is a valid email.
- static String? Function(String?) email({
- String errorMessage = 'Please enter a valid email address',
+ static fields.FieldValidator email({
+ bool allowUnicode = false,
+ bool requireTld = true,
+ bool allowIpDomain = true,
+ int maxLength = 254,
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isEmail);
+ return _build(
+ 'email',
+ 'Please enter a valid email address',
+ errorMessage,
+ (value) => rules.isEmail(
+ value,
+ allowUnicode: allowUnicode,
+ requireTld: requireTld,
+ allowIpDomain: allowIpDomain,
+ maxLength: maxLength,
+ ),
+ parameters: {'allowUnicode': allowUnicode, 'requireTld': requireTld},
+ );
}
/// Ensures the string is a valid URL.
- static String? Function(String?) url({
- String errorMessage = 'Please enter a valid URL',
+ static fields.FieldValidator url({
+ Iterable protocols = const ['http', 'https'],
+ bool requireTld = false,
+ bool allowLocalhost = true,
+ bool allowUnderscores = false,
+ int maxLength = 2083,
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isURL);
+ return _build(
+ 'url',
+ 'Please enter a valid URL',
+ errorMessage,
+ (value) => rules.isURL(
+ value,
+ protocols: protocols,
+ requireTld: requireTld,
+ allowLocalhost: allowLocalhost,
+ allowUnderscores: allowUnderscores,
+ maxLength: maxLength,
+ ),
+ );
}
/// Ensures the string is a valid IP address.
- static String? Function(String?) ip({
+ static fields.FieldValidator ip({
int? version,
- String errorMessage = 'Please enter a valid IP address',
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => isIP(v, version));
+ return _build(
+ 'ip',
+ 'Please enter a valid IP address',
+ errorMessage,
+ (value) => rules.isIP(value, version),
+ parameters: {'version': version},
+ );
+ }
+
+ /// Ensures the string is parseable using Dart's permissive date parser.
+ static fields.FieldValidator date({String? errorMessage}) {
+ return _build(
+ 'date',
+ 'Please enter a valid date',
+ errorMessage,
+ rules.isDate,
+ );
}
- /// Ensures the string is a valid date.
- static String? Function(String?) date({
- String errorMessage = 'Please enter a valid date',
+ /// Ensures the string is a real `YYYY-MM-DD` calendar date.
+ static fields.FieldValidator iso8601Date({String? errorMessage}) {
+ return _build(
+ 'iso8601Date',
+ 'Please enter a valid date in YYYY-MM-DD format',
+ errorMessage,
+ rules.isISO8601Date,
+ );
+ }
+
+ /// Ensures the string is a 24-hour time.
+ static fields.FieldValidator time({
+ bool allowSeconds = true,
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isDate);
+ return _build(
+ 'time',
+ 'Please enter a valid time',
+ errorMessage,
+ (value) => rules.isTime(value, allowSeconds: allowSeconds),
+ parameters: {'allowSeconds': allowSeconds},
+ );
}
- /// Ensures the string is a valid number.
- static String? Function(String?) numeric({
- String errorMessage = 'Please enter a valid number',
+ /// Ensures the date is before [reference].
+ static fields.FieldValidator before(
+ DateTime reference, {
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isNumeric);
+ return _build(
+ 'before',
+ 'Date must be before the allowed limit',
+ errorMessage,
+ (value) => rules.isBefore(value, reference),
+ parameters: {'reference': reference},
+ );
}
- /// Ensures the string is a valid integer.
- static String? Function(String?) integer({
- String errorMessage = 'Please enter a valid whole number',
+ /// Ensures the date is after [reference].
+ static fields.FieldValidator after(
+ DateTime reference, {
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isInt);
+ return _build(
+ 'after',
+ 'Date must be after the allowed limit',
+ errorMessage,
+ (value) => rules.isAfter(value, reference),
+ parameters: {'reference': reference},
+ );
}
- /// Ensures the string consists only of letters.
- static String? Function(String?) alpha({
- String errorMessage = 'Only letters are allowed',
+ /// Ensures the string is numeric.
+ static fields.FieldValidator numeric({String? errorMessage}) {
+ return _build(
+ 'numeric',
+ 'Please enter a valid number',
+ errorMessage,
+ rules.isNumeric,
+ );
+ }
+
+ /// Ensures the string is an integer.
+ static fields.FieldValidator integer({String? errorMessage}) {
+ return _build(
+ 'integer',
+ 'Please enter a valid whole number',
+ errorMessage,
+ rules.isInt,
+ );
+ }
+
+ /// Ensures the number is positive.
+ static fields.FieldValidator positive({String? errorMessage}) {
+ return _build(
+ 'positive',
+ 'Please enter a number greater than zero',
+ errorMessage,
+ rules.isPositive,
+ );
+ }
+
+ /// Ensures the number is negative.
+ static fields.FieldValidator negative({String? errorMessage}) {
+ return _build(
+ 'negative',
+ 'Please enter a number less than zero',
+ errorMessage,
+ rules.isNegative,
+ );
+ }
+
+ /// Ensures the number is in range.
+ static fields.FieldValidator range(
+ num min,
+ num max, {
+ bool inclusive = true,
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isAlpha);
+ return _build(
+ 'range',
+ 'Please enter a number between $min and $max',
+ errorMessage,
+ (value) => rules.isInRange(value, min, max, inclusive: inclusive),
+ parameters: {'min': min, 'max': max, 'inclusive': inclusive},
+ );
}
- /// Ensures the string consists only of letters and numbers.
- static String? Function(String?) alphanumeric({
- String errorMessage = 'Only letters and numbers are allowed',
+ /// Ensures the number is divisible by [divisor].
+ static fields.FieldValidator divisibleBy(
+ num divisor, {
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isAlphanumeric);
+ return _build(
+ 'divisibleBy',
+ 'Please enter a number divisible by $divisor',
+ errorMessage,
+ (value) => rules.isDivisibleBy(value, divisor),
+ parameters: {'divisor': divisor},
+ );
+ }
+
+ /// Ensures the string consists only of letters.
+ static fields.FieldValidator alpha({String? errorMessage}) {
+ return _build(
+ 'alpha',
+ 'Only letters are allowed',
+ errorMessage,
+ rules.isAlpha,
+ );
+ }
+
+ /// Ensures the string consists only of letters and numbers.
+ static fields.FieldValidator alphanumeric({String? errorMessage}) {
+ return _build(
+ 'alphanumeric',
+ 'Only letters and numbers are allowed',
+ errorMessage,
+ rules.isAlphanumeric,
+ );
}
/// Ensures the string is a valid phone number.
- static String? Function(String?) phone({
- String errorMessage = 'Please enter a valid phone number',
+ static fields.FieldValidator phone({String? errorMessage}) {
+ return _build(
+ 'phone',
+ 'Please enter a valid phone number',
+ errorMessage,
+ rules.isPhone,
+ );
+ }
+
+ /// Ensures the string passes credit-card checks.
+ static fields.FieldValidator creditCard({
+ bool strict = false,
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isPhone);
+ return _build(
+ 'creditCard',
+ 'Please enter a valid credit card number',
+ errorMessage,
+ (value) => rules.isCreditCard(value, strict: strict),
+ parameters: {'strict': strict},
+ );
}
- /// Ensures the string is a valid credit card.
- static String? Function(String?) creditCard({
- String errorMessage = 'Please enter a valid credit card number',
+ /// Ensures the string is a valid CVC.
+ static fields.FieldValidator cardCVC({
+ int minLength = 3,
+ int maxLength = 4,
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isCreditCard);
+ return _build(
+ 'cardCVC',
+ 'Please enter a valid card security code',
+ errorMessage,
+ (value) => rules.isCreditCardCVC(
+ value,
+ minLength: minLength,
+ maxLength: maxLength,
+ ),
+ parameters: {'minLength': minLength, 'maxLength': maxLength},
+ );
}
- /// Ensures the string is valid JSON.
- static String? Function(String?) json({
- String errorMessage = 'Please enter valid JSON',
+ /// Ensures the string is a valid card expiry.
+ static fields.FieldValidator cardExpiration({
+ bool requireFuture = true,
+ DateTime? referenceDate,
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isJson);
+ return _build(
+ 'cardExpiration',
+ 'Please enter a valid card expiration date',
+ errorMessage,
+ (value) => rules.isCreditCardExpirationDate(
+ value,
+ requireFuture: requireFuture,
+ referenceDate: referenceDate,
+ ),
+ );
+ }
+
+ /// Ensures the string is a valid IBAN.
+ static fields.FieldValidator iban({String? errorMessage}) {
+ return _build(
+ 'iban',
+ 'Please enter a valid IBAN',
+ errorMessage,
+ rules.isIBAN,
+ );
+ }
+
+ /// Ensures the string is a valid BIC.
+ static fields.FieldValidator bic({String? errorMessage}) {
+ return _build('bic', 'Please enter a valid BIC', errorMessage, rules.isBIC);
+ }
+
+ /// Ensures the string is valid JSON.
+ static fields.FieldValidator json({String? errorMessage}) {
+ return _build(
+ 'json',
+ 'Please enter valid JSON',
+ errorMessage,
+ rules.isJson,
+ );
}
/// Ensures the string is a valid UUID.
- static String? Function(String?) uuid({
- String errorMessage = 'Please enter a valid UUID',
+ static fields.FieldValidator uuid({
+ int? version,
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isUUID);
+ return _build(
+ 'uuid',
+ 'Please enter a valid UUID',
+ errorMessage,
+ (value) => rules.isUUID(value, version),
+ parameters: {'version': version},
+ );
}
- /// Ensures the string is a valid hex color.
- static String? Function(String?) hexColor({
- String errorMessage = 'Please enter a valid hex color (e.g. #ff0000)',
+ /// Ensures the string is a valid ISBN.
+ static fields.FieldValidator isbn({
+ int? version,
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isHexColor);
+ return _build(
+ 'isbn',
+ 'Please enter a valid ISBN',
+ errorMessage,
+ (value) => rules.isISBN(value, version: version),
+ parameters: {'version': version},
+ );
}
- /// Ensures the string only contains ASCII characters.
- static String? Function(String?) ascii({
- String errorMessage = 'Only ASCII characters are allowed',
- }) {
- return _build(errorMessage, (v) => v.isAscii);
+ /// Ensures the string is a valid hex color.
+ static fields.FieldValidator hexColor({String? errorMessage}) {
+ return _build(
+ 'hexColor',
+ 'Please enter a valid hex color (e.g. #ff0000)',
+ errorMessage,
+ rules.isHexColor,
+ );
}
- /// Ensures the string is a valid Base32 encoded string.
- static String? Function(String?) base32({
- String errorMessage = 'Please enter a valid Base32 encoded string',
- }) {
- return _build(errorMessage, (v) => v.isBase32);
+ /// Ensures the string only contains ASCII.
+ static fields.FieldValidator ascii({String? errorMessage}) {
+ return _build(
+ 'ascii',
+ 'Only ASCII characters are allowed',
+ errorMessage,
+ rules.isAscii,
+ );
}
- /// Ensures the string is a valid Base58 encoded string.
- static String? Function(String?) base58({
- String errorMessage = 'Please enter a valid Base58 encoded string',
+ /// Ensures the string uses Base32 encoding.
+ static fields.FieldValidator base32({
+ bool strict = false,
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isBase58);
+ return _build(
+ 'base32',
+ 'Please enter a valid Base32 encoded string',
+ errorMessage,
+ (value) => rules.isBase32(value, strict: strict),
+ parameters: {'strict': strict},
+ );
}
- /// Ensures the string represents a boolean value.
- static String? Function(String?) boolean({
- String errorMessage = 'Please enter true or false',
- }) {
- return _build(errorMessage, (v) => v.isBoolean);
+ /// Ensures the string uses Base58 encoding.
+ static fields.FieldValidator base58({String? errorMessage}) {
+ return _build(
+ 'base58',
+ 'Please enter a valid Base58 encoded string',
+ errorMessage,
+ rules.isBase58,
+ );
}
- /// Ensures the string matches the comparison string exactly.
- static String? Function(String?) equals(
+ /// Ensures the string represents a boolean.
+ static fields.FieldValidator boolean({String? errorMessage}) {
+ return _build(
+ 'boolean',
+ 'Please enter true or false',
+ errorMessage,
+ rules.isBoolean,
+ );
+ }
+
+ /// Ensures an exact match.
+ static fields.FieldValidator equals(
String comparison, {
- String errorMessage = 'Values do not match',
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.equals(comparison));
+ return _build(
+ 'equals',
+ 'Values do not match',
+ errorMessage,
+ (value) => rules.equals(value, comparison),
+ parameters: {'comparison': comparison},
+ );
}
- /// Ensures the string length falls within the specified range.
- static String? Function(String?) length(
+ /// Ensures the character length is in range.
+ static fields.FieldValidator length(
int min, {
int? max,
- String errorMessage = 'Length is out of range',
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isLength(min, max));
+ return _build(
+ 'length',
+ 'Length is out of range',
+ errorMessage,
+ (value) => rules.isLength(value, min, max),
+ parameters: {'min': min, 'max': max},
+ );
}
- /// Ensures the string is entirely lowercase.
- static String? Function(String?) lowercase({
- String errorMessage = 'Must be lowercase',
+ /// Ensures the string starts with [prefix].
+ static fields.FieldValidator startsWith(
+ String prefix, {
+ bool ignoreCase = false,
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isLowercase);
+ return _build(
+ 'startsWith',
+ 'Value must start with $prefix',
+ errorMessage,
+ (value) => rules.startsWith(value, prefix, ignoreCase: ignoreCase),
+ parameters: {'prefix': prefix, 'ignoreCase': ignoreCase},
+ );
}
- /// Ensures the string is entirely uppercase.
- static String? Function(String?) uppercase({
- String errorMessage = 'Must be uppercase',
+ /// Ensures the string ends with [suffix].
+ static fields.FieldValidator endsWith(
+ String suffix, {
+ bool ignoreCase = false,
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isUppercase);
+ return _build(
+ 'endsWith',
+ 'Value must end with $suffix',
+ errorMessage,
+ (value) => rules.endsWith(value, suffix, ignoreCase: ignoreCase),
+ parameters: {'suffix': suffix, 'ignoreCase': ignoreCase},
+ );
}
- /// Ensures the string is a hexadecimal number.
- static String? Function(String?) hexadecimal({
- String errorMessage = 'Please enter a valid hexadecimal number',
- }) {
- return _build(errorMessage, (v) => v.isHexadecimal);
+ /// Ensures the string contains no line breaks.
+ static fields.FieldValidator singleLine({String? errorMessage}) {
+ return _build(
+ 'singleLine',
+ 'Please enter a single line',
+ errorMessage,
+ rules.isSingleLine,
+ );
}
- /// Ensures the string is an octal number.
- static String? Function(String?) octal({
- String errorMessage = 'Please enter a valid octal number',
+ /// Ensures the word count is in range.
+ static fields.FieldValidator wordCount({
+ int min = 0,
+ int? max,
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isOctal);
+ return _build(
+ 'wordCount',
+ 'Word count is out of range',
+ errorMessage,
+ (value) => rules.hasWordCount(value, min: min, max: max),
+ parameters: {'min': min, 'max': max},
+ );
}
- /// Ensures the string is a valid MongoDB ObjectId.
- static String? Function(String?) mongoId({
- String errorMessage = 'Please enter a valid MongoDB ObjectId',
- }) {
- return _build(errorMessage, (v) => v.isMongoId);
+ /// Ensures the string is lowercase.
+ static fields.FieldValidator lowercase({String? errorMessage}) {
+ return _build(
+ 'lowercase',
+ 'Must be lowercase',
+ errorMessage,
+ rules.isLowercase,
+ );
}
- /// Ensures the string is a valid MD5 hash.
- static String? Function(String?) md5({
- String errorMessage = 'Please enter a valid MD5 hash',
- }) {
- return _build(errorMessage, (v) => v.isMD5);
+ /// Ensures the string is uppercase.
+ static fields.FieldValidator uppercase({String? errorMessage}) {
+ return _build(
+ 'uppercase',
+ 'Must be uppercase',
+ errorMessage,
+ rules.isUppercase,
+ );
}
- /// Ensures the string is a valid port number.
- static String? Function(String?) port({
- String errorMessage = 'Please enter a valid port number',
- }) {
- return _build(errorMessage, (v) => v.isPort);
+ /// Ensures the string is hexadecimal.
+ static fields.FieldValidator hexadecimal({String? errorMessage}) {
+ return _build(
+ 'hexadecimal',
+ 'Please enter a valid hexadecimal number',
+ errorMessage,
+ rules.isHexadecimal,
+ );
}
- /// Ensures the string is a valid Semantic Version.
- static String? Function(String?) semVer({
- String errorMessage = 'Please enter a valid semantic version',
- }) {
- return _build(errorMessage, (v) => v.isSemVer);
+ /// Ensures the string is octal.
+ static fields.FieldValidator octal({String? errorMessage}) {
+ return _build(
+ 'octal',
+ 'Please enter a valid octal number',
+ errorMessage,
+ rules.isOctal,
+ );
}
- /// Ensures the string is a valid URL slug.
- static String? Function(String?) slug({
- String errorMessage = 'Please enter a valid slug',
- }) {
- return _build(errorMessage, (v) => v.isSlug);
+ /// Ensures the string is a MongoDB ObjectId.
+ static fields.FieldValidator mongoId({String? errorMessage}) {
+ return _build(
+ 'mongoId',
+ 'Please enter a valid MongoDB ObjectId',
+ errorMessage,
+ rules.isMongoId,
+ );
}
- /// Ensures the string is a valid MAC address.
- static String? Function(String?) macAddress({
- String errorMessage = 'Please enter a valid MAC address',
- }) {
- return _build(errorMessage, (v) => v.isMACAddress);
+ /// Ensures the string is an MD5 hash.
+ static fields.FieldValidator md5({String? errorMessage}) {
+ return _build(
+ 'md5',
+ 'Please enter a valid MD5 hash',
+ errorMessage,
+ rules.isMD5,
+ );
}
- /// Ensures the string is a valid `latitude,longitude` pair.
- static String? Function(String?) latLong({
- String errorMessage = 'Please enter valid coordinates',
+ /// Ensures the string is a digest for [algorithm].
+ static fields.FieldValidator hash(
+ rules.HashAlgorithm algorithm, {
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isLatLong);
+ return _build(
+ 'hash',
+ 'Please enter a valid ${algorithm.name.toUpperCase()} hash',
+ errorMessage,
+ (value) => rules.isHash(value, algorithm),
+ parameters: {'algorithm': algorithm.name},
+ );
+ }
+
+ /// Ensures the string is a MIME type.
+ static fields.FieldValidator mimeType({String? errorMessage}) {
+ return _build(
+ 'mimeType',
+ 'Please enter a valid MIME type',
+ errorMessage,
+ rules.isMimeType,
+ );
+ }
+
+ /// Ensures the string is a data URI.
+ static fields.FieldValidator dataURI({String? errorMessage}) {
+ return _build(
+ 'dataURI',
+ 'Please enter a valid data URI',
+ errorMessage,
+ rules.isDataURI,
+ );
}
- /// Ensures the string is a valid JSON Web Token.
- static String? Function(String?) jwt({
- String errorMessage = 'Please enter a valid JWT',
+ /// Ensures the string is a port.
+ static fields.FieldValidator port({String? errorMessage}) {
+ return _build(
+ 'port',
+ 'Please enter a valid port number',
+ errorMessage,
+ rules.isPort,
+ );
+ }
+
+ /// Ensures the string is a semantic version.
+ static fields.FieldValidator semVer({String? errorMessage}) {
+ return _build(
+ 'semVer',
+ 'Please enter a valid semantic version',
+ errorMessage,
+ rules.isSemVer,
+ );
+ }
+
+ /// Ensures the string is a slug.
+ static fields.FieldValidator slug({String? errorMessage}) {
+ return _build(
+ 'slug',
+ 'Please enter a valid slug',
+ errorMessage,
+ rules.isSlug,
+ );
+ }
+
+ /// Ensures the string is a MAC address.
+ static fields.FieldValidator macAddress({String? errorMessage}) {
+ return _build(
+ 'macAddress',
+ 'Please enter a valid MAC address',
+ errorMessage,
+ rules.isMACAddress,
+ );
+ }
+
+ /// Ensures the string is a latitude/longitude pair.
+ static fields.FieldValidator latLong({String? errorMessage}) {
+ return _build(
+ 'latLong',
+ 'Please enter valid coordinates',
+ errorMessage,
+ rules.isLatLong,
+ );
+ }
+
+ /// Ensures the string has JWT structure.
+ static fields.FieldValidator jwt({
+ bool strict = false,
+ bool allowUnsigned = true,
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isJWT);
+ return _build(
+ 'jwt',
+ 'Please enter a valid JWT',
+ errorMessage,
+ (value) =>
+ rules.isJWT(value, strict: strict, allowUnsigned: allowUnsigned),
+ parameters: {'strict': strict, 'allowUnsigned': allowUnsigned},
+ );
}
/// Ensures the string is a fully qualified domain name.
- static String? Function(String?) fqdn({
- String errorMessage = 'Please enter a valid domain name',
+ static fields.FieldValidator fqdn({
+ bool requireTld = true,
+ bool allowTrailingDot = true,
+ bool allowUnderscores = false,
+ bool strict = false,
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isFQDN);
+ return _build(
+ 'fqdn',
+ 'Please enter a valid domain name',
+ errorMessage,
+ (value) => rules.isFQDN(
+ value,
+ requireTld: requireTld,
+ allowTrailingDot: allowTrailingDot,
+ allowUnderscores: allowUnderscores,
+ strict: strict,
+ ),
+ );
}
- /// Ensures the string is Base64 encoded.
- static String? Function(String?) base64({
+ /// Ensures the string is Base64.
+ static fields.FieldValidator base64({
bool urlSafe = false,
- String errorMessage = 'Please enter a valid Base64 encoded string',
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isBase64(urlSafe: urlSafe));
+ return _build(
+ 'base64',
+ 'Please enter a valid Base64 encoded string',
+ errorMessage,
+ (value) => rules.isBase64(value, urlSafe: urlSafe),
+ parameters: {'urlSafe': urlSafe},
+ );
}
- /// Ensures the string represents a decimal number.
- static String? Function(String?) decimal({
- String errorMessage = 'Please enter a valid decimal number',
- }) {
- return _build(errorMessage, (v) => v.isDecimal);
+ /// Ensures the string is decimal.
+ static fields.FieldValidator decimal({String? errorMessage}) {
+ return _build(
+ 'decimal',
+ 'Please enter a valid decimal number',
+ errorMessage,
+ rules.isDecimal,
+ );
}
- /// Ensures the string contains the [seed] substring.
- static String? Function(String?) contains(
+ /// Ensures the string contains [seed].
+ static fields.FieldValidator contains(
String seed, {
bool ignoreCase = false,
int minOccurrences = 1,
- String errorMessage = 'Required text is missing',
+ String? errorMessage,
}) {
return _build(
+ 'contains',
+ 'Required text is missing',
errorMessage,
- (v) => contains_fn.contains(
- v,
+ (value) => rules.contains(
+ value,
seed,
ignoreCase: ignoreCase,
minOccurrences: minOccurrences,
),
+ parameters: {
+ 'seed': seed,
+ 'ignoreCase': ignoreCase,
+ 'minOccurrences': minOccurrences,
+ },
);
}
- /// Ensures the string matches the given [pattern].
- static String? Function(String?) matches(
+ /// Ensures the string matches [pattern].
+ static fields.FieldValidator matches(
Pattern pattern, {
- String errorMessage = 'Invalid format',
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.matches(pattern));
+ return _build(
+ 'matches',
+ 'Invalid format',
+ errorMessage,
+ (value) => rules.matches(value, pattern),
+ );
}
- /// Ensures the string is one of the [allowed] values.
- static String? Function(String?) inList(
+ /// Ensures the string is one of [allowed].
+ static fields.FieldValidator inList(
Iterable allowed, {
- String errorMessage = 'Value is not allowed',
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => isIn(v, allowed));
+ return _build(
+ 'inList',
+ 'Value is not allowed',
+ errorMessage,
+ (value) => rules.isIn(value, allowed),
+ );
}
/// Ensures the string is a finite floating-point number.
- static String? Function(String?) float({
+ static fields.FieldValidator float({
double? min,
double? max,
- String errorMessage = 'Please enter a valid number',
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isFloat(min: min, max: max));
+ return _build(
+ 'float',
+ 'Please enter a valid number',
+ errorMessage,
+ (value) => rules.isFloat(value, min: min, max: max),
+ parameters: {'min': min, 'max': max},
+ );
}
- /// Ensures the string's UTF-8 byte length falls within a range.
- static String? Function(String?) byteLength(
+ /// Ensures UTF-8 byte length is in range.
+ static fields.FieldValidator byteLength(
int min, {
int? max,
- String errorMessage = 'Length is out of range',
+ String? errorMessage,
}) {
- return _build(errorMessage, (v) => v.isByteLength(min, max));
+ return _build(
+ 'byteLength',
+ 'Length is out of range',
+ errorMessage,
+ (value) => rules.isByteLength(value, min, max),
+ parameters: {'min': min, 'max': max},
+ );
}
- /// Ensures the string is a strong password.
- static String? Function(String?) strongPassword({
+ /// Ensures the password meets configurable strength rules.
+ static fields.FieldValidator strongPassword({
int minLength = 8,
int minLowercase = 1,
int minUppercase = 1,
int minNumbers = 1,
int minSymbols = 1,
- String errorMessage = 'Password is not strong enough',
+ String? errorMessage,
}) {
return _build(
+ 'strongPassword',
+ 'Password is not strong enough',
errorMessage,
- (v) => v.isStrongPassword(
+ (value) => rules.isStrongPassword(
+ value,
minLength: minLength,
minLowercase: minLowercase,
minUppercase: minUppercase,
minNumbers: minNumbers,
minSymbols: minSymbols,
),
+ parameters: {
+ 'minLength': minLength,
+ 'minLowercase': minLowercase,
+ 'minUppercase': minUppercase,
+ 'minNumbers': minNumbers,
+ 'minSymbols': minSymbols,
+ },
);
}
- /// Internal helper to construct the validator closure
- static String? Function(String?) _build(
- String errorMessage,
- bool Function(String) test,
- ) {
- return (String? value) {
- if (value == null || value.isEmpty) {
- return null; // Let 'required' handle empty states
- }
- return test(value) ? null : errorMessage;
+ static fields.FieldValidator _build(
+ String key,
+ String fallback,
+ String? errorMessage,
+ bool Function(String value) test, {
+ Map parameters = const {},
+ }) {
+ return (value) {
+ if (value == null || value.isEmpty) return null;
+ return test(value)
+ ? null
+ : _resolve(key, fallback, errorMessage, parameters: parameters);
};
}
+
+ static String _resolve(
+ String key,
+ String fallback,
+ String? errorMessage, {
+ Map parameters = const {},
+ }) {
+ return ValidationMessages.resolve(
+ ValidationMessage(key, fallback, parameters: parameters),
+ errorMessage: errorMessage,
+ );
+ }
}
diff --git a/lib/validation_message.dart b/lib/validation_message.dart
new file mode 100644
index 0000000..3a8d33a
--- /dev/null
+++ b/lib/validation_message.dart
@@ -0,0 +1,35 @@
+/// Describes a validation message without depending on a localization package.
+class ValidationMessage {
+ /// Creates a message with a stable [key], English [fallback], and parameters.
+ const ValidationMessage(
+ this.key,
+ this.fallback, {
+ this.parameters = const {},
+ });
+
+ /// Stable identifier applications can map to localized text.
+ final String key;
+
+ /// English text used when no resolver is configured.
+ final String fallback;
+
+ /// Values a resolver can interpolate into localized text.
+ final Map parameters;
+}
+
+/// Resolves a validation message into text for the application's locale.
+typedef ValidationMessageResolver = String Function(ValidationMessage message);
+
+/// Global, dependency-free configuration for validation messages.
+abstract final class ValidationMessages {
+ /// The active resolver, or `null` to use bundled English fallbacks.
+ static ValidationMessageResolver? resolver;
+
+ /// Resolves [message], with an explicit [errorMessage] taking precedence.
+ static String resolve(ValidationMessage message, {String? errorMessage}) {
+ return errorMessage ?? resolver?.call(message) ?? message.fallback;
+ }
+
+ /// Restores fallback English messages.
+ static void reset() => resolver = null;
+}
diff --git a/lib/validators/base32.dart b/lib/validators/base32.dart
index 3dc32a9..47e79d7 100644
--- a/lib/validators/base32.dart
+++ b/lib/validators/base32.dart
@@ -1,52 +1,43 @@
-/// Checks if the string is base32 encoded.
+/// Checks whether [str] uses the RFC 4648 Base32 alphabet.
///
-/// Base32 encoding uses a 32-character set (A-Z and 2-7) to represent binary data.
-/// Valid base32 strings may include padding with '=' characters.
-///
-/// Returns `true` if the string is valid base32 encoded, otherwise returns `false`.
-///
-/// Example:
-/// ```dart
-/// isBase32('JBSWY3DPEHPK3PXP'); // true
-/// isBase32('hello world'); // false
-/// ```
-bool isBase32(String str) => _isBase32(str);
-
-/// Extension providing base32 validation methods on [String].
-extension Base32X on String {
- /// Checks if the string is base32 encoded.
- ///
- /// Base32 encoding uses a 32-character set (A-Z and 2-7) to represent binary data.
- /// Valid base32 strings may include padding with '=' characters.
- ///
- /// Returns `true` if the string is valid base32 encoded, otherwise returns `false`.
- ///
- /// Example:
- /// ```dart
- /// 'JBSWY3DPEHPK3PXP'.isBase32; // true
- /// 'hello world'.isBase32; // false
- /// ```
- bool get isBase32 {
- return _isBase32(this);
- }
+/// Set [strict] to enforce valid encoded lengths and exact padding.
+bool isBase32(String str, {bool strict = false}) {
+ return _isBase32(str, strict: strict);
}
-/// Internal implementation for base32 validation.
-///
-/// 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.
-bool _isBase32(String str) {
- if (str.isEmpty) return false;
- if (!RegExp(r'^[A-Z2-7]+={0,6}$').hasMatch(str)) return false;
+bool _isBase32(String str, {required bool strict}) {
+ if (str.isEmpty || !RegExp(r'^[A-Z2-7]+={0,6}$').hasMatch(str)) return false;
- final paddingIdx = str.indexOf('=');
- if (paddingIdx != -1) {
- if (str.length % 8 != 0) return false;
- final padCount = str.length - paddingIdx;
- if (padCount != 1 && padCount != 3 && padCount != 4 && padCount != 6) {
+ final paddingIndex = str.indexOf('=');
+ final dataLength = paddingIndex == -1 ? str.length : paddingIndex;
+ final padding = str.length - dataLength;
+ if (padding > 0) {
+ if (str.length % 8 != 0 || !const {1, 3, 4, 6}.contains(padding)) {
return false;
}
}
- return true;
+ if (!strict) return true;
+
+ final remainder = dataLength % 8;
+ if (!const {0, 2, 4, 5, 7}.contains(remainder)) return false;
+ final expectedPadding = switch (remainder) {
+ 0 => 0,
+ 2 => 6,
+ 4 => 4,
+ 5 => 3,
+ 7 => 1,
+ _ => -1,
+ };
+ return padding == 0 || padding == expectedPadding;
+}
+
+/// Base32 validation helpers on [String].
+extension Base32X on String {
+ /// Uses the compatibility Base32 rules.
+ bool get isBase32 => _isBase32(this, strict: false);
+
+ /// Validates Base32 with optional strict length and padding rules.
+ bool isBase32With({bool strict = false}) {
+ return _isBase32(this, strict: strict);
+ }
}
diff --git a/lib/validators/credit_card.dart b/lib/validators/credit_card.dart
index bd522f6..f2619be 100644
--- a/lib/validators/credit_card.dart
+++ b/lib/validators/credit_card.dart
@@ -1,40 +1,58 @@
-/// Checks if the string is a valid credit card number.
+/// Checks whether [str] passes the Luhn credit-card checksum.
///
-/// Strips hyphens and spaces before validating using the Luhn algorithm.
-///
-/// Example:
-/// ```dart
-/// isCreditCard('4111111111111111'); // true
-/// isCreditCard('1234567890123456'); // false
-/// ```
-bool isCreditCard(String str) => _isCreditCard(str);
-
-/// Extension providing credit card validation methods on [String].
-extension CreditCardX on String {
- /// Checks if the string is a valid credit card number.
- bool get isCreditCard {
- return _isCreditCard(this);
- }
+/// Set [strict] to also reject repeated digits and require a known card-network
+/// prefix and length. This never proves that a card exists or can be charged.
+bool isCreditCard(String str, {bool strict = false}) {
+ return _isCreditCard(str, strict: strict);
}
-bool _isCreditCard(String str) {
- final sanitized = str.replaceAll(RegExp(r'[\s\-]'), '');
- if (!RegExp(r'^\d{13,19}$').hasMatch(sanitized)) return false;
-
- int sum = 0;
- bool alternate = false;
+bool _isCreditCard(String str, {required bool strict}) {
+ final value = str.replaceAll(RegExp(r'[\s-]'), '');
+ if (!RegExp(r'^\d{13,19}$').hasMatch(value)) return false;
+ if (strict) {
+ if (RegExp(r'^(\d)\1+$').hasMatch(value) || !_hasKnownCardPattern(value)) {
+ return false;
+ }
+ }
- for (int i = sanitized.length - 1; i >= 0; i--) {
- int n = int.parse(sanitized[i]);
+ var sum = 0;
+ var alternate = false;
+ for (var i = value.length - 1; i >= 0; i--) {
+ var digit = int.parse(value[i]);
if (alternate) {
- n *= 2;
- if (n > 9) {
- n = (n % 10) + 1;
- }
+ digit *= 2;
+ if (digit > 9) digit -= 9;
}
- sum += n;
+ sum += digit;
alternate = !alternate;
}
-
return sum % 10 == 0;
}
+
+bool _hasKnownCardPattern(String value) {
+ final visa = RegExp(r'^4(?:\d{12}|\d{15}|\d{18})$');
+ final mastercard = RegExp(
+ r'^(?:5[1-5]\d{14}|2(?:2(?:2[1-9]|[3-9]\d)|[3-6]\d{2}|7(?:[01]\d|20))\d{12})$',
+ );
+ final amex = RegExp(r'^3[47]\d{13}$');
+ final discover = RegExp(r'^6(?:011|5\d{2})\d{12}$');
+ final jcb = RegExp(r'^(?:2131|1800)\d{11}$|^35\d{14}$');
+ return [
+ visa,
+ mastercard,
+ amex,
+ discover,
+ jcb,
+ ].any((pattern) => pattern.hasMatch(value));
+}
+
+/// Credit-card validation helpers on [String].
+extension CreditCardX on String {
+ /// Uses the compatibility Luhn-only rules.
+ bool get isCreditCard => _isCreditCard(this, strict: false);
+
+ /// Validates with optional strict network and repeated-digit checks.
+ bool isCreditCardWith({bool strict = false}) {
+ return _isCreditCard(this, strict: strict);
+ }
+}
diff --git a/lib/validators/data_rules.dart b/lib/validators/data_rules.dart
new file mode 100644
index 0000000..ab6a1a7
--- /dev/null
+++ b/lib/validators/data_rules.dart
@@ -0,0 +1,73 @@
+import 'dart:convert';
+
+/// Supported fixed-length hexadecimal hash formats.
+enum HashAlgorithm {
+ md5(32),
+ sha1(40),
+ sha256(64),
+ sha384(96),
+ sha512(128);
+
+ const HashAlgorithm(this.hexLength);
+
+ /// Number of hexadecimal characters in the encoded digest.
+ final int hexLength;
+}
+
+/// Checks whether [str] is a hexadecimal digest for [algorithm].
+bool isHash(String str, HashAlgorithm algorithm) {
+ return RegExp('^[a-fA-F0-9]{${algorithm.hexLength}}\$').hasMatch(str);
+}
+
+/// Checks an Internet media type such as `application/json`.
+bool isMimeType(String str) {
+ const token = r"[A-Za-z0-9!#$&^_.+-]+";
+ return RegExp('^$token/$token\$').hasMatch(str);
+}
+
+/// Checks an RFC 2397-style data URI.
+bool isDataURI(String str) {
+ if (!str.startsWith('data:')) return false;
+ final comma = str.indexOf(',');
+ if (comma < 5) return false;
+ final metadata = str.substring(5, comma);
+ final payload = str.substring(comma + 1);
+ final parts = metadata.split(';');
+ if (parts.first.isNotEmpty && !isMimeType(parts.first)) return false;
+
+ final isBase64 = parts.skip(1).contains('base64');
+ for (final parameter in parts.skip(1)) {
+ if (parameter == 'base64') continue;
+ if (!RegExp(r'^[A-Za-z0-9!#$&^_.+-]+=[^;\s]+$').hasMatch(parameter)) {
+ return false;
+ }
+ }
+
+ try {
+ if (isBase64) {
+ base64.decode(payload);
+ } else {
+ Uri.decodeComponent(payload);
+ }
+ return true;
+ } on FormatException {
+ return false;
+ }
+}
+
+/// Data and security validation helpers on [String].
+extension DataRulesX on String {
+ /// Whether this is a fixed-length hexadecimal digest.
+ bool isHashFor(HashAlgorithm algorithm) => isHash(this, algorithm);
+
+ /// Whether this is an Internet media type.
+ bool get isMimeType => _DataValidation.mimeType(this);
+
+ /// Whether this is a valid data URI.
+ bool get isDataURI => _DataValidation.dataUri(this);
+}
+
+abstract final class _DataValidation {
+ static bool mimeType(String value) => isMimeType(value);
+ static bool dataUri(String value) => isDataURI(value);
+}
diff --git a/lib/validators/date.dart b/lib/validators/date.dart
index 6f93968..5abd1a7 100644
--- a/lib/validators/date.dart
+++ b/lib/validators/date.dart
@@ -1,22 +1,63 @@
-/// Checks if the string represents a valid date.
+/// Checks whether [str] can be parsed by [DateTime.tryParse].
///
-/// Uses Dart's built-in `DateTime.tryParse` to validate the string formatting.
-///
-/// Example:
-/// ```dart
-/// isDate('2023-12-01'); // true
-/// isDate('invalid date'); // false
-/// ```
-bool isDate(String str) => _isDate(str);
-
-/// Extension providing date validation methods on [String].
+/// This intentionally preserves permissive 1.2 behavior, including rollover.
+bool isDate(String str) => DateTime.tryParse(str) != null;
+
+/// Checks a calendar date in exactly `YYYY-MM-DD` format.
+bool isISO8601Date(String str) => _isISO8601Date(str);
+
+bool _isISO8601Date(String str) {
+ final match = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').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)!);
+ if (month < 1 || month > 12 || day < 1) return false;
+ final parsed = DateTime.utc(year, month, day);
+ return parsed.year == year && parsed.month == month && parsed.day == day;
+}
+
+/// Checks a 24-hour time in `HH:mm` or `HH:mm:ss` format.
+bool isTime(String str, {bool allowSeconds = true}) {
+ return _isTime(str, allowSeconds: allowSeconds);
+}
+
+bool _isTime(String str, {required bool allowSeconds}) {
+ final pattern =
+ allowSeconds
+ ? RegExp(r'^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$')
+ : RegExp(r'^(?:[01]\d|2[0-3]):[0-5]\d$');
+ return pattern.hasMatch(str);
+}
+
+/// Checks whether [str] parses to a date before [reference].
+bool isBefore(String str, DateTime reference) {
+ final value = DateTime.tryParse(str);
+ return value != null && value.isBefore(reference);
+}
+
+/// Checks whether [str] parses to a date after [reference].
+bool isAfter(String str, DateTime reference) {
+ final value = DateTime.tryParse(str);
+ return value != null && value.isAfter(reference);
+}
+
+/// Date and time validation helpers on [String].
extension DateX on String {
- /// Checks if the string represents a valid date.
- bool get isDate {
- return _isDate(this);
+ /// Uses Dart's permissive date parser.
+ bool get isDate => DateTime.tryParse(this) != null;
+
+ /// Requires a real `YYYY-MM-DD` calendar date.
+ bool get isISO8601Date => _isISO8601Date(this);
+
+ /// Checks a 24-hour time.
+ bool isValidTime({bool allowSeconds = true}) {
+ return _isTime(this, allowSeconds: allowSeconds);
}
-}
-bool _isDate(String str) {
- return DateTime.tryParse(str) != null;
+ /// Checks whether this value parses before [reference].
+ bool isBeforeDate(DateTime reference) => isBefore(this, reference);
+
+ /// Checks whether this value parses after [reference].
+ bool isAfterDate(DateTime reference) => isAfter(this, reference);
}
diff --git a/lib/validators/email.dart b/lib/validators/email.dart
index 4683c05..ebc7ff9 100644
--- a/lib/validators/email.dart
+++ b/lib/validators/email.dart
@@ -1,47 +1,108 @@
-/// Checks if the string is a valid email address.
+/// Checks whether [str] is an email address.
///
-/// Uses a regular expression to verify if the string matches standard email format.
-/// Validates both local part (before @) and domain part (after @) of the email.
-///
-/// Returns `true` if the string is a valid email address, otherwise returns `false`.
-///
-/// Example:
-/// ```dart
-/// isEmail('user@example.com'); // true
-/// isEmail('user.name+tag@example.co.uk'); // true
-/// isEmail('invalid@email'); // false
-/// ```
-bool isEmail(String str) => _isEmail(str);
-
-/// Extension providing email validation methods on [String].
-extension EmailX on String {
- /// Checks if the string is a valid email address.
- ///
- /// Uses a regular expression to verify if the string matches standard email format.
- /// Validates both local part (before @) and domain part (after @) of the email.
- ///
- /// Returns `true` if the string is a valid email address, otherwise returns `false`.
- ///
- /// Example:
- /// ```dart
- /// 'user@example.com'.isEmail; // true
- /// 'user.name+tag@example.co.uk'.isEmail; // true
- /// 'invalid@email'.isEmail; // false
- /// ```
- bool get isEmail {
- return _isEmail(this);
+/// The defaults preserve the package's 1.2 behavior. Set [allowUnicode] to
+/// accept Unicode local parts and internationalized domain labels.
+bool isEmail(
+ String str, {
+ bool allowUnicode = false,
+ bool requireTld = true,
+ bool allowIpDomain = true,
+ int maxLength = 254,
+}) {
+ return _isEmail(
+ str,
+ allowUnicode: allowUnicode,
+ requireTld: requireTld,
+ allowIpDomain: allowIpDomain,
+ maxLength: maxLength,
+ );
+}
+
+bool _isEmail(
+ String str, {
+ required bool allowUnicode,
+ required bool requireTld,
+ required bool allowIpDomain,
+ required int maxLength,
+}) {
+ if (!allowUnicode && requireTld && allowIpDomain && maxLength == 254) {
+ 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);
+ }
+ if (str.isEmpty || str.length > maxLength) return false;
+ final at = str.lastIndexOf('@');
+ if (at <= 0 || at == str.length - 1) return false;
+
+ final local = str.substring(0, at);
+ final domain = str.substring(at + 1);
+ if (local.length > 64) return false;
+
+ if (allowIpDomain &&
+ RegExp(r'^\[(?:\d{1,3}\.){3}\d{1,3}\]$').hasMatch(domain)) {
+ final octets = domain.substring(1, domain.length - 1).split('.');
+ return octets.every((part) => int.parse(part) <= 255);
+ }
+
+ final localPattern =
+ allowUnicode
+ ? RegExp(
+ r'^[^\s<>()[\]\\,;:@"\u0000-\u001F\u007F]+(?:\.[^\s<>()[\]\\,;:@"\u0000-\u001F\u007F]+)*$',
+ unicode: true,
+ )
+ : RegExp(
+ r'^([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+")$',
+ );
+ if (!localPattern.hasMatch(local)) return false;
+
+ final labels = domain.split('.');
+ if (requireTld && labels.length < 2) return false;
+ if (labels.any((label) => label.isEmpty || label.length > 63)) return false;
+
+ final labelPattern =
+ allowUnicode
+ ? RegExp(
+ r'^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$',
+ unicode: true,
+ )
+ : RegExp(r'^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$');
+ if (!labels.every(labelPattern.hasMatch)) return false;
+
+ if (requireTld) {
+ final tld = labels.last;
+ final validTld =
+ allowUnicode
+ ? RegExp(r'^(?:[\p{L}]{2,}|xn--[a-zA-Z0-9-]+)$', unicode: true)
+ : RegExp(r'^(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9-]+)$');
+ if (!validTld.hasMatch(tld)) return false;
}
+ return true;
}
-/// Internal implementation for email validation.
-///
-/// Uses a comprehensive regular expression that validates:
-/// - Local part: can include alphanumerics, dots, and special characters
-/// - 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.
-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);
+/// Email validation helpers on [String].
+extension EmailX on String {
+ /// Uses the backward-compatible default email rules.
+ bool get isEmail => _isEmail(
+ this,
+ allowUnicode: false,
+ requireTld: true,
+ allowIpDomain: true,
+ maxLength: 254,
+ );
+
+ /// Validates with configurable email rules.
+ bool isEmailWith({
+ bool allowUnicode = false,
+ bool requireTld = true,
+ bool allowIpDomain = true,
+ int maxLength = 254,
+ }) {
+ return _isEmail(
+ this,
+ allowUnicode: allowUnicode,
+ requireTld: requireTld,
+ allowIpDomain: allowIpDomain,
+ maxLength: maxLength,
+ );
+ }
}
diff --git a/lib/validators/finance.dart b/lib/validators/finance.dart
new file mode 100644
index 0000000..dbdb188
--- /dev/null
+++ b/lib/validators/finance.dart
@@ -0,0 +1,81 @@
+/// Checks an International Bank Account Number checksum.
+bool isIBAN(String str) {
+ final value = str.replaceAll(RegExp(r'\s+'), '').toUpperCase();
+ if (!RegExp(r'^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$').hasMatch(value)) {
+ return false;
+ }
+
+ final rearranged = '${value.substring(4)}${value.substring(0, 4)}';
+ var remainder = 0;
+ for (final codeUnit in rearranged.codeUnits) {
+ final chunk =
+ codeUnit >= 65 ? '${codeUnit - 55}' : String.fromCharCode(codeUnit);
+ for (final digit in chunk.codeUnits) {
+ remainder = (remainder * 10 + digit - 48) % 97;
+ }
+ }
+ return remainder == 1;
+}
+
+/// Checks a SWIFT Business Identifier Code.
+bool isBIC(String str) {
+ return RegExp(
+ r'^[A-Z]{6}[A-Z0-9]{2}(?:[A-Z0-9]{3})?$',
+ ).hasMatch(str.toUpperCase());
+}
+
+/// Checks a card verification code without identifying a specific card.
+bool isCreditCardCVC(String str, {int minLength = 3, int maxLength = 4}) {
+ if (minLength < 1 || maxLength < minLength) {
+ throw ArgumentError('CVC length bounds are invalid');
+ }
+ return RegExp('^\\d{$minLength,$maxLength}\$').hasMatch(str);
+}
+
+/// Checks a card expiry in `MM/YY` or `MM/YYYY` format.
+///
+/// [referenceDate] makes boundary-sensitive tests and server workflows
+/// deterministic. A card remains valid through the end of its expiry month.
+bool isCreditCardExpirationDate(
+ String str, {
+ bool requireFuture = true,
+ DateTime? referenceDate,
+}) {
+ final match = RegExp(r'^(0[1-9]|1[0-2])\/(\d{2}|\d{4})$').firstMatch(str);
+ if (match == null) return false;
+ final month = int.parse(match.group(1)!);
+ var year = int.parse(match.group(2)!);
+ if (year < 100) year += 2000;
+ if (!requireFuture) return true;
+
+ final now = referenceDate ?? DateTime.now();
+ return year > now.year || (year == now.year && month >= now.month);
+}
+
+/// Finance validation helpers on [String].
+extension FinanceX on String {
+ /// Whether this is a checksum-valid IBAN.
+ bool get isIBAN => _FinanceValidation.iban(this);
+
+ /// Whether this is a valid 8- or 11-character BIC.
+ bool get isBIC => _FinanceValidation.bic(this);
+
+ /// Whether this is a numeric CVC with the configured length.
+ bool isCardCVC({int minLength = 3, int maxLength = 4}) {
+ return isCreditCardCVC(this, minLength: minLength, maxLength: maxLength);
+ }
+
+ /// Whether this is a valid card expiry.
+ bool isCardExpiration({bool requireFuture = true, DateTime? referenceDate}) {
+ return isCreditCardExpirationDate(
+ this,
+ requireFuture: requireFuture,
+ referenceDate: referenceDate,
+ );
+ }
+}
+
+abstract final class _FinanceValidation {
+ static bool iban(String value) => isIBAN(value);
+ static bool bic(String value) => isBIC(value);
+}
diff --git a/lib/validators/fqdn.dart b/lib/validators/fqdn.dart
index 67719d6..2af9568 100644
--- a/lib/validators/fqdn.dart
+++ b/lib/validators/fqdn.dart
@@ -1,43 +1,77 @@
-/// Checks if the string is a fully qualified domain name (FQDN).
-///
-/// Requires at least two labels separated by dots, a valid top-level domain,
-/// and labels that do not start or end with a hyphen.
-///
-/// Example:
-/// ```dart
-/// isFQDN('example.com'); // true
-/// isFQDN('sub.example.co.uk'); // true
-/// isFQDN('localhost'); // false (no TLD)
-/// isFQDN('-bad.com'); // false
-/// ```
-bool isFQDN(String str) => _isFQDN(str);
-
-/// Extension providing FQDN validation methods on [String].
-extension FQDNX on String {
- /// Checks if the string is a fully qualified domain name (FQDN).
- bool get isFQDN {
- return _isFQDN(this);
- }
+/// Checks whether [str] is a fully qualified domain name.
+bool isFQDN(
+ String str, {
+ bool requireTld = true,
+ bool allowTrailingDot = true,
+ bool allowUnderscores = false,
+ bool strict = false,
+}) {
+ return _isFQDN(
+ str,
+ requireTld: requireTld,
+ allowTrailingDot: allowTrailingDot,
+ allowUnderscores: allowUnderscores,
+ strict: strict,
+ );
}
-final _fqdnLabel = RegExp(r'^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$');
-final _fqdnTld = RegExp(r'^[a-zA-Z]{2,}$|^xn--[a-zA-Z0-9]+$');
-
-bool _isFQDN(String str) {
+bool _isFQDN(
+ String str, {
+ required bool requireTld,
+ required bool allowTrailingDot,
+ required bool allowUnderscores,
+ required bool strict,
+}) {
if (str.isEmpty) return false;
var domain = str;
if (domain.endsWith('.')) {
+ if (!allowTrailingDot) return false;
domain = domain.substring(0, domain.length - 1);
}
+ if (strict && domain.length > 253) return false;
+
final parts = domain.split('.');
- if (parts.length < 2) return false;
+ if (requireTld && parts.length < 2) return false;
+ if (parts.any((part) => part.isEmpty || part.length > 63)) return false;
- final tld = parts.removeLast();
- if (!_fqdnTld.hasMatch(tld)) return false;
+ if (requireTld) {
+ final tld = parts.last;
+ if (!RegExp(r'^[a-zA-Z]{2,}$|^xn--[a-zA-Z0-9-]+$').hasMatch(tld)) {
+ return false;
+ }
+ }
+
+ final label =
+ allowUnderscores
+ ? RegExp(r'^[a-zA-Z0-9_](?:[a-zA-Z0-9_-]*[a-zA-Z0-9_])?$')
+ : RegExp(r'^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$');
+ return parts.every(label.hasMatch);
+}
+
+/// FQDN validation helpers on [String].
+extension FQDNX on String {
+ /// Uses the backward-compatible FQDN rules.
+ bool get isFQDN => _isFQDN(
+ this,
+ requireTld: true,
+ allowTrailingDot: true,
+ allowUnderscores: false,
+ strict: false,
+ );
- for (final part in parts) {
- if (part.isEmpty || part.length > 63) return false;
- if (!_fqdnLabel.hasMatch(part)) return false;
+ /// Validates with configurable FQDN rules.
+ bool isFQDNWith({
+ bool requireTld = true,
+ bool allowTrailingDot = true,
+ bool allowUnderscores = false,
+ bool strict = false,
+ }) {
+ return _isFQDN(
+ this,
+ requireTld: requireTld,
+ allowTrailingDot: allowTrailingDot,
+ allowUnderscores: allowUnderscores,
+ strict: strict,
+ );
}
- return true;
}
diff --git a/lib/validators/isbn.dart b/lib/validators/isbn.dart
new file mode 100644
index 0000000..9928a9c
--- /dev/null
+++ b/lib/validators/isbn.dart
@@ -0,0 +1,46 @@
+/// Checks an ISBN-10 or ISBN-13 identifier.
+///
+/// Spaces and hyphens are ignored. Set [version] to `10` or `13` to require a
+/// specific format.
+bool isISBN(String str, {int? version}) {
+ return _isISBN(str, version: version);
+}
+
+bool _isISBN(String str, {int? version}) {
+ if (version != null && version != 10 && version != 13) {
+ throw ArgumentError.value(version, 'version', 'Must be 10 or 13');
+ }
+ final value = str.replaceAll(RegExp(r'[\s-]'), '').toUpperCase();
+ if ((version == null || version == 10) && _isISBN10(value)) return true;
+ if ((version == null || version == 13) && _isISBN13(value)) return true;
+ return false;
+}
+
+bool _isISBN10(String value) {
+ if (!RegExp(r'^\d{9}[\dX]$').hasMatch(value)) return false;
+ var sum = 0;
+ for (var i = 0; i < 10; i++) {
+ final digit = value[i] == 'X' ? 10 : int.parse(value[i]);
+ sum += digit * (10 - i);
+ }
+ return sum % 11 == 0;
+}
+
+bool _isISBN13(String value) {
+ if (!RegExp(r'^\d{13}$').hasMatch(value)) return false;
+ var sum = 0;
+ for (var i = 0; i < 12; i++) {
+ sum += int.parse(value[i]) * (i.isEven ? 1 : 3);
+ }
+ final check = (10 - sum % 10) % 10;
+ return check == int.parse(value[12]);
+}
+
+/// ISBN validation helpers on [String].
+extension ISBNX on String {
+ /// Whether this is a checksum-valid ISBN-10 or ISBN-13.
+ bool get isISBN => _isISBN(this);
+
+ /// Checks a specific ISBN version.
+ bool isISBNVersion([int? version]) => _isISBN(this, version: version);
+}
diff --git a/lib/validators/jwt.dart b/lib/validators/jwt.dart
index 66e50be..a33afe5 100644
--- a/lib/validators/jwt.dart
+++ b/lib/validators/jwt.dart
@@ -1,31 +1,49 @@
-/// Checks if the string is a valid JSON Web Token (JWT).
-///
-/// A JWT consists of three base64url-encoded segments separated by dots.
-/// The signature segment may be empty (for unsigned tokens).
+import 'dart:convert';
+
+final _base64Url = RegExp(r'^[A-Za-z0-9_-]+$');
+
+/// Checks whether [str] has JWT structure.
///
-/// Example:
-/// ```dart
-/// isJWT('eyJhbGci.eyJzdWIi.SflKxwRJ'); // true
-/// isJWT('eyJhbGci.eyJzdWIi'); // false (only two segments)
-/// ```
-bool isJWT(String str) => _isJWT(str);
+/// The compatibility default only checks the three Base64URL-like segments.
+/// With [strict], header and payload must decode to JSON objects.
+bool isJWT(String str, {bool strict = false, bool allowUnsigned = true}) {
+ return _isJWT(str, strict: strict, allowUnsigned: allowUnsigned);
+}
-/// Extension providing JWT validation methods on [String].
-extension JWTX on String {
- /// Checks if the string is a valid JSON Web Token (JWT).
- bool get isJWT {
- return _isJWT(this);
+bool _isJWT(String str, {required bool strict, required bool allowUnsigned}) {
+ final parts = str.split('.');
+ if (parts.length != 3 || parts[0].isEmpty || parts[1].isEmpty) return false;
+ if (!_base64Url.hasMatch(parts[0]) || !_base64Url.hasMatch(parts[1])) {
+ return false;
+ }
+ if (parts[2].isNotEmpty && !_base64Url.hasMatch(parts[2])) return false;
+ if (!allowUnsigned && parts[2].isEmpty) return false;
+ if (!strict) return true;
+
+ try {
+ final header = jsonDecode(
+ utf8.decode(base64Url.decode(base64Url.normalize(parts[0]))),
+ );
+ final payload = jsonDecode(
+ utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))),
+ );
+ if (header is! Map || payload is! Map) {
+ return false;
+ }
+ if (header['alg'] == 'none' && !allowUnsigned) return false;
+ return true;
+ } on FormatException {
+ return false;
}
}
-final _base64Url = RegExp(r'^[A-Za-z0-9_-]+$');
+/// JWT validation helpers on [String].
+extension JWTX on String {
+ /// Uses the compatibility structural check.
+ bool get isJWT => _isJWT(this, strict: false, allowUnsigned: true);
-bool _isJWT(String str) {
- final parts = str.split('.');
- if (parts.length != 3) return false;
- if (parts[0].isEmpty || parts[1].isEmpty) return false;
- for (var i = 0; i < 2; i++) {
- if (!_base64Url.hasMatch(parts[i])) return false;
+ /// Validates JWT structure with optional decoded JSON checks.
+ bool isJWTWith({bool strict = false, bool allowUnsigned = true}) {
+ return _isJWT(this, strict: strict, allowUnsigned: allowUnsigned);
}
- return parts[2].isEmpty || _base64Url.hasMatch(parts[2]);
}
diff --git a/lib/validators/number_rules.dart b/lib/validators/number_rules.dart
new file mode 100644
index 0000000..9bca955
--- /dev/null
+++ b/lib/validators/number_rules.dart
@@ -0,0 +1,51 @@
+/// Checks whether [str] is a number greater than zero.
+bool isPositive(String str) {
+ final value = num.tryParse(str);
+ return value != null && value.isFinite && value > 0;
+}
+
+/// Checks whether [str] is a number less than zero.
+bool isNegative(String str) {
+ final value = num.tryParse(str);
+ return value != null && value.isFinite && value < 0;
+}
+
+/// Checks whether [str] is between [min] and [max].
+bool isInRange(String str, num min, num max, {bool inclusive = true}) {
+ if (min > max) {
+ throw ArgumentError.value(min, 'min', 'Must be less than or equal to max');
+ }
+ final value = num.tryParse(str);
+ if (value == null || !value.isFinite) return false;
+ return inclusive ? value >= min && value <= max : value > min && value < max;
+}
+
+/// Checks whether [str] is evenly divisible by [divisor].
+bool isDivisibleBy(String str, num divisor) {
+ if (divisor == 0) return false;
+ final value = num.tryParse(str);
+ return value != null && value.isFinite && value % divisor == 0;
+}
+
+/// Additional numeric validation helpers on [String].
+extension NumberRulesX on String {
+ /// Whether this is a finite number greater than zero.
+ bool get isPositive {
+ final value = num.tryParse(this);
+ return value != null && value.isFinite && value > 0;
+ }
+
+ /// Whether this is a finite number less than zero.
+ bool get isNegative {
+ final value = num.tryParse(this);
+ return value != null && value.isFinite && value < 0;
+ }
+
+ /// Whether this is within the configured numeric range.
+ bool isInNumericRange(num min, num max, {bool inclusive = true}) {
+ return isInRange(this, min, max, inclusive: inclusive);
+ }
+
+ /// Whether this is evenly divisible by [divisor].
+ bool isDivisibleByNumber(num divisor) => isDivisibleBy(this, divisor);
+}
diff --git a/lib/validators/text_rules.dart b/lib/validators/text_rules.dart
new file mode 100644
index 0000000..4c8f80c
--- /dev/null
+++ b/lib/validators/text_rules.dart
@@ -0,0 +1,45 @@
+/// Checks whether [str] starts with [prefix].
+bool startsWith(String str, String prefix, {bool ignoreCase = false}) {
+ if (!ignoreCase) return str.startsWith(prefix);
+ return str.toLowerCase().startsWith(prefix.toLowerCase());
+}
+
+/// Checks whether [str] ends with [suffix].
+bool endsWith(String str, String suffix, {bool ignoreCase = false}) {
+ if (!ignoreCase) return str.endsWith(suffix);
+ return str.toLowerCase().endsWith(suffix.toLowerCase());
+}
+
+/// Checks whether [str] contains no line breaks.
+bool isSingleLine(String str) => !str.contains(RegExp(r'[\r\n]'));
+
+/// Checks whether the whitespace-delimited word count is in range.
+bool hasWordCount(String str, {int min = 0, int? max}) {
+ if (min < 0 || (max != null && max < min)) {
+ throw ArgumentError('Word-count bounds are invalid');
+ }
+ final trimmed = str.trim();
+ final count = trimmed.isEmpty ? 0 : trimmed.split(RegExp(r'\s+')).length;
+ return count >= min && (max == null || count <= max);
+}
+
+/// Text-rule helpers on [String].
+extension TextRulesX on String {
+ /// Checks a prefix with optional case folding.
+ bool startsWithText(String prefix, {bool ignoreCase = false}) {
+ return startsWith(this, prefix, ignoreCase: ignoreCase);
+ }
+
+ /// Checks a suffix with optional case folding.
+ bool endsWithText(String suffix, {bool ignoreCase = false}) {
+ return endsWith(this, suffix, ignoreCase: ignoreCase);
+ }
+
+ /// Whether this string contains no line breaks.
+ bool get isSingleLine => !contains(RegExp(r'[\r\n]'));
+
+ /// Whether this string's word count is in range.
+ bool hasWords({int min = 0, int? max}) {
+ return hasWordCount(this, min: min, max: max);
+ }
+}
diff --git a/lib/validators/url.dart b/lib/validators/url.dart
index 5ea0e1e..8ddaf57 100644
--- a/lib/validators/url.dart
+++ b/lib/validators/url.dart
@@ -1,26 +1,98 @@
-/// Checks if the string is a valid URL.
-///
-/// Returns `true` if the string can be parsed as a valid Uri, otherwise returns `false`.
-///
-/// Example:
-/// ```dart
-/// isURL('https://google.com'); // true
-/// isURL('invalid-url'); // false
-/// ```
-bool isURL(String str) => _isURL(str);
+import 'fqdn.dart';
-/// Extension providing URL validation methods on [String].
-extension UrlX on String {
- /// Checks if the string is a valid URL.
- bool get isURL {
- return _isURL(this);
- }
+/// Checks whether [str] is a URL accepted by the configured rules.
+bool isURL(
+ String str, {
+ Iterable protocols = const ['http', 'https'],
+ bool requireTld = false,
+ bool allowLocalhost = true,
+ bool allowUnderscores = false,
+ int maxLength = 2083,
+}) {
+ return _isURL(
+ str,
+ protocols: protocols,
+ requireTld: requireTld,
+ allowLocalhost: allowLocalhost,
+ allowUnderscores: allowUnderscores,
+ maxLength: maxLength,
+ );
}
-bool _isURL(String str) {
- if (str.isEmpty) return false;
+bool _isURL(
+ String str, {
+ required Iterable protocols,
+ required bool requireTld,
+ required bool allowLocalhost,
+ required bool allowUnderscores,
+ required int maxLength,
+}) {
+ if (protocols.length == 2 &&
+ protocols.contains('http') &&
+ protocols.contains('https') &&
+ !requireTld &&
+ allowLocalhost &&
+ !allowUnderscores &&
+ maxLength == 2083) {
+ if (str.isEmpty) return false;
+ final uri = Uri.tryParse(str);
+ return uri != null &&
+ uri.hasAuthority &&
+ (uri.scheme == 'http' || uri.scheme == 'https');
+ }
+ if (str.isEmpty || str.length > maxLength) return false;
final uri = Uri.tryParse(str);
- return uri != null &&
- uri.hasAuthority &&
- (uri.scheme == 'http' || uri.scheme == 'https');
+ if (uri == null || !uri.hasAuthority || !protocols.contains(uri.scheme)) {
+ return false;
+ }
+
+ final host = uri.host;
+ if (host.isEmpty) return false;
+ if (host == 'localhost') return allowLocalhost;
+ if (Uri.tryParse('http://$host')?.host.isEmpty ?? true) return false;
+ if (RegExp(r'^\d{1,3}(?:\.\d{1,3}){3}$').hasMatch(host)) {
+ return host.split('.').every((part) => int.parse(part) <= 255);
+ }
+ if (host.contains(':')) return true;
+ if (!requireTld && !host.contains('.')) {
+ return RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(host) &&
+ (allowUnderscores || !host.contains('_'));
+ }
+ return isFQDN(
+ host,
+ requireTld: requireTld,
+ allowUnderscores: allowUnderscores,
+ strict: requireTld,
+ );
+}
+
+/// URL validation helpers on [String].
+extension UrlX on String {
+ /// Uses the backward-compatible HTTP/HTTPS URL rules.
+ bool get isURL => _isURL(
+ this,
+ protocols: const ['http', 'https'],
+ requireTld: false,
+ allowLocalhost: true,
+ allowUnderscores: false,
+ maxLength: 2083,
+ );
+
+ /// Validates with configurable URL rules.
+ bool isURLWith({
+ Iterable protocols = const ['http', 'https'],
+ bool requireTld = false,
+ bool allowLocalhost = true,
+ bool allowUnderscores = false,
+ int maxLength = 2083,
+ }) {
+ return _isURL(
+ this,
+ protocols: protocols,
+ requireTld: requireTld,
+ allowLocalhost: allowLocalhost,
+ allowUnderscores: allowUnderscores,
+ maxLength: maxLength,
+ );
+ }
}
diff --git a/lib/validators/uuid.dart b/lib/validators/uuid.dart
index 8457d36..93902ed 100644
--- a/lib/validators/uuid.dart
+++ b/lib/validators/uuid.dart
@@ -1,24 +1,24 @@
-/// Checks if the string is a valid UUID (versions 1, 3, 4, or 5).
+/// Checks whether [str] is a UUID.
///
-/// Returns `true` if the string matches the UUID format, otherwise returns `false`.
-///
-/// Example:
-/// ```dart
-/// isUUID('123e4567-e89b-12d3-a456-426614174000'); // true
-/// isUUID('invalid-uuid'); // false
-/// ```
-bool isUUID(String str) => _isUUID(str);
-
-/// Extension providing UUID validation methods on [String].
-extension UuidX on String {
- /// Checks if the string is a valid UUID.
- bool get isUUID {
- return _isUUID(this);
- }
+/// With no [version], versions 1, 3, 4, and 5 are accepted for compatibility.
+bool isUUID(String str, [int? version]) {
+ return _isUUID(str, version);
}
-bool _isUUID(String str) {
+bool _isUUID(String str, [int? version]) {
+ final versionPattern = version == null ? '[1345]' : '$version';
+ if (version != null && (version < 1 || version > 5)) return false;
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}$',
+ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-$versionPattern[0-9a-fA-F]{3}-'
+ '[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}\$',
).hasMatch(str);
}
+
+/// UUID validation helpers on [String].
+extension UuidX on String {
+ /// Accepts UUID versions 1, 3, 4, and 5.
+ bool get isUUID => _isUUID(this);
+
+ /// Validates a specific UUID [version], or the compatibility set when null.
+ bool isUUIDVersion([int? version]) => _isUUID(this, version);
+}
diff --git a/pubspec.lock b/pubspec.lock
index 977425b..753c1e8 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -66,7 +66,7 @@ packages:
source: hosted
version: "3.0.1"
coverage:
- dependency: transitive
+ dependency: "direct dev"
description:
name: coverage
sha256: ad538fa2e8f6b828d54c04a438af816ce814de404690136d3b9dfb3a436cd01c
@@ -387,4 +387,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..0cddcaa 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,13 +1,20 @@
name: flutter_validators
-description: A pure dart package of String validators and sanitizers. Inspired by validator.js.
-version: 1.2.0
+description: Dependency-free validators, sanitizers, composition, messages, and Flutter form helpers for Dart and Flutter.
+version: 1.3.0
homepage: https://github.com/StacDev/flutter_validators
repository: https://github.com/StacDev/flutter_validators
+issue_tracker: https://github.com/StacDev/flutter_validators/issues
+topics:
+ - validation
+ - forms
+ - sanitization
+ - flutter
+ - dart
environment:
sdk: ^3.7.0
- flutter: ">=1.17.0"
dev_dependencies:
+ coverage: ^1.0.3
test: ^1.25.15
- flutter_lints: ^5.0.0
\ No newline at end of file
+ flutter_lints: ^5.0.0
diff --git a/test/composition_and_messages_test.dart b/test/composition_and_messages_test.dart
new file mode 100644
index 0000000..66f2169
--- /dev/null
+++ b/test/composition_and_messages_test.dart
@@ -0,0 +1,136 @@
+import 'package:flutter_validators/flutter_validators.dart';
+import 'package:test/test.dart';
+
+void main() {
+ tearDown(Validator.resetMessageResolver);
+
+ group('generic composition', () {
+ final required = Validator.required(errorMessage: 'required');
+ final email = Validator.email(errorMessage: 'email');
+
+ test('compose returns the first error', () {
+ final validator = compose([required, email]);
+ expect(validator(null), 'required');
+ expect(validator('bad'), 'email');
+ expect(validator('a@example.com'), isNull);
+ });
+
+ test('aggregate returns every error', () {
+ final validator = aggregate([
+ (_) => 'first',
+ (_) => null,
+ (_) => 'second',
+ ], separator: ', ');
+ expect(validator('value'), 'first, second');
+ });
+
+ test('any succeeds when one validator succeeds', () {
+ final validator = any([
+ Validator.email(),
+ Validator.url(),
+ ], errorMessage: 'email or URL');
+ expect(validator('person@example.com'), isNull);
+ expect(validator('https://example.com'), isNull);
+ expect(validator('neither'), 'email or URL');
+ expect(() => any([]), throwsArgumentError);
+ });
+
+ test('conditions and transform work with generic values', () {
+ final positiveInt = when(
+ (value) => value != null,
+ (value) => value! > 0 ? null : 'positive',
+ );
+ expect(positiveInt(null), isNull);
+ expect(positiveInt(-1), 'positive');
+
+ final evenLength = transform, int>(
+ (value) => value?.length,
+ (value) => value == null || value.isEven ? null : 'even length',
+ );
+ expect(evenLength([1, 2]), isNull);
+ expect(evenLength([1]), 'even length');
+
+ final trueOnly = unless(
+ (value) => value == null,
+ (value) => value == true ? null : 'must be true',
+ );
+ expect(trueOnly(null), isNull);
+ expect(trueOnly(false), 'must be true');
+
+ final skipped = skipWhen>(
+ (value) => value == null,
+ (value) => value!.isEmpty ? 'empty' : null,
+ );
+ expect(skipped(null), isNull);
+ expect(skipped({}), 'empty');
+ });
+
+ test('fluent composition supports and, or, conditions, and messages', () {
+ final combined = required
+ .and(email)
+ .when((value) => value != 'skip')
+ .withMessage('invalid');
+ expect(combined('skip'), isNull);
+ expect(combined('bad'), 'invalid');
+
+ final alternative = email.or(
+ Validator.url(),
+ errorMessage: 'email or URL',
+ );
+ expect(alternative('https://dart.dev'), isNull);
+ expect(alternative('bad'), 'email or URL');
+ expect(email.unless((value) => value == 'skip')('skip'), isNull);
+ expect(email.skipWhen((value) => value == 'skip')('skip'), isNull);
+ });
+
+ test('Validator exposes the complete generic composition facade', () {
+ String? fail(T? _) => 'fail';
+ String? pass(T? _) => null;
+
+ expect(Validator.compose([pass, fail])(1), 'fail');
+ expect(
+ Validator.aggregate([fail, fail], separator: ',')(1),
+ 'fail,fail',
+ );
+ expect(Validator.any([fail, pass])(1), isNull);
+ expect(Validator.any([fail], errorMessage: 'none')(1), 'none');
+ expect(() => Validator.any([]), throwsArgumentError);
+ expect(Validator.conditional((_) => false, fail)(1), isNull);
+ expect(Validator.when((_) => true, fail)(1), 'fail');
+ expect(Validator.unless((_) => true, fail)(1), isNull);
+ expect(Validator.skipWhen((_) => false, fail)(1), 'fail');
+ expect(
+ Validator.transform(
+ (value) => value == null ? null : int.tryParse(value),
+ fail,
+ )('1'),
+ 'fail',
+ );
+ });
+ });
+
+ group('validation messages', () {
+ test('uses fallback English without a resolver', () {
+ expect(Validator.email()('bad'), 'Please enter a valid email address');
+ });
+
+ test('resolves keys and parameters at validation time', () {
+ final validator = Validator.range(1, 10);
+ Validator.messageResolver =
+ (message) => 'es:${message.key}:${message.parameters['min']}';
+ expect(Validator.messageResolver, isNotNull);
+ expect(validator('20'), 'es:range:1');
+
+ Validator.messageResolver = (message) => 'fr:${message.key}';
+ expect(validator('20'), 'fr:range');
+ });
+
+ test('explicit error messages override the resolver', () {
+ Validator.messageResolver = (message) => 'resolved:${message.key}';
+ expect(
+ Validator.email(errorMessage: 'Custom email')('bad'),
+ 'Custom email',
+ );
+ });
+ });
+}
diff --git a/test/readme_examples_test.dart b/test/readme_examples_test.dart
new file mode 100644
index 0000000..0b41f9a
--- /dev/null
+++ b/test/readme_examples_test.dart
@@ -0,0 +1,130 @@
+import 'package:flutter_validators/flutter_validators.dart';
+import 'package:test/test.dart'
+ hide endsWith, isNegative, isPositive, startsWith;
+
+void main() {
+ tearDown(Validator.resetMessageResolver);
+
+ test('five-minute quick start', () {
+ final cleaned = trim(' Test.User+news@GMAIL.com ');
+ final normalized = normalizeEmail(cleaned);
+ final emailRule = compose([
+ Validator.required(),
+ Validator.email(),
+ ]);
+
+ expect(normalized, 'testuser@gmail.com');
+ expect(emailRule(normalized), isNull);
+ expect(isURL('https://dart.dev'), isTrue);
+ expect('2024-02-29'.isISO8601Date, isTrue);
+ });
+
+ test('composition examples', () {
+ final passwordReport = aggregate([
+ Validator.length(12, errorMessage: 'Use at least 12 characters'),
+ Validator.matches(RegExp(r'\d'), errorMessage: 'Add a number'),
+ ]);
+ expect(passwordReport('short'), 'Use at least 12 characters\nAdd a number');
+
+ final contact = any([
+ Validator.email(),
+ Validator.url(),
+ ], errorMessage: 'Enter an email or URL');
+ expect(contact('https://dart.dev'), isNull);
+
+ final fluent = Validator.required()
+ .and(Validator.email())
+ .when((value) => value != 'skip')
+ .withMessage('Enter a usable email');
+ expect(fluent('bad'), 'Enter a usable email');
+
+ final positiveAge = when(
+ (value) => value != null,
+ (value) => value! > 0 ? null : 'Age must be positive',
+ );
+ expect(positiveAge(-1), 'Age must be positive');
+
+ final evenItemCount = transform, int>(
+ (items) => items?.length,
+ (length) => length == null || length.isEven ? null : 'Use an even count',
+ );
+ expect(evenItemCount(['one']), 'Use an even count');
+ });
+
+ test('strict and configurable examples', () {
+ expect(isDate('2023-13-01'), isTrue);
+ expect(isISO8601Date('2023-13-01'), isFalse);
+ expect(isJWT('aaa.bbb.'), isTrue);
+ expect(isJWT('aaa.bbb.', strict: true), isFalse);
+ expect(isBase32('ABC'), isTrue);
+ expect(isBase32('ABC', strict: true), isFalse);
+ expect(isCreditCard('0000000000000000'), isTrue);
+ expect(isCreditCard('0000000000000000', strict: true), isFalse);
+ expect(isEmail('δοκιμή@παράδειγμα.δοκιμή', allowUnicode: true), isTrue);
+ expect(
+ isURL('ftp://example.com', protocols: const ['ftp'], requireTld: true),
+ isTrue,
+ );
+ expect(isFQDN('service_name.example.com', allowUnderscores: true), isTrue);
+ expect(isUUID('550e8400-e29b-41d4-a716-446655440000', 4), isTrue);
+ });
+
+ test('new validator examples', () {
+ expect(isPositive('12.5'), isTrue);
+ expect(isNegative('-2'), isTrue);
+ expect(isInRange('18', 13, 120), isTrue);
+ expect(isDivisibleBy('24', 6), isTrue);
+ expect(isTime('23:59:59'), isTrue);
+ expect(isBefore('2024-12-31', DateTime.utc(2025)), isTrue);
+ expect(isAfter('2025-01-02', DateTime.utc(2025)), isTrue);
+ expect(startsWith('Flutter', 'flutter', ignoreCase: true), isTrue);
+ expect(endsWith('report.pdf', '.pdf'), isTrue);
+ expect(isSingleLine('one line'), isTrue);
+ expect(hasWordCount('a short profile', min: 2, max: 20), isTrue);
+ expect(isIBAN('GB82 WEST 1234 5698 7654 32'), isTrue);
+ expect(isBIC('DEUTDEFF'), isTrue);
+ expect(isCreditCardCVC('123'), isTrue);
+ expect(
+ isCreditCardExpirationDate('06/30', referenceDate: DateTime(2030, 6)),
+ isTrue,
+ );
+ expect(isISBN('0-306-40615-2', version: 10), isTrue);
+ expect(isISBN('978-0-306-40615-7', version: 13), isTrue);
+ expect(
+ isHash(
+ 'e3b0c44298fc1c149afbf4c8996fb924'
+ '27ae41e4649b934ca495991b7852b855',
+ HashAlgorithm.sha256,
+ ),
+ isTrue,
+ );
+ expect(isMimeType('application/json'), isTrue);
+ expect(isDataURI('data:text/plain;base64,SGVsbG8='), isTrue);
+ });
+
+ test('sanitization and messages examples', () {
+ final email = normalizeEmail(trim(' User@Example.COM '));
+ expect(email, 'User@example.com');
+ expect(Validator.required().and(Validator.email())(email), isNull);
+ expect(whitelist('+1 (415) 555-0100', '0123456789'), '14155550100');
+ expect(toInt('42'), 42);
+
+ var locale = 'en';
+ Validator.messageResolver = (message) {
+ const translations = {
+ 'es': {
+ 'required': 'Este campo es obligatorio',
+ 'email': 'Introduce un correo válido',
+ },
+ };
+ return translations[locale]?[message.key] ?? message.fallback;
+ };
+ final rule = Validator.required().and(Validator.email());
+ locale = 'es';
+ expect(rule('bad'), 'Introduce un correo válido');
+ expect(
+ Validator.email(errorMessage: 'Account email is invalid')('bad'),
+ 'Account email is invalid',
+ );
+ });
+}
diff --git a/test/validators/new_validators_test.dart b/test/validators/new_validators_test.dart
new file mode 100644
index 0000000..5874f09
--- /dev/null
+++ b/test/validators/new_validators_test.dart
@@ -0,0 +1,145 @@
+import 'package:flutter_validators/flutter_validators.dart';
+import 'package:test/test.dart'
+ hide endsWith, isNegative, isPositive, startsWith;
+
+void main() {
+ group('numeric rules', () {
+ test('function and extension APIs', () {
+ expect(isPositive('1.5'), isTrue);
+ expect('1'.isPositive, isTrue);
+ expect(isNegative('-2'), isTrue);
+ expect('-2'.isNegative, isTrue);
+ expect(isInRange('10', 1, 10), isTrue);
+ expect('10'.isInNumericRange(1, 10), isTrue);
+ expect(isInRange('10', 1, 10, inclusive: false), isFalse);
+ expect(isDivisibleBy('12', 3), isTrue);
+ expect('12'.isDivisibleByNumber(5), isFalse);
+ expect(isDivisibleBy('12', 0), isFalse);
+ });
+
+ test('form APIs skip empty values', () {
+ expect(Validator.positive()('2'), isNull);
+ expect(Validator.negative()('2'), isNotNull);
+ expect(Validator.range(1, 3)('2'), isNull);
+ expect(Validator.divisibleBy(2)('3'), isNotNull);
+ expect(Validator.range(1, 3)(''), isNull);
+ });
+ });
+
+ group('date and time rules', () {
+ final boundary = DateTime.utc(2025, 1, 1);
+
+ test('strict date, time, before, and after', () {
+ expect(isISO8601Date('2024-02-29'), isTrue);
+ expect('2024-02-29'.isISO8601Date, isTrue);
+ expect(isISO8601Date('2023-02-29'), isFalse);
+ expect(isISO8601Date('2023-13-01'), isFalse);
+ expect(isTime('23:59:59'), isTrue);
+ expect('23:59'.isValidTime(), isTrue);
+ expect(isTime('23:59:59', allowSeconds: false), isFalse);
+ expect(isBefore('2024-12-31', boundary), isTrue);
+ expect('2024-12-31'.isBeforeDate(boundary), isTrue);
+ expect('2025-01-02'.isAfterDate(boundary), isTrue);
+ });
+
+ test('form APIs', () {
+ expect(Validator.iso8601Date()('2023-02-29'), isNotNull);
+ expect(Validator.time()('12:30'), isNull);
+ expect(Validator.before(boundary)('2025-01-02'), isNotNull);
+ expect(Validator.after(boundary)('2025-01-02'), isNull);
+ });
+ });
+
+ group('text rules', () {
+ test('function and extension APIs', () {
+ expect(startsWith('Flutter', 'flu', ignoreCase: true), isTrue);
+ expect('Flutter'.startsWithText('Flu'), isTrue);
+ expect(endsWith('photo.JPG', '.jpg', ignoreCase: true), isTrue);
+ expect('photo.jpg'.endsWithText('.jpg'), isTrue);
+ expect(isSingleLine('one line'), isTrue);
+ expect('two\nlines'.isSingleLine, isFalse);
+ expect(hasWordCount('one two three', min: 2, max: 3), isTrue);
+ expect('one two'.hasWords(min: 3), isFalse);
+ });
+
+ test('form APIs', () {
+ expect(Validator.startsWith('app-')('app-user'), isNull);
+ expect(Validator.endsWith('.dev')('example.com'), isNotNull);
+ expect(Validator.singleLine()('a\nb'), isNotNull);
+ expect(Validator.wordCount(min: 2, max: 3)('one two'), isNull);
+ });
+ });
+
+ group('finance and identifiers', () {
+ const iban = 'GB82 WEST 1234 5698 7654 32';
+
+ test('IBAN, BIC, CVC, and expiry', () {
+ expect(isIBAN(iban), isTrue);
+ expect(iban.isIBAN, isTrue);
+ expect(isIBAN('GB82 TEST 1234'), isFalse);
+ expect(isBIC('DEUTDEFF'), isTrue);
+ expect('DEUTDEFF500'.isBIC, isTrue);
+ expect(isCreditCardCVC('123'), isTrue);
+ expect('1234'.isCardCVC(), isTrue);
+ expect(
+ isCreditCardExpirationDate(
+ '06/25',
+ referenceDate: DateTime(2025, 6, 30),
+ ),
+ isTrue,
+ );
+ expect(
+ isCreditCardExpirationDate(
+ '05/25',
+ referenceDate: DateTime(2025, 6, 1),
+ ),
+ isFalse,
+ );
+ expect(
+ '06/25'.isCardExpiration(referenceDate: DateTime(2025, 6)),
+ isTrue,
+ );
+ });
+
+ test('ISBN-10 and ISBN-13 checksums', () {
+ expect(isISBN('0-306-40615-2', version: 10), isTrue);
+ expect('978-0-306-40615-7'.isISBN, isTrue);
+ expect('0-8044-2957-X'.isISBNVersion(10), isTrue);
+ expect(isISBN('9780306406158'), isFalse);
+ });
+
+ test('form APIs', () {
+ expect(Validator.iban()(iban), isNull);
+ expect(Validator.bic()('bad'), isNotNull);
+ expect(Validator.cardCVC()('12'), isNotNull);
+ expect(
+ Validator.cardExpiration(referenceDate: DateTime(2025, 6))('06/25'),
+ isNull,
+ );
+ expect(Validator.isbn(version: 13)('9780306406157'), isNull);
+ });
+ });
+
+ group('data and security', () {
+ const sha256 =
+ 'e3b0c44298fc1c149afbf4c8996fb924'
+ '27ae41e4649b934ca495991b7852b855';
+
+ test('hash, MIME, and data URI', () {
+ expect(isHash(sha256, HashAlgorithm.sha256), isTrue);
+ expect(sha256.isHashFor(HashAlgorithm.sha256), isTrue);
+ expect(isMimeType('application/json'), isTrue);
+ expect('image/svg+xml'.isMimeType, isTrue);
+ expect(isMimeType('not a/type'), isFalse);
+ expect(isDataURI('data:text/plain;base64,SGVsbG8='), isTrue);
+ expect('data:,Hello%20World'.isDataURI, isTrue);
+ expect(isDataURI('data:text/plain;base64,***'), isFalse);
+ });
+
+ test('form APIs', () {
+ expect(Validator.hash(HashAlgorithm.sha256)(sha256), isNull);
+ expect(Validator.mimeType()('application/json'), isNull);
+ expect(Validator.dataURI()('bad'), isNotNull);
+ });
+ });
+}
diff --git a/test/validators/strict_configuration_test.dart b/test/validators/strict_configuration_test.dart
new file mode 100644
index 0000000..71f81d3
--- /dev/null
+++ b/test/validators/strict_configuration_test.dart
@@ -0,0 +1,85 @@
+import 'package:flutter_validators/flutter_validators.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('strict validation is opt-in', () {
+ test('JWT checks decoded JSON only in strict mode', () {
+ const valid =
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.'
+ 'eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature';
+ expect(isJWT('aaa.bbb.'), isTrue);
+ expect(isJWT('aaa.bbb.', strict: true), isFalse);
+ expect('aaa.bbb.'.isJWTWith(strict: true), isFalse);
+ expect(isJWT(valid, strict: true), isTrue);
+ expect(Validator.jwt(strict: true, allowUnsigned: false)(valid), isNull);
+ });
+
+ test('Base32 enforces legal encoded lengths in strict mode', () {
+ expect(isBase32('ABC'), isTrue);
+ expect(isBase32('ABC', strict: true), isFalse);
+ expect('MZXW6YTB'.isBase32With(strict: true), isTrue);
+ expect(Validator.base32(strict: true)('ABC'), isNotNull);
+ });
+
+ test('credit-card strict mode rejects repeated and unknown patterns', () {
+ expect(isCreditCard('0000000000000000'), isTrue);
+ expect(isCreditCard('0000000000000000', strict: true), isFalse);
+ expect('4111111111111111'.isCreditCardWith(strict: true), isTrue);
+ expect(Validator.creditCard(strict: true)('0000000000000000'), isNotNull);
+ });
+ });
+
+ group('configurable validators', () {
+ test('email supports Unicode and local domains by request', () {
+ expect(isEmail('δοκιμή@παράδειγμα.δοκιμή'), isFalse);
+ expect(isEmail('δοκιμή@παράδειγμα.δοκιμή', allowUnicode: true), isTrue);
+ expect(
+ 'δοκιμή@παράδειγμα.δοκιμή'.isEmailWith(allowUnicode: true),
+ isTrue,
+ );
+ expect(isEmail('user@localhost', requireTld: false), isTrue);
+ expect(
+ Validator.email(allowUnicode: true)('δοκιμή@παράδειγμα.δοκιμή'),
+ isNull,
+ );
+ });
+
+ test('URL protocols, TLD, localhost, and underscore options', () {
+ expect(isURL('ftp://example.com'), isFalse);
+ expect(
+ isURL('ftp://example.com', protocols: const ['ftp'], requireTld: true),
+ isTrue,
+ );
+ expect(
+ 'ftp://example.com'.isURLWith(
+ protocols: const ['ftp'],
+ requireTld: true,
+ ),
+ isTrue,
+ );
+ expect(
+ isURL('https://localhost', requireTld: true, allowLocalhost: false),
+ isFalse,
+ );
+ expect(Validator.url(requireTld: true)('https://example.com'), isNull);
+ });
+
+ test('FQDN and UUID expose rule options', () {
+ expect(isFQDN('service_name.example.com'), isFalse);
+ expect(
+ isFQDN('service_name.example.com', allowUnderscores: true),
+ isTrue,
+ );
+ expect(
+ 'service_name.example.com'.isFQDNWith(allowUnderscores: true),
+ isTrue,
+ );
+ expect(isFQDN('example.com.', allowTrailingDot: false), isFalse);
+ const v4 = '550e8400-e29b-41d4-a716-446655440000';
+ expect(isUUID(v4, 4), isTrue);
+ expect(isUUID(v4, 1), isFalse);
+ expect(v4.isUUIDVersion(4), isTrue);
+ expect(Validator.uuid(version: 4)(v4), isNull);
+ });
+ });
+}
diff --git a/tool/check_coverage.dart b/tool/check_coverage.dart
new file mode 100644
index 0000000..f6dccbb
--- /dev/null
+++ b/tool/check_coverage.dart
@@ -0,0 +1,36 @@
+import 'dart:io';
+
+void main(List arguments) {
+ if (arguments.length != 2) {
+ stderr.writeln('Usage: check_coverage.dart ');
+ exitCode = 64;
+ return;
+ }
+
+ final file = File(arguments[0]);
+ final minimum = double.tryParse(arguments[1]);
+ if (!file.existsSync() || minimum == null) {
+ stderr.writeln('Coverage file or minimum percentage is invalid.');
+ exitCode = 64;
+ return;
+ }
+
+ var found = 0;
+ var hit = 0;
+ for (final line in file.readAsLinesSync()) {
+ if (!line.startsWith('DA:')) continue;
+ final parts = line.substring(3).split(',');
+ if (parts.length != 2) continue;
+ found++;
+ if (int.parse(parts[1]) > 0) hit++;
+ }
+
+ final percentage = found == 0 ? 0.0 : hit * 100 / found;
+ stdout.writeln(
+ 'Line coverage: ${percentage.toStringAsFixed(2)}% ($hit/$found)',
+ );
+ if (percentage < minimum) {
+ stderr.writeln('Required line coverage: ${minimum.toStringAsFixed(2)}%');
+ exitCode = 1;
+ }
+}
diff --git a/tool/pure_dart_smoke.dart b/tool/pure_dart_smoke.dart
new file mode 100644
index 0000000..ae0f519
--- /dev/null
+++ b/tool/pure_dart_smoke.dart
@@ -0,0 +1,10 @@
+import 'package:flutter_validators/flutter_validators.dart';
+
+void main() {
+ final rule = Validator.required().and(Validator.email());
+ final normalized = normalizeEmail(trim(' User@Example.COM '));
+
+ if (normalized != 'User@example.com' || rule(normalized) != null) {
+ throw StateError('Pure Dart validation smoke test failed');
+ }
+}