Skip to content

Day 4: go_router, routes, redirect, HomeShell

Today you read how the app decides which screen is on top. Footrace uses go_router, which turns navigation into URLs: every screen has a path, a single redirect function sends you to login or the splash depending on auth state, and a shell route gives the four bottom tabs their own back stacks. By the end you can look at any context.go(...) or context.push(...) call in the app and say what the stack looks like afterwards.

Day 2 showed FootraceApp handing routerConfig: ref.watch(routerProvider) to MaterialApp.router (app/lib/app/app.dart:16-20), and you followed one context.push(AppRoutes.leaderboard). Day 3 covered the FrNavigationBar family of components you will see in the shell.

Today mentions providers (ref.watch, ref.listen, ref.read) without explaining them; read them as “subscribe”, “call me back when it changes” and “read once”. Day 5 is about exactly that. The reference pages are App shell and the auth flow.

Open app/lib/app/routes.dart. The whole file is 21 lines:

/// Route paths. Screens reference these rather than string literals.
abstract final class AppRoutes {
static const splash = '/splash';
static const login = '/login';
static const register = '/register';
// Tab shell branches.
static const home = '/home';
static const activity = '/activity';
static const history = '/history';
static const profile = '/profile';
static const lobby = '/lobby';
static const leaderboard = '/leaderboard';
static const dev = '/dev';
static String queue(String raceType) => '/queue/$raceType';
static String user(String id) => '/users/$id';
static String race(String id) => '/race/$id';
static String raceResult(String id) => '/race/$id/result';
}

The same abstract final class namespace you saw for FrColors yesterday. Constants for fixed paths, static functions for paths with a parameter. These are Rails path helpers (user_path(id)) or Phoenix’s ~p"/users/#{id}", without the compile-time check that the route exists: the route patterns live in router.dart as plain strings ('/users/:id'), and nothing ties AppRoutes.user to them except the test that opens a profile.

Paths matter even on a phone app because go_router models navigation as “go to this location”, and the location is a URL. That is also what makes deep links and the router.go('/nope') test below possible.

Open app/lib/app/router.dart. The router is built inside a Riverpod provider so it can depend on other providers:

final routerProvider = Provider<GoRouter>((ref) {
final config = ref.watch(appConfigProvider);
final authListenable = ValueNotifier<AuthStatus>(
ref.read(authStatusProvider),
);
ref.listen(authStatusProvider, (_, next) => authListenable.value = next);
ref.onDispose(authListenable.dispose);
final router = GoRouter(
initialLocation: AppRoutes.home,
refreshListenable: authListenable,
redirect: (context, state) =>
_redirect(ref.read(authStatusProvider), state.matchedLocation),
routes: [
// ...
],
errorBuilder: (_, state) =>
ComingSoonScreen(title: 'Page not found', detail: state.uri.toString()),
);
ref.onDispose(router.dispose);
return router;
});

(app/lib/app/router.dart:27-128, routes trimmed.)

The bridge from Riverpod to go_router is lines 30-34. go_router knows nothing about Riverpod; it accepts a Listenable (Flutter’s minimal observer interface, one addListener method) and re-runs redirect whenever it fires. So the provider makes a ValueNotifier holding the current AuthStatus, and ref.listen copies every change of authStatusProvider into it. ref.onDispose cleans up both the notifier and the router (line 126) if the provider is ever rebuilt. Why ref.listen and not ref.watch for the auth status? watch would rebuild the whole GoRouter, losing the navigation stack, every time you signed in or out. listen leaves the router in place and just pokes it.

ref.watch(appConfigProvider) (line 28) is a watch on purpose: config never changes at runtime, but tests override it, and the dev route depends on it.

The route table (lines 41-122), as a picture:

flowchart TD
  R[GoRouter] --> S["/splash"]
  R --> L["/login"]
  R --> G["/register"]
  R --> SH["StatefulShellRoute.indexedStack (HomeShell)"]
  SH --> B1["branch 0: /home (RaceTab)"]
  SH --> B2["branch 1: /activity"]
  SH --> B3["branch 2: /history"]
  SH --> B4["branch 3: /profile"]
  R --> LB["/leaderboard"]
  R --> U["/users/:id"]
  R --> LO["/lobby"]
  R --> Q["/queue/:raceType"]
  R --> RA["/race/:id"]
  RA --> RR["result (/race/:id/result)"]
  R -.only if devTools.-> D["/dev"]

Things to read in the code:

  • A plain route is GoRoute(path: ..., builder: (_, __) => const LoginScreen()) (line 43). The builder gets the context and a GoRouterState; most ignore both.
  • Path parameters come from state.pathParameters: OpponentProfileScreen(userId: state.pathParameters['id']!) (lines 90-94). The map returns String?, so ! asserts it is there, which it always is when the pattern matched. This is conn.params["id"] in a Phoenix controller.
  • A nested route (lines 104-114): result is a child of /race/:id, so its full path is /race/:id/result and it inherits :id. Going to a result builds the race screen underneath it in the stack.
  • A conditional route (lines 117-121): if (config.devTools) GoRoute(...) is a collection if from Day 1. With dev tools off, /dev does not exist at all and falls to errorBuilder (lines 123-124), which renders “Page not found”.
  • Everything outside the shell (/leaderboard, /users/:id, /lobby, /queue/..., /race/...) is a top-level route, so it is shown full-screen over the tabs with no bottom bar. Inside the shell are only the four tab roots.

The gate is a pure function at the end of the file:

String? _redirect(AuthStatus auth, String location) {
final onAuthPage =
location == AppRoutes.login || location == AppRoutes.register;
switch (auth) {
case AuthStatus.unknown:
return location == AppRoutes.splash ? null : AppRoutes.splash;
case AuthStatus.signedOut:
return onAuthPage ? null : AppRoutes.login;
case AuthStatus.signedIn:
return onAuthPage || location == AppRoutes.splash ? AppRoutes.home : null;
}
}

(app/lib/app/router.dart:136-147.) It returns null for “carry on” or a path to go to instead. As a table:

Auth status Going to Result
unknown (session still being checked) /splash allowed
unknown anything else /splash
signedOut /login or /register allowed
signedOut anything else /login
signedIn /login, /register, /splash /home
signedIn anything else allowed

The switch statement has no default. Dart checks it covers every AuthStatus value, so adding a fourth status is a compile error here until someone decides what it means.

AuthStatus comes from one small provider:

final authStatusProvider = Provider<AuthStatus>((ref) {
final auth = ref.watch(authProvider);
return auth.value?.status ?? AuthStatus.unknown;
});

(app/lib/features/auth/auth_notifier.dart:131-134.) While authProvider is still loading (restoring the session from the keychain and calling /api/me), there is no value, so the status is unknown.

Put together, a cold start with a saved token looks like this:

sequenceDiagram
  participant M as main / MaterialApp
  participant R as GoRouter
  participant A as authStatusProvider
  M->>R: initialLocation /home
  R->>A: read status
  A-->>R: unknown
  R->>R: redirect to /splash
  Note over A: AuthNotifier.build reads token, GET /api/me
  A-->>R: signedIn (via authListenable)
  R->>R: redirect runs again: on /splash and signedIn, go to /home
  R->>M: HomeShell, Race tab

The same machinery handles every other auth change, and nobody navigates by hand:

  • Login. LoginScreen calls ref.read(authProvider.notifier).login(...) and then does nothing about navigation. The comment at app/lib/features/auth/login_screen.dart:45 says why: “The router’s auth gate moves us to the home tab.” The status flips to signedIn, authListenable fires, redirect sees /login while signed in and returns /home.
  • Session expiry. Any API call that gets a 401 calls sessionExpired() (app/lib/features/auth/auth_notifier.dart:74-78), which sets the state to signed out. Wherever you were, even mid-lobby, the next redirect run returns /login. The auth flow follows that path through the Dio interceptor.

redirectFor (lines 132-134) is a public wrapper marked @visibleForTesting, so the test can call the private function. app/test/app_test.dart:19-30 is the table above as expectations.

You have written this gate before, on the server. The API routes the app calls are behind a pipeline (backend/lib/footrace_web/router.ex:20-24):

pipeline :authorized_api do
plug :api
# plug :fetch_session
plug Footrace.Users.Pipeline
end

and the LiveView login pages use the inverse gate, on_mount: [{FootraceWeb.UserAuth, :redirect_if_user_is_authenticated}] (backend/lib/footrace_web/router.ex:175-176, implemented at backend/lib/footrace_web/user_auth.ex:224). The go_router redirect is both of those in one function: “not signed in, go to login” and “signed in, get off the login page”.

Where the analogy breaks:

  • It is one function for every route. Phoenix attaches plugs per scope; here one global redirect sees every location and decides by path. (go_router also supports per-route redirect; this app does not use it.)
  • It runs again without a request. A plug runs once per request. redirect runs on every navigation and whenever refreshListenable fires, so a state change in the background moves the user with no navigation call at all. It is closer to a LiveView handle_info that calls push_navigate when the session is revoked.
  • It is not security. It decides what the UI shows. The server still enforces auth on every request; a client that skipped the redirect would get 401s, not data.

Back in router.dart, the four tabs are one route (lines 48-85):

StatefulShellRoute.indexedStack(
builder: (_, __, navigationShell) =>
HomeShell(navigationShell: navigationShell),
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: AppRoutes.home,
builder: (_, __) => const RaceTab(),
),
],
),
// ... activity, history, profile
],
),

Each StatefulShellBranch is one tab with its own navigator, so its own back stack. indexedStack means all visited tabs stay alive in an IndexedStack (a Stack that shows one child and keeps the others built but hidden), so switching tabs keeps scroll positions and state.

Open app/lib/features/home/home_shell.dart:

class HomeShell extends StatelessWidget {
const HomeShell({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: FrNavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (index) => navigationShell.goBranch(
index,
// Tapping the active tab again pops it to its root.
initialLocation: index == navigationShell.currentIndex,
),
destinations: const [
FrNavDestination(/* ... */ label: 'Race'),
// ... Activity, History, Profile
],
),
);
}
}

(app/lib/features/home/home_shell.dart:8-49, trimmed.) The shell is a plain StatelessWidget. go_router hands it navigationShell, which is both the body (the current branch’s navigator) and the controller (currentIndex, goBranch). The initialLocation argument (line 22) gives the iOS behaviour where tapping the selected tab pops it to its root. FrNavigationBar upper-cases the labels (app/lib/design/components/fr_navigation_bar.dart:113), which is why tests tap 'PROFILE', not 'Profile'.

If you built this in UIKit, it would be a UITabBarController whose four children are each a UINavigationController. On Android it is a BottomNavigationView with Navigation component multiple back stacks. Same shape. What is different is that the selected tab is derived from the URL: go('/history') from anywhere selects the History tab, because /history lives in branch 2.

go_router adds methods to BuildContext (another extension, like context.fr). Two matter:

  • context.push(path) pushes a new page on top of the current stack. Back returns to where you were. pushViewController.
  • context.go(path) replaces the stack with whatever the route table says that location means. /race/abc is a top-level route, so after go the stack is just the race page: the shell and anything pushed on it are gone. /race/abc/result gives the race page with the result on top, because result is nested under it. Closest to setting window.location, or to replacing a UINavigationController’s viewControllers array.

The app’s choices follow one rule: push when the user should be able to come back, go when the previous screen no longer makes sense.

Call Where Why that one
context.push(AppRoutes.leaderboard) app/lib/features/race_tab/race_tab.dart:51 Back returns to the Race tab
context.push(AppRoutes.user(entry.userId)) app/lib/features/leaderboard/leaderboard_screen.dart:217 Back returns to the list
context.push(AppRoutes.register) app/lib/features/auth/login_screen.dart:110 Back returns to login
context.go(AppRoutes.race(matched.raceId)) app/lib/features/queue/queue_screen.dart:82 Matched: the queue is over, Back must not return to it
context.go(AppRoutes.raceResult(widget.raceId)) app/lib/features/race/race_screen.dart:61 Race over: replace the live screen
context.go(AppRoutes.race(id)) app/lib/features/race_tab/race_tab.dart:34 App was killed mid-race: jump straight back in
context.go(AppRoutes.home); context.push(AppRoutes.queue(slug)); app/lib/features/race/result_screen.dart:171-172 “Race again”: reset to the Race tab, then put the queue on top so Back lands on Race
context.canPop() ? context.pop() : context.go(AppRoutes.home) app/lib/features/race/result_screen.dart:299 “Done” on the spectator result card: pop if the result was pushed (from Activity or History), otherwise there is nothing below it, so go home

Two habits show up next to these calls:

  • After the frame. The queue and race screens navigate inside WidgetsBinding.instance.addPostFrameCallback (app/lib/features/queue/queue_screen.dart:81-83, app/lib/features/race/race_screen.dart:60-62), because the decision to leave is made inside build (queue_screen.dart:99, race_screen.dart:114), and the tree cannot change mid-build. You saw the same pattern in the permissions sheet on Day 2.
  • mounted after await. if (mounted) context.go(AppRoutes.race(race.id)); (app/lib/features/lobby/lobby_screen.dart:72) runs after an API call; by then the screen might be gone.

One more: the permissions sheet closes with Navigator.of(context).pop(true) (app/lib/features/permissions/permissions_screen.dart:65). A modal bottom sheet is pushed on Flutter’s underlying Navigator directly, not through a go_router route, so it is popped the same way. go_router sits on top of that Navigator; both APIs work, and the app uses the lower one only for sheets and dialogs.

1. Extend the redirect table. Run the routing tests on their own:

Terminal window
flutter test test/app_test.dart --name "redirect table"

Now add three expectations inside that test (app/test/app_test.dart:19-30), predicting each result before you run it:

expect(redirectFor(AuthStatus.signedOut, AppRoutes.leaderboard), /* ? */);
expect(redirectFor(AuthStatus.unknown, AppRoutes.login), /* ? */);
expect(redirectFor(AuthStatus.signedIn, AppRoutes.race('r1')), /* ? */);

Fill in your predictions, run, and check them against the table above. Then delete the lines or keep them.

2. Change the gate and watch the widget tests notice. In _redirect (app/lib/app/router.dart:145), make a signed-in user on the splash go to History instead of Home:

case AuthStatus.signedIn:
if (location == AppRoutes.splash) return AppRoutes.history;
return onAuthPage ? AppRoutes.home : null;

Run flutter test test/app_test.dart. The redirect table fails on the splash line, as expected. Read the widget tests below it (app/test/app_test.dart:49-54): does “a stored token that /api/me accepts opens the tab shell” still pass? It should, because History is inside the shell too, and that test only checks for HomeShell. A gate change that the widget tests do not catch is worth knowing about. Revert.

  1. Why does routerProvider use ref.listen for the auth status instead of ref.watch?
  2. After a successful login, which code moves the user from /login to /home?
  3. You are on the Race tab, open the leaderboard, then a runner’s profile. What is on the stack, and does the bottom tab bar show?
  4. The queue screen uses context.go to enter a race, but the leaderboard uses context.push to open a profile. What would go wrong if the queue used push?
  5. Name two ways the go_router redirect differs from a Phoenix plug pipeline.
Answers
  1. ref.watch would rebuild the provider, creating a new GoRouter and losing the navigation state on every auth change. ref.listen copies the new status into authListenable, which the existing router uses as refreshListenable to re-run redirect.
  2. None in the login screen. The auth state change fires refreshListenable, redirect runs, and _redirect returns AppRoutes.home for a signed-in user on an auth page (router.dart:144-145).
  3. The shell with the Race tab, then /leaderboard, then /users/:id pushed on top. Both are top-level routes outside the StatefulShellRoute, so they cover the shell and the tab bar does not show.
  4. Back from the race would return to the queue screen for a match that is already over. go replaces the stack so the queue is gone.
  5. Any two of: one global function sees every location instead of per-scope plugs; it re-runs when refreshListenable fires, with no navigation; it decides only what the UI shows and is not a security boundary, the server still checks the token on every request.

Day 5: Riverpod through the auth feature opens authProvider and explains the ref.watch, ref.listen and ref.read calls you took on trust today.