fifty_forms 0.2.1
fifty_forms: ^0.2.1 copied to clipboard
Production-ready form building with validation, multi-step wizards, and draft persistence for the Fifty Flutter Kit.
Fifty Forms #
Full validation pipeline for Flutter forms -- you provide the layout, we handle the state.
25 built-in validators, async debounce validation, multi-step wizards with custom navigation, draft persistence that survives app kills, and optional builder callbacks that let you replace the navigation buttons, progress indicator, error summary, and submit button individually. Part of Fifty Flutter Kit.
| Home | Login Form | Registration | Multi-Step |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
Why fifty_forms #
- Full validation pipeline, you build the UI --
FiftyFormControllerhandles field registration, 25 built-in validators, async debounce validation, and draft persistence; you provide the layout. - Wizard forms with custom everything --
FiftyMultiStepFormcomes with step validation, progress tracking, and navigation; replace the navigation buttons, progress display, error summary, and submit button individually via optional builders. - Async validators with debounce --
AsyncCustom<String>debounces server-side checks (username availability, email exists) so API calls fire only when the user pauses typing. - Draft persistence that survives app kills --
DraftManagerauto-saves form state to GetStorage with configurable debounce; restore drafts on next open with a singlehasDraft()check.
Installation #
dependencies:
fifty_forms: ^0.2.1
For Contributors #
dependencies:
fifty_forms:
path: ../fifty_forms
Dependencies: fifty_tokens, fifty_theme, fifty_ui, fifty_storage, get_storage
Quick Start #
final controller = FiftyFormController(
initialValues: {'email': '', 'password': ''},
validators: {
'email': [Required(), Email()],
'password': [Required(), MinLength(8)],
},
);
Column(
children: [
FiftyTextFormField(
name: 'email',
controller: controller,
label: 'Email',
keyboardType: TextInputType.emailAddress,
),
FiftyTextFormField(
name: 'password',
controller: controller,
label: 'Password',
obscureText: true,
),
FiftySubmitButton(
controller: controller,
label: 'LOGIN',
onPressed: () => controller.submit((values) async {
await api.login(values['email'], values['password']);
}),
),
],
)
Architecture #
fifty_forms
+-- core/
| +-- FiftyFormController # Central state manager
| +-- FieldState # Immutable per-field state
+-- validators/
| +-- Validator # Sync validator base
| +-- AsyncValidator # Async validator with debounce
| +-- Built-ins # Required, Email, MinLength, etc.
+-- fields/
| +-- FiftyTextFormField # fifty_ui field wrappers
| +-- FiftyDropdownFormField
| +-- FiftyCheckboxFormField (+ others)
+-- widgets/
| +-- FiftyForm # Form container
| +-- FiftySubmitButton # Submit with loading state
| +-- FiftyMultiStepForm # Wizard container
| +-- FiftyFormArray # Dynamic repeating fields
| +-- FiftyValidationSummary
+-- models/
| +-- FormStep # Step definition for wizards
| +-- FormStatus # Form lifecycle enum
+-- persistence/
+-- DraftManager # Auto-save via GetStorage
Core Components #
| Component | Description |
|---|---|
FiftyFormController |
Central state manager: values, validation, submission |
FieldState<T> |
Immutable container for a single field's state |
FormStatus |
Enum: idle, validating, submitting, submitted, error |
Validator |
Composable synchronous validator base class |
AsyncValidator |
Asynchronous validator with debounce support |
DraftManager |
Persists and restores form drafts via GetStorage |
FiftyMultiStepForm |
Wizard-style multi-step form widget |
FiftyFormArray |
Dynamic add/remove repeating field groups |
Customization #
Every form UI widget accepts an optional builder callback. When provided, the builder replaces the default FDL rendering while the widget retains ownership of controller listening, state computation, and animations.
Custom Navigation Buttons #
Replace the default back/next/complete buttons on FiftyMultiStepForm:
FiftyMultiStepForm(
controller: controller,
steps: mySteps,
stepBuilder: (context, index, step) => buildStepContent(index),
onComplete: (values) => api.createUser(values),
navigationBuilder: (isFirstStep, isLastStep, isSubmitting, onNext, onPrevious) {
return Row(
children: [
if (!isFirstStep)
TextButton(onPressed: onPrevious, child: const Text('Back')),
const Spacer(),
ElevatedButton(
onPressed: isSubmitting ? null : onNext,
child: Text(isLastStep ? 'Submit' : 'Continue'),
),
],
);
},
)
Custom Submit Button #
Replace the default FiftyButton on FiftySubmitButton:
FiftySubmitButton(
controller: controller,
label: 'SAVE',
onPressed: () => controller.submit(save),
buttonBuilder: (isLoading, isDisabled, onPressed, label) {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: ElevatedButton(
onPressed: onPressed,
child: isLoading
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: Text(label),
),
);
},
)
Custom Validation Summary #
Replace the default error card on FiftyValidationSummary:
FiftyValidationSummary(
controller: controller,
contentBuilder: (errors, onFieldTap) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: errors.entries.map((e) {
return GestureDetector(
onTap: onFieldTap != null ? () => onFieldTap(e.key) : null,
child: Text('${e.key}: ${e.value}', style: const TextStyle(color: Colors.red)),
);
}).toList(),
);
},
)
Custom Progress Indicator #
Replace the default step circles on FiftyFormProgress:
FiftyFormProgress(
currentStep: 2,
totalSteps: 4,
stepLabels: ['Account', 'Profile', 'Preferences', 'Review'],
contentBuilder: (currentStep, totalSteps, stepLabels) {
return LinearProgressIndicator(value: currentStep / totalSteps);
},
)
Builder Signatures #
| Widget | Builder parameter | Callback signature |
|---|---|---|
FiftyMultiStepForm |
navigationBuilder |
Widget Function(bool isFirstStep, bool isLastStep, bool isSubmitting, VoidCallback onNext, VoidCallback onPrevious) |
FiftySubmitButton |
buttonBuilder |
Widget Function(bool isLoading, bool isDisabled, VoidCallback? onPressed, String label) |
FiftyValidationSummary |
contentBuilder |
Widget Function(Map<String, String> errors, void Function(String fieldName)? onFieldTap) |
FiftyFormProgress |
contentBuilder |
Widget Function(int currentStep, int totalSteps, List<String>? stepLabels) |
All builders are optional. Omit them to use the default FDL UI.
API Reference #
FiftyFormController #
The central state manager for forms. Handles field registration, value tracking, validation (sync and async), and form submission.
final controller = FiftyFormController(
initialValues: {'name': '', 'age': 0},
validators: {
'name': [Required(), MinLength(2)],
'age': [Required(), Min(18)],
},
onValidationChanged: (isValid) => print('Valid: $isValid'),
);
// Get/set values
controller.setValue('name', 'John');
final name = controller.getValue<String>('name');
// Check state
controller.isValid; // All fields valid?
controller.isDirty; // Any field changed?
controller.isValidating; // Async validation running?
// Field operations
controller.registerField('newField', initialValue: '');
controller.unregisterField('fieldToRemove');
controller.markTouched('name');
controller.markAllTouched();
// Actions
await controller.validate(); // Validate all fields
await controller.validateField('email'); // Validate single field
controller.clearErrors(); // Clear all validation errors
controller.reset(); // Reset to initial values
controller.clear(); // Clear all values
// Submit
await controller.submit((values) async {
await api.save(values);
});
// Cleanup
controller.dispose();
Key Properties:
| Property | Type | Description |
|---|---|---|
status |
FormStatus |
Current form lifecycle status |
isValid |
bool |
All fields valid and not validating |
isDirty |
bool |
Any field changed from initial value |
isValidating |
bool |
Async validation in progress |
values |
Map<String, dynamic> |
All current field values |
errors |
Map<String, String> |
All fields with errors |
fieldNames |
List<String> |
All registered field names |
FieldState #
Immutable state container for a form field. Tracks the current value, validation error, and interaction state.
class FieldState<T> {
final T? value; // Current value
final String? error; // Validation error
final bool isTouched; // Field has been focused
final bool isDirty; // Value differs from initial
final bool isValidating; // Async validation running
bool get isValid => error == null && !isValidating;
bool get hasError => error != null;
}
// Usage
final state = controller.getFieldState('email');
if (state.isTouched && state.hasError) {
print(state.error);
}
FormStatus #
Enum representing the form lifecycle status:
| Status | Description |
|---|---|
idle |
Default state, ready for input |
validating |
Validation in progress |
submitting |
Form submission in progress |
submitted |
Form successfully submitted |
error |
Validation or submission failed |
Validators #
String Validators:
| Validator | Description | Example |
|---|---|---|
Required() |
Non-null, non-empty | Required(message: 'Required') |
MinLength(n) |
Minimum length | MinLength(8) |
MaxLength(n) |
Maximum length | MaxLength(100) |
Email() |
Valid email format | Email() |
Url() |
Valid URL format | Url() |
Pattern(regex) |
Matches pattern | Pattern(RegExp(r'^[A-Z]+$')) |
AlphaNumeric() |
Letters and numbers only | AlphaNumeric() |
Number Validators:
| Validator | Description | Example |
|---|---|---|
Min(n) |
Minimum value | Min(0) |
Max(n) |
Maximum value | Max(100) |
Range(min, max) |
Within range (inclusive) | Range(1, 10) |
Integer() |
Must be integer | Integer() |
Positive() |
Must be positive (> 0) | Positive() |
Date Validators:
| Validator | Description | Example |
|---|---|---|
MinDate(date) |
On or after date | MinDate(DateTime.now()) |
MaxDate(date) |
On or before date | MaxDate(DateTime(2030)) |
MinAge(years) |
Minimum age | MinAge(18) |
FutureDate() |
Must be future | FutureDate() |
PastDate() |
Must be past | PastDate() |
Password Validators:
| Validator | Description | Example |
|---|---|---|
HasUppercase() |
Contains uppercase | HasUppercase() |
HasLowercase() |
Contains lowercase | HasLowercase() |
HasNumber() |
Contains digit | HasNumber() |
HasSpecialChar() |
Contains special char | HasSpecialChar() |
Comparison Validators:
| Validator | Description | Example |
|---|---|---|
Equals(field) |
Equals another field | Equals('password') |
NotEquals(field) |
Differs from field | NotEquals('oldPassword') |
Form Fields #
Wrapper components for fifty_ui widgets that integrate with FiftyFormController:
| Field | Wraps | Use Case |
|---|---|---|
FiftyTextFormField |
FiftyTextField |
Text input |
FiftyDropdownFormField |
FiftyDropdown |
Selection from list |
FiftyCheckboxFormField |
FiftyCheckbox |
Boolean toggle |
FiftySwitchFormField |
FiftySwitch |
On/off toggle |
FiftyRadioFormField |
FiftyRadioGroup |
Single selection from options |
FiftySliderFormField |
FiftySlider |
Numeric range selection |
FiftyDateFormField |
Date picker | Date input |
FiftyTimeFormField |
Time picker | Time input |
FiftyFileFormField |
File picker | File upload |
UI Widgets #
| Widget | Description |
|---|---|
FiftyForm |
Form container with controller binding |
FiftySubmitButton |
Submit button with loading state; optional buttonBuilder |
FiftyFormProgress |
Step progress indicator; optional contentBuilder |
FiftyMultiStepForm |
Multi-step wizard container; optional navigationBuilder |
FiftyFormArray |
Dynamic repeating fields |
FiftyFormError |
Form-level error display |
FiftyFieldError |
Field-level error display |
FiftyValidationSummary |
All errors summary; optional contentBuilder |
FiftyFormField |
Generic field wrapper |
Configuration #
FiftyFormController #
| Parameter | Type | Default | Description |
|---|---|---|---|
initialValues |
Map<String, dynamic> |
required | Initial field values keyed by field name |
validators |
Map<String, List<Validator>> |
{} |
Sync validators per field |
asyncValidators |
Map<String, List<AsyncValidator>> |
{} |
Async validators per field |
onValidationChanged |
void Function(bool)? |
null |
Callback when overall validity changes |
validateOnChange |
bool |
true |
Re-validate fields when values change |
FiftyMultiStepForm #
| Parameter | Type | Default | Description |
|---|---|---|---|
controller |
FiftyFormController |
required | Form controller instance |
steps |
List<FormStep> |
required | Step definitions |
stepBuilder |
Widget Function(BuildContext, int, FormStep) |
required | Builder for each step's content |
onComplete |
void Function(Map<String, dynamic>) |
required | Callback on final step completion |
onStepChanged |
void Function(int)? |
null |
Callback when active step changes |
showProgress |
bool |
true |
Show step progress indicator |
validateOnNext |
bool |
true |
Validate current step before advancing |
nextLabel |
String |
'NEXT' |
Label for the next button |
previousLabel |
String |
'BACK' |
Label for the previous button |
completeLabel |
String |
'COMPLETE' |
Label for the final step button |
navigationBuilder |
MultiStepNavigationBuilder? |
null |
Optional builder to replace the default back/next/complete buttons |
FormStep #
| Parameter | Type | Default | Description |
|---|---|---|---|
title |
String |
required | Step title for progress indicator |
description |
String? |
null |
Optional step description |
fields |
List<String> |
required | Field names to validate for this step |
isOptional |
bool |
false |
Can skip if all fields empty |
validator |
String? Function(Map<String, dynamic>)? |
null |
Custom step-level validation |
DraftManager #
| Parameter | Type | Default | Description |
|---|---|---|---|
controller |
FiftyFormController |
required | Form controller to manage |
key |
String |
required | Unique storage key for draft |
debounce |
Duration |
2 seconds |
Delay before auto-save triggers |
containerName |
String? |
'fifty_forms_drafts' |
GetStorage container name |
FiftyFormArray #
| Parameter | Type | Default | Description |
|---|---|---|---|
controller |
FiftyFormController |
required | Form controller instance |
name |
String |
required | Base name for array fields |
minItems |
int |
0 |
Minimum items required |
maxItems |
int |
10 |
Maximum items allowed |
initialCount |
int |
1 |
Initial number of items |
animate |
bool |
true |
Animate add/remove transitions |
itemSpacing |
double |
16 |
Spacing between items |
itemBuilder |
Widget Function(BuildContext, int, VoidCallback) |
required | Builder for each array item |
addButtonBuilder |
Widget Function(VoidCallback)? |
null |
Custom add button builder |
Usage Patterns #
Strong Password Validation #
validators: {
'password': [
Required(message: 'Password is required'),
MinLength(8, message: 'At least 8 characters'),
HasUppercase(message: 'Must contain uppercase letter'),
HasLowercase(message: 'Must contain lowercase letter'),
HasNumber(message: 'Must contain a number'),
HasSpecialChar(message: 'Must contain special character'),
],
}
Password Confirmation #
validators: {
'password': [Required(), MinLength(8)],
'confirmPassword': [Required(), Equals('password', message: 'Passwords must match')],
}
Custom Validators #
// Synchronous
Custom<String>((value) {
if (value?.contains('banned') == true) {
return 'Contains banned word';
}
return null;
})
// Asynchronous with debounce
AsyncCustom<String>(
(value) async {
final exists = await api.checkUsername(value);
return exists ? 'Username taken' : null;
},
debounce: Duration(milliseconds: 500),
)
Composite Validators #
// And -- all must pass
And([Required(), MinLength(8), HasUppercase()])
// Or -- at least one must pass
Or([Email(), Pattern(phoneRegex)])
Multi-Step Form #
FiftyMultiStepForm(
controller: controller,
steps: [
FormStep(
title: 'Account',
description: 'Create your credentials',
fields: ['email', 'password'],
),
FormStep(
title: 'Profile',
fields: ['name', 'bio'],
isOptional: true,
),
FormStep(
title: 'Review',
fields: [],
validator: (values) {
if (values['bio']?.isEmpty == true) {
return 'Consider adding a bio';
}
return null;
},
),
],
stepBuilder: (context, index, step) => _buildStep(index),
onComplete: (values) => api.createUser(values),
onStepChanged: (step) => print('Now on step $step'),
showProgress: true,
validateOnNext: true,
nextLabel: 'NEXT',
previousLabel: 'BACK',
completeLabel: 'COMPLETE',
)
Dynamic Form Arrays #
FiftyFormArray(
controller: controller,
name: 'addresses',
minItems: 1,
maxItems: 5,
itemBuilder: (context, index, remove) => Column(
children: [
FiftyTextFormField(
name: 'addresses[$index].street',
controller: controller,
label: 'Street',
),
FiftyTextFormField(
name: 'addresses[$index].city',
controller: controller,
label: 'City',
),
IconButton(
icon: Icon(Icons.delete),
onPressed: remove,
),
],
),
addButtonBuilder: (add) => FiftyButton(
label: 'Add Address',
onPressed: add,
variant: FiftyButtonVariant.ghost,
icon: Icons.add,
),
)
Accessing Array Values:
// Get single field
final street = controller.getArrayValue<String>('addresses', 0, 'street');
// Set single field
controller.setArrayValue('addresses', 0, 'city', 'New York');
// Get all items as list of maps
final addresses = controller.getArrayValues('addresses');
// Returns: [{'street': '123 Main', 'city': 'NYC'}, ...]
// Get array length
final count = controller.getArrayLength('addresses');
// Remove item (shifts subsequent indices)
controller.removeArrayItem('addresses', 1);
Draft Persistence #
// Initialize storage once at app start
await DraftManager.initStorage();
// Create draft manager
final draftManager = DraftManager(
controller: controller,
key: 'registration_form',
debounce: Duration(seconds: 2),
);
// Start auto-save
draftManager.start();
// Check for existing draft
if (await draftManager.hasDraft()) {
final restored = await draftManager.restoreDraft();
if (restored != null) {
showSnackBar('Draft restored');
}
}
// Manual save
await draftManager.saveDraft();
// Clear after successful submission
await controller.submit((values) async {
await api.save(values);
await draftManager.clearDraft();
});
// Stop auto-save (keeps draft)
draftManager.stop();
// Cleanup
draftManager.dispose();
FiftySubmitButton #
FiftySubmitButton(
controller: controller,
label: 'SUBMIT',
icon: Icons.send,
loadingText: 'SAVING...',
onPressed: () => controller.submit((values) async {
await api.save(values);
}),
disableWhenInvalid: true,
expanded: true,
variant: FiftyButtonVariant.primary,
)
Platform Support #
| Platform | Support | Notes |
|---|---|---|
| Android | Yes | |
| iOS | Yes | |
| macOS | Yes | |
| Linux | Yes | |
| Windows | Yes | |
| Web | Yes |
Fifty Design Language Integration #
This package is part of Fifty Flutter Kit:
- fifty_tokens - All spacing, radius, and typography values sourced from design tokens
- fifty_ui - Form field widgets wrap FDL components (
FiftyTextField,FiftyDropdown,FiftyCheckbox, etc.) - fifty_theme - Consumes theming from the FDL theme system; no custom theme classes defined
- fifty_storage - Draft persistence layer integrates with the FDL storage abstraction
Version #
Current: 0.2.1
License #
MIT License - see LICENSE for details.
Part of Fifty Flutter Kit.




