Руководства

Первый типизированный Route

Запускаемый сценарий Catalog и Product с каноническим URL, одним владельцем Session, точным Push и типизированным результатом.

v1.1.0Начальный уровень

Первый типизированный Route

Вы соберёте приложение из двух экранов. На Home есть кнопка Открыть товар. Product показывает типизированный productId и возвращает true после покупки. Home отображает результат. Прямое открытие /products/keyboard-42 создаёт оба экрана, поэтому Back ведёт на Home.

Для первого запуска оставьте весь код в lib/main.dart. Разделить Destination, Route и страницы можно после проверки сценария.

01

Установите и импортируйте пакет

shell
flutter pub add ark_navigation:^1.1.0
dart
import 'package:ark_navigation/ark_navigation.dart';
import 'package:flutter/material.dart';
02

Опишите типизированное намерение

dart
final class HomeDestination extends NavigationDestination<void> {
  const HomeDestination();
}

final class ProductDestination extends NavigationDestination<bool> {
  const ProductDestination({required this.productId});
  final String productId;
}

Generic-параметр bool означает, что Product может завершиться через Pop со значением bool?. Destination не содержит Flutter Page, URI и зависимости от Router.

03

Свяжите Destination с Page и URI

dart
final homeRoute = NavigationRouteDefinition<HomeDestination, void>(
  id: const NavigationRouteId('home'),
  pageBuilder: (context, destination, entry) => const HomePage(),
  uriCodec: CallbackNavigationUriCodec<HomeDestination>(
    pattern: '/',
    encoder: (destination) => Uri(path: '/'),
    decoder: (match) => const HomeDestination(),
  ),
);

final productRoute = NavigationRouteDefinition<ProductDestination, bool>(
  id: const NavigationRouteId('product'),
  pageBuilder: (context, destination, entry) => ProductPage(
    productId: destination.productId,
  ),
  uriCodec: CallbackNavigationUriCodec<ProductDestination>(
    pattern: '/products/:productId',
    encoder: (destination) => Uri(
      pathSegments: <String>['products', destination.productId],
    ),
    decoder: (match) => ProductDestination(
      productId: match.path('productId'),
    ),
  ),
  stackPlanBuilder: (destination) => <NavigationTarget<Object?>>[
    const NavigationTarget<Object?>(destination: HomeDestination()),
    NavigationTarget<Object?>(destination: destination),
  ],
);

stackPlanBuilder используется при прямом открытии Product URI и атомарно восстанавливает Home → Product. Обычный push добавляет Product к существующему стеку.

04

Соберите проверяемый Graph

dart
NavigationGraph createGraph() {
  const rootScopeId = NavigationScopeId('root');
  const catalogModuleId = NavigationModuleId('catalog');

  return NavigationGraph(
    rootScopeId: rootScopeId,
    scopes: const <NavigationScopeDefinition>[
      NavigationScopeDefinition(
        id: rootScopeId,
        restorationScopeId: 'root-navigation',
      ),
    ],
    modules: <NavigationModule>[
      NavigationModule(
        id: catalogModuleId,
        routes: <NavigationRouteDefinitionBase>[homeRoute, productRoute],
      ),
    ],
    mounts: <NavigationMount>[
      NavigationMount(
        id: const NavigationMountId('catalog-root'),
        moduleId: catalogModuleId,
        scopeId: rootScopeId,
      ),
    ],
  );
}

Module владеет Route Definition. Mount, принадлежащий приложению, помещает Module в корневой Scope и URI-префикс /.

05

Назначьте одного владельца Session

dart
final class ExampleHost extends StatefulWidget {
  const ExampleHost({super.key});
  @override
  State<ExampleHost> createState() => _ExampleHostState();
}

final class _ExampleHostState extends State<ExampleHost> {
  late final NavigationSession session = NavigationSession(
    id: const NavigationSessionId('main'),
    graph: createGraph(),
  );

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(routerConfig: session.routerConfig);
  }
}

Не создавайте Session внутри build(): rebuild не должен заменять Controller, ключи Navigator, историю и ожидающие Ticket.

06

Откройте Product и проверьте результат операции

dart
final NavigationTicket<bool> ticket = context.readNavigation.push<bool>(
  const ProductDestination(productId: 'keyboard-42'),
);

final NavigationOperationResult commit = await ticket.committed;
if (commit is! NavigationCommitted) return;

switch (await ticket.completed) {
  case NavigationPopped<bool>(:final result):
    debugPrint('Purchased: $result');
  case NavigationRemoved<bool>():
    debugPrint('Entry удалён перестройкой стека.');
  case NavigationNotCommitted<bool>():
    throw StateError('Неуспешная операция уже обработана выше.');
}

После успешной фиксации корневой Scope содержит [Home, Product]. ticket.completed остаётся незавершённым до удаления Product.

07

Верните типизированный результат

dart
FilledButton(
  onPressed: () => context.readNavigation.pop(result: true),
  child: const Text('Buy and return'),
)

Стек становится [Home], а вызывающий код получает NavigationPopped<bool>(result: true). Системный Back вернул бы тот же тип с null.

08

Добавьте две страницы

dart
final class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

final class _HomePageState extends State<HomePage> {
  String message = 'Результата пока нет';

  Future<void> openProduct() async {
    final ticket = context.readNavigation.push<bool>(
      const ProductDestination(productId: 'keyboard-42'),
    );
    final commit = await ticket.committed;
    if (commit is! NavigationCommitted) {
      if (mounted) setState(() => message = 'Переход не выполнен');
      return;
    }

    final completion = await ticket.completed;
    if (!mounted) return;
    setState(() {
      message = switch (completion) {
        NavigationPopped<bool>(:final result) => 'Покупка: $result',
        NavigationRemoved<bool>() => 'Product удалён при перестройке стека',
        NavigationNotCommitted<bool>() => 'Product не был открыт',
      };
    });
  }

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(title: const Text('Catalog')),
    body: Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          Text(message),
          FilledButton(onPressed: openProduct, child: const Text('Открыть товар')),
        ],
      ),
    ),
  );
}

final class ProductPage extends StatelessWidget {
  const ProductPage({required this.productId, super.key});
  final String productId;

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(title: Text('Product $productId')),
    body: Center(
      child: FilledButton(
        onPressed: () => context.readNavigation.pop(result: true),
        child: const Text('Купить и вернуться'),
      ),
    ),
  );
}

Запустите приложение. Если открылся Catalog, значит Session, Graph, Mount, Route Definition и Flutter Router соединены правильно. Кнопка проверяет типизированный переход и возврат результата.