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:

  1. Define Schemas: Create AckSchema instances for each form field using rules from Validation Rules.
  2. TextFormField.validator: Inside the validator function, call schema.safeParse(value). If result.isFail, return result.getError()?.message to display the error (see Error Handling).
  3. GlobalKey<FormState>: Use a key to manage the form and trigger validation on submission via _formKey.currentState!.validate().
  4. AutovalidateMode: Set autovalidateMode (e.g., AutovalidateMode.onUserInteraction) on TextFormField for real-time feedback as the user types or interacts.
  5. 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:

  1. State Variable: Maintain a state variable (e.g., _emailErrorText) to hold the current error message for the field.
  2. Controller Listener: Add a listener to the TextEditingController in initState.
  3. Validation Logic: Inside the listener, call schema.safeParse() with the controller's text.
  4. setState: Call setState to update the error state variable based on the validation result. This triggers a UI rebuild to show/hide the error.
  5. 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:

  1. Object Schema: Create an Ack.object schema representing the entire form's data structure.
  2. Reuse Schemas: Reuse the individual field schemas within the object schema.
  3. Cross-Field Validation: Use custom constraints (.constrain) within the object schema to perform validation that depends on multiple fields. See the Custom Validation guide.
  4. Centralized Validation: Validate the complete formData map against the _formSchema on submission.
  5. 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 using TextFormField's built-in validation display.