formix_flutter

pub package License: MIT

Flutter integration for the Formix validation ecosystem. Seamlessly use Formix validators with TextFormField and other Flutter form widgets.

Table of Contents

Installation

Add formix_flutter to your pubspec.yaml:

dependencies:
  formix_flutter: ^0.1.0
  formix_validators: ^0.1.0  # Recommended: built-in validators

Then run:

flutter pub get

Features

Feature Description
🔌 Seamless Integration Convert any Formix validator to Flutter's FormFieldValidator<String>
🛡️ Null Safety Configurable NullPolicy to handle null values from Flutter forms
🎯 Custom Error Types Support for enums, sealed classes, and any error type with formatters
⏱️ Debounced Validation Built-in debouncing for expensive or async validators
🚀 Performance Optimized Works with Formix caching for efficient real-time validation
🌍 i18n Ready Easy localization with custom error formatters

Quick Start

import 'package:flutter/material.dart';
import 'package:formix_flutter/formix_flutter.dart';
import 'package:formix_validators/formix_validators.dart';

// 1. Define your validator
final emailValidator = Validate.all<String, String>([
  StringRules.required(error: 'Email is required'),
  StringRules.email(error: 'Invalid email format'),
]);

// 2. Use it in TextFormField
TextFormField(
  decoration: const InputDecoration(labelText: 'Email'),
  keyboardType: TextInputType.emailAddress,
  validator: emailValidator.toFieldValidator(),
)

That's it! Your Formix validator now works with Flutter forms.

Basic Usage

Simple String Errors

The simplest approach uses String as the error type:

import 'package:flutter/material.dart';
import 'package:formix_flutter/formix_flutter.dart';
import 'package:formix_validators/formix_validators.dart';

class EmailField extends StatelessWidget {
  // Define validator once, reuse everywhere
  static final _validator = Validate.all<String, String>([
    StringRules.required(error: 'Email is required'),
    StringRules.email(error: 'Invalid email format'),
  ]);

  const EmailField({super.key});

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      decoration: const InputDecoration(
        labelText: 'Email',
        hintText: 'Enter your email address',
        prefixIcon: Icon(Icons.email),
      ),
      keyboardType: TextInputType.emailAddress,
      validator: _validator.toFieldValidator(),
    );
  }
}

Complete Login Form

class LoginForm extends StatefulWidget {
  const LoginForm({super.key});

  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  // Static validators for better performance
  static final _emailValidator = Validate.all<String, String>([
    StringRules.required(error: 'Email is required'),
    StringRules.email(error: 'Invalid email'),
  ]);

  static final _passwordValidator = Validate.all<String, String>([
    StringRules.required(error: 'Password is required'),
    StringRules.minLength(8, error: 'At least 8 characters'),
  ]);

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          TextFormField(
            controller: _emailController,
            decoration: const InputDecoration(
              labelText: 'Email',
              prefixIcon: Icon(Icons.email),
            ),
            keyboardType: TextInputType.emailAddress,
            textInputAction: TextInputAction.next,
            validator: _emailValidator.toFieldValidator(),
          ),
          const SizedBox(height: 16),
          TextFormField(
            controller: _passwordController,
            decoration: const InputDecoration(
              labelText: 'Password',
              prefixIcon: Icon(Icons.lock),
            ),
            obscureText: true,
            textInputAction: TextInputAction.done,
            validator: _passwordValidator.toFieldValidator(),
            onFieldSubmitted: (_) => _submit(),
          ),
          const SizedBox(height: 24),
          ElevatedButton(
            onPressed: _submit,
            child: const Text('Login'),
          ),
        ],
      ),
    );
  }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      // Form is valid, proceed with login
      debugPrint('Email: ${_emailController.text}');
      debugPrint('Password: ${_passwordController.text}');
    }
  }

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }
}

Null Policy

Flutter's TextFormField can pass null to validators (e.g., when the field is empty and hasn't been interacted with). NullPolicy lets you configure how to handle this:

TreatAsEmpty (Default)

Treats null as an empty string, letting validators like required() handle it naturally:

validator.toFieldValidator(
  nullPolicy: NullPolicy.treatAsEmpty(),  // This is the default
)

TreatAsInvalid

Immediately returns an error for null values without running the validator:

validator.toFieldValidator(
  nullPolicy: NullPolicy.invalid(error: 'Field is required'),
)

Transform

Transform null to a custom default value before validation:

validator.toFieldValidator(
  nullPolicy: NullPolicy.transform(() => 'default@example.com'),
)

Custom Error Types

One of Formix's strengths is support for custom error types. This enables type-safe errors and easy localization.

With Enum Errors

// Define error enum
enum EmailError { required, invalidFormat, tooLong }

// Create validator with enum errors
final emailValidator = Validate.all<String, EmailError>([
  StringRules.required(error: EmailError.required),
  StringRules.email(error: EmailError.invalidFormat),
  StringRules.maxLength(255, error: EmailError.tooLong),
]);

// Use with formatter
TextFormField(
  decoration: const InputDecoration(labelText: 'Email'),
  validator: emailValidator.toFieldValidator(
    formatter: (error) => switch (error) {
      EmailError.required => 'Email is required',
      EmailError.invalidFormat => 'Invalid email address',
      EmailError.tooLong => 'Email is too long',
    },
  ),
)

With Sealed Class Errors (i18n-Ready)

For complex errors with parameters, use sealed classes:

// Define errors with parameters
sealed class PasswordError {
  const PasswordError();
}

class PasswordRequired extends PasswordError {
  const PasswordRequired();
}

class PasswordTooShort extends PasswordError {
  final int minLength;
  const PasswordTooShort(this.minLength);
}

class PasswordMissingChar extends PasswordError {
  final String charType;
  const PasswordMissingChar(this.charType);
}

// Create validator
final passwordValidator = Validate.all<String, PasswordError>([
  StringRules.required(error: const PasswordRequired()),
  StringRules.minLength(8, error: const PasswordTooShort(8)),
  StringRules.hasUppercase(error: const PasswordMissingChar('uppercase')),
  StringRules.hasDigit(error: const PasswordMissingChar('digit')),
]);

// Format errors with localization
String formatPasswordError(PasswordError error, AppLocalizations l10n) {
  return switch (error) {
    PasswordRequired() => l10n.passwordRequired,
    PasswordTooShort(:final minLength) => l10n.passwordTooShort(minLength),
    PasswordMissingChar(:final charType) => l10n.passwordMissingChar(charType),
  };
}

// Use in widget with localization
class PasswordField extends StatelessWidget {
  const PasswordField({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    
    return TextFormField(
      decoration: InputDecoration(labelText: l10n.password),
      obscureText: true,
      validator: passwordValidator.toFieldValidator(
        formatter: (error) => formatPasswordError(error, l10n),
      ),
    );
  }
}

Real-Time Validation

For immediate feedback as the user types, use autovalidateMode:

TextFormField(
  validator: emailValidator.toFieldValidator(),
  autovalidateMode: AutovalidateMode.onUserInteraction,
)

With Caching for Performance

When using real-time validation, enable caching to avoid redundant validations:

// Single-value cache - perfect for form fields
final cachedValidator = emailValidator.cached();

TextFormField(
  validator: cachedValidator.toFieldValidator(),
  autovalidateMode: AutovalidateMode.onUserInteraction,
)

For fields where users might backspace (like search), use LRU cache:

// LRU cache - remembers recent values
final lruValidator = searchValidator.lruCached(maxSize: 20);

TextFormField(
  validator: lruValidator.toFieldValidator(),
  onChanged: (value) {
    // User types: "hel" -> "hell" -> "hel" (backspace)
    // LRU cache hits on backspace! No recomputation.
  },
)

Debounced Validation

For expensive validations (like API calls), use debouncing:

import 'package:formix_flutter/formix_flutter.dart';

// Create a debounced validator
final usernameValidator = DebouncedValidator<String, String>(
  validator: Validate.all<String, String>([
    StringRules.required(error: 'Username is required'),
    StringRules.minLength(3, error: 'At least 3 characters'),
  ]),
  duration: const Duration(milliseconds: 500),
);

// Use in widget
TextFormField(
  decoration: const InputDecoration(labelText: 'Username'),
  validator: usernameValidator.toFieldValidator(),
  autovalidateMode: AutovalidateMode.onUserInteraction,
)

Advanced Examples

Registration Form

A complete registration form with multiple validators:

class RegistrationForm extends StatefulWidget {
  const RegistrationForm({super.key});

  @override
  State<RegistrationForm> createState() => _RegistrationFormState();
}

class _RegistrationFormState extends State<RegistrationForm> {
  final _formKey = GlobalKey<FormState>();
  final _passwordController = TextEditingController();

  // Validators
  static final _usernameValidator = Validate.all<String, String>([
    StringRules.required(error: 'Username is required'),
    StringRules.minLength(3, error: 'At least 3 characters'),
    StringRules.maxLength(20, error: 'Maximum 20 characters'),
    StringRules.alphanumeric(error: 'Only letters and numbers'),
  ]);

  static final _emailValidator = Validate.all<String, String>([
    StringRules.required(error: 'Email is required'),
    StringRules.email(error: 'Invalid email format'),
  ]);

  // Collect all errors instead of stopping at first
  static final _passwordValidator = Validate.allCollect<String, String>([
    StringRules.required(error: 'Password is required'),
    StringRules.minLength(8, error: 'At least 8 characters'),
    StringRules.hasUppercase(error: 'Need uppercase letter'),
    StringRules.hasLowercase(error: 'Need lowercase letter'),
    StringRules.hasDigit(error: 'Need a number'),
    StringRules.hasSpecialChar(error: 'Need special character'),
  ]);

  // Dynamic validator for confirm password
  Formix<String, String> get _confirmPasswordValidator {
    return Validate.all<String, String>([
      StringRules.required(error: 'Please confirm password'),
      StringRules.equals(
        _passwordController.text,
        error: 'Passwords do not match',
      ),
    ]);
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          // Username
          TextFormField(
            decoration: const InputDecoration(
              labelText: 'Username',
              prefixIcon: Icon(Icons.person),
            ),
            textInputAction: TextInputAction.next,
            validator: _usernameValidator.toFieldValidator(),
          ),
          const SizedBox(height: 16),

          // Email
          TextFormField(
            decoration: const InputDecoration(
              labelText: 'Email',
              prefixIcon: Icon(Icons.email),
            ),
            keyboardType: TextInputType.emailAddress,
            textInputAction: TextInputAction.next,
            validator: _emailValidator.toFieldValidator(),
          ),
          const SizedBox(height: 16),

          // Password
          TextFormField(
            controller: _passwordController,
            decoration: const InputDecoration(
              labelText: 'Password',
              prefixIcon: Icon(Icons.lock),
              helperText: '8+ chars, uppercase, lowercase, number, special',
              helperMaxLines: 2,
            ),
            obscureText: true,
            textInputAction: TextInputAction.next,
            validator: _passwordValidator.toFieldValidator(),
          ),
          const SizedBox(height: 16),

          // Confirm Password
          TextFormField(
            decoration: const InputDecoration(
              labelText: 'Confirm Password',
              prefixIcon: Icon(Icons.lock_outline),
            ),
            obscureText: true,
            textInputAction: TextInputAction.done,
            validator: _confirmPasswordValidator.toFieldValidator(),
            onFieldSubmitted: (_) => _submit(),
          ),
          const SizedBox(height: 24),

          // Submit Button
          FilledButton(
            onPressed: _submit,
            child: const Text('Create Account'),
          ),
        ],
      ),
    );
  }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Registration successful!')),
      );
    }
  }

  @override
  void dispose() {
    _passwordController.dispose();
    super.dispose();
  }
}

Dynamic Cross-Field Validation

Validate fields based on other field values:

class ShippingForm extends StatefulWidget {
  const ShippingForm({super.key});

  @override
  State<ShippingForm> createState() => _ShippingFormState();
}

class _ShippingFormState extends State<ShippingForm> {
  final _formKey = GlobalKey<FormState>();
  String _selectedCountry = 'US';

  // Dynamic ZIP code validator based on country
  Formix<String, String> get _zipCodeValidator {
    return switch (_selectedCountry) {
      'US' => Validate.all<String, String>([
          StringRules.required(error: 'ZIP code is required'),
          StringRules.matches(
            RegExp(r'^\d{5}(-\d{4})?$'),
            error: 'Invalid US ZIP code (e.g., 12345 or 12345-6789)',
          ),
        ]),
      'CA' => Validate.all<String, String>([
          StringRules.required(error: 'Postal code is required'),
          StringRules.matches(
            RegExp(r'^[A-Za-z]\d[A-Za-z][ -]?\d[A-Za-z]\d$'),
            error: 'Invalid Canadian postal code (e.g., A1B 2C3)',
          ),
        ]),
      'UK' => Validate.all<String, String>([
          StringRules.required(error: 'Postcode is required'),
          StringRules.matches(
            RegExp(r'^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$', caseSensitive: false),
            error: 'Invalid UK postcode',
          ),
        ]),
      _ => Validate.all<String, String>([
          StringRules.required(error: 'Postal code is required'),
        ]),
    };
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          DropdownButtonFormField<String>(
            value: _selectedCountry,
            decoration: const InputDecoration(labelText: 'Country'),
            items: const [
              DropdownMenuItem(value: 'US', child: Text('United States')),
              DropdownMenuItem(value: 'CA', child: Text('Canada')),
              DropdownMenuItem(value: 'UK', child: Text('United Kingdom')),
            ],
            onChanged: (value) {
              setState(() => _selectedCountry = value!);
              // Re-validate the form when country changes
              _formKey.currentState?.validate();
            },
          ),
          const SizedBox(height: 16),
          TextFormField(
            decoration: const InputDecoration(labelText: 'ZIP/Postal Code'),
            validator: _zipCodeValidator.toFieldValidator(),
          ),
        ],
      ),
    );
  }
}

Multi-Step Form Wizard

Validate each step before proceeding:

class FormWizard extends StatefulWidget {
  const FormWizard({super.key});

  @override
  State<FormWizard> createState() => _FormWizardState();
}

class _FormWizardState extends State<FormWizard> {
  final _pageController = PageController();
  final _step1Key = GlobalKey<FormState>();
  final _step2Key = GlobalKey<FormState>();
  int _currentStep = 0;

  // Step 1 validators
  static final _nameValidator = Validate.all<String, String>([
    StringRules.required(error: 'Name is required'),
    StringRules.minLength(2, error: 'At least 2 characters'),
  ]);

  // Step 2 validators
  static final _phoneValidator = Validate.all<String, String>([
    StringRules.required(error: 'Phone is required'),
    StringRules.matches(
      RegExp(r'^\+?[\d\s-]{10,}$'),
      error: 'Invalid phone number',
    ),
  ]);

  void _nextStep() {
    final currentFormKey = _currentStep == 0 ? _step1Key : _step2Key;
    
    if (currentFormKey.currentState!.validate()) {
      if (_currentStep < 1) {
        setState(() => _currentStep++);
        _pageController.nextPage(
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeInOut,
        );
      } else {
        // Submit form
        _submitForm();
      }
    }
  }

  void _submitForm() {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Form submitted!')),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Step indicator
        LinearProgressIndicator(value: (_currentStep + 1) / 2),
        
        // Form pages
        Expanded(
          child: PageView(
            controller: _pageController,
            physics: const NeverScrollableScrollPhysics(),
            children: [
              // Step 1: Personal Info
              Form(
                key: _step1Key,
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: TextFormField(
                    decoration: const InputDecoration(labelText: 'Full Name'),
                    validator: _nameValidator.toFieldValidator(),
                  ),
                ),
              ),
              
              // Step 2: Contact Info
              Form(
                key: _step2Key,
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: TextFormField(
                    decoration: const InputDecoration(labelText: 'Phone Number'),
                    keyboardType: TextInputType.phone,
                    validator: _phoneValidator.toFieldValidator(),
                  ),
                ),
              ),
            ],
          ),
        ),
        
        // Navigation buttons
        Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              if (_currentStep > 0)
                OutlinedButton(
                  onPressed: () {
                    setState(() => _currentStep--);
                    _pageController.previousPage(
                      duration: const Duration(milliseconds: 300),
                      curve: Curves.easeInOut,
                    );
                  },
                  child: const Text('Back'),
                )
              else
                const SizedBox(),
              FilledButton(
                onPressed: _nextStep,
                child: Text(_currentStep < 1 ? 'Next' : 'Submit'),
              ),
            ],
          ),
        ),
      ],
    );
  }

  @override
  void dispose() {
    _pageController.dispose();
    super.dispose();
  }
}

API Reference

toFieldValidator() Extension

extension FormixFlutterExtension<E> on Formix<String, E> {
  /// Converts this Formix validator to Flutter's FormFieldValidator<String>.
  ///
  /// [formatter] - Converts error type E to String for display.
  ///               Required when E is not String.
  /// [nullPolicy] - How to handle null values. Defaults to NullPolicy.treatAsEmpty().
  String? Function(String?) toFieldValidator({
    String Function(E error)? formatter,
    NullPolicy<E>? nullPolicy,
  });
}

NullPolicy

Policy Description Use Case
NullPolicy.treatAsEmpty() Converts null to '' Default, works with required()
NullPolicy.invalid(error: E) Returns error immediately When null is never acceptable
NullPolicy.transform(fn) Converts null to custom value Default values, computed defaults

DebouncedValidator

class DebouncedValidator<T, E> {
  /// Creates a debounced validator.
  ///
  /// [validator] - The underlying Formix validator.
  /// [duration] - Debounce duration (default: 300ms).
  DebouncedValidator({
    required Formix<T, E> validator,
    Duration duration = const Duration(milliseconds: 300),
  });

  /// Converts to Flutter's FormFieldValidator.
  String? Function(String?) toFieldValidator({
    String Function(E error)? formatter,
    NullPolicy<E>? nullPolicy,
  });
}

Best Practices

1. Define Validators as Static Constants

// ✅ Good - created once, reused
class MyForm extends StatelessWidget {
  static final _emailValidator = Validate.all<String, String>([...]);
  
  @override
  Widget build(BuildContext context) {
    return TextFormField(validator: _emailValidator.toFieldValidator());
  }
}

// ❌ Bad - recreated on every build
class MyForm extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final validator = Validate.all<String, String>([...]); // Wasteful!
    return TextFormField(validator: validator.toFieldValidator());
  }
}

2. Use Caching for Real-Time Validation

// ✅ Good - cached validation
static final _validator = emailValidator.cached();

TextFormField(
  validator: _validator.toFieldValidator(),
  autovalidateMode: AutovalidateMode.onUserInteraction,
)

3. Use Type-Safe Errors for Complex Forms

// ✅ Good - type-safe, localizable
enum FormError { required, invalidFormat, tooShort }

// ❌ Avoid - typos possible, hard to refactor
const error = 'Feild is required'; // Typo!

4. Separate Validator Logic from UI

// validators.dart
class Validators {
  static final email = Validate.all<String, String>([...]);
  static final password = Validate.all<String, String>([...]);
}

// form_widget.dart
TextFormField(validator: Validators.email.toFieldValidator())
Package Description
formix_core Core validation engine
formix_validators Built-in validation rules
formix_test Testing utilities
formix Umbrella package (includes all)

License

MIT License - see LICENSE file for details.


Made with ❤️ by Fady Fayez Younan

Libraries

formix_flutter
Flutter adapters for the Formix validation ecosystem.