Skip to main content

mk_forms 1.0.0 copy "mk_forms: ^1.0.0" to clipboard
mk_forms: ^1.0.0 copied to clipboard

A flexible, reactive, and type-safe Flutter form management library powered by flutter_bloc. Handles minimal to complex forms with ease.

mk_forms ๐Ÿš€ #

A reactive, type-safe, and highly performant Flutter form management library powered by flutter_bloc. Designed to handle everything from minimal login forms to complex enterprise workflows โ€” multi-step wizards, multi-route onboarding, independent subforms, dynamic field arrays, cross-field validation, async validation, and autovalidation โ€” with zero boilerplate and surgically fine-grained UI rebuilds.

pub version License: MIT CI


โœจ Features at a Glance #

Feature Description
โšก Fine-grained rebuilds Typing in one field only rebuilds that widget โ€” never the whole screen
๐ŸŽฏ Zero-boilerplate minimal forms MkSimpleFormBloc without needing a custom class
๐Ÿชœ Multi-step & multi-route wizards MkStepFormBloc with step validation gating across Navigator routes
๐ŸŒ Remote draft hydration loadInitialData() + populateValues() โ€” pristine fields, no premature errors
๐Ÿ“ฆ Independent subforms MkSubformBuilder + validateGroup() for isolated section validation
๐Ÿ”„ Cross-field reactive validation dependsOn: [otherField] auto-revalidates without recursion loops
โณ Debounced async validation Built-in debounce, cancels stale in-flight requests automatically
๐Ÿ›ก๏ธ Pristine initial state Errors never appear on load โ€” only after touch or submit
๐Ÿงน Clean submission lifecycle Stale server errors are auto-cleared when user edits
โœ… AutovalidateMode Per-scope or per-field: always, onUserInteraction, onUnfocus, disabled
๐ŸŒ Full i18n MkFormLocalizations.delegate, scoped overrides, runtime locale switching
๐Ÿ“‹ Dynamic field arrays MkArrayBloc โ€” add, insert, remove, reorder
๐ŸŽจ Rich widget suite Text, Email, Password, Checkbox, Radio, Dropdown, MultiSelect, custom builder

๐Ÿ“ฆ Installation #

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^8.1.6
  mk_forms: ^0.1.0

For i18n support, add flutter_localizations and configure your MaterialApp:

dependencies:
  flutter_localizations:
    sdk: flutter

๐Ÿš€ Quick Start โ€” Minimal Login Form #

For simple forms use MkSimpleFormBloc โ€” no subclass required:

import 'package:flutter/material.dart';
import 'package:mk_forms/mk_forms.dart';

class LoginScreen extends StatefulWidget {
  const LoginScreen({super.key});

  @override
  State<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  late final MkSimpleFormBloc _form;

  @override
  void initState() {
    super.initState();
    _form = MkSimpleFormBloc(
      controls: {
        'email': MkFieldBloc<String>(
          validators: [MkValidators.required(), MkValidators.email()],
        ),
        'password': MkFieldBloc<String>(
          validators: [MkValidators.required(), MkValidators.minLength(6)],
        ),
      },
      onSubmit: (values) async {
        await authRepository.signIn(
          values['email'] as String,
          values['password'] as String,
        );
      },
    );
  }

  @override
  void dispose() {
    _form.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: MkFormScope(
        formBloc: _form,
        child: MkFormListener(
          formBloc: _form,
          onSuccess: (context, _) =>
              Navigator.pushReplacementNamed(context, '/home'),
          onFailure: (context, error) => ScaffoldMessenger.of(context)
              .showSnackBar(SnackBar(content: Text('$error'))),
          child: Column(
            children: [
              const MkEmailField(name: 'email'),
              const SizedBox(height: 16),
              const MkPasswordField(name: 'password'),
              const SizedBox(height: 24),
              BlocBuilder<MkFormBloc, MkFormState>(
                bloc: _form,
                builder: (context, state) => FilledButton(
                  onPressed: state.isSubmitting ? null : _form.submit,
                  child: state.isSubmitting
                      ? const CircularProgressIndicator()
                      : const Text('Sign In'),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

๐Ÿง  Core Concepts #

Field Blocs (MkFieldBloc<T>) #

Every form field is powered by an MkFieldBloc<T> โ€” a reactive BLoC managing value, validation, dirty/touched state, and async validation.

// String field with sync validators
final username = MkFieldBloc<String>(
  initialValue: '',
  validators: [
    MkValidators.required(),
    MkValidators.minLength(3),
    MkValidators.maxLength(20),
  ],
);

// Numeric field
final age = MkFieldBloc<int>(
  validators: [MkValidators.required(), MkValidators.min(18)],
);

// Boolean field (checkbox / toggle)
final agreeTerms = MkFieldBloc<bool>(
  validators: [MkValidators.required()], // required() treats false as invalid
);

// Multi-value field
final interests = MkFieldBloc<List<String>>(
  validators: [MkValidators.required()],
);

Key properties on MkFieldState<T>:

Property Type Description
value T? Current value
initialValue T? Value at creation/reset
error String? Current validation error
isDirty bool Value changed from initialValue
isTouched bool User has interacted with the field
isValidating bool Async validation in progress
isValid bool No error and not validating
wasFormSubmitted bool Form submission was attempted
showError bool Convenience getter โ€” shouldShowError() with default mode
shouldShowError([mode]) bool Whether to display error per AutovalidateMode

Form Blocs #

MkSimpleFormBloc โ€” Inline submit handler

final form = MkSimpleFormBloc(
  controls: {
    'name': MkFieldBloc<String>(validators: [MkValidators.required()]),
  },
  onSubmit: (values) async => api.save(values),
  onSubmitSuccess: (response) => print('Saved: $response'),
  onSubmitFailure: (error) => print('Error: $error'),
);

MkFormBloc โ€” Custom subclass

class RegistrationFormBloc extends MkFormBloc {
  RegistrationFormBloc()
      : super(controls: {
          'email': MkFieldBloc<String>(
            validators: [MkValidators.required(), MkValidators.email()],
          ),
          'password': MkFieldBloc<String>(
            validators: [MkValidators.required(), MkValidators.minLength(8)],
          ),
        });

  @override
  Future<void> onSubmit(Map<String, dynamic> values) async {
    await authRepository.register(
      email: values['email'] as String,
      password: values['password'] as String,
    );
  }

  @override
  void onSubmitSuccess(dynamic _) {
    // Navigate, show banner, etc.
  }

  @override
  void onSubmitFailure(Object error) {
    // Map server errors to fields
    if (error is ApiException && error.fieldErrors != null) {
      setFieldErrors(error.fieldErrors!);
    }
  }
}

Key MkFormBloc methods:

Method Description
submit() Validate all fields then invoke onSubmit
reset() Reset form and all controls to initial state
populateValues(data) Hydrate fields from API data, optionally marks pristine
setFieldErrors(Map<String, String>) Map server field errors onto controls
validateGroup(name) Validate only a named group/subform
validateControls([names]) Validate a specific subset of controls
field<T>(name) Look up a typed MkFieldBloc<T> by name
control<C>(name) Look up any MkControlBloc by name

MkFormScope & Context Extensions #

MkFormScope is an InheritedWidget providing the form bloc and default AutovalidateMode down the tree.

MkFormScope(
  formBloc: myFormBloc,
  autovalidateMode: AutovalidateMode.onUserInteraction, // default
  child: MyFormWidget(),
)

Context extensions (available anywhere inside MkFormScope):

// Access the form bloc
context.mkForm.submit();

// Access a field by name
final emailField = context.mkField<String>('email');

// Access the active autovalidate mode
final mode = context.mkAutovalidateMode;

MkFormListener #

Listens to submission status transitions without causing rebuilds of its subtree:

MkFormListener(
  formBloc: _form, // optional: falls back to MkFormScope lookup
  onSubmitting: (context) => showLoadingDialog(context),
  onSuccess: (context, response) {
    hideLoadingDialog(context);
    context.go('/dashboard'); // auto_route / go_router
  },
  onFailure: (context, error) {
    hideLoadingDialog(context);
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Error: $error')),
    );
  },
  child: MyFormBody(),
)

โœ… AutovalidateMode #

Control when field errors are surfaced to the user โ€” at the scope level or per-field.

Scope-Level Mode #

MkFormScope(
  formBloc: _form,
  autovalidateMode: AutovalidateMode.always, // Show errors immediately even on pristine fields
  child: MyForm(),
)

Per-Field Override #

MkTextField(
  name: 'username',
  autovalidateMode: AutovalidateMode.disabled, // Only show after submit
)

Mode Reference #

Mode When error is shown
AutovalidateMode.always Immediately, even before any interaction
AutovalidateMode.onUserInteraction (default) After touch (isTouched) or after submit attempt
AutovalidateMode.onUnfocus After the field loses focus or after submit attempt
AutovalidateMode.onUserInteractionIfError Same as onUserInteraction
AutovalidateMode.disabled Only after submit attempt (wasFormSubmitted)

Custom Widget Integration #

When building custom fields with MkFieldBuilder, use shouldShowError(mode):

MkFieldBuilder<String>(
  fieldBloc: _form.field<String>('username')!,
  builder: (context, state) {
    final mode = context.mkAutovalidateMode;
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        TextField(
          onChanged: (v) => _form.field<String>('username')!.changeValue(v),
          decoration: InputDecoration(
            errorText: state.shouldShowError(mode) ? state.error : null,
          ),
        ),
      ],
    );
  },
)

๐Ÿงฉ Built-in Validators #

// Required (null, empty string, empty list, false bool โ€” all invalid)
MkValidators.required()
MkValidators.required('Please fill this field') // custom message

// String validators
MkValidators.email()
MkValidators.minLength(3)
MkValidators.maxLength(100)
MkValidators.pattern(RegExp(r'^\d+$'), 'Numbers only')

// Numeric validators
MkValidators.min(18)
MkValidators.max(120)

// Match / confirm (for password confirmation)
MkValidators.match(() => passwordField.value, 'Passwords do not match')
MkValidators.matchControl(passwordField, 'Passwords do not match')

// Compose multiple validators (returns first error)
MkValidators.compose([MkValidators.required(), MkValidators.email()])

// Compose multiple async validators
MkValidators.composeAsync([checkUsername, checkProfanity])

๐Ÿ”„ Cross-Field Dependent Validation #

Use dependsOn to automatically revalidate a field when another field changes โ€” no infinite loops:

final password = MkFieldBloc<String>(
  validators: [MkValidators.required(), MkValidators.minLength(8)],
);

final confirmPassword = MkFieldBloc<String>(
  validators: [
    MkValidators.required(),
    MkValidators.matchControl(password, 'Passwords do not match'),
  ],
  dependsOn: [password], // โ† Auto-revalidates when password changes
);

// When password changes โ†’ confirmPassword revalidates immediately
// When confirmPassword changes โ†’ password does NOT revalidate (no loop)

โณ Debounced Async Validation #

Check uniqueness or availability with automatic debouncing and stale request cancellation:

final username = MkFieldBloc<String>(
  validators: [MkValidators.required(), MkValidators.minLength(3)],
  asyncValidators: [
    (val) async {
      final available = await userRepository.checkUsernameAvailable(val!);
      return available ? null : 'Username is already taken';
    },
  ],
  debounceTime: const Duration(milliseconds: 400),
);

Display async validation state in the UI:

MkFieldBuilder<String>(
  fieldBloc: _form.field<String>('username')!,
  builder: (context, state) {
    return TextField(
      decoration: InputDecoration(
        suffixIcon: state.isValidating
            ? const SizedBox(
                width: 16,
                height: 16,
                child: CircularProgressIndicator(strokeWidth: 2),
              )
            : state.isValid && state.isTouched
                ? const Icon(Icons.check_circle, color: Colors.green)
                : null,
        errorText: state.showError ? state.error : null,
      ),
    );
  },
)

๐Ÿชœ Multi-Step Forms & Multi-Route Wizards #

Define the Bloc #

class OnboardingFormBloc extends MkStepFormBloc {
  OnboardingFormBloc()
      : super(
          controls: {
            // Step 0 โ€” Account
            'username': MkFieldBloc<String>(validators: [MkValidators.required()]),
            'email': MkFieldBloc<String>(
              validators: [MkValidators.required(), MkValidators.email()],
            ),
            // Step 1 โ€” Profile
            'fullName': MkFieldBloc<String>(validators: [MkValidators.required()]),
            'role': MkFieldBloc<String>(initialValue: 'developer'),
            // Step 2 โ€” Preferences
            'interests': MkFieldBloc<List<String>>(validators: [MkValidators.required()]),
            'agreeTerms': MkFieldBloc<bool>(validators: [MkValidators.required()]),
          },
          steps: const [
            MkFormStep(controlNames: ['username', 'email'], title: 'Account'),
            MkFormStep(controlNames: ['fullName', 'role'], title: 'Profile'),
            MkFormStep(
              controlNames: ['interests', 'agreeTerms'],
              title: 'Preferences',
            ),
          ],
        );

  @override
  Future<void> onSubmit(Map<String, dynamic> values) async {
    await onboardingRepository.complete(values);
  }
}

Step 0 Screen โ€” Validate & Navigate #

// Only validates Step 0's fields โ€” Step 1 and 2 remain pristine
final canProceed = await formBloc.nextStep();
if (canProceed && context.mounted) {
  context.pushRoute(const OnboardingStep1Route()); // auto_route
}

Load Draft From API #

@override
void initState() {
  super.initState();
  _formBloc.loadInitialData(
    () => draftRepository.fetchDraft(),
    resolveInitialStep: (data) => data['lastCompletedStep'] as int? ?? 0,
  );
}

Smart redirect behavior: If the API draft has values for Step 2 but Step 0 has a validation issue (e.g. a missing required field), loadInitialData automatically:

  1. Populates all step values (pristine โ€” no premature errors)
  2. Detects the earliest invalid step
  3. Redirects to that step and shows errors only on that step
  4. Marks previous valid steps as completed

Listen to Loading State #

BlocBuilder<OnboardingFormBloc, MkStepFormState>(
  bloc: _formBloc,
  builder: (context, state) {
    if (state.isLoadingInitialData) {
      return const Center(child: CircularProgressIndicator());
    }
    return MyStepContent(currentStep: state.currentStep);
  },
)

Step Navigation Methods #

formBloc.nextStep();          // Validate current step โ†’ advance if valid
formBloc.previousStep();      // Go back unconditionally
formBloc.goToStep(2);         // Jump to step 2 (validates intermediate steps if going forward)
formBloc.validateStep(0);     // Validate step 0 controls only

MkSimpleStepFormBloc โ€” Inline Handler #

final form = MkSimpleStepFormBloc(
  controls: { /* ... */ },
  steps: [ /* ... */ ],
  onSubmit: (values) async => api.submit(values),
);

๐Ÿ“ฆ Independent Subforms #

Group form sections so that validating Shipping never touches Billing fields:

Define Subforms #

final formBloc = MkSimpleFormBloc(
  controls: {
    'shipping': MkGroupBloc({
      'street': MkFieldBloc<String>(validators: [MkValidators.required()]),
      'city': MkFieldBloc<String>(validators: [MkValidators.required()]),
      'zip': MkFieldBloc<String>(validators: [MkValidators.required()]),
    }),
    'billing': MkGroupBloc({
      'street': MkFieldBloc<String>(validators: [MkValidators.required()]),
      'city': MkFieldBloc<String>(validators: [MkValidators.required()]),
      'zip': MkFieldBloc<String>(validators: [MkValidators.required()]),
    }),
  },
  onSubmit: (values) async => checkoutRepository.placeOrder(values),
);

// Validate ONLY shipping โ€” billing fields stay pristine
final shippingValid = await formBloc.validateGroup('shipping');

Build Subforms Efficiently #

MkFormScope(
  formBloc: formBloc,
  child: Column(
    children: [
      // Changes in shipping NEVER rebuild billing and vice versa
      MkSubformBuilder(
        name: 'shipping',
        builder: (context, state, groupBloc) => Column(
          children: [
            Text('Shipping', style: Theme.of(context).textTheme.titleMedium),
            MkTextField(fieldBloc: groupBloc.field('street'), label: 'Street'),
            MkTextField(fieldBloc: groupBloc.field('city'), label: 'City'),
            MkTextField(fieldBloc: groupBloc.field('zip'), label: 'ZIP'),
          ],
        ),
      ),
      const Divider(),
      MkSubformBuilder(
        name: 'billing',
        builder: (context, state, groupBloc) => Column(
          children: [
            Text('Billing', style: Theme.of(context).textTheme.titleMedium),
            MkTextField(fieldBloc: groupBloc.field('street'), label: 'Street'),
            MkTextField(fieldBloc: groupBloc.field('city'), label: 'City'),
            MkTextField(fieldBloc: groupBloc.field('zip'), label: 'ZIP'),
          ],
        ),
      ),
    ],
  ),
)

๐Ÿ“‹ Dynamic Field Arrays (MkArrayBloc) #

Manage a dynamic list of fields โ€” phone numbers, invoice rows, tags:

final phones = MkArrayBloc<MkFieldBloc<String>>([
  MkFieldBloc<String>(validators: [MkValidators.required()]),
]);

// Append a new field
phones.add(MkFieldBloc<String>(validators: [MkValidators.required()]));

// Insert at position
phones.insert(0, MkFieldBloc<String>());

// Remove by index
phones.removeAt(1);

// Reorder (e.g. from ReorderableListView)
phones.move(oldIndex, newIndex);

Build Dynamic Array UI #

BlocBuilder<MkArrayBloc<MkFieldBloc<String>>, MkArrayState<MkFieldBloc<String>>>(
  bloc: phones,
  builder: (context, state) {
    return Column(
      children: [
        ...state.controls.asMap().entries.map((e) => Row(
          children: [
            Expanded(
              child: MkTextField(
                fieldBloc: e.value,
                label: 'Phone ${e.key + 1}',
              ),
            ),
            if (state.controls.length > 1)
              IconButton(
                icon: const Icon(Icons.remove_circle_outline),
                onPressed: () => phones.removeAt(e.key),
              ),
          ],
        )),
        TextButton.icon(
          icon: const Icon(Icons.add),
          label: const Text('Add Phone'),
          onPressed: () => phones.add(
            MkFieldBloc<String>(validators: [MkValidators.required()]),
          ),
        ),
      ],
    );
  },
)

๐ŸŽจ Widget Reference #

MkTextField #

MkTextField(
  name: 'username',              // Looks up field from MkFormScope
  // OR:
  fieldBloc: myFieldBloc,        // Direct field reference

  label: 'Username',
  hint: 'Enter your username',
  prefixIcon: const Icon(Icons.person),
  maxLines: 1,
  keyboardType: TextInputType.text,
  autovalidateMode: AutovalidateMode.onUserInteraction,
  decoration: const InputDecoration(border: OutlineInputBorder()),
)

MkEmailField #

Pre-configured with email keyboard, autofill hints, and email format validation:

const MkEmailField(name: 'email')
// or
MkEmailField(fieldBloc: emailBloc, label: 'Work Email')

MkPasswordField #

Obscured input with show/hide toggle:

const MkPasswordField(name: 'password')
MkPasswordField(
  fieldBloc: passwordBloc,
  label: 'New Password',
  autovalidateMode: AutovalidateMode.onUnfocus,
)

MkCheckboxField #

MkCheckboxField(
  name: 'agreeTerms',
  label: const Text('I agree to the Terms of Service'),
)
// or
MkCheckboxField(
  fieldBloc: agreeTermsBloc,
  label: RichText(
    text: TextSpan(children: [
      const TextSpan(text: 'I agree to the '),
      TextSpan(
        text: 'Terms of Service',
        style: const TextStyle(color: Colors.blue),
        recognizer: TapGestureRecognizer()..onTap = openTerms,
      ),
    ]),
  ),
)

MkRadioGroup<T> #

MkRadioGroup<String>(
  name: 'role',
  options: const [
    MkRadioOption(value: 'developer', label: 'Developer'),
    MkRadioOption(value: 'designer', label: 'Designer'),
    MkRadioOption(value: 'manager', label: 'Manager'),
  ],
  direction: Axis.vertical,  // or Axis.horizontal
)

Custom option builder:

MkRadioGroup<String>(
  fieldBloc: roleBloc,
  options: roles,
  optionBuilder: (context, option, isSelected, onTap) => Card(
    color: isSelected ? Theme.of(context).colorScheme.primaryContainer : null,
    child: ListTile(
      leading: Icon(option.icon),
      title: Text(option.label),
      onTap: onTap,
    ),
  ),
)

MkDropdownField<T> #

MkDropdownField<String>(
  name: 'country',
  items: countries.map((c) => DropdownMenuItem(
    value: c.code,
    child: Text(c.name),
  )).toList(),
  label: 'Country',
)

MkMultiSelectSearchDropdown<T> #

Multi-selection with search, chips, select all / clear all:

MkMultiSelectSearchDropdown<String>(
  name: 'interests',
  label: 'Interests',
  options: [
    MkMultiSelectOption(value: 'flutter', label: 'Flutter'),
    MkMultiSelectOption(value: 'dart', label: 'Dart'),
    MkMultiSelectOption(value: 'firebase', label: 'Firebase'),
  ],
  searchHint: 'Search interests...',
  displayMode: MkMultiSelectDisplayMode.bottomSheet, // or dialog
)

MkFieldBuilder<T> โ€” Custom Field Widget #

Build a completely custom field widget with fine-grained rebuild control:

MkFieldBuilder<int>(
  fieldBloc: ratingBloc,
  buildWhen: (prev, curr) => prev.value != curr.value || prev.showError != curr.showError,
  builder: (context, state) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          children: List.generate(5, (i) => IconButton(
            icon: Icon(
              i < (state.value ?? 0) ? Icons.star : Icons.star_border,
              color: Colors.amber,
            ),
            onPressed: () => ratingBloc.changeValue(i + 1),
          )),
        ),
        if (state.showError && state.error != null)
          Text(state.error!, style: const TextStyle(color: Colors.red)),
      ],
    );
  },
)

๐ŸŒ Internationalization (i18n) #

Standard Setup #

MaterialApp(
  localizationsDelegates: const [
    MkFormLocalizations.delegate,
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
    GlobalCupertinoLocalizations.delegate,
  ],
  supportedLocales: MkFormLocalizations.supportedLocales, // ['en', 'es']
  home: const HomeScreen(),
)

Global Override #

Override all validation messages globally (useful for app-level branded messages):

MkFormLocalizations.current = MyCustomMessages();

Scoped Override #

Override messages for a specific subtree:

MkLocalizationScope(
  messages: SpanishMessages(),
  child: MySpanishFormScreen(),
)

Dynamic Locale Switching #

Wrap with Localizations.override for live locale switching โ€” validation errors update immediately:

Localizations.override(
  context: context,
  locale: _selectedLocale,
  child: MyFormWidget(),
)

Custom Messages #

class MyAppMessages extends MkFormMessages {
  @override
  String required() => 'This field cannot be empty';

  @override
  String email() => 'Please enter a valid email address';

  @override
  String minLength(int min) => 'Must be at least $min characters';

  @override
  String maxLength(int max) => 'Must be at most $max characters';

  @override
  String min(num min) => 'Must be at least $min';

  @override
  String max(num max) => 'Must be at most $max';

  @override
  String pattern() => 'Invalid format';

  @override
  String match() => 'Values do not match';
}

๐Ÿ—๏ธ Server Error Mapping #

After a failed API call, map server-returned field errors directly onto form controls:

try {
  await formBloc.submit();
} catch (e) {
  if (e is ApiException) {
    formBloc.setFieldErrors({
      'email': 'This email is already registered',
      'username': 'Username is already taken',
    });
  }
}

Or override onSubmitFailure in your custom MkFormBloc:

@override
void onSubmitFailure(Object error) {
  if (error is ServerValidationException) {
    setFieldErrors(error.fieldErrors);
  }
}

๐Ÿ› ๏ธ Programmatic Control #

Control fields programmatically outside the UI:

final emailField = formBloc.field<String>('email')!;

// Change value programmatically (marks dirty)
emailField.changeValue('user@example.com');

// Reset field to initial value (clears dirty/touched)
emailField.reset();

// Reset with a new initial value
emailField.reset('new@example.com');

// Manually touch the field (reveals any error)
emailField.touch();

// Set a server error directly
emailField.setError('Email is already taken');

// Enable / disable
emailField.setEnabled(false);
emailField.setEnabled(true);

// Show / hide in UI
emailField.setVisible(false);

๐Ÿงช Testing #

Unit Testing Field Blocs #

import 'package:flutter_test/flutter_test.dart';
import 'package:mk_forms/mk_forms.dart';

void main() {
  test('required validator shows error after touch', () {
    final field = MkFieldBloc<String>(
      validators: [MkValidators.required()],
    );

    expect(field.state.showError, isFalse); // pristine โ€” no error shown

    field.touch();

    expect(field.state.showError, isTrue); // touched โ€” error visible
    expect(field.state.error, isNotNull);

    field.changeValue('hello');
    expect(field.state.error, isNull); // now valid
  });
}

Unit Testing Form Blocs #

import 'package:bloc_test/bloc_test.dart';
import 'package:mk_forms/mk_forms.dart';

void main() {
  group('LoginFormBloc', () {
    late LoginFormBloc sut;

    setUp(() => sut = LoginFormBloc());
    tearDown(() => sut.close());

    test('is initially invalid', () {
      expect(sut.state.isValid, isFalse);
    });

    test('becomes valid with valid credentials', () async {
      sut.field<String>('email')!.changeValue('test@example.com');
      sut.field<String>('password')!.changeValue('password123');
      await Future.microtask(() {}); // allow async validators to settle
      expect(sut.state.isValid, isTrue);
    });

    blocTest<LoginFormBloc, MkFormState>(
      'emits submitting then success',
      build: () => LoginFormBloc(),
      act: (bloc) {
        bloc.field<String>('email')!.changeValue('test@example.com');
        bloc.field<String>('password')!.changeValue('validpass');
        bloc.submit();
      },
      expect: () => [
        isA<MkFormState>().having((s) => s.isSubmitting, 'isSubmitting', isTrue),
        isA<MkFormState>().having((s) => s.isSuccess, 'isSuccess', isTrue),
      ],
    );
  });
}

Running Tests #

# All tests
flutter test

# With coverage
flutter test --coverage

# Analyze code
flutter analyze

๐Ÿ“ Example App Architecture #

The example/ app demonstrates Clean Architecture with a 2-layer repository pattern:

example/lib/
โ”œโ”€โ”€ core/
โ”‚   โ”œโ”€โ”€ di/              # GetIt + Injectable dependency injection
โ”‚   โ””โ”€โ”€ router/          # AutoRoute declarative routing
โ”œโ”€โ”€ data/
โ”‚   โ”œโ”€โ”€ client/          # ApiClient interface + MockApiClient implementation
โ”‚   โ”œโ”€โ”€ models/          # AuthCredentials, UserProfile, OnboardingData, etc.
โ”‚   โ””โ”€โ”€ repositories/    # Abstract contracts + concrete implementations
โ””โ”€โ”€ presentation/
    โ”œโ”€โ”€ screens/          # HomeScreen (root navigation)
    โ””โ”€โ”€ features/
        โ”œโ”€โ”€ minimal_login/         # Simple login with MkSimpleFormBloc
        โ”œโ”€โ”€ onboarding_flow/       # Multi-route 3-step wizard with API draft
        โ”œโ”€โ”€ subforms_checkout/     # Independent subforms with validateGroup()
        โ”œโ”€โ”€ dependent_fields/      # Cross-validating password + confirm password
        โ”œโ”€โ”€ async_validation/      # Debounced username availability check
        โ”œโ”€โ”€ dynamic_array/         # Dynamic task list with reordering
        โ”œโ”€โ”€ all_fields_showcase/   # Full field type gallery
        โ”œโ”€โ”€ autovalidate_demo/     # AutovalidateMode switcher demo
        โ”œโ”€โ”€ i18n_demo/             # Live locale switching EN โ†” ES
        โ””โ”€โ”€ custom_design_system/  # Custom card radios, styled chips

Key architecture decisions:

  • No datasource layer โ€” repositories call the API client directly
  • BLoC handles all presentation logic โ€” no business logic in widget build methods
  • @injectable / @lazySingleton with GetIt for compile-safe DI
  • @RoutePage() with AutoRoute for type-safe, nested navigation

๐Ÿ“„ License #

MIT License. Copyright (c) 2026.

0
likes
0
points
657
downloads

Publisher

verified publisherkishormainali.com

Weekly Downloads

A flexible, reactive, and type-safe Flutter form management library powered by flutter_bloc. Handles minimal to complex forms with ease.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

bloc, equatable, flutter, flutter_bloc, meta, rxdart

More

Packages that depend on mk_forms