In Flutter a form is a Form widget with a GlobalKey<FormState> and TextFormField fields with a validator function that returns an error text or null. To keep errors from appearing while the user types, give the form AutovalidateMode.onUnfocus and the fields onUserInteractionIfError. Below is a ready lead form for Flutter 3.47 with a phone mask, a sending state, server errors, screen reader labels and 11 tests.
After this you will be able to
- build a
Formwith aGlobalKey<FormState>,TextFormFieldand validators that show an error after the user leaves a field and clear it once fixed - add a phone mask with a
TextInputFormatterthat accepts 8, +7 and pasting a whole number - show a sending state, a server error on a specific field with
forceErrorText, and a general network error - verify the form with widget tests, including
meetsGuidelineaccessibility checks
Know beforehand
Verified with
Flutter 3.47.7 (stable)Dart 3.13.5flutter_lints 6.0.0
Checked against docs: October 12, 2026
Contents
The task: a lead form that does not lose customers
We need a "Leave a request" form for a mobile app: name, phone and an optional comment. The behavior rules come from the article on painless forms: stay silent while the user types, check when they leave a field, clear the error as soon as it is fixed, and on submit lead to the first problem without wiping what was entered.
Name
- Keyboard and autofill
TextCapitalization.words,AutofillHints.name- Check
- Not empty, at least 2 characters
- Error text
- "How should we address you?"
Phone
- Keyboard and autofill
TextInputType.phone,AutofillHints.telephoneNumber, mask- Check
- 10 digits after +7
- Error text
- "Enter 10 digits after +7"
Comment
- Keyboard and autofill
- Multiline, counter up to 500
- Check
- None, the field is optional
- Error text
- —
Validation in Flutter is simple: validator receives the field text and returns an error string or null, and _formKey.currentState!.validate() runs the validators of every field in the form. The docs recommend creating the GlobalKey<FormState> once in State, not in build. The recipe is verified on Flutter 3.47.7 and Dart 3.13.5 and needs no third-party packages.
Validators and the phone mask
Validators and the mask are plain functions and classes without widgets, so they are easy to unit test and reuse. The phone field shows the country code as a +7 prefix, so the field itself holds only 10 digits: (701) 123-45-67. Per the InputDecoration.prefixText docs, the prefix is not part of the field value.
import 'package:flutter/services.dart';
final _nonDigits = RegExp(r'\D');
String _digits(String s) => s.replaceAll(_nonDigits, '');
/// Normalizes a number to +7XXXXXXXXXX or returns null.
String? normalizePhone(String input) {
var digits = _digits(input);
if (digits.length == 10) digits = '7$digits'; // no country code
if (digits.length != 11) return null;
if (digits.startsWith('8')) digits = '7${digits.substring(1)}';
return digits.startsWith('7') ? '+$digits' : null;
}
/// A mask for a field with a "+7 " prefix: the field holds 10 digits, (701) 123-45-67.
/// Accepts a leading 8 or +7, spaces, brackets and pasting a whole number.
class KzPhoneFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final text = newValue.text;
if (text == '+') return newValue; // typing "+7": wait for the 7
var digits = _digits(text);
// digits left of the cursor: the cursor returns there after formatting
final end = newValue.selection.isValid
? newValue.selection.end
: text.length;
var cursor = _digits(text.substring(0, end)).length;
// how many digits arrived in one action (paste, autofill)
final sel = oldValue.selection;
final kept = sel.isValid
? _digits(sel.textBefore(oldValue.text) + sel.textAfter(oldValue.text))
.length
: _digits(oldValue.text).length;
final pastedFull = digits.length == 11 && digits.length - kept == 11;
// the country code is in the prefix: drop 8, +7 or 7 from a whole number
if (digits.startsWith('8') ||
text.startsWith('+7') ||
(pastedFull && digits.startsWith('7'))) {
digits = digits.substring(1);
if (cursor > 0) cursor--;
}
if (digits.length > 10) return oldValue; // reject an extra digit
final formatted = _format(digits);
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(
offset: _offsetAfter(formatted, cursor),
),
);
}
static String _format(String digits) {
final out = StringBuffer();
for (var i = 0; i < digits.length; i++) {
if (i == 0) out.write('(');
if (i == 3) out.write(') ');
if (i == 6 || i == 8) out.write('-');
out.write(digits[i]);
}
return out.toString();
}
// the position right after the count-th digit in the formatted string
static int _offsetAfter(String text, int count) {
if (count == 0) return 0;
var seen = 0;
for (var i = 0; i < text.length; i++) {
if (_nonDigits.hasMatch(text[i])) continue;
if (++seen == count) return i + 1;
}
return text.length;
}
}import 'phone.dart';
/// The lead data sent to the server.
class Lead {
const Lead({required this.name, required this.phone, this.comment = ''});
final String name;
final String phone; // already normalized: +7XXXXXXXXXX
final String comment;
}
/// The server rejected the lead: a general message and per-field errors, if any.
class LeadRejected implements Exception {
const LeadRejected(this.message, {this.fieldErrors = const {}});
final String message;
final Map<String, String> fieldErrors; // 'phone' → error text
}
/// Sends a lead. In the app it is an HTTP request, in tests a fake.
typedef SendLead = Future<void> Function(Lead lead);
String? validateName(String? value) {
final name = value?.trim() ?? '';
if (name.isEmpty) return 'How should we address you?';
if (name.length < 2) return 'The name is too short';
return null;
}
String? validatePhone(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Enter a phone number so we can call you back';
}
if (normalizePhone(value) == null) return 'Enter 10 digits after +7';
return null;
}Typed 7011234567
- What the field shows
(701) 123-45-67
Typed 8 701 123 45 67
- What the field shows
(701) 123-45-67, the 8 stands for the prefix
Pasted +7 701 123 45 67 or 87011234567
- What the field shows
(701) 123-45-67
Typed an 11th digit into a full number
- What the field shows
- Nothing changes, the extra digit is rejected
Deleted a digit in the middle
- What the field shows
- The number is rebuilt, the cursor stays at the edit
The form widget: submit, server errors and accessibility
import 'package:flutter/material.dart';
import 'lead.dart';
import 'phone.dart';
class LeadForm extends StatefulWidget {
const LeadForm({super.key, required this.send});
final SendLead send;
@override
State<LeadForm> createState() => _LeadFormState();
}
class _LeadFormState extends State<LeadForm> {
final _formKey = GlobalKey<FormState>();
final _nameKey = GlobalKey<FormFieldState<String>>();
final _phoneKey = GlobalKey<FormFieldState<String>>();
final _name = TextEditingController();
final _phone = TextEditingController();
final _comment = TextEditingController();
final _nameFocus = FocusNode();
final _phoneFocus = FocusNode();
bool _sending = false;
bool _sent = false;
String? _serverError;
Map<String, String> _fieldErrors = const {};
@override
void dispose() {
for (final c in [_name, _phone, _comment]) {
c.dispose();
}
_nameFocus.dispose();
_phoneFocus.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_sending) return; // ignore a second tap while sending
setState(() {
_serverError = null;
_fieldErrors = const {};
});
final invalid = _formKey.currentState!.validateGranularly();
if (invalid.isNotEmpty) {
// lead to the first error: focus and keyboard on that field
if (invalid.contains(_nameKey.currentState)) {
_nameFocus.requestFocus();
} else if (invalid.contains(_phoneKey.currentState)) {
_phoneFocus.requestFocus();
}
return;
}
setState(() => _sending = true);
try {
await widget.send(
Lead(
name: _name.text.trim(),
phone: normalizePhone(_phone.text)!,
comment: _comment.text.trim(),
),
);
if (mounted) setState(() => _sent = true);
} on LeadRejected catch (e) {
if (mounted) {
setState(() {
_serverError = e.message;
_fieldErrors = e.fieldErrors;
});
}
} on Exception {
if (mounted) {
setState(
() => _serverError = 'Could not send the request. Check your connection and try again.',
);
}
} finally {
if (mounted) setState(() => _sending = false);
}
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
if (_sent) {
return Semantics(
liveRegion: true, // the screen reader announces the new text by itself
child: Text(
'Request sent. We will call you back.',
style: Theme.of(context).textTheme.titleMedium,
),
);
}
return Form(
key: _formKey,
// an error shows when the user leaves the field, not while typing
autovalidateMode: AutovalidateMode.onUnfocus,
child: AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 16,
children: [
TextFormField(
key: _nameKey,
controller: _name,
focusNode: _nameFocus,
decoration: const InputDecoration(labelText: 'Name'),
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.name],
// fixed: the error disappears at once, no second submit needed
autovalidateMode: AutovalidateMode.onUserInteractionIfError,
validator: validateName,
),
TextFormField(
key: _phoneKey,
controller: _phone,
focusNode: _phoneFocus,
decoration: const InputDecoration(
labelText: 'Phone',
prefixText: '+7 ', // no need to type the country code
hintText: '(700) 000-00-00',
),
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.telephoneNumber],
inputFormatters: [KzPhoneFormatter()],
autovalidateMode: AutovalidateMode.onUserInteractionIfError,
validator: validatePhone,
// a server error for the field overrides the local validator
forceErrorText: _fieldErrors['phone'],
onChanged: (_) {
// editing started: the server error for the field is stale
if (_fieldErrors.isNotEmpty) {
setState(() => _fieldErrors = const {});
}
},
),
TextFormField(
controller: _comment,
decoration: const InputDecoration(
labelText: 'Comment (optional)',
),
minLines: 2,
maxLines: 4,
maxLength: 500,
),
if (_serverError != null)
Semantics(
liveRegion: true,
child: Text(
_serverError!,
style: TextStyle(color: colors.error),
),
),
FilledButton(
onPressed: _sending ? null : _submit,
child: _sending
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
semanticsLabel: 'Sending the request',
),
)
: const Text('Send request'),
),
],
),
),
);
}
}- When to show an error.
FormgetsAutovalidateMode.onUnfocus, so a field is checked when it loses focus. Fields getonUserInteractionIfError, so a field with an error is re-checked on every change and the error disappears as soon as the value is valid. - Focus on the first error.
validateGranularly()returns the set of invalid fields; the_nameKeyand_phoneKeykeys find the first one, and focus moves there together with the keyboard. - Submitting. While the request runs, the button is disabled and shows a spinner, so a second tap cannot send the lead twice. Field data stays on any error.
- A server error on a field.
forceErrorTextputs a field into the error state without calling the validator; this is how a server answer like "this number cannot be reached" is shown. As soon as the user edits the number, the error is cleared. - Network errors. The
on Exceptionbranch catches network failures and timeouts but not programming errors (Error): those are better seen in a crash report than hidden behind "try again". - After
awaitwe checkmounted: the user may have closed the screen while the request was running.
Accessibility comes almost for free if you do not fight the framework. labelText becomes the field label for VoiceOver and TalkBack. In the Flutter 3.47 source, FormField marks an invalid field in the semantics tree itself, and form validation, both validate() and validateGranularly(), announces the first error message where the platform supports announcements. The spinner gets a semanticsLabel, and the server error and the final message are wrapped in Semantics(liveRegion: true): per the docs, on Android and iOS changes to such a region are read out automatically, even without focus. AutofillGroup groups the fields for autofill and, by default, when the form disappears after submit, tells the platform the input can be saved.
How to plug it in
Put three files into lib/lead_form
phone.dart,lead.dartandlead_form.dartdo not depend on the rest of the app.Implement sending
Pass a
SendLeadfunction toLeadForm. Inside it, call your backend. Turn a response with field errors (usually status 422) intoLeadRejectedwithfieldErrors, and let network failures stay exceptions.Place the form in a scrollable container
With the keyboard open there is little room on screen:
SingleChildScrollVieworListViewlets the user reach the button.
flutter create lead_app
cd lead_app
mkdir lib/lead_formimport 'package:flutter/material.dart';
import 'lead_form/lead.dart';
import 'lead_form/lead_form.dart';
void main() => runApp(const LeadApp());
/// A stand-in for the server: a real app makes an HTTP request here.
Future<void> fakeSend(Lead lead) async {
await Future<void>.delayed(const Duration(seconds: 1));
if (lead.phone.endsWith('0000')) {
throw const LeadRejected(
'Check the phone number',
fieldErrors: {'phone': 'This number cannot be reached'},
);
}
}
class LeadApp extends StatelessWidget {
const LeadApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Request',
theme: ThemeData(colorSchemeSeed: Colors.indigo),
home: Scaffold(
appBar: AppBar(title: const Text('Leave a request')),
body: const SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.all(16),
child: LeadForm(send: fakeSend),
),
),
),
);
}
}How to verify
Unit tests check normalization and the mask without widgets. Widget tests render the form in a test environment: enterText types text, tap presses, pump renders a frame. The last test uses the Accessibility Guideline API from the Flutter docs: tap targets of at least 48×48 on Android and 44×44 on iOS, a label on each one, and sufficient text contrast.
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lead_app/lead_form/phone.dart';
final formatter = KzPhoneFormatter();
// input at the end of the field: one typed character or a pasted string
TextEditingValue type(TextEditingValue old, String chars) {
final text = old.text + chars;
return formatter.formatEditUpdate(
old,
TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
),
);
}
TextEditingValue typeEach(String chars) {
var value = TextEditingValue.empty;
for (final char in chars.split('')) {
value = type(value, char);
}
return value;
}
void main() {
test(
'normalizePhone accepts 8, +7 and a number without the country code',
() {
expect(normalizePhone('8 (701) 123-45-67'), '+77011234567');
expect(normalizePhone('+7 701 123 45 67'), '+77011234567');
expect(normalizePhone('(701) 123-45-67'), '+77011234567');
expect(normalizePhone('701 123'), isNull);
expect(normalizePhone('+1 202 555 0100'), isNull);
},
);
test('typing 701…, 8 701… and +7 701… gives the same number', () {
for (final input in ['7011234567', '87011234567', '+77011234567']) {
final value = typeEach(input);
expect(value.text, '(701) 123-45-67', reason: input);
expect(value.selection.baseOffset, value.text.length);
}
expect(typeEach('701').text, '(701');
expect(typeEach('7011').text, '(701) 1');
// an 11th digit never gets into a full number
expect(type(typeEach('7011234567'), '8').text, '(701) 123-45-67');
});
test('pasting a whole number in any common format', () {
for (final pasted in [
'+7 701 123 45 67',
'8 (701) 123-45-67',
'77011234567',
'7011234567',
]) {
expect(
type(TextEditingValue.empty, pasted).text,
'(701) 123-45-67',
reason: pasted,
);
}
});
test('selecting the whole number and pasting a new one', () {
const full = TextEditingValue(
text: '(701) 123-45-67',
selection: TextSelection(baseOffset: 0, extentOffset: 15),
);
final pasted = formatter.formatEditUpdate(
full,
const TextEditingValue(
text: '77771234567',
selection: TextSelection.collapsed(offset: 11),
),
);
expect(pasted.text, '(777) 123-45-67');
});
test('deleting a digit in the middle keeps the cursor at the edit', () {
const full = TextEditingValue(
text: '(701) 123-45-67',
selection: TextSelection.collapsed(offset: 7),
);
// deleted the "1" in "123": the cursor was after it
final edited = formatter.formatEditUpdate(
full,
const TextEditingValue(
text: '(701) 23-45-67',
selection: TextSelection.collapsed(offset: 6),
),
);
expect(edited.text, '(701) 234-56-7');
expect(edited.selection.baseOffset, 4); // right after "701"
});
}import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lead_app/lead_form/lead.dart';
import 'package:lead_app/lead_form/lead_form.dart';
Widget app(SendLead send) => MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: LeadForm(send: send),
),
),
);
Finder field(String label) => find.widgetWithText(TextFormField, label);
bool hasFocus(WidgetTester tester, String label) => tester
.widget<TextField>(
find.descendant(of: field(label), matching: find.byType(TextField)),
)
.focusNode!
.hasFocus;
Future<void> fillValid(WidgetTester tester) async {
await tester.enterText(field('Name'), 'Anna');
await tester.enterText(field('Phone'), '87011234567');
}
void main() {
testWidgets('empty submit: field errors and focus on the first one', (
tester,
) async {
var calls = 0;
await tester.pumpWidget(app((_) async => calls++));
await tester.tap(find.text('Send request'));
await tester.pump();
expect(find.text('How should we address you?'), findsOneWidget);
expect(
find.text('Enter a phone number so we can call you back'),
findsOneWidget,
);
expect(hasFocus(tester, 'Name'), isTrue);
expect(calls, 0);
});
testWidgets(
'an error shows after leaving the field, a fix removes it at once',
(tester) async {
await tester.pumpWidget(app((_) async {}));
await tester.enterText(field('Name'), 'A');
await tester.pump();
expect(
find.text('The name is too short'),
findsNothing,
); // silent while typing
await tester.tap(field('Phone')); // left the field
await tester.pump();
expect(find.text('The name is too short'), findsOneWidget);
await tester.enterText(field('Name'), 'Anna');
await tester.pump();
expect(find.text('The name is too short'), findsNothing);
},
);
testWidgets('successful submit: loading, double-tap guard, result', (
tester,
) async {
final done = Completer<void>();
final sent = <Lead>[];
await tester.pumpWidget(
app((lead) {
sent.add(lead);
return done.future;
}),
);
await fillValid(tester);
expect(
tester
.widget<EditableText>(find.byType(EditableText).at(1))
.controller
.text,
'(701) 123-45-67',
);
await tester.tap(find.byType(FilledButton));
await tester.pump();
expect(find.bySemanticsLabel('Sending the request'), findsOneWidget);
await tester.tap(find.byType(FilledButton)); // the button is disabled
await tester.pump();
expect(sent, hasLength(1));
expect(sent.single.phone, '+77011234567');
expect(sent.single.name, 'Anna');
done.complete();
await tester.pumpAndSettle();
expect(find.text('Request sent. We will call you back.'), findsOneWidget);
});
testWidgets('the server rejected the number: field error, data kept', (
tester,
) async {
await tester.pumpWidget(
app((_) async {
throw const LeadRejected(
'Check the phone number',
fieldErrors: {'phone': 'This number cannot be reached'},
);
}),
);
await fillValid(tester);
await tester.tap(find.text('Send request'));
await tester.pumpAndSettle();
expect(find.text('Check the phone number'), findsOneWidget);
expect(find.text('This number cannot be reached'), findsOneWidget);
expect(find.text('Anna'), findsOneWidget);
await tester.enterText(field('Phone'), '87011234568');
await tester.pump();
expect(find.text('This number cannot be reached'), findsNothing);
});
testWidgets('no network: a general message and a resubmit', (tester) async {
var attempt = 0;
await tester.pumpWidget(
app((_) async {
if (++attempt == 1) throw Exception('SocketException');
}),
);
await fillValid(tester);
await tester.tap(find.text('Send request'));
await tester.pumpAndSettle();
expect(find.textContaining('Could not send the request'), findsOneWidget);
await tester.tap(find.text('Send request'));
await tester.pumpAndSettle();
expect(find.text('Request sent. We will call you back.'), findsOneWidget);
});
testWidgets('accessibility: labels, target size and contrast', (
tester,
) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(app((_) async {}));
expect(find.bySemanticsLabel('Name'), findsOneWidget);
expect(find.bySemanticsLabel('Phone'), findsOneWidget);
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
}$ flutter analyze
Analyzing lead_app...
No issues found!
$ flutter test
00:01 +11: All tests passed!Without a local SDK the same runs in a Flutter Docker image. At the time of checking, the stable tag of cirruslabs/flutter shipped Flutter 3.44: the recipe code passes analysis and tests on it too, but a project created with 3.47 requires Dart ^3.13.5, so lower the sdk constraint in pubspec.yaml or switch the SDK in the container to the current stable.
docker run --rm -v "$PWD:/app" -w /app ghcr.io/cirruslabs/flutter:stable \
sh -c "flutter pub get && flutter analyze && flutter test"The tests were checked in reverse: remove onUnfocus from the form, onUserInteractionIfError from the field, the focus move or the server error reset on edit, and the matching test fails. They do not replace a device, so run through a short list on a phone.
- The phone keyboard is numeric, "Next" moves from name to phone
- Autofill suggests the name and number from the profile
- Pasting a number from a message or contact gives
(701) 123-45-67 - On an empty submit the keyboard opens on the "Name" field
- Airplane mode: a general error, data kept, the lead goes through once the network is back
- VoiceOver and TalkBack read the field labels, the error and "Sending the request"
Limitations and alternatives
- The mask only covers +7 numbers. A fixed-length mask does not fit international numbers: let the user pick a country, validate the number with a phone number parsing library, and store it in E.164.
- Form state lives in the widget
State. If the lead has to be sent from several places or survive leaving the screen, move sending and status into a model, as in the lesson on Flutter state and navigation. - There is no offline send queue: a lead submitted in the subway is lost once the app is closed. For that, save a draft and retry when the network is back.
- Forms with dozens of interdependent fields are easier with dedicated form management packages; for a lead form the built-in
Formis enough.
How this was verified4 · passed1 · not runchecked 12 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.