Forms & Validation
Form creation, validation, multi-step workflows, and advanced patterns
The Vyuh Entity System provides a comprehensive form management system built on top of vyuh_feature_forms. This guide covers form creation, validation, multi-step workflows, and advanced form patterns.
Form System Overview
The form system consists of:
- EntityFormDescriptor - Defines form structure and data transformation
- Field Types - Rich set of input components
- Validators - Built-in and custom validation
- Form Controllers - State management
- StepForm - Multi-step form workflows
EntityFormDescriptor
The EntityFormDescriptor is the bridge between entities and forms:
abstract class EntityFormDescriptor<T extends EntityBase> {
/// Prepare form for create/edit operations
StepForm prepare(T? entity);
/// Convert entity to form data
Map<String, dynamic> toFormData(T entity);
/// Create entity from form data
T fromFormData(Map<String, dynamic> data, {T? entity});
}Basic Implementation
class ProductFormDescriptor extends EntityFormDescriptor<Product> {
@override
StepForm prepare(Product? entity) {
final isEdit = entity != null;
return singleFormAsStep(
title: isEdit ? 'Edit Product' : 'New Product',
form: FormBuilder(
title: 'Product Details',
fields: [
TextField(
name: 'name',
label: 'Product Name',
value: entity?.name,
validators: [
required(),
minLength(3),
maxLength(100),
],
helperText: 'Enter a descriptive product name',
),
NumberField(
name: 'price',
label: 'Price',
value: entity?.price,
validators: [
required(),
min(0),
max(999999.99),
],
decimal: true,
prefix: '\$',
),
],
),
);
}
@override
Map<String, dynamic> toFormData(Product entity) {
return {
'name': entity.name,
'price': entity.price,
};
}
@override
Product fromFormData(Map<String, dynamic> data, {Product? entity}) {
return Product(
id: entity?.id ?? const Uuid().v4(),
schemaType: 'products',
name: data['name'],
price: data['price'],
createdAt: entity?.createdAt ?? DateTime.now(),
);
}
}Field Types
TextField
Basic text input with extensive options:
TextField(
name: 'email',
label: 'Email Address',
value: initialValue,
validators: [required(), email()],
// Input options
multiline: false,
maxLines: 1,
minLines: 1,
maxLength: 255,
// UI options
prefix: const Icon(Icons.email),
suffix: IconButton(
icon: const Icon(Icons.clear),
onPressed: () => controller.setValue('email', ''),
),
helperText: 'We\'ll never share your email',
// Behavior
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.emailAddress,
inputFormatters: [
FilteringTextInputFormatter.deny(RegExp(r'\s')), // No spaces
],
// Events
onChanged: (value) => vyuh.log.debug('Email changed: $value'),
onEditingComplete: () => _validateEmail(),
)NumberField
Numeric input with validation:
NumberField(
name: 'quantity',
label: 'Quantity',
value: 1,
validators: [
required(),
min(1),
max(1000),
],
// Number options
decimal: false,
signed: false,
// UI options
prefix: const Text('Units:'),
suffix: IconButton(
icon: const Icon(Icons.add),
onPressed: () => _incrementQuantity(),
),
// Stepping
step: 1,
largeStep: 10,
// Format display
formatter: (value) => NumberFormat('#,###').format(value),
)SelectField
Dropdown selection:
SelectField(
name: 'category',
label: 'Category',
value: entity?.category,
validators: [required()],
// Static options
options: [
Option(value: 'electronics', label: 'Electronics'),
Option(value: 'clothing', label: 'Clothing'),
Option(value: 'food', label: 'Food & Beverages'),
],
// Or dynamic options
optionsBuilder: () async {
final categories = await CategoryApi().list();
return categories.map((c) => Option(
value: c.id,
label: c.name,
description: c.description,
icon: c.icon,
)).toList();
},
// UI options
hint: 'Select a category',
searchable: true,
clearable: true,
// Groups
groupBy: (option) => option.value.startsWith('tech') ? 'Technology' : 'Other',
)MultiSelectField
Multiple selection:
MultiSelectField(
name: 'tags',
label: 'Tags',
value: entity?.tags ?? [],
validators: [
minSelection(1, 'Select at least one tag'),
maxSelection(5, 'Maximum 5 tags allowed'),
],
options: availableTags.map((tag) => Option(
value: tag.id,
label: tag.name,
color: tag.color,
)).toList(),
// UI options
chipDisplay: true,
searchable: true,
createNewOption: (value) => Option(
value: value.toLowerCase(),
label: value,
),
)DateTimeField
Date and time selection:
DateTimeField(
name: 'expiryDate',
label: 'Expiry Date',
value: entity?.expiryDate,
validators: [
required(),
futureDate('Expiry date must be in the future'),
],
// Date/time options
mode: DateTimeFieldMode.date, // date, time, or dateTime
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 5)),
// UI options
format: DateFormat('MMM dd, yyyy'),
helperText: 'Select the expiration date',
// Calendar options
selectableDayPredicate: (date) {
// Disable weekends
return date.weekday < 6;
},
)ToggleField
Boolean switches:
ToggleField(
name: 'isActive',
label: 'Active',
value: entity?.isActive ?? true,
// UI options
description: 'Inactive items won\'t appear in searches',
controlAffinity: ListTileControlAffinity.leading,
// Custom builder
builder: (context, value, onChanged) => SwitchListTile(
title: const Text('Account Status'),
subtitle: Text(value ? 'Active' : 'Inactive'),
value: value,
onChanged: onChanged,
secondary: Icon(
value ? Icons.check_circle : Icons.cancel,
color: value ? Colors.green : Colors.red,
),
),
)Custom Fields
Create specialized field types:
class ColorPickerField extends FormField {
final Color? initialValue;
@override
Widget build(BuildContext context, FormFieldState state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
InkWell(
onTap: () async {
final color = await showColorPicker(
context,
initialColor: state.value ?? Colors.blue,
);
if (color != null) {
state.setValue(color.value);
}
},
child: Container(
height: 50,
decoration: BoxDecoration(
color: Color(state.value ?? Colors.blue.value),
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'#${(state.value ?? Colors.blue.value).toRadixString(16).padLeft(8, '0').substring(2)}',
style: TextStyle(
color: ThemeData.estimateBrightnessForColor(
Color(state.value ?? Colors.blue.value),
) == Brightness.dark ? Colors.white : Colors.black,
),
),
),
),
),
if (state.hasError)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
state.errorText!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
],
);
}
}Validators
Built-in Validators
// Required field
required(String? message)
// String validators
minLength(int length, [String? message])
maxLength(int length, [String? message])
pattern(String pattern, [String? message])
email([String? message])
url([String? message])
alphanumeric([String? message])
// Number validators
min(num value, [String? message])
max(num value, [String? message])
between(num min, num max, [String? message])
// Date validators
futureDate([String? message])
pastDate([String? message])
dateRange(DateTime start, DateTime end, [String? message])
// Selection validators
minSelection(int count, [String? message])
maxSelection(int count, [String? message])
// File validators
fileSize(int maxBytes, [String? message])
fileType(List<String> extensions, [String? message])Custom Validators
// Synchronous validator
Validator<String> uniqueUsername() {
return (value) {
if (value == null || value.isEmpty) return null;
if (reservedUsernames.contains(value.toLowerCase())) {
return 'This username is reserved';
}
if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) {
return 'Username can only contain letters, numbers, and underscores';
}
return null;
};
}
// Async validator
AsyncValidator<String> uniqueEmail(String? currentEmail) {
return AsyncValidator(
validator: (value) async {
if (value == null || value.isEmpty) return null;
if (value == currentEmail) return null; // No change
final exists = await UserApi().emailExists(value);
return exists ? 'Email already in use' : null;
},
debounceTime: const Duration(milliseconds: 500),
);
}
// Conditional validator
Validator<T> conditionalRequired<T>(
bool Function(Map<String, dynamic>) condition,
) {
return ConditionalValidator(
condition: condition,
validator: required(),
);
}
// Usage
TextField(
name: 'vatNumber',
label: 'VAT Number',
validators: [
conditionalRequired((data) => data['country'] == 'EU'),
],
)Cross-field Validation
class PasswordForm extends FormBuilder {
PasswordForm() : super(
title: 'Change Password',
fields: [
TextField(
name: 'password',
label: 'New Password',
obscureText: true,
validators: [
required(),
minLength(8),
pattern(
r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])',
'Password must contain uppercase, lowercase, number, and special character',
),
],
),
TextField(
name: 'confirmPassword',
label: 'Confirm Password',
obscureText: true,
validators: [
required(),
],
),
],
// Form-level validation
validator: (data) {
if (data['password'] != data['confirmPassword']) {
return {
'confirmPassword': 'Passwords do not match',
};
}
return null;
},
);
}Multi-Step Forms
StepForm
Create wizard-like forms:
class UserRegistrationForm extends EntityFormDescriptor<User> {
@override
StepForm prepare(User? entity) {
return StepForm(
title: 'User Registration',
steps: [
// Step 1: Account Information
FormStep(
title: 'Account',
description: 'Basic account information',
icon: Icons.account_circle,
form: FormBuilder(
title: 'Account Details',
fields: [
TextField(
name: 'email',
label: 'Email',
validators: [required(), email()],
),
TextField(
name: 'password',
label: 'Password',
obscureText: true,
validators: [required(), minLength(8)],
),
],
),
// Step validation
canProceed: (data) =>
data['email'] != null && data['password'] != null,
),
// Step 2: Personal Information
FormStep(
title: 'Personal',
description: 'Personal details',
icon: Icons.person,
form: FormBuilder(
title: 'Personal Information',
fields: [
TextField(
name: 'firstName',
label: 'First Name',
validators: [required()],
),
TextField(
name: 'lastName',
label: 'Last Name',
validators: [required()],
),
DateTimeField(
name: 'birthDate',
label: 'Date of Birth',
mode: DateTimeFieldMode.date,
validators: [
required(),
pastDate(),
(value) {
if (value == null) return null;
final age = DateTime.now().year - value.year;
return age < 18 ? 'Must be 18 or older' : null;
},
],
),
],
),
),
// Step 3: Preferences
FormStep(
title: 'Preferences',
description: 'Customize your experience',
icon: Icons.settings,
optional: true, // Can skip this step
form: FormBuilder(
title: 'Preferences',
fields: [
SelectField(
name: 'theme',
label: 'Theme',
value: 'system',
options: [
Option(value: 'light', label: 'Light'),
Option(value: 'dark', label: 'Dark'),
Option(value: 'system', label: 'System'),
],
),
MultiSelectField(
name: 'interests',
label: 'Interests',
options: _getInterestOptions(),
),
ToggleField(
name: 'newsletter',
label: 'Subscribe to newsletter',
value: true,
),
],
),
),
],
// Form configuration
allowStepNavigation: true,
showStepIndicator: true,
onStepChanged: (oldStep, newStep) {
analytics.track('registration_step_changed', {
'from': oldStep,
'to': newStep,
});
},
onComplete: (data) async {
// Create user account
final user = User.fromFormData(data);
await UserApi().register(user);
},
);
}
}Dynamic Steps
Add steps based on form data:
class DynamicStepForm extends StepForm {
@override
List<FormStep> getSteps(Map<String, dynamic> currentData) {
final steps = <FormStep>[
// Always show first step
FormStep(
title: 'Type Selection',
form: FormBuilder(
fields: [
SelectField(
name: 'entityType',
label: 'Entity Type',
options: [
Option(value: 'individual', label: 'Individual'),
Option(value: 'company', label: 'Company'),
],
),
],
),
),
];
// Add type-specific steps
if (currentData['entityType'] == 'individual') {
steps.add(FormStep(
title: 'Personal Details',
form: _buildIndividualForm(),
));
} else if (currentData['entityType'] == 'company') {
steps.addAll([
FormStep(
title: 'Company Details',
form: _buildCompanyForm(),
),
FormStep(
title: 'Representatives',
form: _buildRepresentativesForm(),
),
]);
}
// Always add summary step
steps.add(FormStep(
title: 'Review',
form: _buildSummaryForm(currentData),
readOnly: true,
));
return steps;
}
}Form State Management
FormController
Manage form state programmatically:
class ComplexFormWidget extends StatefulWidget {
@override
State<ComplexFormWidget> createState() => _ComplexFormWidgetState();
}
class _ComplexFormWidgetState extends State<ComplexFormWidget> {
late FormController controller;
@override
void initState() {
super.initState();
controller = FormController(
initialData: {
'name': 'Initial Value',
'quantity': 1,
},
onChanged: (field, value) {
// React to field changes
if (field == 'country') {
_updateStateOptions(value);
}
},
);
}
void _updateStateOptions(String? country) {
if (country == 'US') {
controller.updateFieldOptions('state', usStates);
} else if (country == 'CA') {
controller.updateFieldOptions('state', canadianProvinces);
} else {
controller.updateFieldOptions('state', []);
}
}
@override
Widget build(BuildContext context) {
return FormBuilder(
controller: controller,
fields: [
SelectField(
name: 'country',
label: 'Country',
options: countries,
),
SelectField(
name: 'state',
label: 'State/Province',
options: [], // Updated dynamically
enabled: controller.watchValue('country') != null,
),
],
actions: [
TextButton(
onPressed: () => controller.reset(),
child: const Text('Reset'),
),
ElevatedButton(
onPressed: controller.isValid ? _submit : null,
child: const Text('Submit'),
),
],
);
}
Future<void> _submit() async {
if (!controller.validate()) return;
final data = controller.getData();
try {
controller.setLoading(true);
await api.submit(data);
controller.setSuccess('Submitted successfully!');
} catch (e) {
controller.setError('submission', e.toString());
} finally {
controller.setLoading(false);
}
}
}Field Dependencies
Handle complex field relationships:
class DependentFieldsForm extends FormBuilder {
DependentFieldsForm() : super(
fields: [
SelectField(
name: 'department',
label: 'Department',
options: departments,
),
SelectField(
name: 'manager',
label: 'Manager',
dependsOn: ['department'],
optionsBuilder: (formData) async {
final dept = formData['department'];
if (dept == null) return [];
final managers = await api.getManagersForDepartment(dept);
return managers.map((m) => Option(
value: m.id,
label: m.name,
)).toList();
},
),
MultiSelectField(
name: 'projects',
label: 'Projects',
dependsOn: ['department', 'manager'],
optionsBuilder: (formData) async {
final dept = formData['department'];
final manager = formData['manager'];
if (dept == null || manager == null) return [];
final projects = await api.getProjects(dept, manager);
return projects.map((p) => Option(
value: p.id,
label: p.name,
description: p.description,
)).toList();
},
),
],
);
}Advanced Form Patterns
Inline Editing
Enable inline field editing:
class InlineEditableForm extends StatelessWidget {
final User user;
@override
Widget build(BuildContext context) {
return Column(
children: [
InlineEditField(
label: 'Name',
value: user.name,
onSave: (value) async {
await UserApi().update(
user.id,
user.copyWith(name: value),
);
},
validator: required(),
),
InlineEditField(
label: 'Email',
value: user.email,
fieldType: FieldType.email,
onSave: (value) async {
await UserApi().update(
user.id,
user.copyWith(email: value),
);
},
validators: [required(), email()],
),
],
);
}
}Form Arrays
Handle dynamic lists of fields:
class ContactListForm extends FormBuilder {
ContactListForm() : super(
fields: [
FormArrayField(
name: 'contacts',
label: 'Contacts',
minItems: 1,
maxItems: 5,
itemBuilder: (index) => FormBuilder(
title: 'Contact ${index + 1}',
fields: [
TextField(
name: 'name',
label: 'Name',
validators: [required()],
),
TextField(
name: 'phone',
label: 'Phone',
validators: [required(), phoneNumber()],
),
SelectField(
name: 'type',
label: 'Type',
options: [
Option(value: 'primary', label: 'Primary'),
Option(value: 'secondary', label: 'Secondary'),
Option(value: 'emergency', label: 'Emergency'),
],
),
],
),
addButtonLabel: 'Add Contact',
removeButtonLabel: 'Remove',
emptyMessage: 'No contacts added',
),
],
);
}Conditional Fields
Show/hide fields based on conditions:
FormBuilder(
fields: [
SelectField(
name: 'hasAllergies',
label: 'Do you have any allergies?',
options: [
Option(value: 'yes', label: 'Yes'),
Option(value: 'no', label: 'No'),
],
),
ConditionalField(
showWhen: (data) => data['hasAllergies'] == 'yes',
field: TextField(
name: 'allergyDetails',
label: 'Please describe your allergies',
multiline: true,
validators: [
conditionalRequired((data) => data['hasAllergies'] == 'yes'),
],
),
),
],
)Auto-save Forms
Automatically save form progress:
class AutoSaveForm extends StatefulWidget {
final String draftId;
@override
State<AutoSaveForm> createState() => _AutoSaveFormState();
}
class _AutoSaveFormState extends State<AutoSaveForm> {
late FormController controller;
Timer? _saveTimer;
@override
void initState() {
super.initState();
controller = FormController(
onChanged: (field, value) {
_scheduleSave();
},
);
_loadDraft();
}
void _scheduleSave() {
_saveTimer?.cancel();
_saveTimer = Timer(const Duration(seconds: 2), () {
_saveDraft();
});
}
Future<void> _saveDraft() async {
final data = controller.getData();
await DraftService.save(widget.draftId, data);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Draft saved'),
duration: Duration(seconds: 1),
),
);
}
}
Future<void> _loadDraft() async {
final draft = await DraftService.load(widget.draftId);
if (draft != null) {
controller.setData(draft);
}
}
@override
void dispose() {
_saveTimer?.cancel();
super.dispose();
}
}Form Submission
Handling Submission
class SubmissionHandler {
final FormController controller;
final EntityApi api;
Future<void> submit() async {
// Validate
if (!controller.validate()) {
_showValidationErrors();
return;
}
// Show loading
controller.setLoading(true);
try {
// Get form data
final data = controller.getData();
// Transform to entity
final entity = fromFormData(data);
// Submit to API
final result = await api.create(entity);
// Handle success
controller.setSuccess('Created successfully!');
// Navigate to detail view
context.go('/entities/${result.id}');
} on ValidationException catch (e) {
// Handle validation errors from server
controller.setFieldErrors(e.fieldErrors);
} on NetworkException catch (e) {
// Handle network errors
controller.setError('submission', 'Network error. Please try again.');
} catch (e) {
// Handle other errors
controller.setError('submission', 'An error occurred: $e');
} finally {
controller.setLoading(false);
}
}
}Optimistic Updates
Update UI before server confirmation:
class OptimisticFormSubmission {
Future<void> submitOptimistically() async {
final data = controller.getData();
final tempId = const Uuid().v4();
// Create temporary entity
final tempEntity = Entity(
id: tempId,
...data,
isTemporary: true,
);
// Update UI immediately
entityList.add(tempEntity);
try {
// Submit to server
final realEntity = await api.create(Entity.fromData(data));
// Replace temporary with real
final index = entityList.indexWhere((e) => e.id == tempId);
if (index != -1) {
entityList[index] = realEntity;
}
} catch (e) {
// Remove temporary on failure
entityList.removeWhere((e) => e.id == tempId);
// Show error
showError(e);
}
}
}Best Practices
- Validation - Validate on client and server
- User Experience - Provide clear error messages and help text
- Progress Saving - Save drafts for complex forms
- Accessibility - Ensure forms work with screen readers
- Mobile Optimization - Test forms on mobile devices
- Performance - Lazy load options for large datasets
- Security - Never trust client-side validation alone
Next: Permissions - Security and access control