From 4cde5eac95dbaac12cb819c22a3036ab8a1f7fdb Mon Sep 17 00:00:00 2001 From: Pedro Moreira Date: Tue, 13 Oct 2026 12:56:07 -0300 Subject: [PATCH] feat: add FormzInputAsyncValidationMixin to hold the result of an asynchronous validation --- README.md | 72 ++++++++++++ example/android/build.gradle | 4 +- example/android/gradle.properties | 4 + example/lib/async_form.dart | 181 ++++++++++++++++++++++++++++++ example/lib/main.dart | 30 ++++- example/test/async_form_test.dart | 124 ++++++++++++++++++++ lib/formz.dart | 92 +++++++++++++++ test/formz_test.dart | 144 ++++++++++++++++++++++++ test/helpers/name_input.dart | 60 ++++++++++ 9 files changed, 704 insertions(+), 7 deletions(-) create mode 100644 example/lib/async_form.dart create mode 100644 example/test/async_form_test.dart diff --git a/README.md b/README.md index 15700ab..913fef6 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,78 @@ class Email extends FormzInput } ``` +## Asynchronous validation + +When part of the validation is asynchronous, for example checking if a username is taken or validating a phone number with a plugin, use the `FormzInputAsyncValidationMixin` mixin. Formz does not run the asynchronous validation itself. You run it wherever you manage your state and store its result in the input through `asyncStatus` and `asyncError`. + +```dart +import 'package:formz/formz.dart'; + +enum PhoneNumberError { empty, invalid } + +class PhoneNumber extends FormzInput + with FormzInputAsyncValidationMixin { + const PhoneNumber.pure() + : asyncStatus = FormzAsyncValidationStatus.initial, + asyncError = null, + super.pure(''); + + const PhoneNumber.dirty( + super.value, { + this.asyncStatus = FormzAsyncValidationStatus.initial, + this.asyncError, + }) : super.dirty(); + + @override + final FormzAsyncValidationStatus asyncStatus; + + @override + final PhoneNumberError? asyncError; + + // The synchronous validator still runs and takes precedence. + @override + PhoneNumberError? validator(String value) { + return value.isEmpty ? PhoneNumberError.empty : null; + } +} +``` + +The input is only valid once `asyncStatus` is `completed` and neither validation returned an error. A typical flow in a Bloc looks like this: + +```dart +Future _onPhoneNumberChanged( + PhoneNumberChanged event, + Emitter emit, +) async { + final value = event.value; + emit( + state.copyWith( + phoneNumber: PhoneNumber.dirty( + value, + asyncStatus: FormzAsyncValidationStatus.inProgress, + ), + ), + ); + + final isValid = await _phoneNumberValidator.isValid(value); + + // Ignore the result if the value changed while validating. + if (state.phoneNumber.value != value) return; + + emit( + state.copyWith( + phoneNumber: PhoneNumber.dirty( + value, + asyncStatus: FormzAsyncValidationStatus.completed, + asyncError: isValid ? null : PhoneNumberError.invalid, + ), + ), + ); +} +``` + +When combining it with `FormzInputErrorCacheMixin`, apply `FormzInputAsyncValidationMixin` last. + [ci_badge]: https://github.com/VeryGoodOpenSource/formz/actions/workflows/main.yaml/badge.svg [ci_link]: https://github.com/VeryGoodOpenSource/formz/actions [coverage_badge]: https://raw.githubusercontent.com/VeryGoodOpenSource/formz/main/coverage_badge.svg diff --git a/example/android/build.gradle b/example/android/build.gradle index 24047dc..22d96ff 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -26,6 +26,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { - delete rootProject.buildDir +tasks.register("clean", Delete) { + delete rootProject.layout.buildDirectory } diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 94adc3a..eac5ff9 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/example/lib/async_form.dart b/example/lib/async_form.dart new file mode 100644 index 0000000..65b0f4d --- /dev/null +++ b/example/lib/async_form.dart @@ -0,0 +1,181 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:formz/formz.dart'; + +/// Usernames that are already in use, used to simulate an asynchronous check. +const _takenUsernames = {'admin', 'dash', 'flutter'}; + +class AsyncForm extends StatefulWidget { + const new({super.key}); + + @override + State createState() => _AsyncFormState(); +} + +class _AsyncFormState extends State { + late AsyncFormState _state; + late final TextEditingController _usernameController; + + void _onUsernameChanged() { + final value = _usernameController.text; + if (!_state.username.isPure && value == _state.username.value) return; + + // Only run the asynchronous check when the synchronous validation passes. + if (_state.username.validator(value) != null) { + setState(() => _state = AsyncFormState(username: Username.dirty(value))); + return; + } + + setState(() { + _state = AsyncFormState( + username: Username.dirty( + value, + asyncStatus: FormzAsyncValidationStatus.inProgress, + ), + ); + }); + unawaited(_validateUsernameAsync(value)); + } + + Future _validateUsernameAsync(String value) async { + final isTaken = await _isUsernameTaken(value); + + // Ignore the result if the username changed while it was being checked. + if (!mounted || _state.username.value != value) return; + + setState(() { + _state = AsyncFormState( + username: Username.dirty( + value, + asyncStatus: FormzAsyncValidationStatus.completed, + asyncError: isTaken ? UsernameValidationError.taken : null, + ), + ); + }); + } + + Future _isUsernameTaken(String username) async { + await Future.delayed(const Duration(milliseconds: 500)); + return _takenUsernames.contains(username.toLowerCase()); + } + + void _onSubmit() { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text('Welcome, ${_state.username.value}! 🎉')), + ); + + _usernameController.clear(); + setState(() => _state = const AsyncFormState()); + } + + @override + void initState() { + super.initState(); + _state = const AsyncFormState(); + _usernameController = TextEditingController(text: _state.username.value) + ..addListener(_onUsernameChanged); + } + + @override + void dispose() { + _usernameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final username = _state.username; + + return Column( + children: [ + TextField( + key: const Key('asyncForm_usernameInput'), + controller: _usernameController, + decoration: InputDecoration( + icon: const Icon(Icons.person), + labelText: 'Username', + helperText: 'Try "dash" to see a username that is already taken', + errorText: username.displayError?.text(), + suffixIcon: username.asyncStatus.isInProgress + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : username.isValid + ? const Icon(Icons.check_circle, color: Colors.green) + : null, + ), + textInputAction: TextInputAction.done, + ), + const SizedBox(height: 24), + ElevatedButton( + key: const Key('asyncForm_submit'), + // The form is only valid once the asynchronous check has completed. + onPressed: _state.isValid ? _onSubmit : null, + child: const Text('Create account'), + ), + ], + ); + } +} + +class AsyncFormState with FormzMixin { + const new({this.username = const Username.pure()}); + + final Username username; + + @override + List> get inputs => [username]; +} + +enum UsernameValidationError { empty, tooShort, taken } + +class Username extends FormzInput + with FormzInputAsyncValidationMixin { + const new pure() + : asyncStatus = FormzAsyncValidationStatus.initial, + asyncError = null, + super.pure(''); + + const new dirty( + super.value, { + this.asyncStatus = FormzAsyncValidationStatus.initial, + this.asyncError, + }) : super.dirty(); + + @override + final FormzAsyncValidationStatus asyncStatus; + + @override + final UsernameValidationError? asyncError; + + @override + UsernameValidationError? validator(String value) { + if (value.isEmpty) { + return UsernameValidationError.empty; + } else if (value.length < 3) { + return UsernameValidationError.tooShort; + } + + return null; + } +} + +extension on UsernameValidationError { + String text() { + switch (this) { + case UsernameValidationError.empty: + return 'Please enter a username'; + case UsernameValidationError.tooShort: + return 'Username must be at least 3 characters'; + case UsernameValidationError.taken: + return 'This username is already taken'; + } + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index fecbbec..ba1bc8d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:example/async_form.dart'; import 'package:flutter/material.dart'; import 'package:formz/formz.dart'; @@ -11,11 +12,30 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - home: Scaffold( - appBar: AppBar(title: const Text('Formz Example')), - body: Padding( - padding: const EdgeInsets.all(24), - child: SingleChildScrollView(child: MyForm()), + home: DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Formz Example'), + bottom: const TabBar( + tabs: [ + Tab(text: 'Basic'), + Tab(text: 'Async validation'), + ], + ), + ), + body: TabBarView( + children: [ + Padding( + padding: const EdgeInsets.all(24), + child: SingleChildScrollView(child: MyForm()), + ), + const Padding( + padding: EdgeInsets.all(24), + child: SingleChildScrollView(child: AsyncForm()), + ), + ], + ), ), ), ); diff --git a/example/test/async_form_test.dart b/example/test/async_form_test.dart new file mode 100644 index 0000000..154b7e1 --- /dev/null +++ b/example/test/async_form_test.dart @@ -0,0 +1,124 @@ +import 'package:example/async_form.dart'; +import 'package:example/main.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final Finder _usernameInput = find.byKey(const Key('asyncForm_usernameInput')); +final Finder _submitButton = find.byKey(const Key('asyncForm_submit')); + +void main() { + group('$MyApp', () { + testWidgets('renders $AsyncForm in the async validation tab', ( + tester, + ) async { + await tester.pumpWidget(const MyApp()); + + await tester.tap(find.text('Async validation')); + await tester.pumpAndSettle(); + + expect(find.byType(AsyncForm), findsOneWidget); + }); + }); + + group('$AsyncForm', () { + testWidgets('submit is disabled initially', (tester) async { + await tester.pumpAsyncForm(); + + expect(tester.isSubmitEnabled, isFalse); + }); + + testWidgets('shows error when username is empty', (tester) async { + await tester.pumpAsyncForm(); + + await tester.enterText(_usernameInput, 'joe'); + await tester.pumpAndSettle(); + await tester.enterText(_usernameInput, ''); + await tester.pumpAndSettle(); + + expect(find.text('Please enter a username'), findsOneWidget); + expect(tester.isSubmitEnabled, isFalse); + }); + + testWidgets('shows error without checking when username is too short', ( + tester, + ) async { + await tester.pumpAsyncForm(); + + await tester.enterText(_usernameInput, 'jo'); + await tester.pump(); + + expect( + find.text('Username must be at least 3 characters'), + findsOneWidget, + ); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(tester.isSubmitEnabled, isFalse); + }); + + testWidgets('shows progress indicator while checking username', ( + tester, + ) async { + await tester.pumpAsyncForm(); + + await tester.enterText(_usernameInput, 'joe'); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(tester.isSubmitEnabled, isFalse); + + await tester.pumpAndSettle(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byIcon(Icons.check_circle), findsOneWidget); + expect(tester.isSubmitEnabled, isTrue); + }); + + testWidgets('shows error when username is already taken', (tester) async { + await tester.pumpAsyncForm(); + + await tester.enterText(_usernameInput, 'Dash'); + await tester.pumpAndSettle(); + + expect(find.text('This username is already taken'), findsOneWidget); + expect(tester.isSubmitEnabled, isFalse); + }); + + testWidgets('ignores result of an outdated check', (tester) async { + await tester.pumpAsyncForm(); + + await tester.enterText(_usernameInput, 'joe'); + await tester.pump(const Duration(milliseconds: 100)); + await tester.enterText(_usernameInput, 'dash'); + await tester.pumpAndSettle(); + + expect(find.text('This username is already taken'), findsOneWidget); + expect(tester.isSubmitEnabled, isFalse); + }); + + testWidgets('submits and resets the form', (tester) async { + await tester.pumpAsyncForm(); + + await tester.enterText(_usernameInput, 'joe'); + await tester.pumpAndSettle(); + await tester.tap(_submitButton); + await tester.pumpAndSettle(); + + expect(find.text('Welcome, joe! 🎉'), findsOneWidget); + expect( + tester.widget(_usernameInput).controller!.text, + isEmpty, + ); + expect(find.text('Please enter a username'), findsNothing); + expect(tester.isSubmitEnabled, isFalse); + }); + }); +} + +extension on WidgetTester { + Future pumpAsyncForm() async { + await pumpWidget(const MaterialApp(home: Scaffold(body: AsyncForm()))); + } + + bool get isSubmitEnabled => + widget(_submitButton).onPressed != null; +} diff --git a/lib/formz.dart b/lib/formz.dart index 720e160..bea4db0 100644 --- a/lib/formz.dart +++ b/lib/formz.dart @@ -143,6 +143,98 @@ mixin FormzInputErrorCacheMixin on FormzInput { bool get isValid => _error == null; } +/// Enum representing the status of an asynchronous validation. +enum FormzAsyncValidationStatus { + /// The asynchronous validation has not started yet. + initial, + + /// The asynchronous validation is in progress. + inProgress, + + /// The asynchronous validation has completed. + completed, +} + +/// Useful extensions on [FormzAsyncValidationStatus] +extension FormzAsyncValidationStatusX on FormzAsyncValidationStatus { + /// Indicates whether the asynchronous validation has not started yet. + bool get isInitial => this == FormzAsyncValidationStatus.initial; + + /// Indicates whether the asynchronous validation is in progress. + bool get isInProgress => this == FormzAsyncValidationStatus.inProgress; + + /// Indicates whether the asynchronous validation has completed. + bool get isCompleted => this == FormzAsyncValidationStatus.completed; +} + +/// {@template formz_input_async_validation_mixin} +/// Mixin for [FormzInput] that adds support for asynchronous validation. +/// +/// Formz does not run the asynchronous validation itself. It is run outside +/// of the input, for example in a Bloc, and its result is stored in a new +/// instance of the input through [asyncStatus] and [asyncError]. +/// +/// The synchronous `validator` still runs and its error takes precedence over +/// [asyncError]. The input is only valid once the asynchronous validation has +/// completed and neither validation returned an error. +/// +/// ```dart +/// enum PhoneNumberError { empty, invalid } +/// +/// class PhoneNumber extends FormzInput +/// with FormzInputAsyncValidationMixin { +/// const PhoneNumber.pure() +/// : asyncStatus = FormzAsyncValidationStatus.initial, +/// asyncError = null, +/// super.pure(''); +/// +/// const PhoneNumber.dirty( +/// super.value, { +/// this.asyncStatus = FormzAsyncValidationStatus.initial, +/// this.asyncError, +/// }) : super.dirty(); +/// +/// @override +/// final FormzAsyncValidationStatus asyncStatus; +/// +/// @override +/// final PhoneNumberError? asyncError; +/// +/// @override +/// PhoneNumberError? validator(String value) { +/// return value.isEmpty ? PhoneNumberError.empty : null; +/// } +/// } +/// ``` +/// +/// When combined with [FormzInputErrorCacheMixin], this mixin must be applied +/// last so that the cached synchronous error is used. +/// {@endtemplate} +mixin FormzInputAsyncValidationMixin on FormzInput { + /// The status of the asynchronous validation. + FormzAsyncValidationStatus get asyncStatus; + + /// The error returned by the asynchronous validation, if any. + E? get asyncError; + + @override + E? get error => super.error ?? asyncError; + + @override + bool get isValid => asyncStatus.isCompleted && error == null; + + @override + int get hashCode => Object.hashAll([value, isPure, asyncStatus, asyncError]); + + @override + bool operator ==(Object other) { + return super == other && + other is FormzInputAsyncValidationMixin && + other.asyncStatus == asyncStatus && + other.asyncError == asyncError; + } +} + /// Class which contains methods that help manipulate and manage /// validity of [FormzInput] instances. class Formz { diff --git a/test/formz_test.dart b/test/formz_test.dart index 05a80af..d70f6a9 100644 --- a/test/formz_test.dart +++ b/test/formz_test.dart @@ -109,6 +109,150 @@ void main() { }); }); + group('FormzInputAsyncValidationMixin', () { + test('is not valid before async validation completes', () { + expect(const UsernameInput.pure().isValid, isFalse); + expect(const UsernameInput.dirty(value: 'joe').isValid, isFalse); + expect( + const UsernameInput.dirty( + value: 'joe', + asyncStatus: FormzAsyncValidationStatus.inProgress, + ).isValid, + isFalse, + ); + }); + + test('is valid when async validation completes without error', () { + const input = UsernameInput.dirty( + value: 'joe', + asyncStatus: FormzAsyncValidationStatus.completed, + ); + expect(input.isValid, isTrue); + expect(input.isNotValid, isFalse); + expect(input.error, isNull); + expect(input.displayError, isNull); + }); + + test('is not valid when async validation returns an error', () { + const input = UsernameInput.dirty( + value: 'joe', + asyncStatus: FormzAsyncValidationStatus.completed, + asyncError: UsernameError.taken, + ); + expect(input.isValid, isFalse); + expect(input.error, UsernameError.taken); + expect(input.displayError, UsernameError.taken); + }); + + test('sync error takes precedence over async error', () { + const input = UsernameInput.dirty( + asyncStatus: FormzAsyncValidationStatus.completed, + asyncError: UsernameError.taken, + ); + expect(input.isValid, isFalse); + expect(input.error, UsernameError.empty); + }); + + test('works with FormzInputErrorCacheMixin', () { + final input = UsernameInputErrorCache.dirty( + value: 'joe', + asyncStatus: FormzAsyncValidationStatus.completed, + asyncError: UsernameError.taken, + ); + expect(input.error, UsernameError.taken); + expect(input.isValid, isFalse); + expect(input.error, UsernameError.taken); + expect(input.validatorCalls, 1); + }); + + test('works with FormzMixin', () { + expect(UsernameInputFormzMixin().isValid, isFalse); + expect( + UsernameInputFormzMixin( + username: const UsernameInput.dirty( + value: 'joe', + asyncStatus: FormzAsyncValidationStatus.completed, + ), + ).isValid, + isTrue, + ); + }); + + test('supports value comparison', () { + expect( + const UsernameInput.dirty( + value: 'joe', + asyncStatus: FormzAsyncValidationStatus.completed, + ), + equals( + const UsernameInput.dirty( + value: 'joe', + asyncStatus: FormzAsyncValidationStatus.completed, + ), + ), + ); + expect( + const UsernameInput.dirty(value: 'joe'), + isNot( + equals( + const UsernameInput.dirty( + value: 'joe', + asyncStatus: FormzAsyncValidationStatus.inProgress, + ), + ), + ), + ); + expect( + const UsernameInput.dirty( + value: 'joe', + asyncStatus: FormzAsyncValidationStatus.completed, + ), + isNot( + equals( + const UsernameInput.dirty( + value: 'joe', + asyncStatus: FormzAsyncValidationStatus.completed, + asyncError: UsernameError.taken, + ), + ), + ), + ); + }); + + test('hashCode reflects async state', () { + expect( + const UsernameInput.dirty(value: 'joe').hashCode, + const UsernameInput.dirty(value: 'joe').hashCode, + ); + expect( + const UsernameInput.dirty(value: 'joe').hashCode, + isNot( + const UsernameInput.dirty( + value: 'joe', + asyncStatus: FormzAsyncValidationStatus.completed, + ).hashCode, + ), + ); + }); + }); + + group('FormzAsyncValidationStatusX', () { + test('isInitial', () { + expect(FormzAsyncValidationStatus.initial.isInitial, isTrue); + expect(FormzAsyncValidationStatus.inProgress.isInitial, isFalse); + }); + + test('isInProgress', () { + expect(FormzAsyncValidationStatus.inProgress.isInProgress, isTrue); + expect(FormzAsyncValidationStatus.completed.isInProgress, isFalse); + }); + + test('isCompleted', () { + expect(FormzAsyncValidationStatus.completed.isCompleted, isTrue); + expect(FormzAsyncValidationStatus.initial.isCompleted, isFalse); + }); + }); + group('FormzInput', () { test('value is correct', () { expect(NameInput.pure(value: 'joe').value, 'joe'); diff --git a/test/helpers/name_input.dart b/test/helpers/name_input.dart index 9c8f4ea..3509a75 100644 --- a/test/helpers/name_input.dart +++ b/test/helpers/name_input.dart @@ -71,3 +71,63 @@ class NameInputErrorCacheMixin extends FormzInput return value.isEmpty ? NameInputError.empty : null; } } + +enum UsernameError { empty, taken } + +class UsernameInput extends FormzInput + with FormzInputAsyncValidationMixin { + const new pure({String value = ''}) + : asyncStatus = FormzAsyncValidationStatus.initial, + asyncError = null, + super.pure(value); + const new dirty({ + String value = '', + this.asyncStatus = FormzAsyncValidationStatus.initial, + this.asyncError, + }) : super.dirty(value); + + @override + final FormzAsyncValidationStatus asyncStatus; + + @override + final UsernameError? asyncError; + + @override + UsernameError? validator(String value) { + return value.isEmpty ? UsernameError.empty : null; + } +} + +// Test fixture so allowable +// ignore: must_be_immutable +class UsernameInputErrorCache extends FormzInput + with FormzInputErrorCacheMixin, FormzInputAsyncValidationMixin { + new dirty({ + String value = '', + this.asyncStatus = FormzAsyncValidationStatus.initial, + this.asyncError, + }) : super.dirty(value); + + @override + final FormzAsyncValidationStatus asyncStatus; + + @override + final UsernameError? asyncError; + + int validatorCalls = 0; + + @override + UsernameError? validator(String value) { + validatorCalls++; + return value.isEmpty ? UsernameError.empty : null; + } +} + +class UsernameInputFormzMixin with FormzMixin { + new({this.username = const UsernameInput.pure()}); + + final UsernameInput username; + + @override + List> get inputs => [username]; +}