formix_flutter 0.1.0 copy "formix_flutter: ^0.1.0" to clipboard
formix_flutter: ^0.1.0 copied to clipboard

Flutter adapters for the Formix validation ecosystem. Seamlessly integrate validators with TextFormField and other form widgets.

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

0
likes
150
points
22
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

Flutter adapters for the Formix validation ecosystem. Seamlessly integrate validators with TextFormField and other form widgets.

Homepage
Repository (GitHub)
View/report issues

Topics

#validation #form #flutter #input-validation #form-validation

License

MIT (license)

Dependencies

flutter, formix_core

More

Packages that depend on formix_flutter