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 Banner

-

-

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. -

- Pub Version - CI - MIT License - Stars -

- -

- Inspired by validator.js · 40+ validators & sanitizers · Works with Flutter Forms out of the box -

+[![Pub Version](https://img.shields.io/pub/v/flutter_validators.svg?logo=dart&color=blue)](https://pub.dev/packages/flutter_validators) +[![CI](https://github.com/StacDev/flutter_validators/actions/workflows/test.yml/badge.svg)](https://github.com/StacDev/flutter_validators/actions/workflows/test.yml) +[![License](https://img.shields.io/badge/License-MIT-green)](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'); + } +}