fabrik_forms
A clean, UI-agnostic form state and validation system for Flutter.
What's included
| API | What it does |
|---|---|
FabrikField<T> |
Holds a value, validators, and metadata (isTouched, isDirty, error, visibleError) |
FabrikForm |
Named field container with isValid, isDirty, values, errors, markAllTouched, reset |
FabrikFormNotifier |
ValueNotifier wrapper for reactive form state |
FabrikFormBuilder |
Declarative widget builder that rebuilds on form updates |
RequiredValidator |
Ensures the field is not empty |
EmailValidator |
Validates email format, optional or required |
MinLengthValidator |
Enforces a minimum character count |
MaxLengthValidator |
Enforces a maximum character count |
PasswordValidator |
Configurable complexity rules (uppercase, digit, special char, min length) |
UrlValidator |
Validates HTTP/HTTPS URLs, optional HTTPS-only mode |
PhoneValidator |
Validates local and international phone number formats |
RangeValidator |
Validates that a numeric value falls within an inclusive range |
FieldsMatchValidator |
Form-level rule requiring two fields to be equal (password confirmation) |
FabrikFormRule |
Form-level rule built from a plain function |
Installation
dependencies:
fabrik_forms: ^0.1.0
flutter pub get
Usage
Setting up a form
Each field declares its own type, so one form can mix String, int and
bool values:
final formNotifier = FabrikFormNotifier(
FabrikForm({
'email': FabrikField<String>(
value: '',
validators: [const EmailValidator()],
),
'password': FabrikField<String>(
value: '',
validators: [
const PasswordValidator(
requireDigit: true,
requireSpecialChar: true,
),
],
),
'age': FabrikField<int>(
value: 18,
validators: [const RangeValidator(min: 18, max: 120)],
),
'subscribed': FabrikField<bool>(value: false),
}),
);
Read fields back at their own type with get<T>:
final String email = formNotifier.get<String>('email').value;
final int age = formNotifier.get<int>('age').value;
Building the UI
FabrikFormBuilder(
formNotifier: formNotifier,
builder: (context, form, get) {
final emailField = get<String>('email');
final passwordField = get<String>('password');
return Column(
children: [
TextField(
onChanged: (val) => formNotifier.update('email', val),
decoration: InputDecoration(
labelText: 'Email',
errorText: emailField.visibleError,
),
),
TextField(
onChanged: (val) => formNotifier.update('password', val),
obscureText: true,
decoration: InputDecoration(
labelText: 'Password',
errorText: passwordField.visibleError,
),
),
ElevatedButton(
onPressed: () {
if (formNotifier.isValid) {
// submit formNotifier.values
} else {
formNotifier.markAllTouched(); // reveal all errors
}
},
child: const Text('Sign In'),
),
],
);
},
);
Resetting a form
// Restore all fields to initial values and clear touched/dirty state
formNotifier.reset();
Validators
RequiredValidator
RequiredValidator()
RequiredValidator(message: 'Name is required', trim: false)
EmailValidator
EmailValidator() // required by default
EmailValidator(isRequired: false) // optional — empty is valid
EmailValidator(invalidMessage: 'Bad email')
MinLengthValidator / MaxLengthValidator
MinLengthValidator(min: 3)
MaxLengthValidator(max: 50, message: 'Keep it under 50 chars')
PasswordValidator
PasswordValidator() // requires 8+ chars, non-empty
PasswordValidator(isRequired: false) // optional password
PasswordValidator(
minLength: 12,
requireUppercase: true,
requireDigit: true,
requireSpecialChar: true,
)
UrlValidator
UrlValidator() // accepts http and https
UrlValidator(requireHttps: true) // https only
UrlValidator(isRequired: false) // optional — empty is valid
PhoneValidator
PhoneValidator() // required by default
PhoneValidator(isRequired: false) // optional — empty is valid
// Accepts: +1 234 567 8900 · (123) 456-7890 · 123-456-7890 · 1234567890
RangeValidator
RangeValidator(min: 1, max: 100)
RangeValidator(min: 0.0, max: 1.0, minMessage: 'Too low', maxMessage: 'Too high')
Custom validators
class UsernameValidator extends FabrikValidator<String> {
const UsernameValidator();
@override
String? call(String value) {
if (value.contains(' ')) return 'No spaces allowed';
return null;
}
}
Cross-field validation
Some rules span more than one field — password confirmation being the obvious one. Those live at the form level:
FabrikForm(
{
'password': FabrikField<String>(value: ''),
'confirmPassword': FabrikField<String>(value: ''),
},
validators: [
const FieldsMatchValidator(
field: 'password',
matchField: 'confirmPassword',
message: 'Passwords do not match',
),
],
);
The result surfaces on the form rather than on a single field:
form.formError; // 'Passwords do not match'
form.isValid; // false — form-level rules count toward validity
For one-off rules, FabrikFormRule wraps a plain function:
FabrikFormRule(
(values) => (values['end'] as int) > (values['start'] as int)
? null
: 'End must be after start',
);
Field metadata
| Property | Type | Description |
|---|---|---|
value |
T |
Current field value |
error |
String? |
First validation error (always set, regardless of touch) |
errors |
List<String> |
Every failing rule, in validator order |
visibleError |
String? |
First error, only exposed after the field is touched |
visibleErrors |
List<String> |
Every error, only exposed after the field is touched |
isValid |
bool |
No active errors |
isTouched |
bool |
User has interacted with the field |
isDirty |
bool |
Value differs from the original |
Use errors when several rules should be shown at once:
Column(
children: [
for (final message in passwordField.visibleErrors) Text(message),
],
);
Documentation
Full API reference and guides at fabriktool.com
Contributing
Found a bug or have a suggestion? Open an issue or pull request on GitHub.