Vyuh CDX
Advanced

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:

  1. EntityFormDescriptor - Defines form structure and data transformation
  2. Field Types - Rich set of input components
  3. Validators - Built-in and custom validation
  4. Form Controllers - State management
  5. 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

  1. Validation - Validate on client and server
  2. User Experience - Provide clear error messages and help text
  3. Progress Saving - Save drafts for complex forms
  4. Accessibility - Ensure forms work with screen readers
  5. Mobile Optimization - Test forms on mobile devices
  6. Performance - Lazy load options for large datasets
  7. Security - Never trust client-side validation alone

Next: Permissions - Security and access control