Forms and input
Enforces Form + GlobalKey<FormState> with TextFormField whose sync validator returns a localized String? (never a hardcoded literal), AutovalidateMode.onUserInteraction, async availability checks moved OUT of the sync validator into a debounced Riverpod Notifier that surfaces errors through state, FocusNode/TextInputAction/onFieldSubmitted traversal, keyboardType/textCapitalization/autofillHints/TextInputFormatter, mandatory TextEditingController/FocusNode disposal, submit-enabled derived from validity (not stored), and scoped rebuilds so a keystroke never rebuilds the whole form. Use when building a Form, TextFormField, or FormField; wiring sync or async validation; managing FocusNode, focus traversal, autofocus, TextInputAction, onFieldSubmitted, or onEditingComplete; setting keyboardType, autofillHints, textCapitalization, or InputFormatter; disposing TextEditingController/FocusNode; enabling/disabling a submit button; or handling keyboard-avoidance on submit.From its SKILL.md
npx -y skills add zakariaf/Flutter-Skills --skill forms-and-inputAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 20 days oldThe repository was created 20 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
11.6 KB, ~2.5k tokens by cl100k_base, as published. Nobody here has run it
Forms and input
Text input is where most disposal leaks, un-localized strings, and jank enter a Flutter app. A form is a small state machine: fields hold text, a FormState validates them, and a ViewModel owns anything that touches the network or the clock. Keep those three responsibilities separate.
Read the reference for the task at hand:
references/validation-sync-and-async.md— syncvalidatorreturning localizedString?,AutovalidateModechoice, and the debounced-async-in-a-Notifier pattern (why async must NOT live in the sync validator).references/focus-and-keyboard.md—FocusNodelifecycle, traversal order,autofocus,FocusTraversalGroup,TextInputAction,onFieldSubmitted/onEditingComplete,keyboardType,autofillHints,TextInputFormatter, keyboard-avoidance.
Run scripts/check_forms.sh before a PR.
Non-negotiable rules
- Every
TextEditingControllerandFocusNodecreated in aStateis disposed indispose(). They hold native resources and listeners; a leak survives the widget and fires callbacks against a dead tree. If the value must outlive the widget, hold it in a Notifier instead — seestate-management-riverpod. - Validator messages are localized, never hardcoded. A
validatorreturnsAppLocalizations.of(context).fieldRequired, not'Required'. Error text is user-facing UI copy and is owned byi18n-rtl-l10n. Thecheck_forms.shgrep fails on string literals returned from a validator. - The sync
validatoris pure and instant — noawait, no network, noFuture.FormFieldValidator<T>isString? Function(T?); it cannot be async and Flutter calls it synchronously during layout. Availability/uniqueness checks belong in a Notifier (rule 4). - Async validation lives in a debounced Riverpod Notifier and surfaces through state. Debounce with a
dart:asyncTimer(kept deterministic in tests viafakeAsync, not by the clock), run the check, and exposeAsyncValue/a sealed status the field reads viaInputDecoration.errorText. Any timestamp the check records comes fromref.read(clockProvider).now(), neverDateTime.now()— the Clock seam is owned byservice-boundary-and-native. Never block a keystroke on I/O. Seeasync-safetyfor cancel-on-dispose. - Submit-enabled is DERIVED from validity, never stored as a separate
bool. A stored_isValidflag drifts out of sync with the fields. Compute it fromFormState/Notifier state at build time. Seeflutter-performance(derive-don't-store). - A keystroke rebuilds one field, not the whole form. Give each field its own controller/
FormField; do not lift raw text into a top-levelsetState/watchthat rebuilds every sibling. Scope rebuilds with small widgets andref.watch(....select(...)). Seewidget-compositionandflutter-performance. - Choose
AutovalidateModedeliberately. Default toAutovalidateMode.onUserInteraction: silent until the user touches a field, then live. Neveralways(screams before the user types). Validate-on-submit only for short forms where per-field feedback is noise. - Keyboard type, capitalization, and autofill are declared per field.
keyboardType,textCapitalization,autofillHints, andTextInputFormatters are structural input contracts, not decoration. A missingautofillHintsbreaks OS autofill and password managers. - Errors are announced, not just colored.
InputDecoration.labelText/errorTextcarry semantics that screen readers read on change; never signal an error with color alone. Seeaccessibility-as-code.
Form skeleton
Form + a GlobalKey<FormState> is the coordination point. The key lets the submit handler call validate()/save() across all fields at once.
class TaskForm extends StatefulWidget {
const TaskForm({super.key, required this.onSubmit});
final void Function(String title) onSubmit;
@override
State<TaskForm> createState() => _TaskFormState();
}
class _TaskFormState extends State<TaskForm> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _titleFocus = FocusNode();
@override
void dispose() {
_titleController.dispose(); // rule 1: always dispose
_titleFocus.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState?.validate() ?? false) {
widget.onSubmit(_titleController.text.trim());
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction, // rule 7
child: Column(
children: [
TextFormField(
controller: _titleController,
focusNode: _titleFocus,
autofocus: true,
textInputAction: TextInputAction.done,
keyboardType: TextInputType.text,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: l10n.taskTitleLabel),
validator: (value) => // rule 2 + 3: localized, pure
(value == null || value.trim().isEmpty) ? l10n.fieldRequired : null,
onFieldSubmitted: (_) => _submit(),
),
],
),
);
}
}
Sync validation
The validator is a total, synchronous function of the field value. Return null for valid, a localized message otherwise. Compose small checks; keep the closure short.
String? validateTitle(String? value, AppLocalizations l10n) {
final text = value?.trim() ?? '';
if (text.isEmpty) return l10n.fieldRequired;
if (text.length > 120) return l10n.fieldTooLong; // structural bound, not design
return null;
}
Async validation (out of the validator)
An availability check (is this account name taken?) is I/O. It runs in a Notifier, debounced against clockProvider, and the field reads the result through errorText. The sync validator stays pure and only guards the shape of the input. Full pattern in references/validation-sync-and-async.md and examples/async_field_notifier.dart.
// The field is driven by Notifier state, not by an async validator.
final status = ref.watch(nameAvailabilityNotifierProvider);
TextFormField(
controller: _nameController,
onChanged: ref.read(nameAvailabilityNotifierProvider.notifier).onNameChanged,
decoration: InputDecoration(
labelText: l10n.accountNameLabel,
errorText: switch (status) {
AsyncData(:final value) when value == NameCheck.taken => l10n.nameTaken,
AsyncError() => l10n.nameCheckFailed,
_ => null, // idle / loading / available: no error
},
),
);
Focus and keyboard flow
TextInputAction.next moves to the next field; .done submits. Advance focus in onFieldSubmitted with FocusScope.of(context).nextFocus() or by requesting a specific node. Group related fields with FocusTraversalGroup to control tab order. Details in references/focus-and-keyboard.md.
TextFormField(
focusNode: _titleFocus,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _dueDateFocus.requestFocus(),
),
Submit button derived from validity
Do not store an _isFormValid bool. Derive enablement each build; disable while an async submit is in flight (from Notifier state).
final submitting = ref.watch(taskFormNotifierProvider).isLoading;
FilledButton(
onPressed: submitting ? null : _submit, // rule 5
child: Text(l10n.saveAction),
);
Keyboard avoidance
Wrap long forms so the focused field scrolls above the keyboard: a SingleChildScrollView inside the body lets Scaffold (with resizeToAvoidBottomInset: true, the default) push content up. For last-field submit, ensure the submit button is reachable — put it in the scroll view or a bottomNavigationBar.
Anti-patterns
validator: (v) async => await repo.isTaken(v)— a validator cannot be async; theFutureis truthy so it always "passes." Move to a Notifier (rule 4).- Returning
'Required'/'Invalid email'from a validator — un-localized; breaks every non-English locale. UseAppLocalizations. - Creating a
TextEditingController/FocusNodeinbuild()— a fresh one every rebuild, losing text and cursor. Create inState, dispose indispose(). bool _isValidtoggled inonChangedto enable submit — drifts from real validity. Derive it.autovalidateMode: AutovalidateMode.always— errors shout before the user types a character.- One
TextEditingControllerlistener that callssetStateon the whole form — every keystroke rebuilds every field. Scope the rebuild. debouncetiming rolled by hand withDateTime.now()diffs — use adart:asyncTimer(deterministic underfakeAsync); and any timestamp the check records comes fromref.read(clockProvider).now(), neverDateTime.now().
Definition of done
- Every controller/
FocusNodedisposed (or state lives in a Notifier);check_forms.shclean. - No string literal returned from any
validator; all messages viaAppLocalizations. - No
await/Futureinside a syncvalidator; async checks in a debounced Notifier surfaced througherrorText. AutovalidateMode.onUserInteraction(or an intentional submit-only choice).- Submit enablement derived, not stored; disabled during in-flight submit.
- Each field declares
keyboardType,textInputAction, andautofillHintswhere applicable; focus advances correctly. - Errors announced via
InputDecorationsemantics, never color-only.
Related skills
state-management-riverpod— the Notifier that owns async validation and submit state.async-safety— cancel debounce timers/subscriptions on dispose; mounted guards after await.i18n-rtl-l10n— localized validator messages and labels; the non-nullAppLocalizations.of(context)getter.accessibility-as-code— field labels, error announcement, never-color-alone, target sizes.flutter-performance— scoped rebuilds and derive-don't-store.widget-composition— small const field widgets over_buildFieldmethods.navigation-and-routing—PopScopefor unsaved-changes confirmation when leaving a dirty form.service-boundary-and-native— the Clock seam (clockProvider) any async check reads timestamps from.
References
- Form: https://api.flutter.dev/flutter/widgets/Form-class.html
- TextFormField: https://api.flutter.dev/flutter/material/TextFormField-class.html
- FocusNode: https://api.flutter.dev/flutter/widgets/FocusNode-class.html
- TextInputAction: https://api.flutter.dev/flutter/services/TextInputAction.html
- Autofill: https://api.flutter.dev/flutter/services/AutofillHints-class.html
- Forms cookbook: https://docs.flutter.dev/cookbook/forms/validation
What ships with it: 5 files
23.1 KB alongside SKILL.md, 1 of them executable
examples/
- async_field_notifier.dart4.7 KB
- task_form.dart4.8 KB
references/
scripts/
- check_forms.shruns2.9 KB