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.
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.
Install and import the package
flutter pub add ark_navigation:^1.1.0
import 'package:ark_navigation/ark_navigation.dart';
import 'package:flutter/material.dart';
Define typed application intent
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.
Connect each Destination to a page and URI
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.
Compose a validated Graph
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 /.
Give the Session one stable lifecycle owner
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.
Push Product and handle admission
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.
Pop with a typed result
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.
Add the two pages
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.