Как делать12 октября 2026 г.6 мин чтения
Flutter + DartШаг 4 из 6

Рецепт: форма с валидацией во Flutter

Коротко

Форму во Flutter собирают из виджета Form с ключом GlobalKey<FormState> и полей TextFormField с функцией validator: она возвращает текст ошибки или null. Чтобы ошибки не появлялись, пока человек печатает, форме задают AutovalidateMode.onUnfocus, а полям — onUserInteractionIfError. Ниже — готовая форма заявки для Flutter 3.47 с маской телефона, состоянием отправки, ошибками сервера, подписями для экранного диктора и 11 тестами.

Рецепт кодаУровень: СреднийКомпоненты FlutterПрактика: ~20–40 мин

После материала вы сможете

  • собрать 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 г.

Содержание
  1. 01Задача: форма заявки, которая не теряет клиентов
  2. 02Валидаторы и маска телефона
  3. 03Виджет формы: отправка, ошибки сервера и доступность
  4. 04Как подключить
  5. 05Как проверить
  6. 06Ограничения и альтернативы

Задача: форма заявки, которая не теряет клиентов

Нужна форма «Оставить заявку» для мобильного приложения: имя, телефон и необязательный комментарий. Правила поведения берём из материала о формах без боли: пока человек печатает — молчим, ушёл из поля — проверяем, исправил — сразу убираем ошибку, при отправке ведём к первой проблеме и не стираем введённое.

Имя

Клавиатура и автозаполнение
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 префикс не входит в значение поля.

lib/lead_form/phone.dart
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;
  }
}
Нормализация номера и маска ввода
lib/lead_form/lead.dart
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-ю цифру в полный номер

Что в поле
Ничего не изменилось — лишняя цифра отклонена

Стёр цифру в середине

Что в поле
Номер пересобран, курсор остался у места правки

Виджет формы: отправка, ошибки сервера и доступность

lib/lead_form/lead_form.dart
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('Отправить заявку'),
            ),
          ],
        ),
      ),
    );
  }
}
Готовый виджет LeadForm
  • Когда показывать ошибку. 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 объединяет поля для автозаполнения и по умолчанию, когда форма исчезает после отправки, сообщает платформе, что введённое можно сохранить.

Как подключить

  1. Положите три файла в lib/lead_form

    phone.dart, lead.dart и lead_form.dart не зависят от остального приложения.

  2. Реализуйте отправку

    Передайте в LeadForm функцию SendLead. Внутри — запрос к вашему backend. Ответ с ошибками полей (обычно статус 422) превращайте в LeadRejected с fieldErrors, сетевые сбои пусть остаются исключениями.

  3. Разместите форму в прокручиваемом контейнере

    Когда открыта клавиатура, места на экране мало: SingleChildScrollView или ListView позволит дотянуться до кнопки.

bash
flutter create lead_app
cd lead_app
mkdir lib/lead_form
Проект без сторонних пакетов
lib/main.dart
import '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, у каждой есть подпись, контраст текста достаточный.

test/phone_test.dart
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»
  });
}
Unit-тесты маски и нормализации
test/lead_form_test.dart
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();
  });
}
Widget-тесты: валидация, фокус, отправка, ошибки сервера и доступность
terminal
$ flutter analyze
Analyzing lead_app...
No issues found!
$ flutter test
00:01 +11: All tests passed!
Прогон: анализатор без замечаний, 11 тестов

Без локального SDK то же самое запускается в Docker-образе Flutter. На дату проверки тег stable образа cirruslabs/flutter содержал Flutter 3.44: код рецепта проходит анализ и тесты и на нём, но проект, созданный в 3.47, требует Dart ^3.13.5 — понизьте ограничение sdk в pubspec.yaml или переключите SDK в контейнере на актуальный stable.

bash
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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.