Skip to main content

mk_forms 1.3.0 copy "mk_forms: ^1.3.0" to clipboard
mk_forms: ^1.3.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

Using an AI coding agent to add mk_forms to your app? Point it at llms.txt β€” a dense, self-contained API reference designed for that.


✨ 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: ^9.1.1
  mk_forms: ^1.3.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.value(
        value: _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 (collections compare by contents)
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
isReadOnly bool Excludes the field from validation entirely (see below)

Read-only fields. Pass isReadOnly: true (constructor) or call emailField.setReadOnly(true) to exclude a field from validation: no validator (sync or async) runs, error/isValidating are cleared, and isValid is always true β€” so the field never blocks the enclosing MkGroupBloc/MkFormBloc. Turning it back off (setReadOnly(false)) immediately re-validates against the current value. This is independent of the presentational readOnly prop on MkTextField/MkEmailField/MkPasswordField, which only blocks typing and has no effect on validation.

final promoCode = MkFieldBloc<String>(
  isReadOnly: true, // pre-filled, non-editable, never blocks form validity
  initialValue: 'WELCOME10',
  validators: [MkValidators.required()],
);

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 built on BlocProvider, so it provides the form bloc (as MkFormBloc, reachable via context.read<MkFormBloc>() too) and a default AutovalidateMode down the tree. Like BlocProvider, it has two constructors:

// The scope creates the bloc and closes it when removed from the tree.
MkFormScope(
  create: (context) => LoginFormBloc(),
  lazy: true, // default
  autovalidateMode: AutovalidateMode.onUserInteraction, // default
  child: MyFormWidget(),
)

// The scope exposes an existing bloc (e.g. owned by a State) and never closes it.
MkFormScope.value(
  value: myFormBloc,
  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;

MkFormBuilder #

Rebuilds when the form state changes β€” handy for submit buttons. Use buildWhen to limit rebuilds:

MkFormBuilder(
  formBloc: _form, // optional: falls back to MkFormScope lookup
  buildWhen: (prev, curr) =>
      prev.isValid != curr.isValid ||
      prev.submissionStatus != curr.submissionStatus,
  builder: (context, state, form) => FilledButton(
    onPressed: state.isValid && !state.submissionStatus.isSubmitting
        ? form.submit
        : null,
    child: const Text('Submit'),
  ),
)

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.value(
  value: _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, bloc) {
    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)

A dependent field revalidates when the dependency's value changes (not when it merely re-validates). Synchronous validators re-run immediately; async validators are debounced like after changeValue, and adding dependsOn never fires an async validator (a network call) on its own at construction.


⏳ 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),
);

If an async validator throws (e.g. the network is down), the field becomes invalid with a generic, localized message (MkErrorCodes.asyncValidationFailed) instead of the raw exception text. The original error is reported through the field's onError / BlocObserver.onError so you can log it. Return a string from the validator for errors you want users to see.

Display async validation state in the UI:

MkFieldBuilder<String>(
  fieldBloc: _form.field<String>('username')!,
  builder: (context, state, bloc) {
    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,
      ),
    );
  },
)

🧬 Extending Form State #

Need more than idle / submitting / success / failure? MkFormState<T> has a generic extra slot for whatever your form needs beyond the base fields. Reuse MkSubmissionStatus for each independent API call, so one form can track an avatar upload, a username check and an OTP request at the same time β€” with no state subclass to write.

1. Declare the extra payload #

Any type works: a freezed class (recommended β€” copyWith, == and toString come for free), or a plain hand-written class. It should implement value equality (==/hashCode, Equatable, or freezed), because extra feeds MkFormState's own equality directly.

@freezed
abstract class ProfileExtra with _$ProfileExtra {
  const ProfileExtra._();

  const factory ProfileExtra({
    @Default(MkSubmissionStatus.idle()) MkSubmissionStatus avatarStatus,
    @Default(MkSubmissionStatus.idle()) MkSubmissionStatus otpStatus,
  }) = _ProfileExtra;

  /// Submit is blocked while either call is running.
  bool get isBusy => avatarStatus.isSubmitting || otpStatus.isSubmitting;
}

2. Parameterize the bloc β€” no state subclass #

MkFormBloc<T> builds the base fields (isValid, values, ...) from controls for you, exactly as it already does for a plain, no-extras form β€” pass initialExtra/extraReset/isExtraBusy and it does the rest:

class ProfileFormBloc extends MkFormBloc<ProfileExtra> {
  ProfileFormBloc(this._api)
    : super(
        controls: {'username': MkFieldBloc<String>(initialValue: '')},
        initialExtra: () => const ProfileExtra(),
        extraReset: () => const ProfileExtra(),
        isExtraBusy: (e) => e.isBusy,
      );

  final Api _api;

  Future<void> uploadAvatar(String path) async {
    emit(state.updateExtra((e) => e.copyWith(avatarStatus: const MkSubmissionStatus.submitting())));
    try {
      final url = await _api.uploadAvatar(path);
      emit(state.updateExtra((e) => e.copyWith(avatarStatus: MkSubmissionStatus.success(url))));
    } catch (error) {
      emit(state.updateExtra((e) => e.copyWith(avatarStatus: MkSubmissionStatus.failure(error))));
    }
  }

  /// One submit can chain several API calls.
  @override
  Future<dynamic> onSubmit(Map<String, dynamic> values) async {
    await _api.reserveUsername(values['username'] as String);
    return _api.saveProfile(values);
  }
}

state is inherited directly as MkFormState<ProfileExtra> β€” no downcast. Field edits never lose or replace extra: MkFormBloc only ever updates state through the base fields, so the same extra instance carries through every edit untouched. Need full control over the initial state instead (e.g. computing something other than the default base-field aggregation)? Pass initialState: MkFormState<ProfileExtra>(...) directly β€” it takes priority over initialExtra/extraReset/isExtraBusy.

3. Build the UI without casts #

MkFormStateBuilder<MkFormState<ProfileExtra>>(
  buildWhen: (prev, curr) => prev.extra.avatarStatus != curr.extra.avatarStatus,
  builder: (context, state, bloc) => switch (state.extra.avatarStatus) {
    MkSubmissionInProgress() => const CircularProgressIndicator(),
    MkSubmissionSuccess(:final response) => Image.network('$response'),
    MkSubmissionFailure(:final error) => Text('$error'),
    MkSubmissionIdle() => const SizedBox.shrink(),
  },
)

MkFormStateBuilder<S> throws a StateError if the bloc's state is not an S. Write the type argument explicitly (Dart cannot infer it from an unannotated builder closure); use the plain MkFormBuilder when you only need the base fields.

Need a derived, read-only view over the state (like "is this username available right now")? Write a Dart extension method on MkFormState<ProfileExtra> β€” this is the replacement for what a computed getter on a state subclass used to give you:

extension ProfileFormStateX on MkFormState<ProfileExtra> {
  bool get avatarReady => extra.avatarStatus.isSuccess;
}
Member Purpose
MkFormState.extra Your typed payload; T defaults to Object? (null) when unused
MkFormState.updateExtra(fn) Shorthand for copyWith(extra: () => fn(extra))
MkFormState.isBusy isSubmitting plus whatever isExtraBusy(extra) reports; drives canSubmit and blocks submit()
MkFormState.reset(...) Regenerates extra via extraReset (or leaves it unchanged if omitted); called by MkFormBloc.reset()
MkFormStateBuilder<S> Typed access to MkFormState<YourExtra>

Things to watch

  • Every custom status must reach a terminal state on every code path (wrap the call in try/catch as in the example above). A status stuck in submitting keeps isBusy true and submit() returns silently forever.
  • reset() regenerates extra via extraReset, but a call that is still in flight will write its result into the state afterwards. If reset() can run while calls are in flight, guard the emit (for example with a generation counter checked before emitting).
  • Give your extra type value equality (Equatable, freezed, or manual ==/hashCode). It feeds MkFormState's own equality directly, so without it, buildWhen and any duplicate-state suppression fall back to identity.
  • A form built with extends MkFormBloc<MyExtra> where MyExtra is not nullable must always pass initialState (or, for MkStepFormBloc<MyExtra>, initialExtra) β€” omitting it throws immediately at construction rather than silently storing a broken value.

πŸͺœ 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 #

// The bloc's declared state is `MkFormState`; at runtime it is an
// `MkStepFormState`, which `MkFormStateBuilder` casts for you.
MkFormStateBuilder<MkStepFormState>(
  formBloc: _formBloc,
  builder: (context, state, form) {
    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.value(
  value: 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 #

// MkArrayBuilder rebuilds when rows are added/removed/moved, NOT when the
// user types in a row (a plain BlocBuilder on the array would rebuild every
// row on every keystroke). Each MkTextField updates itself.
MkArrayBuilder<MkFieldBloc<String>>(
  arrayBloc: phones, // or name: 'phones' inside an MkFormScope
  builder: (context, state, array) {
    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()]),
          ),
        ),
      ],
    );
  },
)

πŸ“¦ Tree Shaking & Bundle Size #

mk_forms is built to be tree-shaken: it uses no reflection, dart:mirrors, dart:io or top-level side effects, its icons are const, and nothing is registered globally. Importing package:mk_forms/mk_forms.dart costs nothing for the parts you do not use: in release builds, unused widgets, validators, the step-form bloc and the built-in Spanish messages (only pulled in if you use MkFormLocalizations.delegate) are dropped. It also compiles to WebAssembly (flutter build web --wasm) and does not interfere with Flutter's icon tree shaking.

Measured on Flutter 3.47 (web, release, main.dart.js), relative to an app with a plain TextField:

App uses Added size (raw / gzip)
MkTextField + MkFieldBloc + MkValidators.required ~21 KB / ~7 KB
Everything (email, password, multi-select, step form, localization) ~209 KB / ~62 KB

🎨 Widget Reference #

All field widgets take either name: (looked up in the nearest MkFormScope) or fieldBloc: (direct reference), plus an optional autovalidateMode: override. On top of that they expose the parameters of the Flutter widget they wrap, so you rarely need to build your own. Only what the bloc must control is handled for you (value, error text, enabled state); for enabled: the widget is disabled if either the parameter or the bloc says so, and onChanged: callbacks run after the bloc has been updated.

Works inside Flutter's Form #

Every field widget is a real FormField (shared base class MkFormField<T>), so formKey.currentState!.validate(), .save(), .reset(), Form(onChanged:) and forceErrorText: work with them, and each takes onSaved:. The bloc remains the single source of truth (FormFieldState.value/errorText read it, didChange writes to it); FormField.validator/initialValue aren't used. Form.validate() touches the bloc and runs its validators, revealing errors like a submit attempt. A Form is optional: MkFormScope + MkFormBloc.submit() work as before. See doc/12-widgets.md.

MkTextField #

Mirrors TextField: keyboardType, textInputAction, inputFormatters, maxLength, maxLines / minLines / expands, cursor*, autofillHints, contextMenuBuilder, onTap, onEditingComplete, onTapOutside, buildCounter, focusNode, scrollController, undoController, spellCheckConfiguration, … everything except controller, which the widget owns to keep the text in sync with the bloc.

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

  decoration: const InputDecoration(
    labelText: 'Username',
    hintText: 'Enter your username',
    prefixIcon: Icon(Icons.person),
    border: OutlineInputBorder(),
  ),
  keyboardType: TextInputType.text,
  textInputAction: TextInputAction.next,
  inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'\s'))],
  maxLength: 20,
  autofillHints: const [AutofillHints.username],
  autovalidateMode: AutovalidateMode.onUserInteraction,
  showValidatingIndicator: true, // spinner while async validators run
)

Typing does not rebuild the TextField unless something visible changes (error, validating state, …).

MkEmailField #

MkTextField preset with an email keyboard, Email label, hint and icon (any of which your decoration can override). Accepts every MkTextField parameter except maxLines, minLines and expands.

MkEmailField(name: 'email')
MkEmailField(
  fieldBloc: emailBloc,
  decoration: const InputDecoration(labelText: 'Work email'),
  autofillHints: const [AutofillHints.email],
)

MkPasswordField #

Obscured input with a show/hide toggle. Accepts every MkTextField parameter except obscureText (use initiallyObscured), maxLines, minLines and expands.

MkPasswordField(name: 'password')
MkPasswordField(
  fieldBloc: passwordBloc,
  initiallyObscured: false,
  obscuringCharacter: '*',
  showPasswordIcon: const Icon(Icons.visibility),
  hidePasswordIcon: const Icon(Icons.visibility_off),
  autofillHints: const [AutofillHints.newPassword],
  autovalidateMode: AutovalidateMode.onUnfocus,
)

MkCheckboxField #

Mirrors CheckboxListTile (title, subtitle, secondary, controlAffinity, tristate, activeColor, fillColor, shape, side, dense, contentPadding, visualDensity, …). isError defaults to "a validation error is currently shown". Style the error text with errorStyle / errorPadding.

MkCheckboxField(
  name: 'agreeTerms',
  title: const Text('I agree to the Terms of Service'),
  controlAffinity: ListTileControlAffinity.leading, // default
  onChanged: (value) => analytics.log('terms', value),
)

MkRadioGroup<T> #

Vertical layouts render RadioListTiles, horizontal and wrap layouts render Radios. Group-level parameters (activeColor, fillColor, mouseCursor, toggleable, radioSide, dense, controlAffinity, shape, tileColor, …) apply to every option; highlightSelected turns on the selected-tile styling. Per option: label, subtitle, secondary, enabled.

MkRadioGroup<String>(
  name: 'role',
  layout: MkRadioLayout.vertical, // or horizontal / wrap
  title: const Text('Role'),
  options: const [
    MkRadioOption(value: 'developer', label: Text('Developer')),
    MkRadioOption(value: 'designer', label: Text('Designer'), subtitle: Text('UI / UX')),
    MkRadioOption(value: 'manager', label: Text('Manager'), enabled: false),
  ],
  activeColor: Colors.teal,
  onChanged: (role) => debugPrint('role: $role'),
)

Fully custom options with itemBuilder:

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

MkDropdownField<T> #

Mirrors DropdownButtonFormField (selectedItemBuilder, isExpanded, icon, iconSize, dropdownColor, menuMaxHeight, borderRadius, elevation, style, alignment, focusNode, …) except its FormField members (validator, onSaved, …), which the bloc replaces.

MkDropdownField<String>(
  name: 'country',
  decoration: const InputDecoration(labelText: 'Country'),
  isExpanded: true,
  menuMaxHeight: 300,
  items: countries
      .map((c) => DropdownMenuItem(value: c.code, child: Text(c.name)))
      .toList(),
)

MkMultiSelectSearchDropdown<T> #

Multi-selection with search, chips, select all / clear all. Customization is grouped by what it configures: the field (decoration, icon, hintStyle and the InkWell parameters such as borderRadius, focusNode, mouseCursor), the chips (chip*, mirroring InputChip, or chipBuilder), the picker (sheet* for the bottom sheet, dialog* for the dialog) and all texts (hintText, searchHint, modalTitle, doneLabel, selectAllLabel, clearAllLabel, moreLabelBuilder) so they can be localized.

MkMultiSelectSearchDropdown<String>(
  name: 'interests',
  items: const ['Flutter', 'Dart', 'Firebase'],
  itemToString: (item) => item,
  decoration: const InputDecoration(labelText: 'Interests'),
  hintText: 'Pick some…',
  searchHint: 'Search interests…',
  modalType: MkMultiSelectModalType.bottomSheet, // or dialog
  maxChipsDisplayed: 3,
  moreLabelBuilder: (hidden) => '+$hidden more',
  chipBackgroundColor: Colors.teal.shade50,
  sheetShowDragHandle: true,
  onChanged: (selection) => debugPrint('$selection'),
)

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, bloc) {
    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)),
      ],
    );
  },
)

MkFormSelector<R> β€” Rebuild on One Form Flag #

MkFormState.values changes on every keystroke, so an MkFormBuilder without buildWhen rebuilds on every keystroke. Select just what you need:

MkFormSelector<bool>(
  selector: (s) => s.canSubmit, // use a record for several values: (s.isValid, s.isSubmitting)
  builder: (context, canSubmit, form) => FilledButton(
    onPressed: canSubmit ? form.submit : null,
    child: const Text('Save'),
  ),
)

See doc/16-performance.md for more on keeping rebuilds minimal.


MkFieldSelector<T, R> β€” Select Just a Slice of State #

Analogous to flutter_bloc's BlocSelector: rebuilds only when a value derived from MkFieldState<T> actually changes, ignoring every other field-state change (e.g. rebuild on error only, not on every keystroke that changes value):

MkFieldSelector<String, String?>(
  fieldBloc: emailField, // or name: 'email' inside an MkFormScope
  selector: (state) => state.error,
  builder: (context, error, fieldBloc) {
    if (error == null) return const SizedBox.shrink();
    return Text(error, style: const TextStyle(color: Colors.red));
  },
)

The selected value R must be immutable/comparable with == for change detection to work correctly. Set hideWhenInvisible: false to opt out of the default auto-hide-when-isVisible == false behavior.


🌍 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();

Note: current is a fallback used only where no BuildContext exists to resolve through MkFormLocalizations.delegate (e.g. MkValidators, which run in the bloc layer before any widget exists). Every built-in field widget already resolves messages through the delegate (MkLocalizationScope β†’ Localizations.of β†’ current, in that order) β€” the same pattern flutter_localizations uses β€” so scoped overrides and runtime locale switching below take effect without touching current at all. Set current only to change the app-wide default for contexts with no widget tree.

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';
}

Field-Aware Messages #

Built-in validators produce messages that name the field, e.g. "Email is required." instead of "This field is required". A field's label is auto-derived from its key in an MkGroupBloc's controls map β€” which includes every field inside MkFormBloc/MkStepFormBloc, so existing forms pick this up automatically after upgrading, no code changes required:

MkFormBloc(
  controls: {
    'confirmPassword': MkFieldBloc<String>(
      validators: [MkValidators.required()],
    ), // auto-labeled "Confirm password" -> "Confirm password is required."
  },
)

Override the auto-derived label when it isn't right:

MkFieldBloc<String>(
  label: 'Work email',
  validators: [MkValidators.required()],
) // -> "Work email is required."

A field that's never added to a group, with no explicit label, keeps the old generic message ("This field is required").

Custom message classes are unaffected unless they opt in. MyAppMessages above (implementing MkFormMessages directly) keeps its own text even for labeled fields β€” the field-aware methods (requiredField(), emailField(), etc.) default to null on the base class, so field-aware resolution falls back to your required()/email()/etc. To get field-aware text in your own language, override the *Field methods too:

class MyAppMessages extends MkFormMessages {
  // ...required(), email(), etc. as above...

  @override
  String requiredField(String field) => '$field cannot be empty';

  @override
  String emailField(String field) => 'Enter a valid email for $field';

  // Also available: minLengthField, maxLengthField, minField, maxField,
  // patternField, matchField.
}

If you extend MkDefaultEnglishMessages or MkSpanishMessages and only override a few base methods (like CustomFrenchMessages above, overriding just required()/email()), unlabeled fields use your override but labeled fields fall back to the built-in field-aware text unless you also override the matching *Field method.

Labels are derived from the (typically English) controls-map key, so a non-English messages class will show that label next to its own translated sentence (e.g. "Confirm password no coincide.") unless you pass an explicit, translated label: per field.


πŸ—οΈ 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);

// Exclude from validation entirely β€” isValid is always true while read-only
emailField.setReadOnly(true);
emailField.setReadOnly(false); // re-validates against the current value

πŸ§ͺ 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
        β”œβ”€β”€ extended_state/        # Typed extra state, concurrent API calls
        β”œβ”€β”€ form_integration/      # Mk fields inside a Flutter Form (validate/save/reset)
        └── large_form/            # 200 fields + array with selectors (perf patterns)

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
160
points
657
downloads

Documentation

API reference

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

MIT (license)

Dependencies

bloc, equatable, flutter, flutter_bloc, meta

More

Packages that depend on mk_forms