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.
Before you start
Section titled “Before you start”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.
Paths: routes.dart
Section titled “Paths: routes.dart”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.
The router: router.dart
Section titled “The router: router.dart”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 aGoRouterState; most ignore both. - Path parameters come from
state.pathParameters:OpponentProfileScreen(userId: state.pathParameters['id']!)(lines 90-94). The map returnsString?, so!asserts it is there, which it always is when the pattern matched. This isconn.params["id"]in a Phoenix controller. - A nested route (lines 104-114):
resultis a child of/race/:id, so its full path is/race/:id/resultand 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 collectioniffrom Day 1. With dev tools off,/devdoes not exist at all and falls toerrorBuilder(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 auth gate: redirect
Section titled “The auth gate: redirect”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.
LoginScreencallsref.read(authProvider.notifier).login(...)and then does nothing about navigation. The comment atapp/lib/features/auth/login_screen.dart:45says why: “The router’s auth gate moves us to the home tab.” The status flips tosignedIn,authListenablefires,redirectsees/loginwhile 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.
Compared to Phoenix
Section titled “Compared to Phoenix”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.Pipelineendand 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
redirectsees every location and decides by path. (go_router also supports per-routeredirect; this app does not use it.) - It runs again without a request. A plug runs once per request.
redirectruns on every navigation and wheneverrefreshListenablefires, so a state change in the background moves the user with no navigation call at all. It is closer to a LiveViewhandle_infothat callspush_navigatewhen 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.
The tab shell: HomeShell
Section titled “The tab shell: HomeShell”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.
Navigation calls: go versus push
Section titled “Navigation calls: go versus push”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/abcis a top-level route, so aftergothe stack is just the race page: the shell and anything pushed on it are gone./race/abc/resultgives the race page with the result on top, becauseresultis nested under it. Closest to settingwindow.location, or to replacing aUINavigationController’sviewControllersarray.
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 insidebuild(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. mountedafterawait.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.
Try it
Section titled “Try it”1. Extend the redirect table. Run the routing tests on their own:
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.
Check yourself
Section titled “Check yourself”- Why does
routerProvideruseref.listenfor the auth status instead ofref.watch? - After a successful login, which code moves the user from
/loginto/home? - 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?
- The queue screen uses
context.goto enter a race, but the leaderboard usescontext.pushto open a profile. What would go wrong if the queue usedpush? - Name two ways the go_router
redirectdiffers from a Phoenix plug pipeline.
Answers
ref.watchwould rebuild the provider, creating a newGoRouterand losing the navigation state on every auth change.ref.listencopies the new status intoauthListenable, which the existing router uses asrefreshListenableto re-runredirect.- None in the login screen. The auth state change fires
refreshListenable,redirectruns, and_redirectreturnsAppRoutes.homefor a signed-in user on an auth page (router.dart:144-145). - The shell with the Race tab, then
/leaderboard, then/users/:idpushed on top. Both are top-level routes outside theStatefulShellRoute, so they cover the shell and the tab bar does not show. - Back from the race would return to the queue screen for a match that is already over.
goreplaces the stack so the queue is gone. - Any two of: one global function sees every location instead of per-scope plugs; it re-runs
when
refreshListenablefires, 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.