Skip to main content

Navigation

In an app with a router, every screen that a feature declares has a route, and the router role generates a typed way to reach it: context.nav. Screens navigate through it instead of through go_router, so their code works with any router.

Paths and names​

A feature declares its routes relative to its own namespace, the path /<feature id>. The feature home declares the route /, so its full path is /home. Its full name, home.home, joins the id of the feature and the name of the route.

The examples on this page use a feature catalog that declares two routes: a list at / and, below it, the details of an item at item/:id, which takes the path parameter id, an int, and the optional query parameter color, a String. Their full paths are /catalog and /catalog/item/:id, and their full names catalog.list and catalog.item.

context.nav​

context.nav has a getter for each feature and a method for each of its routes, with the parameters of the route:

context.nav.catalog.list()
context.nav.catalog.item(id: 5, color: 'red')

Each returns a NavLink, which navigates in one of three ways:

MethodWhat it does
go()Shows the location with the chain of its parents below it as the stack: go() to an item shows the list below it, so Back returns to the list.
push<T>()Shows the location on top of the current stack. Its Future completes with the value the page returns when it closes.
replace()Replaces the page on top of the stack with the location.
TextButton(
onPressed: () async {
final color = await context.nav.catalog.item(id: 5).push<String>();
// The item page closed, returning a color or null.
},
child: const Text('Item 5'),
)

Behind the methods is a location class for every route, generated into lib/core/router/navigation.dart:

lib/core/router/navigation.dart
/// The location of `catalog.item`: `/catalog/item/:id`.
final class CatalogItemLocation extends AppLocation {
/// Creates the location with the values of the route.
const CatalogItemLocation({required this.id, this.color});

/// The value of the path parameter `id`.
final int id;

/// The value of the query parameter `color`.
final String? color;


String get routeName => 'catalog.item';


String get path => _withQuery('/catalog/item/$id', {'color': color});


AppLocation get parent => const CatalogListLocation();
}

context.nav works with any context of the app, even one above the router, because the generated router navigates itself rather than looking up the router of the context.

Parameters​

A route takes values from its location. Path parameters, such as :id, are always required, and query parameters, such as ?color=red, can be required or optional. A value is a String, an int, a double or a bool. The screen of the route takes each as a named parameter of its constructor, nullable when it is optional:

const ItemScreen({required this.id, this.color, super.key});

With go_router, the router parses an int or a double from the location with tryParse, and a bool is true or false exactly. When a required value is missing or has the wrong type, as in /catalog/item/abc, the location shows the error screen of go_router instead of the page. An optional value that is missing or has the wrong type reaches the screen as null.

The start screen​

A feature can mark a route as one that the app can start on, as home does with /home. The app opens on it, and the path / redirects there. When several routes can start the app, such as /home and /catalog, smf create asks which one:

? Which screen does the app start on? (↑↓ to move, enter to choose)
❯ /home (home)
/catalog (catalog)

A run without a terminal takes the full path from --start, such as --start /catalog, and stops with a usage error without it. --start can also name a route that no feature marks, as long as the route has no required parameters, is outside the flow of a guard and asks for no condition. In a terminal it answers the question in advance. When no route can start the app, / shows the fallback start screen, which names the app and tells you where its first screen goes. If the app has routes but no feature marks one, smf create warns and suggests --start.

Tabs and other main navigation​

With a layout, such as bottom_tabs, the routes that features mark as destinations form the main navigation of the app: a tab for each, in the order of the features. The app opens on the tab of its start screen. The settings screen of settings is a destination too, so with it the app has a Settings tab.

  • Every tab keeps its own stack while another tab is selected. The routes below a destination stay in its tab.
  • The other top-level routes are outside the main navigation. They show over the tabs, and going to one leaves the tabs behind.
  • go() to a location in a tab selects that tab and makes the chain of the location its stack. go() to a location outside the main navigation replaces the whole stack with its chain.
  • push() from a page in a tab shows the location on top of the selected tab's stack, whichever tab the location belongs to, and the selected tab stays.
  • The app shows the main navigation only once. So on a page shown over it, push() and replace() of a location in the main navigation throw a StateError that says to use go() instead, and the stack stays as it is. On a page with no main navigation below it, push() brings the main navigation back on top with the location.

Guards​

A feature can keep the user from the rest of the app until a condition holds. onboarding does: on the first launch, the app shows the onboarding until the user finishes it. sign_in does in an app that nobody uses without an account: the app shows the sign-in until the user is signed in. Such a feature declares a guard: the condition, and one of its own routes to show while the condition does not hold, the target of the guard. The target and the routes below it are the flow of the guard. A guard over the whole app is a gate.

While a gate does not allow, the app shows the target in place of every location outside the flow, whoever asks for it: the start of the app, the platform, or go(), push() and replace() of the routes of any feature. Such a push() completes with null at once. Once the gate allows, the app leaves the flow by itself. It shows the location that was asked for in the meantime, or the screen that the user was on, or the screen that it starts on.

The routes of a flow show only while its guard does not allow. Once the onboarding is finished, go() to /onboarding, or a link to it, shows the screen that the app starts on. So no code navigates into the flow of a gate. A screen of the flow only changes what the guard reads, as Skip and Get started of the onboarding do, and the app shows the flow and leaves it.

With several gates, the app asks them in the order of a first launch, whatever the order of the modules: the onboarding comes before the sign-in.

A guard for some routes​

A guard can also keep the user from some routes only: the routes that need what it stands for, such as an account. The rest of the app shows whatever that guard says. The guard sign_in.hasAccount of sign_in is one: it keeps a user without an account from the screen of the account, and its target is the sign-in.

While such a guard does not allowWhat the app does
go(), push() or replace() of one of its routesIt opens the target of the guard over the page that the user is on, and the request waits.
The user goes back from the target.The user is on the page they were on. A push() that waited completes with null.
The guard starts allowing.It closes the pages of the flow and does what was asked. push() shows the route over the page that the user was on and completes with the value of its page, go() shows it in place of the stack, and replace() in place of that page.
A link to one of its routes opens the app.The target opens over the screen that the app starts on.

When the guard stops allowing, the app closes the pages of its routes, with the pages over them. The user is then on the page below them, or on the screen that the app starts on when no page is below.

Until such a guard allows, the routes of its flow show like any route:

How a page of the flow is asked forWhat the app shows
A screen opens it with push(), as a button that leads a guest to the sign-in does.The page over that screen, so back leads back.
A screen goes to it with go().The page in place of the stack, with no way back.
A link asks for it.The page over the screen that the app starts on, so back leads into the app.

Two cases are known limits, both for a link that opens the app behind a gate that does not allow, as on a first launch with an onboarding. A link to a route that such a guard keeps the user from opens no target. If the guard still does not allow once the gate does, the link is lost, and the user is on the screen that the app starts on. A link to a page of the flow of such a guard is lost behind a gate with another flow too.

After a guard changed​

A screen neither closes itself nor navigates after it changed what a guard reads: the app has done both by then. That goes for a screen of a flow that made its guard allow, and for a screen that a guard keeps the user from once the screen made that guard stop, as a screen with a sign-out button does. A pop() after the change closes the page that is on top by then, which is the page that the user asked for or the page below the one that the app closed.

In the files of the app​

In an app with guards, lib/core/router/app_router.dart has more than the router:

routeGuardsThe guards of the app, in the order the app asks them. The gates come first, and the first one that does not allow decides. The guards for some routes come after them, each with the names of those routes in its routes.
redirectOf(routeName)The location that the guards show when the app is asked for a route, or null when they let the user see it.
flowIsOver(routeName)Whether the route is in a flow that is over.
guardChangesNotifies its listeners when a guard starts or stops allowing.

An app without guards has none of them. In an app with guards, a guard of your own is one more RouteGuard in routeGuards. A gate goes at the place among the gates where its screen comes on a first launch: a guard of the first launch goes before one that asks who the user is. A guard for some routes goes after the gates. The section "Router" of the guide for coding agents of the app says what each needs. Write a feature module shows how a feature module declares a guard.

--start cannot name a route in the flow of a guard, and smf create stops with a usage error that names the guard. It refuses a route that asks for a condition too.

Guards of routes describes what a router does with the guards, in which order the app asks them and where the user comes back to. Conditions of routes describes the guards for some routes.

Following the screens​

Modules can follow the screen the user sees through the router, whichever router it is. firebase_analytics logs a screen view for each screen, with the full name of its route, such as home.home. Switching tabs counts as seeing another screen. Pages that the app shows with a navigator directly, such as with Navigator.push, and dialogs are not screens of the router.

Routes of your own​

SMF generates the navigation from the routes of the modules when it creates the app. A screen that you add later needs its route in three places, which agree on the full name of the route and on its path.

A screen below an existing one​

For a screen AboutScreen below the home screen, at /home/about, the three places are:

  1. The screen, a widget in the directory of its feature, here lib/features/home/about_screen.dart. Its const constructor takes each value of the route as a named parameter, and this route has none.

  2. The location, in lib/core/router/navigation.dart. AppLocation is a sealed class, so the location class goes into that file, and the class of the routes of the feature gets a method for it:

    lib/core/router/navigation.dart
    /// The location of `home.about`: `/home/about`.
    final class HomeAboutLocation extends AppLocation {
    /// Creates the location.
    const HomeAboutLocation();


    String get routeName => 'home.about';


    String get path => '/home/about';


    AppLocation get parent => const HomeHomeLocation();
    }

    // ...

    /// The routes of the module `home`.
    final class HomeRoutes {
    // ...

    /// The route `home.about`: `/home/about`.
    NavLink about() => NavLink(_context, const HomeAboutLocation());
    }
  3. The route itself, where the module that provides the router keeps the routes. With go_router, that is a GoRoute in lib/core/router/app_router_factory.dart, named by the full name of the route, here below the route of the home screen. The file imports the file of the screen with a prefix, about in this example:

    lib/core/router/app_router_factory.dart
    GoRoute(
    path: '/home',
    name: 'home.home',
    builder: (context, state) => const screen0.HomeScreen(),
    routes: [
    GoRoute(
    path: 'about',
    name: 'home.about',
    builder: (context, state) => const about.AboutScreen(),
    ),
    ],
    ),

Then navigate to the screen as to the screens of the modules, with context.nav.home.about().go().

A screen of a new feature​

A feature of your own, such as a catalog at /catalog, has its screen in a directory of its own, lib/features/catalog/. Its route is a top-level one, so its location class has no parent. In lib/core/router/navigation.dart, the feature also gets a class of its routes, and AppNav a getter that returns it:

lib/core/router/navigation.dart
/// The location of `catalog.catalog`: `/catalog`.
final class CatalogCatalogLocation extends AppLocation {
/// Creates the location.
const CatalogCatalogLocation();


String get routeName => 'catalog.catalog';


String get path => '/catalog';
}

// ...

final class AppNav {
// ...

/// The routes of the feature `catalog`.
CatalogRoutes get catalog => CatalogRoutes._(_context);
}

// ...

/// The routes of the feature `catalog`.
final class CatalogRoutes {
const CatalogRoutes._(this._context);

final BuildContext _context;

/// The route `catalog.catalog`: `/catalog`.
NavLink catalog() => NavLink(_context, const CatalogCatalogLocation());
}

Code then navigates with context.nav.catalog.catalog().go().

Where the GoRoute of the screen goes decides how the app shows it. As a route of the list routes of the router, after _mainNavigationRoute, the route of the main navigation, the screen shows over the main navigation. To give it a tab instead, add a Destination to appDestinations, with a function for its label, and a branch for it at the same index of the branches that _mainNavigation() creates:

lib/core/layout/destination.dart
const List<Destination> appDestinations = [
Destination(label: _homeHomeLabel, icon: Icons.home),
Destination(label: _catalogCatalogLabel, icon: Icons.list),
];

String _homeHomeLabel(BuildContext context) => 'Home';

String _catalogCatalogLabel(BuildContext context) => 'Catalog';

The label is a function, so that the list stays const and the tab follows the language of the app. In an app with languages, return a text of the app from it, such as context.l10n.catalogLabel. In the branch, the file of the screen is imported with the prefix catalog:

lib/core/router/app_router_factory.dart
    StatefulShellBranch(
initialLocation: '/catalog',
observers: _observers(),
routes: [
GoRoute(
path: '/catalog',
name: 'catalog.catalog',
builder: (context, state) => const catalog.CatalogScreen(),
),
],
),

The branches are in the order of the destinations. The router calls _mainNavigation() again each time the main navigation leaves its pages, so what a branch has of its own, such as a key of its navigator, is created inside that function. The guide for coding agents of the app has the same steps in its section on the router, so an agent adds a route this way too.

Navigate through context.nav and not through go_router itself. The navigation of the app asks the guards before a go(), a push() or a replace(), keeps the request that waits for a guard, and refuses to put a location of the main navigation on a page shown over it. The modules that follow the screens know a screen by the name of its route.