mk_forms 1.0.0
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.
โจ 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:
- 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 #
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/@lazySingletonwith GetIt for compile-safe DI@RoutePage()with AutoRoute for type-safe, nested navigation
๐ License #
MIT License. Copyright (c) 2026.