formix_flutter 0.1.0
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 #
Flutter integration for the Formix validation ecosystem. Seamlessly use Formix validators with TextFormField and other Flutter form widgets.
Table of Contents #
- Installation
- Features
- Quick Start
- Basic Usage
- Null Policy
- Custom Error Types
- Real-Time Validation
- Debounced Validation
- Advanced Examples
- API Reference
- Best Practices
- Related Packages
- License
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())
Related Packages #
| 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