Форму во Flutter собирают из виджета Form с ключом GlobalKey<FormState> и полей TextFormField с функцией validator: она возвращает текст ошибки или null. Чтобы ошибки не появлялись, пока человек печатает, форме задают AutovalidateMode.onUnfocus, а полям — onUserInteractionIfError. Ниже — готовая форма заявки для Flutter 3.47 с маской телефона, состоянием отправки, ошибками сервера, подписями для экранного диктора и 11 тестами.
После материала вы сможете
- собрать
FormсGlobalKey<FormState>,TextFormFieldи валидаторами, которые показывают ошибку после ухода из поля и снимают её при исправлении - подключить маску телефона на
TextInputFormatter, которая принимает 8, +7 и вставку номера целиком - показать состояние отправки, ошибку сервера у конкретного поля через
forceErrorTextи общую ошибку сети - проверить форму widget-тестами, включая проверки доступности
meetsGuideline
Что знать заранее
Проверено на
Flutter 3.47.7 (stable)Dart 3.13.5flutter_lints 6.0.0
Сверено с документацией: 12 октября 2026 г.
Содержание
Задача: форма заявки, которая не теряет клиентов
Нужна форма «Оставить заявку» для мобильного приложения: имя, телефон и необязательный комментарий. Правила поведения берём из материала о формах без боли: пока человек печатает — молчим, ушёл из поля — проверяем, исправил — сразу убираем ошибку, при отправке ведём к первой проблеме и не стираем введённое.
Имя
- Клавиатура и автозаполнение
TextCapitalization.words,AutofillHints.name- Проверка
- Не пустое, от 2 символов
- Текст ошибки
- «Как к вам обращаться?»
Телефон
- Клавиатура и автозаполнение
TextInputType.phone,AutofillHints.telephoneNumber, маска- Проверка
- 10 цифр после +7
- Текст ошибки
- «Введите 10 цифр после +7»
Комментарий
- Клавиатура и автозаполнение
- Многострочное поле, счётчик до 500
- Проверка
- Нет — поле необязательное
- Текст ошибки
- —
Сама проверка во Flutter устроена просто: validator получает текст поля и возвращает строку ошибки или null, а _formKey.currentState!.validate() вызывает валидаторы всех полей формы. Документация рекомендует создавать GlobalKey<FormState> один раз в State, а не в build. Рецепт проверен на Flutter 3.47.7 и Dart 3.13.5; сторонних пакетов не нужно.
Валидаторы и маска телефона
Валидаторы и маска — чистые функции и классы без виджетов: их легко проверить unit-тестами и переиспользовать. Поле телефона показывает код страны префиксом +7 , поэтому в самом поле хранятся только 10 цифр: (701) 123-45-67. По документации InputDecoration.prefixText префикс не входит в значение поля.
import 'package:flutter/services.dart';
final _nonDigits = RegExp(r'\D');
String _digits(String s) => s.replaceAll(_nonDigits, '');
/// Приводит номер к виду +7XXXXXXXXXX или возвращает null.
String? normalizePhone(String input) {
var digits = _digits(input);
if (digits.length == 10) digits = '7$digits'; // номер без кода страны
if (digits.length != 11) return null;
if (digits.startsWith('8')) digits = '7${digits.substring(1)}';
return digits.startsWith('7') ? '+$digits' : null;
}
/// Маска для поля с префиксом «+7 »: в самом поле — 10 цифр, (701) 123-45-67.
/// Понимает 8 и +7 в начале, пробелы, скобки и вставку номера целиком.
class KzPhoneFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final text = newValue.text;
if (text == '+') return newValue; // начали набирать «+7» — ждём цифру
var digits = _digits(text);
// сколько цифр левее курсора — туда же вернём курсор после форматирования
final end = newValue.selection.isValid
? newValue.selection.end
: text.length;
var cursor = _digits(text.substring(0, end)).length;
// сколько цифр вставили за одно действие (вставка, автозаполнение)
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;
// код страны уже стоит в префиксе: убираем 8, +7 или 7 из целого номера
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; // лишнюю цифру не принимаем
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();
}
// позиция сразу после count-й цифры в отформатированной строке
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';
/// Данные заявки, которые уходят на сервер.
class Lead {
const Lead({required this.name, required this.phone, this.comment = ''});
final String name;
final String phone; // уже нормализован: +7XXXXXXXXXX
final String comment;
}
/// Сервер отклонил заявку: общее сообщение и, если есть, ошибки отдельных полей.
class LeadRejected implements Exception {
const LeadRejected(this.message, {this.fieldErrors = const {}});
final String message;
final Map<String, String> fieldErrors; // 'phone' → текст ошибки
}
/// Отправка заявки. В приложении — HTTP-запрос, в тестах — подмена.
typedef SendLead = Future<void> Function(Lead lead);
String? validateName(String? value) {
final name = value?.trim() ?? '';
if (name.isEmpty) return 'Как к вам обращаться?';
if (name.length < 2) return 'Имя слишком короткое';
return null;
}
String? validatePhone(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Укажите телефон, чтобы мы могли перезвонить';
}
if (normalizePhone(value) == null) return 'Введите 10 цифр после +7';
return null;
}Набрал 7011234567
- Что в поле
(701) 123-45-67
Набрал 8 701 123 45 67
- Что в поле
(701) 123-45-67— 8 заменяет префикс
Вставил +7 701 123 45 67 или 87011234567
- Что в поле
(701) 123-45-67
Набрал 11-ю цифру в полный номер
- Что в поле
- Ничего не изменилось — лишняя цифра отклонена
Стёр цифру в середине
- Что в поле
- Номер пересобран, курсор остался у места правки
Виджет формы: отправка, ошибки сервера и доступность
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; // второе нажатие во время отправки игнорируем
setState(() {
_serverError = null;
_fieldErrors = const {};
});
final invalid = _formKey.currentState!.validateGranularly();
if (invalid.isNotEmpty) {
// ведём к первой ошибке: фокус и клавиатура на этом поле
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 = 'Не удалось отправить заявку. Проверьте интернет и попробуйте ещё раз.',
);
}
} finally {
if (mounted) setState(() => _sending = false);
}
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
if (_sent) {
return Semantics(
liveRegion: true, // экранный диктор сам прочитает новый текст
child: Text(
'Заявка отправлена. Мы перезвоним вам.',
style: Theme.of(context).textTheme.titleMedium,
),
);
}
return Form(
key: _formKey,
// ошибка появляется, когда пользователь покинул поле, а не пока он печатает
autovalidateMode: AutovalidateMode.onUnfocus,
child: AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 16,
children: [
TextFormField(
key: _nameKey,
controller: _name,
focusNode: _nameFocus,
decoration: const InputDecoration(labelText: 'Имя'),
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.name],
// исправил — ошибка исчезает сразу, без повторной отправки
autovalidateMode: AutovalidateMode.onUserInteractionIfError,
validator: validateName,
),
TextFormField(
key: _phoneKey,
controller: _phone,
focusNode: _phoneFocus,
decoration: const InputDecoration(
labelText: 'Телефон',
prefixText: '+7 ', // код страны вводить не нужно
hintText: '(700) 000-00-00',
),
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.telephoneNumber],
inputFormatters: [KzPhoneFormatter()],
autovalidateMode: AutovalidateMode.onUserInteractionIfError,
validator: validatePhone,
// ошибка поля от сервера важнее локального валидатора
forceErrorText: _fieldErrors['phone'],
onChanged: (_) {
// начал исправлять — серверная ошибка поля больше не актуальна
if (_fieldErrors.isNotEmpty) {
setState(() => _fieldErrors = const {});
}
},
),
TextFormField(
controller: _comment,
decoration: const InputDecoration(
labelText: 'Комментарий (необязательно)',
),
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: 'Отправляем заявку',
),
)
: const Text('Отправить заявку'),
),
],
),
),
);
}
}- Когда показывать ошибку.
FormполучаетAutovalidateMode.onUnfocus— поле проверяется, когда теряет фокус. Поля получаютonUserInteractionIfError— поле с ошибкой перепроверяется при каждом изменении, и ошибка исчезает, как только значение стало верным. - Фокус на первой ошибке.
validateGranularly()возвращает множество полей с ошибками; по ключам_nameKeyи_phoneKeyнаходим первое и переводим на него фокус — вместе с клавиатурой. - Отправка. Пока идёт запрос, кнопка неактивна и показывает индикатор, поэтому второе нажатие не отправит заявку повторно. Данные в полях остаются при любой ошибке.
- Ошибка сервера у поля.
forceErrorTextпереводит поле в состояние ошибки без вызова валидатора: так показывают ответ сервера «на этот номер не дозвониться». Как только человек начинает править номер, ошибка снимается. - Ошибки сети. Ветка
on Exceptionловит сетевые сбои и таймауты, но не ошибки программы (Error): их лучше увидеть в отчёте о сбоях, чем спрятать за «попробуйте ещё раз». - После
awaitпроверяемmounted: пользователь мог закрыть экран, пока шёл запрос.
Доступность здесь почти бесплатная, если не мешать фреймворку. labelText становится подписью поля для VoiceOver и TalkBack. В исходном коде Flutter 3.47 FormField сам отмечает поле как невалидное в дереве семантики, а проверка формы — и validate(), и validateGranularly() — озвучивает первое сообщение об ошибке там, где платформа поддерживает объявления. Индикатор загрузки получает semanticsLabel, а ошибка сервера и итоговое сообщение обёрнуты в Semantics(liveRegion: true): по документации на Android и iOS изменения такой области зачитываются автоматически, даже без фокуса. AutofillGroup объединяет поля для автозаполнения и по умолчанию, когда форма исчезает после отправки, сообщает платформе, что введённое можно сохранить.
Как подключить
Положите три файла в lib/lead_form
phone.dart,lead.dartиlead_form.dartне зависят от остального приложения.Реализуйте отправку
Передайте в
LeadFormфункциюSendLead. Внутри — запрос к вашему backend. Ответ с ошибками полей (обычно статус 422) превращайте вLeadRejectedсfieldErrors, сетевые сбои пусть остаются исключениями.Разместите форму в прокручиваемом контейнере
Когда открыта клавиатура, места на экране мало:
SingleChildScrollViewилиListViewпозволит дотянуться до кнопки.
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());
/// Заглушка вместо сервера: в реальном приложении здесь HTTP-запрос.
Future<void> fakeSend(Lead lead) async {
await Future<void>.delayed(const Duration(seconds: 1));
if (lead.phone.endsWith('0000')) {
throw const LeadRejected(
'Проверьте номер телефона',
fieldErrors: {'phone': 'На этот номер не дозвониться'},
);
}
}
class LeadApp extends StatelessWidget {
const LeadApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Заявка',
theme: ThemeData(colorSchemeSeed: Colors.indigo),
home: Scaffold(
appBar: AppBar(title: const Text('Оставить заявку')),
body: const SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.all(16),
child: LeadForm(send: fakeSend),
),
),
),
);
}
}Как проверить
Unit-тесты проверяют нормализацию и маску без виджетов. Widget-тесты рендерят форму в тестовой среде: enterText вводит текст, tap нажимает, pump перерисовывает кадр. Последний тест использует Accessibility Guideline API из документации Flutter: цели нажатия не меньше 48×48 для Android и 44×44 для iOS, у каждой есть подпись, контраст текста достаточный.
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lead_app/lead_form/phone.dart';
final formatter = KzPhoneFormatter();
// ввод в конец поля: один символ с клавиатуры или вставка строки
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 принимает 8, +7 и номер без кода страны', () {
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('маска при наборе: 701…, 8 701… и +7 701… дают один номер', () {
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');
// 11-я цифра в полный номер не попадает
expect(type(typeEach('7011234567'), '8').text, '(701) 123-45-67');
});
test('вставка номера целиком в любом привычном виде', () {
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('выделили весь номер и вставили новый', () {
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('удаление цифры в середине: курсор остаётся у места правки', () {
const full = TextEditingValue(
text: '(701) 123-45-67',
selection: TextSelection.collapsed(offset: 7),
);
// стёрли «1» в «123»: курсор стоял после неё, теперь — перед «2»
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); // сразу после «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('Имя'), 'Анна');
await tester.enterText(field('Телефон'), '87011234567');
}
void main() {
testWidgets('пустая отправка: ошибки у полей и фокус на первом', (
tester,
) async {
var calls = 0;
await tester.pumpWidget(app((_) async => calls++));
await tester.tap(find.text('Отправить заявку'));
await tester.pump();
expect(find.text('Как к вам обращаться?'), findsOneWidget);
expect(
find.text('Укажите телефон, чтобы мы могли перезвонить'),
findsOneWidget,
);
expect(hasFocus(tester, 'Имя'), isTrue);
expect(calls, 0);
});
testWidgets('ошибка — после ухода из поля, исправление сразу её убирает', (
tester,
) async {
await tester.pumpWidget(app((_) async {}));
await tester.enterText(field('Имя'), 'А');
await tester.pump();
expect(
find.text('Имя слишком короткое'),
findsNothing,
); // пока печатает — молчим
await tester.tap(field('Телефон')); // ушёл из поля
await tester.pump();
expect(find.text('Имя слишком короткое'), findsOneWidget);
await tester.enterText(field('Имя'), 'Анна');
await tester.pump();
expect(find.text('Имя слишком короткое'), findsNothing);
});
testWidgets('успешная отправка: загрузка, защита от двойного нажатия, итог', (
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('Отправляем заявку'), findsOneWidget);
await tester.tap(find.byType(FilledButton)); // кнопка неактивна
await tester.pump();
expect(sent, hasLength(1));
expect(sent.single.phone, '+77011234567');
expect(sent.single.name, 'Анна');
done.complete();
await tester.pumpAndSettle();
expect(find.text('Заявка отправлена. Мы перезвоним вам.'), findsOneWidget);
});
testWidgets('сервер отклонил номер: ошибка у поля, данные на месте', (
tester,
) async {
await tester.pumpWidget(
app((_) async {
throw const LeadRejected(
'Проверьте номер телефона',
fieldErrors: {'phone': 'На этот номер не дозвониться'},
);
}),
);
await fillValid(tester);
await tester.tap(find.text('Отправить заявку'));
await tester.pumpAndSettle();
expect(find.text('Проверьте номер телефона'), findsOneWidget);
expect(find.text('На этот номер не дозвониться'), findsOneWidget);
expect(find.text('Анна'), findsOneWidget);
await tester.enterText(field('Телефон'), '87011234568');
await tester.pump();
expect(find.text('На этот номер не дозвониться'), findsNothing);
});
testWidgets('нет сети: общее сообщение и повторная отправка', (tester) async {
var attempt = 0;
await tester.pumpWidget(
app((_) async {
if (++attempt == 1) throw Exception('SocketException');
}),
);
await fillValid(tester);
await tester.tap(find.text('Отправить заявку'));
await tester.pumpAndSettle();
expect(find.textContaining('Не удалось отправить заявку'), findsOneWidget);
await tester.tap(find.text('Отправить заявку'));
await tester.pumpAndSettle();
expect(find.text('Заявка отправлена. Мы перезвоним вам.'), findsOneWidget);
});
testWidgets('доступность: подписи, размер и контраст целей', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(app((_) async {}));
expect(find.bySemanticsLabel('Имя'), findsOneWidget);
expect(find.bySemanticsLabel('Телефон'), 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!Без локального SDK то же самое запускается в Docker-образе Flutter. На дату проверки тег stable образа cirruslabs/flutter содержал Flutter 3.44: код рецепта проходит анализ и тесты и на нём, но проект, созданный в 3.47, требует Dart ^3.13.5 — понизьте ограничение sdk в pubspec.yaml или переключите SDK в контейнере на актуальный stable.
docker run --rm -v "$PWD:/app" -w /app ghcr.io/cirruslabs/flutter:stable \
sh -c "flutter pub get && flutter analyze && flutter test"Тесты проверены «от противного»: если убрать у формы onUnfocus, у поля onUserInteractionIfError, перевод фокуса или сброс серверной ошибки при правке, падает соответствующий тест. Устройство они не заменяют — на телефоне пройдите короткий список.
- Клавиатура телефона цифровая, «Далее» переводит с имени на телефон
- Автозаполнение предлагает имя и номер из профиля
- Вставка номера из сообщения или контакта даёт
(701) 123-45-67 - При пустой отправке клавиатура открывается на поле «Имя»
- Режим полёта: общая ошибка, данные на месте, после включения сети заявка уходит
- VoiceOver и TalkBack читают подписи полей, ошибку и «Отправляем заявку»
Ограничения и альтернативы
- Маска только для номеров +7. Для международных номеров маска с фиксированной длиной не подходит: выбирайте страну отдельно и проверяйте номер библиотекой разбора телефонных номеров, а формат храните в E.164.
- Состояние формы живёт в
Stateвиджета. Если заявку нужно отправлять из нескольких мест или переживать уход с экрана, вынесите отправку и статус в модель — как в уроке о состоянии и навигации во Flutter. - Нет очереди отправки без сети: заявка, отправленная в метро, теряется после закрытия приложения. Для таких случаев сохраняют черновик и повторяют отправку, когда сеть вернулась.
- Для форм из десятков полей с зависимостями между ними удобнее отдельные пакеты управления формами; для формы заявки встроенного
Formдостаточно.
Как проверено4 · пройдено1 · не запускалосьсверено 12 октября 2026 г.
- запущено, работает
lib/lead_form/phone.dart, lib/lead_form/lead.dart, lib/lead_form/lead_form.dart, lib/main.dart (RU и EN-варианты строк/комментариев)
Docker ghcr.io/cirruslabs/flutter:stable, SDK внутри контейнера переключён на тег 3.47.7 (Dart 3.13.5); flutter create (flutter_lints 6.0.0), dart format, flutter analyze — No issues found; flutter build web — ✓ Built build/web; RU и EN — отдельные проекты
- запущено, работает
test/phone_test.dart, test/lead_form_test.dart (RU и EN)
flutter test: 11 тестов (5 unit маски/нормализации, 6 widget: пустая отправка+фокус, onUnfocus+onUserInteractionIfError, загрузка и защита от двойного нажатия, forceErrorText от сервера, сетевая ошибка и повтор, meetsGuideline android/iOS/labeled/textContrast) — All tests passed в RU и EN. Первая версия маски (код страны внутри поля) провалила тест набора «701…» — переписана на префикс +7. Мутационная проверка: без onUnfocus у Form, без onUserInteractionIfError у поля, без перевода фокуса, без сброса серверной ошибки падает соответствующий тест
- запущено, работает
совместимость с образом cirruslabs/flutter:stable без переключения SDK
копия проекта с sdk: ^3.12.0 в pubspec.yaml: Flutter 3.44.0 — flutter analyze без замечаний, 11 тестов прошли; команда docker run из статьи выполнена в этом виде
- запущено, работает
утверждения о семантике формы (FormField → Semantics validationResult; validate/validateGranularly озвучивает первую ошибку; SemanticsService.announce устарел в пользу sendAnnouncement)
сверено с исходниками packages/flutter/lib/src/widgets/form.dart и semantics/semantics_service.dart на теге 3.47.7
- не запускалось
запуск на Android/iOS-устройстве: клавиатура, автозаполнение, вставка номера, VoiceOver/TalkBack вживую
в контейнере нет устройств и эмуляторов; ручной чек-лист в статье, поведение сверено с документацией docs.flutter.dev и api.flutter.dev
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.