diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9b035e8..175e93b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,19 @@
# Changelog
-[0.2.2] - 2025-04-04
+## [0.3.0] - 2025-04-05
+### Added
+- Asynchronous validation support:
+ - New `AsyncValidationRule` base class for creating async-specific validation rules
+ - Async validation state tracking with `AsyncValidationState` class
+ - Debouncing support for async validation to prevent excessive API calls
+ - Manual async validation triggering with `validateAsync()` method
+ - Example implementation of username availability checking
+- New properties for validation state management:
+ - `isValidating` - Indicates if async validation is in progress
+ - `isValid` - Indicates if the last async validation was successful
+ - `errorMessage` - Provides the current error message from either sync or async validation
+
+## [0.2.2] - 2025-04-04
### Changed
- Update documentation
diff --git a/README.md b/README.md
index 8b3cebb..17d8bd7 100644
--- a/README.md
+++ b/README.md
@@ -4,9 +4,11 @@
-[](https://pub.dev/packages/form_shield)
-[](https://opensource.org/licenses/MIT)
-[](https://codecov.io/gh/stevenosse/form_shield)
+
+
+
+
+
A declarative, rule-based form validation library for Flutter apps, offering customizable rules and messages, seamless integration with Flutter forms, type safety, and chainable validation.
@@ -23,6 +25,23 @@ It provides a simple yet powerful way to define and apply validation logic to yo
📚 **Comprehensive Built-in Rules:** Includes common validation scenarios out-of-the-box (required, email, password, length, numeric range, phone, etc.).
🛠️ **Extensible:** Create your own custom validation rules by extending the base class.
+## Table of Contents
+- [Installation](#installation)
+- [Usage](#usage)
+ - [Basic usage](#basic-usage)
+ - [Customizing error messages](#customizing-error-messages)
+ - [Using multiple validation rules](#using-multiple-validation-rules)
+ - [Custom validation rules](#custom-validation-rules)
+ - [Dynamic custom validation](#dynamic-custom-validation)
+ - [Validating numbers](#validating-numbers)
+ - [Phone number validation](#phone-number-validation)
+ - [Password validation with options](#password-validation-with-options)
+ - [Password confirmation](#password-confirmation)
+- [Available validation rules](#available-validation-rules)
+- [Creating your own validation rules](#creating-your-own-validation-rules)
+- [Contributing](#contributing)
+- [License](#license)
+
## Getting started
### Installation
@@ -33,7 +52,7 @@ Add `form_shield` to your `pubspec.yaml` dependencies:
dependencies:
flutter:
sdk: flutter
- form_shield: ^0.1.0
+ form_shield: ^0.3.0
```
Then, run `flutter pub get`.
@@ -265,6 +284,175 @@ Validator([
])
```
+### Asynchronous validation rules
+
+Form Shield supports asynchronous validation for scenarios where validation requires network requests or other async operations (like checking username availability or email uniqueness).
+
+You can create async validation rules by either:
+1. Extending the `ValidationRule` class and overriding the `validateAsync` method
+2. Extending the specialized `AsyncValidationRule` class
+
+#### Example: Username availability checker
+
+```dart
+class UsernameAvailabilityRule extends ValidationRule {
+ final Future Function(String username) _checkAvailability;
+
+ const UsernameAvailabilityRule({
+ required Future Function(String username) checkAvailability,
+ super.errorMessage = 'This username is already taken',
+ }) : _checkAvailability = checkAvailability;
+
+ @override
+ ValidationResult validate(String? value) {
+ // Perform synchronous validation first
+ if (value == null || value.isEmpty) {
+ return ValidationResult.error('Username cannot be empty');
+ }
+ return const ValidationResult.success();
+ }
+
+ @override
+ Future validateAsync(String? value) async {
+ // Run sync validation first
+ final syncResult = validate(value);
+ if (!syncResult.isValid) {
+ return syncResult;
+ }
+
+ try {
+ // Perform the async validation
+ final isAvailable = await _checkAvailability(value!);
+ if (isAvailable) {
+ return const ValidationResult.success();
+ } else {
+ return ValidationResult.error(errorMessage);
+ }
+ } catch (e) {
+ return ValidationResult.error('Error checking username availability: $e');
+ }
+ }
+}
+```
+
+#### Using async validation in forms
+
+When using async validation, you need to:
+1. Create a validator instance as a field in your state class
+2. Initialize it in `initState()`
+3. Dispose of it in `dispose()`
+4. Check both sync and async validation states before submitting
+
+```dart
+class _MyFormState extends State {
+ final _formKey = GlobalKey();
+ final _usernameController = TextEditingController();
+ late final Validator _usernameValidator;
+
+ @override
+ void initState() {
+ super.initState();
+
+ _usernameValidator = Validator([
+ RequiredRule(),
+ UsernameAvailabilityRule(
+ checkAvailability: _checkUsernameAvailability,
+ ),
+ ], debounceDuration: Duration(milliseconds: 500));
+ }
+
+ @override
+ void dispose() {
+ _usernameController.dispose();
+ _usernameValidator.dispose(); // Important to prevent memory leaks
+ super.dispose();
+ }
+
+ Future _checkUsernameAvailability(String username) async {
+ // Simulate API call with delay
+ await Future.delayed(const Duration(seconds: 1));
+ final takenUsernames = ['admin', 'user', 'test'];
+ return !takenUsernames.contains(username.toLowerCase());
+ }
+
+ void _submitForm() {
+ if (_formKey.currentState!.validate() &&
+ !_usernameValidator.isValidating &&
+ _usernameValidator.isValid) {
+ // All validations passed, proceed with form submission
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Form(
+ key: _formKey,
+ child: Column(
+ children: [
+ TextFormField(
+ controller: _usernameController,
+ decoration: InputDecoration(labelText: 'Username'),
+ validator: _usernameValidator,
+ ),
+ // Show async validation state
+ ValueListenableBuilder(
+ valueListenable: _usernameValidator.asyncState,
+ builder: (context, state, _) {
+ if (state.isValidating) {
+ return Text('Checking username availability...');
+ } else if (state.isValid == false) {
+ return Text(
+ state.errorMessage ?? 'Invalid username',
+ style: TextStyle(color: Colors.red),
+ );
+ } else if (state.isValid == true) {
+ return Text(
+ 'Username is available',
+ style: TextStyle(color: Colors.green),
+ );
+ }
+ return SizedBox.shrink();
+ },
+ ),
+ ElevatedButton(
+ onPressed: _submitForm,
+ child: Text('Submit'),
+ ),
+ ],
+ ),
+ );
+ }
+}
+```
+
+#### Debouncing async validation
+
+Form Shield includes built-in debouncing for async validation to prevent excessive API calls during typing. You can customize the debounce duration:
+
+```dart
+Validator([
+ RequiredRule(),
+ UsernameAvailabilityRule(checkAvailability: _checkUsername),
+], debounceDuration: Duration(milliseconds: 800)) // Custom debounce time
+```
+
+#### Manually triggering async validation
+
+You can manually trigger async validation using the `validateAsync` method:
+
+```dart
+Future _checkUsername() async {
+ final isValid = await _usernameValidator.validateAsync(
+ _usernameController.text,
+ debounceDuration: Duration.zero, // Optional: skip debouncing
+ );
+
+ if (isValid) {
+ // Username is valid and available
+ }
+}
+```
+
## Contributing
Contributions are welcome! Please feel free to submit issues, pull requests, or suggest improvements.
diff --git a/example/.gitignore b/example/.gitignore
new file mode 100644
index 0000000..79c113f
--- /dev/null
+++ b/example/.gitignore
@@ -0,0 +1,45 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
diff --git a/example/.metadata b/example/.metadata
new file mode 100644
index 0000000..e5df162
--- /dev/null
+++ b/example/.metadata
@@ -0,0 +1,30 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "09de023485e95e6d1225c2baa44b8feb85e0d45f"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: 09de023485e95e6d1225c2baa44b8feb85e0d45f
+ base_revision: 09de023485e95e6d1225c2baa44b8feb85e0d45f
+ - platform: web
+ create_revision: 09de023485e95e6d1225c2baa44b8feb85e0d45f
+ base_revision: 09de023485e95e6d1225c2baa44b8feb85e0d45f
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/example/README.md b/example/README.md
new file mode 100644
index 0000000..791858a
--- /dev/null
+++ b/example/README.md
@@ -0,0 +1 @@
+# Sample project for Flutter Shield
\ No newline at end of file
diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/example/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/example/lib/async_validation_example.dart b/example/lib/async_validation_example.dart
new file mode 100644
index 0000000..e69a123
--- /dev/null
+++ b/example/lib/async_validation_example.dart
@@ -0,0 +1,268 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:form_shield/form_shield.dart';
+
+class UsernameAvailabilityRule extends AsyncValidationRule {
+ final Future Function(String username) _checkAvailability;
+
+ const UsernameAvailabilityRule({
+ required Future Function(String username) checkAvailability,
+ super.errorMessage = 'This username is already taken',
+ }) : _checkAvailability = checkAvailability;
+
+ @override
+ ValidationResult validate(String? value) {
+ if (value == null || value.isEmpty) {
+ return ValidationResult.error('Username cannot be empty');
+ }
+ return const ValidationResult.success();
+ }
+
+ @override
+ Future validateAsync(String? value) async {
+ final syncResult = validate(value);
+ if (!syncResult.isValid) {
+ return syncResult;
+ }
+
+ try {
+ final isAvailable = await _checkAvailability(value!);
+ if (isAvailable) {
+ return const ValidationResult.success();
+ } else {
+ return ValidationResult.error(errorMessage);
+ }
+ } catch (e) {
+ return ValidationResult.error('Error checking username availability: $e');
+ }
+ }
+}
+
+class AsyncValidationExample extends StatefulWidget {
+ const AsyncValidationExample({super.key});
+
+ @override
+ State createState() => _AsyncValidationExampleState();
+}
+
+class _AsyncValidationExampleState extends State {
+ final _formKey = GlobalKey();
+ final _usernameController = TextEditingController();
+ final _emailController = TextEditingController();
+ late final Validator _usernameValidator;
+
+ @override
+ void initState() {
+ super.initState();
+
+ _usernameValidator = Validator.forString([
+ const RequiredRule(errorMessage: 'Username is required'),
+ LengthRule(
+ minLength: 3,
+ maxLength: 20,
+ errorMessage: 'Username must be between 3 and 20 characters',
+ ),
+ UsernameAvailabilityRule(
+ checkAvailability: _checkUsernameAvailability,
+ errorMessage: 'This username is already taken',
+ ),
+ ], debounceDuration: Duration(milliseconds: 500));
+ }
+
+ @override
+ void dispose() {
+ _usernameController.dispose();
+ _emailController.dispose();
+ _usernameValidator.dispose();
+ super.dispose();
+ }
+
+ Future _checkUsernameAvailability(String username) async {
+ await Future.delayed(const Duration(seconds: 1));
+ final takenUsernames = ['admin', 'user', 'test', 'flutter'];
+ return !takenUsernames.contains(username.toLowerCase());
+ }
+
+ void _submitForm() {
+ if (_formKey.currentState!.validate() &&
+ !_usernameValidator.isValidating &&
+ _usernameValidator.isValid) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Registration successful!'),
+ backgroundColor: Colors.green,
+ behavior: SnackBarBehavior.floating,
+ ),
+ );
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Unified Validation API Example'),
+ elevation: 0,
+ ),
+ body: Center(
+ child: SingleChildScrollView(
+ child: Padding(
+ padding: const EdgeInsets.all(24.0),
+ child: Card(
+ elevation: 8,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(24.0),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const Icon(Icons.person_add, size: 80),
+ const SizedBox(height: 16),
+ const Text(
+ 'Create Account',
+ style: TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.bold,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 24),
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'Username with Async Validation',
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ fontSize: 14,
+ ),
+ ),
+ const SizedBox(height: 4),
+ ListenableBuilder(
+ listenable: _usernameValidator.asyncState,
+ builder: (context, child) {
+ Widget? suffixIcon;
+ if (_usernameValidator.isValidating) {
+ suffixIcon = const SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ ),
+ );
+ } else if (_usernameValidator.isValid) {
+ suffixIcon = const Icon(
+ Icons.check_circle,
+ color: Colors.green,
+ );
+ } else if (!_usernameValidator.isValid) {
+ suffixIcon = const Icon(
+ Icons.error,
+ color: Colors.red,
+ );
+ }
+
+ return TextFormField(
+ controller: _usernameController,
+ decoration: InputDecoration(
+ labelText: 'Username',
+ hintText: 'Choose a username',
+ prefixIcon: const Icon(Icons.person),
+ suffixIcon:
+ suffixIcon != null
+ ? Padding(
+ padding: const EdgeInsets.all(12.0),
+ child: suffixIcon,
+ )
+ : null,
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: const BorderSide(width: 2),
+ ),
+ errorText: _usernameValidator.errorMessage,
+ errorStyle: const TextStyle(
+ color: Colors.red,
+ ),
+ ),
+ validator: (value) {
+ final syncResult = _usernameValidator.call(
+ value,
+ );
+ if (syncResult != null) return syncResult;
+
+ if (!_usernameValidator.isValid &&
+ !_usernameValidator.isValidating) {
+ return _usernameValidator.errorMessage;
+ }
+
+ return null;
+ },
+ onChanged: (value) {
+ if (value.length >= 3) {
+ setState(() {
+ _usernameValidator(value);
+ });
+ }
+ },
+ );
+ },
+ ),
+ const SizedBox(height: 8),
+ const Text(
+ 'The username field combines both sync and async validation rules in a single list.',
+ style: TextStyle(fontSize: 12, color: Colors.grey),
+ ),
+ ],
+ ),
+ const SizedBox(height: 24),
+ ListenableBuilder(
+ listenable: _usernameValidator.asyncState,
+ builder: (context, _) {
+ return ElevatedButton(
+ onPressed:
+ _usernameValidator.isValidating
+ ? null
+ : _submitForm,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.blue,
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ child: const Text(
+ 'Register',
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ color: Colors.white,
+ ),
+ ),
+ );
+ },
+ ),
+ const SizedBox(height: 16),
+ const Text(
+ 'Try usernames: "admin", "user", "test", or "flutter" to see async validation in action',
+ style: TextStyle(fontSize: 12, color: Colors.grey),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/example/lib/example.dart b/example/lib/example.dart
deleted file mode 100644
index 4aaf589..0000000
--- a/example/lib/example.dart
+++ /dev/null
@@ -1,106 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:form_shield/form_shield.dart';
-
-class LoginFormExample extends StatefulWidget {
- const LoginFormExample({super.key});
-
- @override
- State createState() => _LoginFormExampleState();
-}
-
-class _LoginFormExampleState extends State {
- final _formKey = GlobalKey();
- final _emailController = TextEditingController();
- final _passwordController = TextEditingController();
- bool _rememberMe = false;
-
- @override
- void dispose() {
- _emailController.dispose();
- _passwordController.dispose();
- super.dispose();
- }
-
- void _submitForm() {
- if (_formKey.currentState!.validate()) {
- // Form is valid, proceed with login
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Processing login...')),
- );
- }
- }
-
- @override
- Widget build(BuildContext context) {
- return Scaffold(
- appBar: AppBar(title: const Text('Login Form Example')),
- body: Padding(
- padding: const EdgeInsets.all(16.0),
- child: Form(
- key: _formKey,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- TextFormField(
- controller: _emailController,
- decoration: const InputDecoration(
- labelText: 'Email',
- hintText: 'Enter your email',
- prefixIcon: Icon(Icons.email),
- ),
- keyboardType: TextInputType.emailAddress,
- validator: Validator([
- const RequiredRule(errorMessage: 'Email is required'),
- EmailRule(errorMessage: 'Please enter a valid email address'),
- ]).call,
- ),
- const SizedBox(height: 16),
- TextFormField(
- controller: _passwordController,
- decoration: const InputDecoration(
- labelText: 'Password',
- hintText: 'Enter your password',
- prefixIcon: Icon(Icons.lock),
- ),
- obscureText: true,
- validator: Validator(const [
- RequiredRule(errorMessage: 'Password is required'),
- PasswordRule(
- options: PasswordOptions(
- minLength: 8,
- requireUppercase: true,
- requireLowercase: true,
- requireDigit: true,
- requireSpecialChar: true,
- ),
- errorMessage:
- 'Password must be at least 8 characters with uppercase, lowercase, digit, and special character',
- ),
- ]).call,
- ),
- const SizedBox(height: 8),
- Row(
- children: [
- Checkbox(
- value: _rememberMe,
- onChanged: (value) {
- setState(() {
- _rememberMe = value ?? false;
- });
- },
- ),
- const Text('Remember me'),
- ],
- ),
- const SizedBox(height: 24),
- ElevatedButton(
- onPressed: _submitForm,
- child: const Text('Login'),
- ),
- ],
- ),
- ),
- ),
- );
- }
-}
diff --git a/example/lib/login_form_example.dart b/example/lib/login_form_example.dart
new file mode 100644
index 0000000..ac9f9f9
--- /dev/null
+++ b/example/lib/login_form_example.dart
@@ -0,0 +1,179 @@
+import 'package:flutter/material.dart';
+import 'package:form_shield/form_shield.dart';
+
+class LoginFormExample extends StatefulWidget {
+ const LoginFormExample({super.key});
+
+ @override
+ State createState() => _LoginFormExampleState();
+}
+
+class _LoginFormExampleState extends State {
+ final _formKey = GlobalKey();
+ final _emailController = TextEditingController();
+ final _passwordController = TextEditingController();
+ bool _rememberMe = false;
+ bool _obscurePassword = true;
+
+ @override
+ void dispose() {
+ _emailController.dispose();
+ _passwordController.dispose();
+ super.dispose();
+ }
+
+ void _submitForm() {
+ if (_formKey.currentState!.validate()) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Processing login...'),
+ backgroundColor: Colors.blue,
+ behavior: SnackBarBehavior.floating,
+ ),
+ );
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('Login Form Example'), elevation: 0),
+ body: Center(
+ child: SingleChildScrollView(
+ child: Padding(
+ padding: const EdgeInsets.all(24.0),
+ child: Card(
+ elevation: 8,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(24.0),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const Icon(Icons.account_circle, size: 80),
+ const SizedBox(height: 24),
+ TextFormField(
+ controller: _emailController,
+ decoration: InputDecoration(
+ labelText: 'Email',
+ hintText: 'Enter your email',
+ prefixIcon: const Icon(Icons.email),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: const BorderSide(width: 2),
+ ),
+ ),
+ keyboardType: TextInputType.emailAddress,
+ validator:
+ Validator([
+ const RequiredRule(
+ errorMessage: 'Email is required',
+ ),
+ EmailRule(
+ errorMessage:
+ 'Please enter a valid email address',
+ ),
+ ]).call,
+ ),
+ const SizedBox(height: 20),
+ TextFormField(
+ controller: _passwordController,
+ decoration: InputDecoration(
+ labelText: 'Password',
+ hintText: 'Enter your password',
+ prefixIcon: const Icon(Icons.lock),
+ suffixIcon: IconButton(
+ icon: Icon(
+ _obscurePassword
+ ? Icons.visibility
+ : Icons.visibility_off,
+ ),
+ onPressed: () {
+ setState(() {
+ _obscurePassword = !_obscurePassword;
+ });
+ },
+ ),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: const BorderSide(width: 2),
+ ),
+ ),
+ obscureText: _obscurePassword,
+ validator:
+ Validator(const [
+ RequiredRule(
+ errorMessage: 'Password is required',
+ ),
+ PasswordRule(
+ options: PasswordOptions(
+ minLength: 8,
+ requireUppercase: true,
+ requireLowercase: true,
+ requireDigit: true,
+ requireSpecialChar: true,
+ ),
+ errorMessage:
+ 'Password must be at least 8 characters with uppercase, lowercase, digit, and special character',
+ ),
+ ]).call,
+ ),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ Checkbox(
+ value: _rememberMe,
+ onChanged: (value) {
+ setState(() {
+ _rememberMe = value ?? false;
+ });
+ },
+ activeColor: Colors.blue,
+ ),
+ const Text(
+ 'Remember me',
+ style: TextStyle(color: Colors.grey),
+ ),
+ ],
+ ),
+ const SizedBox(height: 24),
+ ElevatedButton(
+ onPressed: _submitForm,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.blue,
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ child: const Text(
+ 'Login',
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ color: Colors.white,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/example/lib/main.dart b/example/lib/main.dart
new file mode 100644
index 0000000..988a3af
--- /dev/null
+++ b/example/lib/main.dart
@@ -0,0 +1,62 @@
+import 'package:flutter/material.dart';
+import 'login_form_example.dart';
+import 'async_validation_example.dart';
+
+void main() {
+ runApp(const MyApp());
+}
+
+class MyApp extends StatelessWidget {
+ const MyApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'Form Shield Examples',
+ theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
+ home: const ExampleSelector(),
+ );
+ }
+}
+
+class ExampleSelector extends StatelessWidget {
+ const ExampleSelector({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('Form Shield Examples')),
+ body: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ ElevatedButton(
+ onPressed: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => const LoginFormExample(),
+ ),
+ );
+ },
+ child: const Text('Basic Form Example'),
+ ),
+ const SizedBox(height: 16),
+ ElevatedButton(
+ onPressed: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => const AsyncValidationExample(),
+ ),
+ );
+ },
+ child: const Text('Async Validation Example'),
+ ),
+ const SizedBox(height: 16),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/example/pubspec.lock b/example/pubspec.lock
new file mode 100644
index 0000000..124572c
--- /dev/null
+++ b/example/pubspec.lock
@@ -0,0 +1,220 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+ async:
+ dependency: transitive
+ description:
+ name: async
+ sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.12.0"
+ boolean_selector:
+ dependency: transitive
+ description:
+ name: boolean_selector
+ sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.2"
+ characters:
+ dependency: transitive
+ description:
+ name: characters
+ sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.0"
+ clock:
+ dependency: transitive
+ description:
+ name: clock
+ sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.2"
+ collection:
+ dependency: transitive
+ description:
+ name: collection
+ sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.19.1"
+ cupertino_icons:
+ dependency: "direct main"
+ description:
+ name: cupertino_icons
+ sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.8"
+ fake_async:
+ dependency: transitive
+ description:
+ name: fake_async
+ sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.2"
+ flutter:
+ dependency: "direct main"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ flutter_lints:
+ dependency: "direct dev"
+ description:
+ name: flutter_lints
+ sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.0.0"
+ flutter_test:
+ dependency: "direct dev"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ form_shield:
+ dependency: "direct main"
+ description:
+ path: ".."
+ relative: true
+ source: path
+ version: "0.2.2"
+ leak_tracker:
+ dependency: transitive
+ description:
+ name: leak_tracker
+ sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
+ url: "https://pub.dev"
+ source: hosted
+ version: "10.0.8"
+ leak_tracker_flutter_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_flutter_testing
+ sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.9"
+ leak_tracker_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_testing
+ sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.1"
+ lints:
+ dependency: transitive
+ description:
+ name: lints
+ sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.1.1"
+ matcher:
+ dependency: transitive
+ description:
+ name: matcher
+ sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.12.17"
+ material_color_utilities:
+ dependency: transitive
+ description:
+ name: material_color_utilities
+ sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.11.1"
+ meta:
+ dependency: transitive
+ description:
+ name: meta
+ sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.16.0"
+ path:
+ dependency: transitive
+ description:
+ name: path
+ sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.9.1"
+ sky_engine:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ source_span:
+ dependency: transitive
+ description:
+ name: source_span
+ sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.10.1"
+ stack_trace:
+ dependency: transitive
+ description:
+ name: stack_trace
+ sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.12.1"
+ stream_channel:
+ dependency: transitive
+ description:
+ name: stream_channel
+ sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.4"
+ string_scanner:
+ dependency: transitive
+ description:
+ name: string_scanner
+ sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.1"
+ term_glyph:
+ dependency: transitive
+ description:
+ name: term_glyph
+ sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.2"
+ test_api:
+ dependency: transitive
+ description:
+ name: test_api
+ sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.4"
+ vector_math:
+ dependency: transitive
+ description:
+ name: vector_math
+ sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.4"
+ vm_service:
+ dependency: transitive
+ description:
+ name: vm_service
+ sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
+ url: "https://pub.dev"
+ source: hosted
+ version: "14.3.1"
+sdks:
+ dart: ">=3.7.0 <4.0.0"
+ flutter: ">=3.18.0-18.0.pre.54"
diff --git a/example/pubspec.yaml b/example/pubspec.yaml
new file mode 100644
index 0000000..2ebdb27
--- /dev/null
+++ b/example/pubspec.yaml
@@ -0,0 +1,22 @@
+name: example
+description: "A new Flutter project."
+publish_to: 'none' # Remove this line if you wish to publish to pub.dev
+version: 1.0.0+1
+
+environment:
+ sdk: ^3.7.0
+
+dependencies:
+ flutter:
+ sdk: flutter
+
+ cupertino_icons: ^1.0.8
+ form_shield:
+ path: ../
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+ flutter_lints: ^5.0.0
+flutter:
+ uses-material-design: true
diff --git a/example/web/favicon.png b/example/web/favicon.png
new file mode 100644
index 0000000..8aaa46a
Binary files /dev/null and b/example/web/favicon.png differ
diff --git a/example/web/icons/Icon-192.png b/example/web/icons/Icon-192.png
new file mode 100644
index 0000000..b749bfe
Binary files /dev/null and b/example/web/icons/Icon-192.png differ
diff --git a/example/web/icons/Icon-512.png b/example/web/icons/Icon-512.png
new file mode 100644
index 0000000..88cfd48
Binary files /dev/null and b/example/web/icons/Icon-512.png differ
diff --git a/example/web/icons/Icon-maskable-192.png b/example/web/icons/Icon-maskable-192.png
new file mode 100644
index 0000000..eb9b4d7
Binary files /dev/null and b/example/web/icons/Icon-maskable-192.png differ
diff --git a/example/web/icons/Icon-maskable-512.png b/example/web/icons/Icon-maskable-512.png
new file mode 100644
index 0000000..d69c566
Binary files /dev/null and b/example/web/icons/Icon-maskable-512.png differ
diff --git a/example/web/index.html b/example/web/index.html
new file mode 100644
index 0000000..29b5808
--- /dev/null
+++ b/example/web/index.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ example
+
+
+
+
+
+
diff --git a/example/web/manifest.json b/example/web/manifest.json
new file mode 100644
index 0000000..096edf8
--- /dev/null
+++ b/example/web/manifest.json
@@ -0,0 +1,35 @@
+{
+ "name": "example",
+ "short_name": "example",
+ "start_url": ".",
+ "display": "standalone",
+ "background_color": "#0175C2",
+ "theme_color": "#0175C2",
+ "description": "A new Flutter project.",
+ "orientation": "portrait-primary",
+ "prefer_related_applications": false,
+ "icons": [
+ {
+ "src": "icons/Icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "icons/Icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ },
+ {
+ "src": "icons/Icon-maskable-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "icons/Icon-maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/lib/form_shield.dart b/lib/form_shield.dart
index 319ff89..1e01507 100644
--- a/lib/form_shield.dart
+++ b/lib/form_shield.dart
@@ -1,6 +1,7 @@
export 'src/validator.dart';
export 'src/validation_rule.dart';
export 'src/validation_result.dart';
+export 'src/async_validation_state.dart';
export 'src/rules/email_rule.dart';
export 'src/rules/password_rule.dart';
export 'src/rules/length_rule.dart';
diff --git a/lib/src/async_validation_state.dart b/lib/src/async_validation_state.dart
new file mode 100644
index 0000000..4fa6ad3
--- /dev/null
+++ b/lib/src/async_validation_state.dart
@@ -0,0 +1,46 @@
+import 'package:flutter/foundation.dart';
+
+/// Manages the state of asynchronous validation with notifiers.
+class AsyncValidationState extends ValueNotifier {
+ AsyncValidationState() : super(AsyncValidationStateData.initial());
+
+ void validating() => value = AsyncValidationStateData.validating();
+ void valid() => value = AsyncValidationStateData.valid();
+ void invalid(String message) =>
+ value = AsyncValidationStateData.invalid(message);
+ void reset() => value = AsyncValidationStateData.initial();
+
+ bool get isValidating => value.isValidating;
+ bool get isValid => value.isValid ?? false;
+ String? get errorMessage => value.errorMessage;
+}
+
+class AsyncValidationStateData {
+ final bool isValidating;
+ final bool? isValid;
+ final String? errorMessage;
+
+ const AsyncValidationStateData._({
+ required this.isValidating,
+ this.isValid,
+ this.errorMessage,
+ });
+
+ factory AsyncValidationStateData.initial() =>
+ const AsyncValidationStateData._(isValidating: false);
+ factory AsyncValidationStateData.validating() =>
+ const AsyncValidationStateData._(isValidating: true);
+ factory AsyncValidationStateData.valid() =>
+ const AsyncValidationStateData._(isValidating: false, isValid: true);
+ factory AsyncValidationStateData.invalid(String message) =>
+ AsyncValidationStateData._(
+ isValidating: false,
+ isValid: false,
+ errorMessage: message,
+ );
+
+ @override
+ String toString() {
+ return 'AsyncValidationStateData(isValidating: $isValidating, isValid: $isValid, errorMessage: $errorMessage)';
+ }
+}
diff --git a/lib/src/validation_rule.dart b/lib/src/validation_rule.dart
index 32e60b5..7c8aad1 100644
--- a/lib/src/validation_rule.dart
+++ b/lib/src/validation_rule.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'validation_result.dart';
/// Base interface for all validation rules.
@@ -10,4 +12,27 @@ abstract class ValidationRule {
/// Validates the given value and returns a [ValidationResult].
ValidationResult validate(T? value);
+
+ /// Validates the given value asynchronously and returns a [Future].
+ ///
+ /// This method is only used for async validation rules. By default, it wraps the
+ /// synchronous [validate] method in a Future.
+ // Asynchronous validation (override if needed)
+ Future validateAsync(T? value) async => validate(value);
+}
+
+/// Base interface for asynchronous validation rules.
+abstract class AsyncValidationRule extends ValidationRule {
+ /// Creates an asynchronous validation rule with the specified error message.
+ const AsyncValidationRule({required super.errorMessage});
+
+ @override
+ ValidationResult validate(T? value) {
+ // This is a fallback for sync validation contexts
+ // Ideally, async rules should be validated using validateAsync
+ return const ValidationResult.success();
+ }
+
+ @override
+ Future validateAsync(T? value);
}
diff --git a/lib/src/validator.dart b/lib/src/validator.dart
index f3a6950..57f8bb4 100644
--- a/lib/src/validator.dart
+++ b/lib/src/validator.dart
@@ -1,74 +1,171 @@
-import 'validation_rule.dart';
+import 'dart:async';
+import 'package:form_shield/form_shield.dart';
/// A validator that can be used with Flutter's form validation system.
///
-/// This class provides a way to chain multiple validation rules and
-/// is compatible with Flutter's built-in form validation.
-/// A generic validator class designed for integration with Flutter's form
-/// validation mechanism.
-///
-/// This class enables the chaining of multiple `ValidationRule` instances
-/// and provides a `call` method compatible with `FormFieldValidator`.
+/// This class provides a way to chain multiple validation rules, supports
+/// asynchronous validation with state notifiers, and includes a customizable
+/// debounce timer for async operations. It is compatible with Flutter's
+/// built-in `FormFieldValidator`.
class Validator {
/// The list of validation rules to apply.
final List> _rules;
- /// Creates a validator with the specified validation rules.
+ /// The state of asynchronous validation.
+ final AsyncValidationState _asyncState;
+
+ Timer? _debounceTimer;
+
+ /// Default debounce duration for async validation.
+ final Duration _debounceDuration;
+
+ String? _syncErrorMessage;
+
+ T? _lastValidatedValue;
+
/// Creates an immutable `Validator` instance with the provided list of rules.
- Validator(List> rules) : _rules = List.unmodifiable(rules);
+ Validator(
+ List> rules, {
+ Duration debounceDuration = const Duration(milliseconds: 300),
+ }) : _rules = List.unmodifiable(rules),
+ _asyncState = AsyncValidationState(),
+ _debounceDuration = debounceDuration,
+ _syncErrorMessage = null;
+
+ static Validator forString(
+ List> rules, {
+ Duration debounceDuration = const Duration(milliseconds: 300),
+ }) {
+ return Validator(rules, debounceDuration: debounceDuration);
+ }
+
+ static Validator forNumber(
+ List> rules, {
+ Duration debounceDuration = const Duration(milliseconds: 300),
+ }) {
+ return Validator(rules, debounceDuration: debounceDuration);
+ }
+
+ static Validator forBoolean(
+ List> rules, {
+ Duration debounceDuration = const Duration(milliseconds: 300),
+ }) {
+ return Validator(rules, debounceDuration: debounceDuration);
+ }
+
+ static Validator forDate(
+ List> rules, {
+ Duration debounceDuration = const Duration(milliseconds: 300),
+ }) {
+ return Validator(rules, debounceDuration: debounceDuration);
+ }
/// Creates a new `Validator` instance by adding the provided [rule]
- /// to the existing list of rules.
- ///
- /// This allows for fluent chaining of validation rules.
+ /// to the existing list of rules, preserving the debounce duration.
Validator addRule(ValidationRule rule) {
- return Validator([..._rules, rule]);
+ return Validator([..._rules, rule], debounceDuration: _debounceDuration);
}
+ /// Returns the async validation state.
+ ///
+ /// Note: This property is maintained for backward compatibility.
+ /// Consider using the direct properties (isValidating, isValid) instead.
+ AsyncValidationState get asyncState => _asyncState;
+
+ /// Returns true if async validation is currently in progress.
+ bool get isValidating => _asyncState.isValidating;
+
+ /// Returns true if the last async validation was successful.
+ bool get isValid => _asyncState.isValid;
+
+ /// Returns the current error message from either sync or async validation.
+ /// Sync errors take precedence over async errors.
+ String? get errorMessage => _syncErrorMessage ?? _asyncState.errorMessage;
+
/// Executes the validation logic for the given [value] against all registered rules.
///
- /// Iterates through the `_rules` list and applies each rule's `validate` method.
- /// Returns `null` if the [value] passes all validation rules.
- /// Otherwise, returns the `errorMessage` from the first `ValidationRule` that fails.
+ /// Synchronous rules are applied immediately, returning an error message if any fail.
+ /// If all sync rules pass and rules exist, triggers debounced async validation.
+ /// Returns `null` if sync validation passes, with async results reflected in `asyncState`.
String? call(T? value) {
+ // Run sync validation
for (final rule in _rules) {
final result = rule.validate(value);
if (!result.isValid) {
- return result.errorMessage;
+ _syncErrorMessage = result.errorMessage;
+ _asyncState.reset();
+ return _syncErrorMessage;
}
}
+
+ _syncErrorMessage = null;
+
+ if (value != _lastValidatedValue) {
+ _lastValidatedValue = value;
+ _triggerAsyncValidation(value);
+ }
+
return null;
}
-}
-/// Provides convenient static factory methods for creating `Validator` instances
-/// for common data types.
-extension ValidatorExtensions on Validator {
- /// Creates a `Validator` specifically for `String` types.
- ///
- /// Takes a list of `ValidationRule` to apply.
- static Validator forString(List> rules) {
- return Validator(rules);
- }
+ /// Triggers asynchronous validation with debouncing for the given [value].
+ void _triggerAsyncValidation(T? value) {
+ _debounceTimer?.cancel();
+ _asyncState.validating();
- /// Creates a `Validator` specifically for `num` types (int or double).
- ///
- /// Takes a list of `ValidationRule` to apply.
- static Validator forNumber(List> rules) {
- return Validator(rules);
+ _debounceTimer = Timer(_debounceDuration, () async {
+ for (final rule in _rules) {
+ try {
+ final result = await rule.validateAsync(value);
+ if (!result.isValid) {
+ _asyncState.invalid(result.errorMessage!);
+ return;
+ }
+ } catch (e) {
+ _asyncState.invalid('Validation error: $e');
+ return;
+ }
+ }
+ _asyncState.valid();
+ });
}
- /// Creates a `Validator` specifically for `bool` types.
+ /// Manually triggers async validation with an optional custom debounce duration.
///
- /// Takes a list of `ValidationRule` to apply.
- static Validator forBoolean(List> rules) {
- return Validator(rules);
+ /// Returns `true` if all validations pass, `false` otherwise.
+ Future validateAsync(T? value, {Duration? debounceDuration}) async {
+ final completer = Completer();
+ _debounceTimer?.cancel();
+
+ _asyncState.validating();
+
+ _debounceTimer = Timer(debounceDuration ?? _debounceDuration, () async {
+ for (final rule in _rules) {
+ if (completer.isCompleted) break;
+ try {
+ final result = await rule.validateAsync(value);
+ if (!result.isValid) {
+ _asyncState.invalid(result.errorMessage!);
+ completer.complete(false);
+ return;
+ }
+ } catch (e) {
+ _asyncState.invalid('Validation error: $e');
+ completer.complete(false);
+ return;
+ }
+ }
+ if (!completer.isCompleted) {
+ _asyncState.valid();
+ completer.complete(true);
+ }
+ });
+
+ return completer.future;
}
- /// Creates a `Validator` specifically for `DateTime` types.
- ///
- /// Takes a list of `ValidationRule` to apply.
- static Validator forDate(List> rules) {
- return Validator(rules);
+ /// Cleans up resources by canceling any pending debounce timer.
+ void dispose() {
+ _debounceTimer?.cancel();
}
}
diff --git a/pubspec.yaml b/pubspec.yaml
index 0932ad4..f77747c 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,6 +1,6 @@
name: form_shield
-description: A declarative, rule-based form validation library for Flutter apps.
-version: 0.2.2
+description: A declarative, rule-based form validation library for Flutter apps. Supports async validation, custom validation logic, and more.
+version: 0.3.0
homepage: https://github.com/stevenosse/form_shield
issue_tracker: https://github.com/stevenosse/form_shield/issues
diff --git a/test/src/async_validation_rule_test.dart b/test/src/async_validation_rule_test.dart
new file mode 100644
index 0000000..0222779
--- /dev/null
+++ b/test/src/async_validation_rule_test.dart
@@ -0,0 +1,95 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:form_shield/form_shield.dart';
+
+class TestAsyncValidationRule extends AsyncValidationRule {
+ final bool shouldPass;
+ final bool shouldThrowError;
+ final Duration delay;
+
+ const TestAsyncValidationRule({
+ required this.shouldPass,
+ this.shouldThrowError = false,
+ this.delay = const Duration(milliseconds: 100),
+ required super.errorMessage,
+ });
+
+ @override
+ Future validateAsync(String? value) async {
+ await Future.delayed(delay);
+
+ if (shouldThrowError) {
+ throw Exception('Test validation error');
+ }
+
+ if (shouldPass) {
+ return const ValidationResult.success();
+ } else {
+ return ValidationResult.error(errorMessage);
+ }
+ }
+}
+
+void main() {
+ group('AsyncValidationRule', () {
+ const errorMessage = 'Test error message';
+
+ test('constructor sets error message correctly', () {
+ final rule = TestAsyncValidationRule(
+ shouldPass: true,
+ errorMessage: errorMessage,
+ );
+
+ expect(rule.errorMessage, errorMessage);
+ });
+
+ test('validate returns success by default for sync validation', () {
+ final rule = TestAsyncValidationRule(
+ shouldPass: false, // Even with shouldPass=false
+ errorMessage: errorMessage,
+ );
+
+ final result = rule.validate('test');
+
+ // Sync validation should always pass for AsyncValidationRule
+ expect(result.isValid, true);
+ expect(result.errorMessage, null);
+ });
+
+ test('validateAsync returns success result when validation passes',
+ () async {
+ final rule = TestAsyncValidationRule(
+ shouldPass: true,
+ errorMessage: errorMessage,
+ );
+
+ final result = await rule.validateAsync('test');
+
+ expect(result.isValid, true);
+ expect(result.errorMessage, null);
+ });
+
+ test(
+ 'validateAsync returns error result with correct message when validation fails',
+ () async {
+ final rule = TestAsyncValidationRule(
+ shouldPass: false,
+ errorMessage: errorMessage,
+ );
+
+ final result = await rule.validateAsync('test');
+
+ expect(result.isValid, false);
+ expect(result.errorMessage, errorMessage);
+ });
+
+ test('validateAsync propagates exceptions', () async {
+ final rule = TestAsyncValidationRule(
+ shouldPass: true,
+ shouldThrowError: true,
+ errorMessage: errorMessage,
+ );
+
+ expect(() => rule.validateAsync('test'), throwsException);
+ });
+ });
+}
diff --git a/test/src/async_validation_state_test.dart b/test/src/async_validation_state_test.dart
new file mode 100644
index 0000000..db76267
--- /dev/null
+++ b/test/src/async_validation_state_test.dart
@@ -0,0 +1,111 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:form_shield/form_shield.dart';
+
+void main() {
+ group('AsyncValidationState', () {
+ late AsyncValidationState state;
+
+ setUp(() {
+ state = AsyncValidationState();
+ });
+
+ test('initial state is not validating and has no validation result', () {
+ expect(state.isValidating, false);
+ expect(state.isValid, false);
+ expect(state.errorMessage, null);
+ });
+
+ test('validating() sets isValidating to true', () {
+ state.validating();
+ expect(state.isValidating, true);
+ expect(state.isValid, false);
+ expect(state.errorMessage, null);
+ });
+
+ test('valid() sets isValidating to false and isValid to true', () {
+ state.valid();
+ expect(state.isValidating, false);
+ expect(state.isValid, true);
+ expect(state.errorMessage, null);
+ });
+
+ test(
+ 'invalid() sets isValidating to false, isValid to false, and sets error message',
+ () {
+ const errorMessage = 'Test error message';
+ state.invalid(errorMessage);
+ expect(state.isValidating, false);
+ expect(state.isValid, false);
+ expect(state.errorMessage, errorMessage);
+ });
+
+ test('reset() returns to initial state', () {
+ // First change the state
+ state.invalid('Error');
+ expect(state.isValid, false);
+ expect(state.errorMessage, 'Error');
+
+ // Then reset
+ state.reset();
+ expect(state.isValidating, false);
+ expect(state.isValid, false);
+ expect(state.errorMessage, null);
+ });
+
+ test('state transitions work correctly in sequence', () {
+ // Initial -> Validating -> Valid
+ expect(state.isValidating, false);
+ state.validating();
+ expect(state.isValidating, true);
+ state.valid();
+ expect(state.isValidating, false);
+ expect(state.isValid, true);
+
+ // Valid -> Validating -> Invalid
+ state.validating();
+ expect(state.isValidating, true);
+ state.invalid('Error');
+ expect(state.isValidating, false);
+ expect(state.isValid, false);
+ expect(state.errorMessage, 'Error');
+ });
+ });
+
+ group('AsyncValidationStateData', () {
+ test('initial factory creates correct state', () {
+ final state = AsyncValidationStateData.initial();
+ expect(state.isValidating, false);
+ expect(state.isValid, null);
+ expect(state.errorMessage, null);
+ });
+
+ test('validating factory creates correct state', () {
+ final state = AsyncValidationStateData.validating();
+ expect(state.isValidating, true);
+ expect(state.isValid, null);
+ expect(state.errorMessage, null);
+ });
+
+ test('valid factory creates correct state', () {
+ final state = AsyncValidationStateData.valid();
+ expect(state.isValidating, false);
+ expect(state.isValid, true);
+ expect(state.errorMessage, null);
+ });
+
+ test('invalid factory creates correct state', () {
+ const errorMessage = 'Test error';
+ final state = AsyncValidationStateData.invalid(errorMessage);
+ expect(state.isValidating, false);
+ expect(state.isValid, false);
+ expect(state.errorMessage, errorMessage);
+ });
+
+ test('toString returns correct representation', () {
+ final state = AsyncValidationStateData.invalid('Error');
+ expect(state.toString(), contains('isValidating: false'));
+ expect(state.toString(), contains('isValid: false'));
+ expect(state.toString(), contains('errorMessage: Error'));
+ });
+ });
+}
diff --git a/test/src/validator_test.dart b/test/src/validator_test.dart
index 5dbb119..17c519f 100644
--- a/test/src/validator_test.dart
+++ b/test/src/validator_test.dart
@@ -74,7 +74,7 @@ void main() {
final rule = MockValidationRule(
shouldPass: false, errorMessage: 'Error');
- final validator = ValidatorExtensions.forString([rule]);
+ final validator = Validator.forString([rule]);
expect(validator, isA>());
expect(validator('test'), 'Error');
@@ -84,7 +84,7 @@ void main() {
final rule =
MockValidationRule(shouldPass: false, errorMessage: 'Error');
- final validator = ValidatorExtensions.forNumber([rule]);
+ final validator = Validator.forNumber([rule]);
expect(validator, isA>());
expect(validator(42), 'Error');
@@ -94,7 +94,7 @@ void main() {
final rule =
MockValidationRule(shouldPass: false, errorMessage: 'Error');
- final validator = ValidatorExtensions.forBoolean([rule]);
+ final validator = Validator.forBoolean([rule]);
expect(validator, isA>());
expect(validator(true), 'Error');
@@ -104,7 +104,7 @@ void main() {
final rule = MockValidationRule(
shouldPass: false, errorMessage: 'Error');
- final validator = ValidatorExtensions.forDate([rule]);
+ final validator = Validator.forDate([rule]);
expect(validator, isA>());
expect(validator(DateTime.now()), 'Error');