Form Validation in Flutter with Ack
This guide shows how to use Ack for validating forms in Flutter applications.
Prerequisites
Before following this guide, you should be familiar with:
- Flutter basics: Creating widgets, managing state, and using forms
- Ack fundamentals: Creating schemas and validation (see Quickstart Tutorial)
- Validation rules: Built-in constraints (see Validation Rules)
You should have Ack installed in your Flutter project:
flutter pub add ack
Basic Form Validation with TextFormField
Ack integrates seamlessly with Flutter's Form
and TextFormField
widgets. You can use Ack schemas directly within the validator
function of a TextFormField
.
import 'package:ack/ack.dart';
import 'package:flutter/material.dart';
class SignUpForm extends StatefulWidget {
const SignUpForm({super.key});
@override
State<SignUpForm> createState() => _SignUpFormState();
}
class _SignUpFormState extends State<SignUpForm> {
// GlobalKey to manage Form state
final _formKey = GlobalKey<FormState>();
// Controllers for input fields
final _usernameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
// Define Ack schemas for validation rules
// See: [Schema Types](../core-concepts/schemas.mdx), [Validation Rules](../core-concepts/validation.mdx)
final _usernameSchema = Ack.string()
.minLength(3)
.maxLength(20)
.matches(r'[a-zA-Z0-9_]+')
.notEmpty();
final _emailSchema = Ack.string()
.email()
.notEmpty();
final _passwordSchema = Ack.string()
.minLength(8)
.matches(r'.*[A-Z].*')
.matches(r'.*[a-z].*')
.matches(r'.*[0-9].*')
.notEmpty();
@override
Widget build(BuildContext context) {
return Form(
key: _formKey, // Associate the key with the Form
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Username Field
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(labelText: 'Username'),
// Use the schema's validate method in the validator
validator: (value) {
final result = _usernameSchema.safeParse(value);
// Return the error message if validation fails
// See: [Error Handling](../core-concepts/error-handling.mdx)
return result.isFail ? result.getError().toString() : null;
},
autovalidateMode: AutovalidateMode.onUserInteraction,
),
const SizedBox(height: 16),
// Email Field
TextFormField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
validator: (value) {
final result = _emailSchema.safeParse(value);
return result.isFail ? result.getError().toString() : null;
},
autovalidateMode: AutovalidateMode.onUserInteraction,
),
const SizedBox(height: 16),
// Password Field
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
validator: (value) {
final result = _passwordSchema.safeParse(value);
return result.isFail ? result.getError().toString() : null;
},
autovalidateMode: AutovalidateMode.onUserInteraction,
),
const SizedBox(height: 24),
// Submit Button
ElevatedButton(
onPressed: _submitForm,
child: const Text('Sign Up'),
),
],
),
);
}
void _submitForm() {
// Validate the entire form using the GlobalKey
if (_formKey.currentState!.validate()) {
// If the form is valid, display a Snackbar or proceed.
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Processing Data')),
);
print('Form is valid!');
print('Username: ${_usernameController.text}');
print('Email: ${_emailController.text}');
// Usually, you would send this data to a server
} else {
print('Form is invalid.');
}
}
@override
void dispose() {
// Dispose controllers when the widget is removed from the widget tree
_usernameController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
}
Key Points:
- Define Schemas: Create
AckSchema
instances for each form field using rules from Validation Rules. TextFormField.validator
: Inside thevalidator
function, callschema.safeParse(value)
. Ifresult.isFail
, returnresult.getError()?.message
to display the error (see Error Handling).GlobalKey<FormState>
: Use a key to manage the form and trigger validation on submission via_formKey.currentState!.validate()
.AutovalidateMode
: SetautovalidateMode
(e.g.,AutovalidateMode.onUserInteraction
) onTextFormField
for real-time feedback as the user types or interacts.- Custom Error Messages: Provide custom error messages directly within the schema definition using the
message:
parameter (see Error Handling).
Real-time Validation with TextField
If you are not using a Form
widget or prefer manual state management for errors, you can listen to controller changes and update the error state directly.
import 'package:ack/ack.dart';
import 'package:flutter/material.dart';
class RealtimeValidationField extends StatefulWidget {
const RealtimeValidationField({super.key});
@override
State<RealtimeValidationField> createState() => _RealtimeValidationFieldState();
}
class _RealtimeValidationFieldState extends State<RealtimeValidationField> {
final _emailController = TextEditingController();
String? _emailErrorText; // State variable to hold the error message
// Define the schema
final _emailSchema = Ack.string()
.email()
.notEmpty();
@override
void initState() {
super.initState();
// Add listener to validate on change
_emailController.addListener(_validateEmail);
}
void _validateEmail() {
final text = _emailController.text;
// Only validate if the field is not empty (or on first interaction)
// Adjust logic based on desired UX (e.g., validate after first blur)
if (text.isNotEmpty) {
final result = _emailSchema.safeParse(text);
// Update the error state variable, triggering a rebuild
setState(() {
_emailErrorText = result.isFail ? result.getError().toString() : null;
});
} else {
// Clear error if field becomes empty
setState(() {
_emailErrorText = null;
});
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _emailController,
decoration: InputDecoration(
labelText: 'Email',
// Display the error text from the state variable
errorText: _emailErrorText,
),
keyboardType: TextInputType.emailAddress,
),
);
}
@override
void dispose() {
_emailController.removeListener(_validateEmail);
_emailController.dispose();
super.dispose();
}
}
Key Points:
- State Variable: Maintain a state variable (e.g.,
_emailErrorText
) to hold the current error message for the field. - Controller Listener: Add a listener to the
TextEditingController
ininitState
. - Validation Logic: Inside the listener, call
schema.safeParse()
with the controller's text. setState
: CallsetState
to update the error state variable based on the validation result. This triggers a UI rebuild to show/hide the error.- Dispose: Remember to remove the listener in
dispose
.
Validating Entire Models/Forms on Submission
Instead of validating field by field, you can validate a complete data structure (like a map representing the whole form) on submission using Ack.object
.
// Custom constraint for password matching (see Custom Validation guide)
class PasswordMatchConstraint extends SchemaConstraint<String> {
final String fieldToMatch;
PasswordMatchConstraint(this.fieldToMatch)
: super(name: 'password_match', message: 'Passwords do not match');
@override
bool validate(String value, [Map<String, dynamic>? data]) {
final otherPassword = data?[fieldToMatch] as String?;
return value == otherPassword;
}
}
// Define a schema for the whole form data
final _formSchema = Ack.object({
'username': _usernameSchema, // Reuse field schemas
'email': _emailSchema,
'password': _passwordSchema,
'confirmPassword': Ack.string()
.notEmpty()
// Example: Custom constraint for cross-field validation
// See: [Custom Validation](./custom-validation.mdx)
.constrain(PasswordMatchConstraint('password')),
});
void _submitForm() {
// Gather all form data into a map
final formData = {
'username': _usernameController.text,
'email': _emailController.text,
'password': _passwordController.text,
'confirmPassword': _confirmPasswordController.text, // Assume this controller exists
};
// Validate the entire map
final result = _formSchema.safeParse(formData);
if (result.isOk) {
print('Entire form data is valid!');
// Submit formData
} else {
final error = result.getError();
print('Form submission error: $error');
// You might need to map the error path back to specific fields
// to display errors if not using TextFormField validators.
}
}
Key Points:
- Object Schema: Create an
Ack.object
schema representing the entire form's data structure. - Reuse Schemas: Reuse the individual field schemas within the object schema.
- Cross-Field Validation: Use custom constraints (
.constrain
) within the object schema to perform validation that depends on multiple fields. See the Custom Validation guide. - Centralized Validation: Validate the complete
formData
map against the_formSchema
on submission. - Error Handling: If validation fails, you might need logic to map the
error.path
back to the specific UI field(s) causing the error, especially if not usingTextFormField
's built-in validation display.