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

Flutter: состояние и навигация

Коротко

Локальное состояние одного виджета во Flutter хранят в State и меняют через setState, а данные, нужные нескольким экранам, выносят в модель на ChangeNotifier и раздают через пакет provider: context.watch в build, context.read в обработчиках. Для переходов есть Navigator (push/pop, данные через конструктор, результат через pop), а для адресов и глубоких ссылок документация рекомендует go_router: маршрут /notes/:id, context.go и context.push.

Пошаговый урокУровень: СреднийFlutterПрактика: ~1,5–3 ч

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

  • отличить локальное состояние от состояния приложения и поднять состояние в общего родителя через параметры и callback
  • вынести общие данные в ChangeNotifier, подключить его через ChangeNotifierProvider и правильно выбрать watch или read
  • открыть экран через Navigator.push, передать данные в конструктор и получить результат из pop
  • настроить go_router с параметром пути /notes/:id, различать go и push и показать загрузку через FutureBuilder

Проверено на

Flutter 3.47.7 (stable)Dart 3.13.5provider 6.1.5+1go_router 18.0.2

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Локальное и общее состояние: где хранить данные
  2. 02ChangeNotifier и provider: состояние для нескольких экранов
  3. 03Navigator: push, pop и передача данных
  4. 04go_router: маршруты, параметры и глубокие ссылки
  5. 05Экраны «Записей» и асинхронная загрузка
  6. 06Проверка результата: widget-тесты
  7. 07Частые ошибки и упражнения

Локальное и общее состояние: где хранить данные

В уроке «Flutter и Dart: первое приложение» кнопка записи хранила своё состояние сама — в объекте State и через setState. Этого достаточно, пока данные нужны одному виджету. Документация Flutter делит состояние на два вида. Локальное (ephemeral, его ещё называют UI-состоянием) аккуратно умещается в одном виджете: выбранная вкладка, текущая страница PageView, ход анимации. Состояние приложения (app state) нужно многим частям интерфейса и часто должно переживать перезапуск: корзина, данные входа, непрочитанные уведомления.

Чёткой границы нет, и документация говорит об этом прямо: состоянием можно управлять через State и setState во всём приложении, а одно и то же значение со временем приходится переносить из одного вида в другой. Практический ориентир: если значение читает или меняет больше одного экрана — это состояние приложения.

Первый шаг от локального состояния к общему — подъём состояния (lifting state up): значение переносят в ближайшего общего родителя, а дети получают его через конструктор и сообщают о действиях через функции обратного вызова (callback). В примере ниже счётчик чашек хранит OrderScreen, а CupsStepper ничего не помнит: показывает value и вызывает onChanged.

lib/basics/lifting.dart
import 'package:flutter/material.dart';

/// Родитель владеет состоянием и передаёт его детям.
class OrderScreen extends StatefulWidget {
  const OrderScreen({super.key});

  @override
  State<OrderScreen> createState() => _OrderScreenState();
}

class _OrderScreenState extends State<OrderScreen> {
  int _cups = 1;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        CupsStepper(
          value: _cups,
          onChanged: (value) => setState(() => _cups = value),
        ),
        Text('Итого чашек: $_cups'),
      ],
    );
  }
}

/// Ребёнок без своего состояния: показывает значение и сообщает о действиях.
class CupsStepper extends StatelessWidget {
  const CupsStepper({super.key, required this.value, required this.onChanged});

  final int value;
  final ValueChanged<int> onChanged;

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        IconButton(
          tooltip: 'Меньше',
          icon: const Icon(Icons.remove),
          // null делает кнопку неактивной
          onPressed: value > 1 ? () => onChanged(value - 1) : null,
        ),
        Text('$value'),
        IconButton(
          tooltip: 'Больше',
          icon: const Icon(Icons.add),
          onPressed: () => onChanged(value + 1),
        ),
      ],
    );
  }
}
Подъём состояния: родитель — StatefulWidget, ребёнок — StatelessWidget с value и onChanged.

ChangeNotifier и provider: состояние для нескольких экранов

Когда список записей нужен экрану списка, экрану деталей и форме добавления, протягивать его через конструкторы каждого экрана неудобно. Документация Flutter перечисляет варианты: setState для локального состояния; ValueNotifier и InheritedNotifier — только средствами SDK; InheritedWidget — низкоуровневый механизм, на котором построены provider и многие другие пакеты; и пакеты сообщества.

Официальная рекомендация для начала — пакет provider. Страница «Simple app state management» говорит: если вы новичок во Flutter и у вас нет веской причины выбрать другой подход (Redux, Rx, hooks), начинать стоит с него — он простой и требует мало кода. Руководство по архитектуре добавляет: разделять интерфейс на View и ViewModel (MVVM) — «настоятельно рекомендуется», provider для внедрения зависимостей — тоже; ChangeNotifier для обновления виджетов помечен как условная рекомендация: удобный встроенный способ, но выбор подхода — дело предпочтений команды.

  1. Модель — ChangeNotifier

    Класс хранит данные и бизнес-логику, а после каждого изменения вызывает notifyListeners() — сигнал подписчикам перерисоваться.

  2. Провайдер над деревом

    ChangeNotifierProvider создаёт модель один раз, отдаёт её всем потомкам и сам вызывает dispose(), когда модель больше не нужна.

  3. Подписка в build

    context.watch<NotesModel>() возвращает модель и перерисовывает виджет при каждом notifyListeners().

  4. Действие в обработчике

    context.read<NotesModel>() возвращает модель без подписки — для onPressed, initState и других мест вне build.

bash
flutter create notes_app
cd notes_app
flutter pub add provider go_router
Проект и два пакета. flutter pub add записывает актуальные совместимые версии в pubspec.yaml.
lib/models/note.dart
class Note {
  const Note({required this.id, required this.title, this.body = ''});

  final String id;
  final String title;
  final String body;
}
lib/notes_model.dart
import 'dart:collection';

import 'package:flutter/foundation.dart';

import 'models/note.dart';

/// Общее состояние приложения: список записей.
class NotesModel extends ChangeNotifier {
  final List<Note> _notes = [];
  bool _loaded = false;
  int _nextId = 1;

  /// Снаружи список можно только читать — менять только через методы модели.
  UnmodifiableListView<Note> get notes => UnmodifiableListView(_notes);

  Note? byId(String id) {
    for (final note in _notes) {
      if (note.id == id) return note;
    }
    return null;
  }

  /// Имитация запроса к серверу; повторный вызов ничего не делает.
  Future<void> load() async {
    if (_loaded) return;
    await Future<void>.delayed(const Duration(milliseconds: 300));
    _notes.addAll(const [
      Note(id: '1', title: 'Купить кофе', body: 'Зерно для эспрессо, 1 кг'),
      Note(id: '2', title: 'Позвонить в сервис', body: 'Записаться на ТО'),
    ]);
    _nextId = 3;
    _loaded = true;
    notifyListeners();
  }

  void add(String title, String body) {
    _notes.add(Note(id: '${_nextId++}', title: title, body: body));
    notifyListeners(); // подписчики перерисуются
  }

  void remove(String id) {
    _notes.removeWhere((note) => note.id == id);
    notifyListeners();
  }
}
Модель отдаёт список только для чтения: изменить его можно лишь методами, которые вызывают notifyListeners().
lib/main.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';

import 'notes_model.dart';
import 'router.dart';

void main() {
  runApp(
    // Модель создаётся один раз над всем приложением и доступна любому экрану.
    ChangeNotifierProvider(
      create: (context) => NotesModel(),
      child: const NotesApp(),
    ),
  );
}

class NotesApp extends StatefulWidget {
  const NotesApp({super.key});

  @override
  State<NotesApp> createState() => _NotesAppState();
}

class _NotesAppState extends State<NotesApp> {
  // Роутер создаём один раз, а не в build.
  final GoRouter _router = createRouter();

  @override
  void dispose() {
    _router.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Записи',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
      ),
      routerConfig: _router,
    );
  }
}
Провайдер над всем приложением; MaterialApp.router получает роутер из следующего раздела.

context.watch<T>()

Что делает
Возвращает T и подписывает виджет на изменения
Где вызывать
В build

context.read<T>()

Что делает
Возвращает T без подписки
Где вызывать
В обработчиках, initState; не в build

context.select<T, R>(…)

Что делает
Подписка только на часть данных
Где вызывать
В build, когда нужна одна величина

Consumer<T>

Что делает
Виджет, перерисовывающий только свой builder
Где вызывать
Глубоко в дереве, чтобы сузить перерисовку

Navigator: push, pop и передача данных

Navigator хранит экраны стопкой. Navigator.of(context).push(…) кладёт новый экран сверху, pop() снимает его. Экран оборачивают в маршрут: MaterialPageRoute добавляет переход в стиле Material. Данные на новый экран передают обычным параметром конструктора — тип проверит компилятор. Результат назад возвращают через pop(значение): push возвращает Future, который завершится этим значением.

lib/basics/navigator_demo.dart
import 'package:flutter/material.dart';

/// Navigator без пакетов: данные туда — через конструктор, обратно — через pop.
class TagButton extends StatefulWidget {
  const TagButton({super.key});

  @override
  State<TagButton> createState() => _TagButtonState();
}

class _TagButtonState extends State<TagButton> {
  String? _tag;

  Future<void> _pickTag() async {
    final tag = await Navigator.of(context).push<String>(
      MaterialPageRoute(
        builder: (context) =>
            const TagPickerScreen(tags: ['Работа', 'Дом', 'Покупки']),
      ),
    );
    if (!mounted) return;
    if (tag != null) setState(() => _tag = tag);
  }

  @override
  Widget build(BuildContext context) {
    return OutlinedButton(
      onPressed: _pickTag,
      child: Text(_tag == null ? 'Выбрать метку' : 'Метка: $_tag'),
    );
  }
}

class TagPickerScreen extends StatelessWidget {
  const TagPickerScreen({super.key, required this.tags});

  final List<String> tags;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Метка')),
      body: ListView(
        children: [
          for (final tag in tags)
            ListTile(
              title: Text(tag),
              onTap: () => Navigator.of(context).pop(tag),
            ),
        ],
      ),
    );
  }
}
Выбор метки: список меток уходит в конструктор TagPickerScreen, выбранная метка возвращается через pop.

После await экран мог уже закрыться, поэтому перед setState или обращением к context проверяют mounted. Если забыть, линтер use_build_context_synchronously из flutter_lints предупредит об использовании BuildContext после асинхронного разрыва, а setState на закрытом экране упадёт с ошибкой setState() called after dispose().

go_router: маршруты, параметры и глубокие ссылки

Императивный Navigator удобен для небольших приложений. Когда экраны должны открываться по адресу — веб-приложение с прямыми ссылками, глубокие ссылки (deep links) из уведомлений и писем на Android и iOS, — документация советует Router API, обычно через пакет go_router. Руководство по архитектуре называет его предпочтительным способом для 90% Flutter-приложений. Пакет публикует команда flutter.dev; на pub.dev он помечен как feature-complete: новые возможности не планируются, работа идёт над исправлениями и стабильностью.

lib/router.dart
import 'package:go_router/go_router.dart';

import 'screens/new_note_screen.dart';
import 'screens/note_details_screen.dart';
import 'screens/notes_list_screen.dart';

GoRouter createRouter() {
  return GoRouter(
    routes: [
      GoRoute(
        path: '/',
        builder: (context, state) => const NotesListScreen(),
        // Вложенные маршруты открываются поверх родителя: /notes/2, /new.
        routes: [
          GoRoute(
            path: 'notes/:id',
            builder: (context, state) =>
                NoteDetailsScreen(id: state.pathParameters['id']!),
          ),
          GoRoute(
            path: 'new',
            builder: (context, state) => const NewNoteScreen(),
          ),
        ],
      ),
    ],
  );
}
Маршруты приложения: список на /, детали на /notes/:id, форма на /new.

Маршрут описывается путём и функцией builder. Сегмент с двоеточием :id — параметр пути, его значение лежит в state.pathParameters['id']. Вложенные маршруты (routes внутри родителя) открываются поверх родительского экрана, поэтому переход на /notes/2 строит стек «список → детали» и показывает кнопку «Назад» — это проверяет один из тестов, имитируя открытие глубокой ссылки.

context.go('/notes/2')

Что делает
Заменяет стек экранов на тот, что соответствует адресу
Когда использовать
Обычные переходы; адрес и стек всегда совпадают

context.push('/new')

Что делает
Кладёт экран поверх текущего стека
Когда использовать
Нужно дождаться результата: await context.push<bool>(…)

context.pop(true)

Что делает
Закрывает экран и возвращает значение
Когда использовать
Форма, выбор, подтверждение

extra:

Что делает
Передаёт объект вместе с переходом
Когда использовать
Редко: в браузере данные сериализуются; надёжнее передать id в пути

Документация go_router предупреждает, что императивная навигация (push) может вести себя неожиданно с историей браузера, поэтому основной инструмент — go, а push оставляют для случаев, когда нужен результат. Версия 18 пакета требует Flutter 3.44 и Dart 3.12 или новее.

Экраны «Записей» и асинхронная загрузка

Список при открытии загружает записи. Пока данные идут, нужен индикатор, при ошибке — сообщение. Это делает FutureBuilder: он перестраивает builder по мере того, как Future переходит из ожидания (ConnectionState.waiting) в завершение (done) с данными или ошибкой. Главное правило из документации API: Future нельзя создавать прямо в build — его получают заранее, например в initState. Иначе каждая перерисовка запускает загрузку заново.

lib/screens/notes_list_screen.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';

import '../notes_model.dart';

class NotesListScreen extends StatefulWidget {
  const NotesListScreen({super.key});

  @override
  State<NotesListScreen> createState() => _NotesListScreenState();
}

class _NotesListScreenState extends State<NotesListScreen> {
  late final Future<void> _loading;

  @override
  void initState() {
    super.initState();
    // Future создаём один раз. В build загрузка запускалась бы заново
    // при каждой перерисовке.
    _loading = context.read<NotesModel>().load();
  }

  Future<void> _openNewNote() async {
    final saved = await context.push<bool>('/new');
    if (!mounted) return; // экран могли закрыть, пока ждали результат
    if (saved == true) {
      ScaffoldMessenger.of(context)
          .showSnackBar(const SnackBar(content: Text('Запись добавлена')));
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Записи')),
      body: FutureBuilder<void>(
        future: _loading,
        builder: (context, snapshot) {
          if (snapshot.connectionState != ConnectionState.done) {
            return const Center(child: CircularProgressIndicator());
          }
          if (snapshot.hasError) {
            return Center(child: Text('Ошибка загрузки: ${snapshot.error}'));
          }
          // watch: перерисовать список при каждом notifyListeners().
          final notes = context.watch<NotesModel>().notes;
          if (notes.isEmpty) {
            return const Center(child: Text('Записей пока нет'));
          }
          return ListView.builder(
            itemCount: notes.length,
            itemBuilder: (context, index) {
              final note = notes[index];
              return ListTile(
                title: Text(note.title),
                subtitle: Text(
                  note.body,
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                ),
                onTap: () => context.go('/notes/${note.id}'),
              );
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        tooltip: 'Новая запись',
        onPressed: _openNewNote,
        child: const Icon(Icons.add),
      ),
    );
  }
}
Список: Future создаётся в initState, список подписан на модель через watch, новая запись открывается через push и возвращает результат.
lib/screens/note_details_screen.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';

import '../notes_model.dart';

class NoteDetailsScreen extends StatelessWidget {
  const NoteDetailsScreen({super.key, required this.id});

  final String id; // параметр из адреса /notes/:id

  @override
  Widget build(BuildContext context) {
    final note = context.watch<NotesModel>().byId(id);
    if (note == null) {
      return Scaffold(
        appBar: AppBar(),
        body: const Center(child: Text('Запись не найдена')),
      );
    }
    return Scaffold(
      appBar: AppBar(
        title: Text(note.title),
        actions: [
          IconButton(
            tooltip: 'Удалить',
            icon: const Icon(Icons.delete_outline),
            onPressed: () {
              // read: в обработчике нужна модель, но не подписка на неё.
              final model = context.read<NotesModel>();
              context.pop();
              model.remove(id);
            },
          ),
        ],
      ),
      body: Padding(padding: const EdgeInsets.all(16), child: Text(note.body)),
    );
  }
}
Детали: id приходит из адреса, запись берётся из модели; если её нет — понятное сообщение вместо падения.
lib/screens/new_note_screen.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';

import '../notes_model.dart';

class NewNoteScreen extends StatefulWidget {
  const NewNoteScreen({super.key});

  @override
  State<NewNoteScreen> createState() => _NewNoteScreenState();
}

class _NewNoteScreenState extends State<NewNoteScreen> {
  final _title = TextEditingController();
  final _body = TextEditingController();

  @override
  void dispose() {
    _title.dispose(); // контроллеры освобождаем вместе с экраном
    _body.dispose();
    super.dispose();
  }

  void _save() {
    final title = _title.text.trim();
    if (title.isEmpty) return;
    context.read<NotesModel>().add(title, _body.text.trim());
    context.pop(true); // вернуть результат экрану, который открыл этот
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Новая запись')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          TextField(
            controller: _title,
            decoration: const InputDecoration(labelText: 'Заголовок'),
          ),
          const SizedBox(height: 12),
          TextField(
            controller: _body,
            decoration: const InputDecoration(labelText: 'Текст'),
            maxLines: 4,
          ),
          const SizedBox(height: 24),
          FilledButton(onPressed: _save, child: const Text('Сохранить')),
        ],
      ),
    );
  }
}
Форма: TextEditingController освобождается в dispose, после сохранения экран закрывается с результатом true.

Обратите внимание на разделение: список записей — состояние приложения и живёт в NotesModel, а текст в полях формы — локальное состояние экрана в TextEditingController. Экран деталей получает только id и сам находит запись в модели, поэтому ему не важно, как его открыли — из списка или по адресу /notes/2. Ограничение учебной версии: если открыть адрес раньше, чем записи загрузятся, до окончания загрузки экран покажет «Запись не найдена». В рабочем приложении модель хранит признак загрузки, и экран деталей в это время показывает индикатор.

Проверка результата: widget-тесты

Тесты проходят по сценарию пользователя: увидеть индикатор загрузки, дождаться списка, открыть запись, вернуться, добавить новую и удалить старую. tester.pump(Duration) проматывает время для имитации загрузки, pumpAndSettle() ждёт окончания анимаций перехода. Пока крутится CircularProgressIndicator, pumpAndSettle() не вызывают: бесконечная анимация не даст ему завершиться.

test/widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';

import 'package:notes_app/basics/lifting.dart';
import 'package:notes_app/basics/navigator_demo.dart';
import 'package:notes_app/main.dart';
import 'package:notes_app/notes_model.dart';
import 'package:notes_app/screens/notes_list_screen.dart';

Widget app() => ChangeNotifierProvider(
  create: (context) => NotesModel(),
  child: const NotesApp(),
);

void main() {
  testWidgets('состояние поднято в родителя', (tester) async {
    await tester.pumpWidget(
      const MaterialApp(home: Scaffold(body: OrderScreen())),
    );
    for (var i = 0; i < 2; i++) {
      await tester.tap(find.byTooltip('Больше'));
      await tester.pump(); // перерисовка: кнопка получает новое value
    }
    expect(find.text('Итого чашек: 3'), findsOneWidget);
    expect(find.text('3'), findsOneWidget); // ребёнок видит то же значение
  });

  testWidgets('Navigator: данные туда и обратно', (tester) async {
    await tester.pumpWidget(
      const MaterialApp(home: Scaffold(body: TagButton())),
    );
    await tester.tap(find.text('Выбрать метку'));
    await tester.pumpAndSettle(); // дождаться анимации перехода
    await tester.tap(find.text('Дом'));
    await tester.pumpAndSettle();
    expect(find.text('Метка: Дом'), findsOneWidget);
  });

  testWidgets('список → детали → назад → новая запись', (tester) async {
    await tester.pumpWidget(app());
    expect(find.byType(CircularProgressIndicator), findsOneWidget);

    await tester.pump(const Duration(milliseconds: 300)); // «загрузка»
    await tester.pump();
    expect(find.text('Купить кофе'), findsOneWidget);

    await tester.tap(find.text('Купить кофе'));
    await tester.pumpAndSettle();
    expect(find.text('Зерно для эспрессо, 1 кг'), findsOneWidget);

    await tester.pageBack();
    await tester.pumpAndSettle();

    await tester.tap(find.byTooltip('Новая запись'));
    await tester.pumpAndSettle();
    await tester.enterText(find.byType(TextField).first, 'Оплатить интернет');
    await tester.tap(find.text('Сохранить'));
    await tester.pumpAndSettle();

    expect(find.text('Оплатить интернет'), findsOneWidget);
    expect(find.text('Запись добавлена'), findsOneWidget);
  });

  testWidgets('переход по адресу /notes/2 строит стек «список → детали»', (
    tester,
  ) async {
    await tester.pumpWidget(app());
    await tester.pump(const Duration(milliseconds: 300));
    await tester.pump();

    final context = tester.element(find.byType(NotesListScreen));
    GoRouter.of(context).go('/notes/2'); // как при открытии глубокой ссылки
    await tester.pumpAndSettle();
    expect(find.text('Записаться на ТО'), findsOneWidget);
    expect(find.byType(BackButton), findsOneWidget);

    await tester.pageBack();
    await tester.pumpAndSettle();
    expect(find.text('Купить кофе'), findsOneWidget);
  });

  testWidgets('удаление на экране деталей', (tester) async {
    await tester.pumpWidget(app());
    await tester.pump(const Duration(milliseconds: 300));
    await tester.pump();

    await tester.tap(find.text('Позвонить в сервис'));
    await tester.pumpAndSettle();
    await tester.tap(find.byTooltip('Удалить'));
    await tester.pumpAndSettle();

    expect(find.text('Позвонить в сервис'), findsNothing);
    expect(find.text('Купить кофе'), findsOneWidget);
  });
}
Пять widget-тестов: подъём состояния, Navigator с результатом, полный сценарий, переход по адресу и удаление.
terminal
$ flutter analyze
Analyzing notes_app...
No issues found! (ran in 8.7s)

$ flutter test
00:00 +0: состояние поднято в родителя
00:00 +1: Navigator: данные туда и обратно
00:00 +2: список → детали → назад → новая запись
00:01 +3: переход по адресу /notes/2 строит стек «список → детали»
00:01 +4: удаление на экране деталей
00:01 +5: All tests passed!
Реальный результат в проверочном проекте (Flutter 3.47.7). flutter build web для этого проекта тоже проходит.
  • при запуске виден индикатор, через долю секунды — две записи
  • нажатие на запись открывает детали со стрелкой «Назад»
  • после «Сохранить» новая запись появляется в списке и показывается «Запись добавлена»
  • после удаления запись исчезает из списка

Частые ошибки и упражнения

ProviderNotFoundException

Причина
Провайдер не стоит выше виджета, который его ищет, или в тесте забыли обернуть приложение
Исправление
Поставить ChangeNotifierProvider над MaterialApp, в тестах — тоже

Экран не обновляется после изменения модели

Причина
Нет notifyListeners() или вместо watch использован read
Исправление
Вызывать notifyListeners() в каждом методе-изменении, в build — watch

Загрузка повторяется бесконечно

Причина
Future для FutureBuilder создаётся в build
Исправление
Создать его в initState и хранить в поле

setState() called after dispose()

Причина
setState после await, когда экран уже закрыт
Исправление
Проверять if (!mounted) return;

await context.push не возвращает результат

Причина
Переход сделан через go, а он заменяет стек
Исправление
Для экрана с результатом — push, закрытие — pop(значение)

Дальше по траектории Flutter + Dart — рецепт формы с валидацией и widget-тестом, затем тестирование мобильных приложений и публикация в сторах.

Как проверено4 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
  • запущено, работает

    lib/models/note.dart, lib/notes_model.dart, lib/router.dart, lib/main.dart, lib/screens/*.dart, lib/basics/*.dart

    flutter create + flutter pub add provider go_router (provider 6.1.5+1, go_router 18.0.2), flutter analyze — No issues found, flutter build web — ✓ Built build/web; Docker ghcr.io/cirruslabs/flutter:stable с SDK на теге 3.47.7 (Dart 3.13.5); RU и EN-версии кода проверены отдельно

  • запущено, работает

    test/widget_test.dart

    flutter test: 5 тестов, All tests passed (RU и EN). Первая версия теста подъёма состояния (два tap без pump) давала 2 вместо 3 — этот результат описан в статье

  • запущено, работает

    Решения упражнений (счётчик в заголовке, подтверждение удаления, поиск)

    копия проекта notes_app_ex с внесёнными решениями, flutter analyze без замечаний, 3 widget-теста прошли (RU и EN); фрагменты в статье взяты из этих файлов

  • запущено, работает

    Утверждение об открытии /notes/2 до окончания загрузки

    отдельный временный тест: переход до загрузки, после 300 мс запись показывается

  • не запускалось

    Запуск на Android/iOS-устройстве, глубокие ссылки ОС, история браузера

    в контейнере нет устройств и браузера; поведение сверено с docs.flutter.dev/ui/navigation и документацией go_router

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

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

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

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

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

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

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