mk_forms 1.3.0
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.
Using an AI coding agent to add
mk_formsto your app? Point it atllms.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
submittingkeepsisBusytrue andsubmit()returns silently forever. reset()regeneratesextraviaextraReset, but a call that is still in flight will write its result into the state afterwards. Ifreset()can run while calls are in flight, guard the emit (for example with a generation counter checked before emitting).- Give your
extratype value equality (Equatable,freezed, or manual==/hashCode). It feedsMkFormState's own equality directly, so without it,buildWhenand any duplicate-state suppression fall back to identity. - A form built with
extends MkFormBloc<MyExtra>whereMyExtrais not nullable must always passinitialState(or, forMkStepFormBloc<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:
- Populates all step values (pristine β no premature errors)
- Detects the earliest invalid step
- Redirects to that step and shows errors only on that step
- 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:
currentis a fallback used only where noBuildContextexists to resolve throughMkFormLocalizations.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 patternflutter_localizationsuses β so scoped overrides and runtime locale switching below take effect without touchingcurrentat all. Setcurrentonly 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/@lazySingletonwith GetIt for compile-safe DI@RoutePage()with AutoRoute for type-safe, nested navigation
π License #
MIT License. Copyright (c) 2026.