Skip to content

Bond Forms

Bond Forms provides declarative, type-safe form state management with built-in validation, localization, and seamless API integration. No more boilerplate - just define your fields and rules.

Why Bond Forms?

Traditional Flutter form handling is verbose and error-prone:

// ❌ Traditional approach - lots of boilerplate
class LoginForm extends StatefulWidget {
  @override
  _LoginFormState createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  String? _emailError;
  String? _passwordError;
  bool _isSubmitting = false;

  String? _validateEmail(String? value) {
    if (value == null || value.isEmpty) return 'Email is required';
    if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
      return 'Enter a valid email';
    }
    return null;
  }

  // More validation methods...
  // Submit logic...
  // Dispose controllers...
}

Bond Forms eliminates this complexity:

// ✅ Bond Forms approach - clean and declarative
final loginForm = BondFormState(fields: {
  'email': TextFieldState('', rules: [Rules.required(), Rules.email()]),
  'password': TextFieldState('', rules: [Rules.required(), Rules.minLength(8)]),
});

// Submit directly to API
final result = await controller.submit(api.login);

Quick Start

1. Define Form Fields

// lib/features/auth/forms/login_form.dart
final loginForm = BondFormState(fields: {
  'email': TextFieldState(
    '', 
    rules: [
      Rules.required(),
      Rules.email(),
    ],
  ),
  'password': TextFieldState(
    '', 
    rules: [
      Rules.required(),
      Rules.minLength(8),
    ],
  ),
});

2. Create Form Controller

// Using Riverpod (recommended)
class LoginFormController extends AutoDisposeFormStateNotifier<AuthResponse, ApiError> {
  LoginFormController() : super(loginForm);

  Future<void> submitLogin() async {
    final result = await submit((data) => 
      bondFire.post<AuthResponse>('/auth/login')
        .body(data.toJson())
        .factory(AuthResponse.fromJson)
        .execute()
    );

    result.fold(
      (error) => showError(error.message),
      (response) => navigateToHome(response.user),
    );
  }
}

final loginFormProvider = StateNotifierProvider.autoDispose<LoginFormController, BondFormState>(
  (ref) => LoginFormController(),
);

3. Build UI

// lib/features/auth/pages/login_page.dart
class LoginPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final formState = ref.watch(loginFormProvider);
    final controller = ref.read(loginFormProvider.notifier);

    return Scaffold(
      body: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          children: [
            // Email field
            BondTextField(
              fieldName: 'email',
              state: formState,
              onChanged: controller.updateText,
              decoration: InputDecoration(
                labelText: 'Email',
                errorText: formState.getFieldError('email'),
              ),
            ),

            // Password field
            BondTextField(
              fieldName: 'password',
              state: formState,
              onChanged: controller.updateText,
              obscureText: true,
              decoration: InputDecoration(
                labelText: 'Password',
                errorText: formState.getFieldError('password'),
              ),
            ),

            // Submit button
            ElevatedButton(
              onPressed: formState.isValid ? controller.submitLogin : null,
              child: formState.isSubmitting 
                ? CircularProgressIndicator()
                : Text('Login'),
            ),
          ],
        ),
      ),
    );
  }
}

Field Types

Text Fields

// Basic text field
'name': TextFieldState('', rules: [Rules.required()]),

// Email field
'email': TextFieldState('', rules: [Rules.required(), Rules.email()]),

// Password field
'password': TextFieldState('', rules: [
  Rules.required(),
  Rules.minLength(8),
  Rules.containsUppercase(),
  Rules.containsNumber(),
]),

// Phone number
'phone': TextFieldState('', rules: [
  Rules.required(),
  Rules.phoneNumber(),
]),

// With initial value
'bio': TextFieldState('Tell us about yourself...', rules: [
  Rules.maxLength(500),
]),

Number Fields

// Age field
'age': NumberFieldState(null, rules: [
  Rules.required(),
  Rules.numeric(),
  Rules.between(18, 100),
]),

// Price field
'price': NumberFieldState(null, rules: [
  Rules.required(),
  Rules.numeric(),
  Rules.min(0),
]),

// Integer only
'quantity': NumberFieldState(null, rules: [
  Rules.required(),
  Rules.integer(),
  Rules.between(1, 999),
]),

Boolean Fields

// Checkbox
'terms_accepted': BooleanFieldState(false, rules: [
  Rules.required(),
  Rules.mustBeTrue(message: 'You must accept the terms'),
]),

// Toggle switch
'notifications_enabled': BooleanFieldState(true),

// Radio button group
'gender': SelectFieldState<String>(null, rules: [Rules.required()]),

Date Fields

// Birth date
'birth_date': DateFieldState(null, rules: [
  Rules.required(),
  Rules.dateBefore(DateTime.now()),
  Rules.dateAfter(DateTime(1900)),
]),

// Event date
'event_date': DateFieldState(null, rules: [
  Rules.required(),
  Rules.dateAfter(DateTime.now()),
]),

List Fields

// Multi-select
'interests': ListFieldState<String>([], rules: [
  Rules.required(),
  Rules.minLength(1),
  Rules.maxLength(5),
]),

// Tags
'tags': ListFieldState<String>([], rules: [
  Rules.maxLength(10),
]),

Validation Rules

Built-in Rules

// Required validation
Rules.required(message: 'This field is required')

// String validation
Rules.email()
Rules.url()
Rules.phoneNumber()
Rules.minLength(8)
Rules.maxLength(100)
Rules.exactLength(10)
Rules.contains('substring')
Rules.startsWith('prefix')
Rules.endsWith('suffix')
Rules.regex(RegExp(r'^[A-Z]+$'), message: 'Only uppercase letters')

// Numeric validation
Rules.numeric()
Rules.integer()
Rules.min(0)
Rules.max(100)
Rules.between(18, 65)

// Date validation
Rules.dateBefore(DateTime.now())
Rules.dateAfter(DateTime(2023))
Rules.dateBetween(startDate, endDate)

// List validation
Rules.minLength(1)  // Minimum items
Rules.maxLength(5)  // Maximum items
Rules.inList(['option1', 'option2'])

// Boolean validation
Rules.mustBeTrue()
Rules.mustBeFalse()

// Comparison validation
Rules.same('password')  // Must match another field
Rules.different('old_password')

Custom Rules

// Create custom validation rule
class CustomRule extends ValidationRule<String> {
  @override
  String? validate(String? value, Map<String, dynamic> allValues) {
    if (value == null || value.isEmpty) return null;

    if (value.contains('forbidden')) {
      return 'This word is not allowed';
    }

    return null;  // Valid
  }
}

// Use custom rule
'username': TextFieldState('', rules: [
  Rules.required(),
  CustomRule(),
]),

// Inline custom rule
'custom_field': TextFieldState('', rules: [
  Rules.custom((value) {
    if (value?.length != 10) {
      return 'Must be exactly 10 characters';
    }
    return null;
  }),
]),

Conditional Rules

// Rule depends on another field
'confirm_password': TextFieldState('', rules: [
  Rules.required(),
  Rules.same('password', message: 'Passwords must match'),
]),

// Rule with condition
'work_phone': TextFieldState('', rules: [
  Rules.conditionalRequired(
    condition: (allValues) => allValues['employment_status'] == 'employed',
    message: 'Work phone is required for employed users',
  ),
]),

Form State Management

State Properties

final formState = BondFormState(fields: {...});

// Check validation status
bool isValid = formState.isValid;
bool hasErrors = formState.hasErrors;
bool isPristine = formState.isPristine;  // No user interaction
bool isDirty = formState.isDirty;        // User has modified fields

// Submission status
bool isSubmitting = formState.isSubmitting;
bool isSubmitted = formState.isSubmitted;
bool hasSubmissionError = formState.hasSubmissionError;

// Get field values
String email = formState.getValue('email');
Map<String, dynamic> allValues = formState.toJson();

// Get field errors
String? emailError = formState.getFieldError('email');
List<String> allErrors = formState.getAllErrors();

Updating Fields

// Update single field
controller.updateText('email', 'user@example.com');
controller.updateNumber('age', 25);
controller.updateBoolean('terms_accepted', true);
controller.updateDate('birth_date', DateTime(1990, 5, 15));
controller.updateList('interests', ['coding', 'music']);

// Update multiple fields
controller.updateFields({
  'email': 'user@example.com',
  'name': 'John Doe',
  'age': 25,
});

// Reset form
controller.reset();

// Reset specific field
controller.resetField('password');

// Clear form (empty all fields)
controller.clear();

State Management Integrations

// Form controller with Riverpod
class ProfileFormController extends AutoDisposeFormStateNotifier<User, ApiError> {
  ProfileFormController(User? initialUser) : super(
    BondFormState(fields: {
      'name': TextFieldState(initialUser?.name ?? '', rules: [Rules.required()]),
      'email': TextFieldState(initialUser?.email ?? '', rules: [Rules.required(), Rules.email()]),
      'bio': TextFieldState(initialUser?.bio ?? '', rules: [Rules.maxLength(500)]),
    }),
  );

  Future<void> saveProfile() async {
    final result = await submit((data) => 
      bondFire.put<User>('/profile')
        .body(data.toJson())
        .factory(User.fromJson)
        .execute()
    );

    result.fold(
      (error) => showSnackBar('Failed to save profile: ${error.message}'),
      (user) => showSnackBar('Profile saved successfully!'),
    );
  }
}

// Provider
final profileFormProvider = StateNotifierProvider.family.autoDispose<
  ProfileFormController, 
  BondFormState,
  User?
>((ref, user) => ProfileFormController(user));

Bloc Integration

// Form bloc
class LoginFormBloc extends FormBloc<AuthResponse, ApiError> {
  LoginFormBloc() : super(
    BondFormState(fields: {
      'email': TextFieldState('', rules: [Rules.required(), Rules.email()]),
      'password': TextFieldState('', rules: [Rules.required(), Rules.minLength(8)]),
    }),
  );

  @override
  Future<Either<ApiError, AuthResponse>> submitForm(Map<String, dynamic> data) async {
    try {
      final response = await bondFire
          .post<AuthResponse>('/auth/login')
          .body(data)
          .factory(AuthResponse.fromJson)
          .execute();

      return Right(response);
    } on ApiError catch (e) {
      return Left(e);
    }
  }
}

// Usage in widget
BlocBuilder<LoginFormBloc, BondFormState>(
  builder: (context, state) {
    return Column(
      children: [
        BondTextField(
          fieldName: 'email',
          state: state,
          onChanged: (value) => context.read<LoginFormBloc>().updateText('email', value),
        ),
        // More fields...
      ],
    );
  },
)

GetX Integration

// Form controller with GetX
class RegisterFormController extends GetxController with BondFormMixin<User, ApiError> {
  @override
  BondFormState get initialFormState => BondFormState(fields: {
    'name': TextFieldState('', rules: [Rules.required()]),
    'email': TextFieldState('', rules: [Rules.required(), Rules.email()]),
    'password': TextFieldState('', rules: [Rules.required(), Rules.minLength(8)]),
    'confirm_password': TextFieldState('', rules: [
      Rules.required(),
      Rules.same('password'),
    ]),
  });

  Future<void> register() async {
    final result = await submit((data) => 
      bondFire.post<User>('/auth/register')
        .body(data.toJson())
        .factory(User.fromJson)
        .execute()
    );

    result.fold(
      (error) => Get.snackbar('Error', error.message),
      (user) => Get.offAllNamed('/home'),
    );
  }
}

Multi-Step Forms

Stepper Forms

// Define stepper form
class OnboardingFormController extends StepperFormController<User, ApiError> {
  OnboardingFormController() : super(
    steps: [
      // Step 1: Personal Info
      BondFormState(fields: {
        'name': TextFieldState('', rules: [Rules.required()]),
        'email': TextFieldState('', rules: [Rules.required(), Rules.email()]),
        'birth_date': DateFieldState(null, rules: [Rules.required()]),
      }),

      // Step 2: Preferences
      BondFormState(fields: {
        'interests': ListFieldState<String>([], rules: [Rules.minLength(1)]),
        'notifications': BooleanFieldState(true),
      }),

      // Step 3: Verification
      BondFormState(fields: {
        'verification_code': TextFieldState('', rules: [
          Rules.required(),
          Rules.exactLength(6),
          Rules.numeric(),
        ]),
      }),
    ],
  );

  @override
  Future<Either<ApiError, User>> submitAllSteps(List<Map<String, dynamic>> stepData) async {
    // Combine all step data
    final combinedData = <String, dynamic>{};
    for (final data in stepData) {
      combinedData.addAll(data);
    }

    try {
      final user = await bondFire
          .post<User>('/onboarding')
          .body(combinedData)
          .factory(User.fromJson)
          .execute();

      return Right(user);
    } on ApiError catch (e) {
      return Left(e);
    }
  }
}

// Usage in widget
class OnboardingPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BondStepperForm(
      controller: OnboardingFormController(),
      onStepCompleted: (step, data) {
        print('Step $step completed with data: $data');
      },
      onFormCompleted: (user) {
        Navigator.pushReplacementNamed(context, '/home');
      },
      stepTitles: ['Personal Info', 'Preferences', 'Verification'],
      stepBuilder: (context, step, state, controller) {
        switch (step) {
          case 0:
            return PersonalInfoStep(state: state, controller: controller);
          case 1:
            return PreferencesStep(state: state, controller: controller);
          case 2:
            return VerificationStep(state: state, controller: controller);
          default:
            return SizedBox();
        }
      },
    );
  }
}

Form Transformers

Data Transformation

// Transform form data before submission
class CreateUserFormController extends FormController<User, ApiError> {
  @override
  Map<String, dynamic> transformSubmissionData(Map<String, dynamic> data) {
    return {
      'name': data['name'],
      'email': data['email'].toLowerCase(),
      'birth_date': (data['birth_date'] as DateTime).toIso8601String(),
      'interests': (data['interests'] as List<String>).join(','),
      'metadata': {
        'source': 'mobile_app',
        'timestamp': DateTime.now().toIso8601String(),
      },
    };
  }

  // Transform response data
  @override
  User transformResponseData(Map<String, dynamic> response) {
    return User.fromJson(response['user']);  // Extract user from wrapper
  }
}

Localization

Multi-language Support

Bond Forms includes built-in localization for validation messages:

// Supported languages: English (en), Arabic (ar)
'email': TextFieldState('', rules: [
  Rules.required(),  // Will show localized "This field is required"
  Rules.email(),     // Will show localized "Enter a valid email address"
]),

// Custom localized messages
'password': TextFieldState('', rules: [
  Rules.required(message: context.l10n.passwordRequired),
  Rules.minLength(8, message: context.l10n.passwordTooShort),
]),

Custom Validation Messages

// Override default messages
class CustomValidationMessages extends ValidationMessages {
  @override
  String get required => 'هذا الحقل مطلوب';  // Arabic

  @override
  String get email => 'يرجى إدخال بريد إلكتروني صحيح';

  @override
  String minLength(int length) => 'يجب أن يكون الحد الأدنى $length أحرف';
}

// Use in form
BondForm.configure(
  validationMessages: CustomValidationMessages(),
);

Advanced Features

Dynamic Forms

// Build forms based on configuration
class DynamicFormBuilder {
  static BondFormState buildFromConfig(List<FieldConfig> config) {
    final fields = <String, FormFieldState>{};

    for (final fieldConfig in config) {
      switch (fieldConfig.type) {
        case 'text':
          fields[fieldConfig.name] = TextFieldState(
            fieldConfig.defaultValue ?? '',
            rules: _buildRules(fieldConfig.validation),
          );
          break;
        case 'number':
          fields[fieldConfig.name] = NumberFieldState(
            fieldConfig.defaultValue,
            rules: _buildRules(fieldConfig.validation),
          );
          break;
        // Handle other types...
      }
    }

    return BondFormState(fields: fields);
  }

  static List<ValidationRule> _buildRules(Map<String, dynamic>? validation) {
    if (validation == null) return [];

    final rules = <ValidationRule>[];

    if (validation['required'] == true) {
      rules.add(Rules.required());
    }

    if (validation['email'] == true) {
      rules.add(Rules.email());
    }

    if (validation['minLength'] != null) {
      rules.add(Rules.minLength(validation['minLength']));
    }

    return rules;
  }
}

Form Arrays

// Handle dynamic lists of forms
class ContactListFormController extends FormController {
  final contacts = <BondFormState>[].obs;

  void addContact() {
    contacts.add(BondFormState(fields: {
      'name': TextFieldState('', rules: [Rules.required()]),
      'email': TextFieldState('', rules: [Rules.required(), Rules.email()]),
      'phone': TextFieldState('', rules: [Rules.phoneNumber()]),
    }));
  }

  void removeContact(int index) {
    if (index >= 0 && index < contacts.length) {
      contacts.removeAt(index);
    }
  }

  bool get allContactsValid => contacts.every((contact) => contact.isValid);

  List<Map<String, dynamic>> get allContactsData => 
      contacts.map((contact) => contact.toJson()).toList();
}

Testing

Unit Testing Forms

void main() {
  group('LoginForm', () {
    late BondFormState form;

    setUp(() {
      form = BondFormState(fields: {
        'email': TextFieldState('', rules: [Rules.required(), Rules.email()]),
        'password': TextFieldState('', rules: [Rules.required(), Rules.minLength(8)]),
      });
    });

    test('should be invalid when empty', () {
      expect(form.isValid, false);
      expect(form.hasErrors, true);
    });

    test('should validate email format', () {
      form = form.updateField('email', 'invalid-email');
      expect(form.getFieldError('email'), isNotNull);

      form = form.updateField('email', 'user@example.com');
      expect(form.getFieldError('email'), isNull);
    });

    test('should require minimum password length', () {
      form = form.updateField('password', '123');
      expect(form.getFieldError('password'), contains('8'));

      form = form.updateField('password', '12345678');
      expect(form.getFieldError('password'), isNull);
    });

    test('should be valid when all fields are correct', () {
      form = form.updateFields({
        'email': 'user@example.com',
        'password': 'securePassword123',
      });

      expect(form.isValid, true);
      expect(form.hasErrors, false);
    });
  });
}

Widget Testing

void main() {
  testWidgets('LoginPage should validate and submit', (tester) async {
    await tester.pumpWidget(
      ProviderScope(
        child: MaterialApp(home: LoginPage()),
      ),
    );

    // Find form fields
    final emailField = find.byKey(Key('email_field'));
    final passwordField = find.byKey(Key('password_field'));
    final submitButton = find.byKey(Key('submit_button'));

    // Initially submit button should be disabled
    expect(tester.widget<ElevatedButton>(submitButton).onPressed, isNull);

    // Enter invalid email
    await tester.enterText(emailField, 'invalid');
    await tester.pump();

    expect(find.text('Enter a valid email'), findsOneWidget);

    // Enter valid data
    await tester.enterText(emailField, 'user@example.com');
    await tester.enterText(passwordField, 'password123');
    await tester.pump();

    // Submit button should be enabled
    expect(tester.widget<ElevatedButton>(submitButton).onPressed, isNotNull);

    // Tap submit
    await tester.tap(submitButton);
    await tester.pump();

    // Should show loading state
    expect(find.byType(CircularProgressIndicator), findsOneWidget);
  });
}

Best Practices

✅ Do's

// Use descriptive field names
'user_email': TextFieldState('', rules: [Rules.email()]),
'confirm_password': TextFieldState('', rules: [Rules.same('password')]),

// Group related validation rules
'password': TextFieldState('', rules: [
  Rules.required(),
  Rules.minLength(8),
  Rules.containsUppercase(),
  Rules.containsNumber(),
  Rules.containsSpecialChar(),
]),

// Handle all form states
if (formState.isSubmitting) {
  return CircularProgressIndicator();
} else if (formState.hasSubmissionError) {
  return ErrorMessage(formState.submissionError);
} else {
  return SubmitButton(onPressed: controller.submit);
}

// Use proper field types
'age': NumberFieldState(null, rules: [Rules.numeric()]),
'birth_date': DateFieldState(null, rules: [Rules.dateBefore(DateTime.now())]),
'terms': BooleanFieldState(false, rules: [Rules.mustBeTrue()]),

❌ Don'ts

// Don't use generic field names
'field1': TextFieldState(''),  // ❌ What is this?
'data': TextFieldState(''),    // ❌ Too generic

// Don't skip validation
'email': TextFieldState(''),   // ❌ No email validation

// Don't ignore form state
ElevatedButton(
  onPressed: controller.submit,  // ❌ Should check if form is valid
  child: Text('Submit'),
);

// Don't use wrong field types
'age': TextFieldState('25'),   // ❌ Should be NumberFieldState
'is_admin': TextFieldState('true'),  // ❌ Should be BooleanFieldState

Integration Examples

With BondFire

// Direct form submission to API
class UserFormController extends FormController<User, ApiError> {
  Future<void> saveUser() async {
    final result = await submit((formData) =>
      bondFire.post<User>('/users')
        .body(formData.toJson())
        .factory(User.fromJson)
        .errorFactory(ApiError.fromJson)
        .execute()
    );

    result.fold(
      (error) => showError(error.message),
      (user) => showSuccess('User saved: ${user.name}'),
    );
  }
}

With Bond Analytics

// Track form events
class AnalyticsFormController extends FormController {
  @override
  void onFieldChanged(String fieldName, dynamic value) {
    super.onFieldChanged(fieldName, value);

    AppAnalytics.fire(FormFieldChangedEvent(
      formName: 'registration_form',
      fieldName: fieldName,
    ));
  }

  @override
  void onSubmissionSuccess(result) {
    super.onSubmissionSuccess(result);

    AppAnalytics.fire(FormSubmittedEvent(
      formName: 'registration_form',
      success: true,
    ));
  }
}

Troubleshooting

Common Issues

Issue: Form not validating

// ❌ Problem: Missing rules
'email': TextFieldState(''),

// ✅ Solution: Add validation rules
'email': TextFieldState('', rules: [Rules.required(), Rules.email()]),

Issue: Submit button always disabled

// ❌ Problem: Not checking form validity
ElevatedButton(
  onPressed: controller.submit,  // Always enabled
  child: Text('Submit'),
);

// ✅ Solution: Check form state
ElevatedButton(
  onPressed: formState.isValid ? controller.submit : null,
  child: Text('Submit'),
);

Issue: Custom validation not working

// ❌ Problem: Not returning validation error
Rules.custom((value) {
  if (value?.contains('bad') == true) {
    print('Invalid value');  // Just printing, not returning error
  }
});

// ✅ Solution: Return error message
Rules.custom((value) {
  if (value?.contains('bad') == true) {
    return 'This value is not allowed';
  }
  return null;  // Valid
});

Next Steps

Bond Forms eliminates form complexity while providing powerful validation and state management. Start with simple forms and gradually add advanced features! 🚀