Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,78 @@ class Email extends FormzInput<String, EmailValidationError>
}
```

## 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<String, PhoneNumberError>
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<void> _onPhoneNumberChanged(
PhoneNumberChanged event,
Emitter<MyState> 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
Expand Down
4 changes: 2 additions & 2 deletions example/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,6 @@ subprojects {
project.evaluationDependsOn(':app')
}

task clean(type: Delete) {
delete rootProject.buildDir
tasks.register("clean", Delete) {
delete rootProject.layout.buildDirectory
}
4 changes: 4 additions & 0 deletions example/android/gradle.properties
Original file line number Diff line number Diff line change
@@ -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
181 changes: 181 additions & 0 deletions example/lib/async_form.dart
Original file line number Diff line number Diff line change
@@ -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<AsyncForm> createState() => _AsyncFormState();
}

class _AsyncFormState extends State<AsyncForm> {
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<void> _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<bool> _isUsernameTaken(String username) async {
await Future<void>.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<FormzInput<dynamic, dynamic>> get inputs => [username];
}

enum UsernameValidationError { empty, tooShort, taken }

class Username extends FormzInput<String, UsernameValidationError>
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';
}
}
}
30 changes: 25 additions & 5 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:math';

import 'package:example/async_form.dart';
import 'package:flutter/material.dart';
import 'package:formz/formz.dart';

Expand All @@ -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()),
),
],
),
),
),
);
Expand Down
Loading
Loading