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 @@ Form Shield Logo

-[![pub version](https://img.shields.io/pub/v/form_shield.svg)](https://pub.dev/packages/form_shield) -[![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT) -[![codecov](https://codecov.io/gh/stevenosse/form_shield/branch/main/graph/badge.svg)](https://codecov.io/gh/stevenosse/form_shield) +

+ pub version + license + codecov +

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');