Tutorials

Build the first typed route

Create a runnable Catalog and Product flow with a canonical URL, one owned Session, an exact push, and a typed result.

v1.1.0Beginner

Build the first typed route

You will build a two-screen Flutter app. Home has an Open product button. Product shows the typed product ID and a Buy and return button. Pressing it pops Product with true; Home displays the result. Opening /products/keyboard-42 directly reconstructs both screens so Back returns to Home.

Create a Flutter project and keep everything in lib/main.dart for this first pass. You can split Destinations, routes, and pages after the flow works.

01

Install and import the package

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

Define typed application intent

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

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

  final String productId;
}

ProductDestination accepts one typed identifier. Its generic parameter says that a product entry may later be popped with bool?. These classes contain no Flutter page, URI, or router dependency.

03

Connect each Destination to a page and 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),
  ],
);

The stack plan matters only when /products/keyboard-42 is restored directly. It reconstructs Home → Product atomically. A normal push appends Product to the existing stack.

04

Compose a validated 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,
      ),
    ],
  );
}

The feature-shaped Module owns the routes. The application-owned Mount places that Module in the root Navigator Scope and at URI prefix /.

05

Give the Session one stable lifecycle owner

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,
    );
  }
}

Do not create the Session inside build(). Rebuilds must not replace the Controller, Navigator keys, history provider, or outstanding tickets.

06

Push Product and handle admission

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

  final NavigationOperationResult commit = await ticket.committed;
  if (commit is! NavigationCommitted) {
    // Product was not inserted. Inspect Rejected, Failed, or NoChange.
    return;
  }

  final NavigationCompletion<bool> completion = await ticket.completed;
  switch (completion) {
    case NavigationPopped<bool>(:final result):
      debugPrint('Purchased: $result');
    case NavigationRemoved<bool>():
      debugPrint('The product entry ended through a stack rewrite.');
    case NavigationNotCommitted<bool>():
      throw StateError('Already handled by committed.');
  }
}

Immediately after a successful commit, the root Scope is [Home, Product]. The completion Future remains pending until Product leaves the stack.

07

Pop with a typed result

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

The root Scope becomes [Home], and the caller receives NavigationPopped<bool>(result: true). System Back would produce the same completion type with a null result.

08

Add the two pages

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

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

final class _HomePageState extends State<HomePage> {
  String message = 'No result yet';

  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 = 'Navigation was rejected');
      return;
    }
    final completion = await ticket.completed;
    if (!mounted) return;
    setState(() {
      message = switch (completion) {
        NavigationPopped<bool>(:final result) => 'Purchased: $result',
        NavigationRemoved<bool>() => 'Product was removed by a stack rewrite',
        NavigationNotCommitted<bool>() => 'Product was not opened',
      };
    });
  }

  @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('Open product')),
        ],
      ),
    ),
  );
}

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('Buy and return'),
      ),
    ),
  );
}

Run the app now. If Home appears, the Session, Graph, Mount, Route Definition, and Flutter Router connection are all working. The next button press verifies typed navigation and typed completion.