Skip to content

App shell

The app shell is the code that runs before any feature does: main(), the root widget, the route table, the auth gate, the bottom tab bar, and the theme. It is small, about 450 lines across six files, and every screen in the app passes through it. Read it once and you know where each screen is mounted, why a signed-out user can never see the Race tab, and why the socket connects without any screen asking for it.

File What it holds
app/lib/main.dart bootstrap: error handlers, the foreground-task port, ProviderScope, runApp
app/lib/app/app.dart FootraceApp, the root widget
app/lib/app/routes.dart AppRoutes, every path as a constant or a builder function
app/lib/app/router.dart routerProvider (the GoRouter) and _redirect, the auth gate
app/lib/features/home/home_shell.dart HomeShell, the four-tab scaffold
app/lib/app/theme.dart footraceTheme(), Material’s ThemeData built from the design tokens
sequenceDiagram
    participant OS
    participant main as main()
    participant PS as ProviderScope
    participant App as FootraceApp.build
    participant Auth as authProvider
    participant R as GoRouter
    OS->>main: launch
    main->>main: ensureInitialized, installErrorHandlers
    main->>main: FlutterForegroundTask.initCommunicationPort()
    main->>PS: runApp(ProviderScope(child: FootraceApp))
    PS->>App: build
    App->>App: ref.watch(socketLifecycleProvider)
    App->>R: ref.watch(routerProvider)
    R->>Auth: read authStatusProvider (unknown)
    R-->>App: redirect /home -> /splash
    Auth-->>R: build() finishes: signedIn or signedOut
    R-->>App: redirect /splash -> /home or /login

The whole file is twenty lines:

void main() {
WidgetsFlutterBinding.ensureInitialized();
installErrorHandlers();
// Opens the channel the foreground-service isolate uses to send GPS
// batches to the UI. Must happen before any tracking starts.
FlutterForegroundTask.initCommunicationPort();
runApp(
const ProviderScope(
observers: [if (kDebugMode) LoggingProviderObserver()],
child: FootraceApp(),
),
);
}

app/lib/main.dart:9-21, line by line:

  • WidgetsFlutterBinding.ensureInitialized() (app/lib/main.dart:10) wires Dart to the engine before runApp would normally do it. Any plugin call made before runApp needs this. The nearest native analogue is the work UIApplicationMain or Application.onCreate does before your first view controller or activity exists.
  • installErrorHandlers() (app/lib/core/logging.dart:14-24) routes two kinds of uncaught error into the app logger: FlutterError.onError catches errors thrown during build, layout and paint, and PlatformDispatcher.instance.onError catches everything else that escapes a Future. It returns true from the second, which tells Flutter the error was handled so the process is not torn down. No crash reporter is attached yet; the comment at app/lib/core/logging.dart:5-6 leaves room for one.
  • FlutterForegroundTask.initCommunicationPort() (app/lib/main.dart:14) opens the port that the GPS foreground-service isolate sends batches through. It has to be first because the service may already be running when the app starts (a race in progress when the UI was killed). See Tracking.
  • ProviderScope (app/lib/main.dart:16-19) is the Riverpod container. Every provider in the app lives inside it. In debug builds a LoggingProviderObserver (app/lib/core/logging.dart:27-43) logs any provider whose build throws. Tests swap this scope for one with overrides; see Testing.

There is no async here, and nothing is awaited before runApp. Session restore, config and storage all happen inside providers once the tree exists. That keeps the first frame fast and means the splash screen is a real Flutter screen, not a native launch image held open while Dart works.

class FootraceApp extends ConsumerWidget {
const FootraceApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Keeps the socket connected for as long as the user is signed in.
ref.watch(socketLifecycleProvider);
return MaterialApp.router(
title: 'Footrace',
theme: footraceTheme(),
routerConfig: ref.watch(routerProvider),
);
}
}

app/lib/app/app.dart:9-22. A ConsumerWidget is a stateless widget whose build gets a WidgetRef, Riverpod’s handle for reading providers from a widget. build here is a render function: it describes what the root should be and Flutter diffs the result. It runs once at start and again whenever something it watches changes.

Two watches matter:

  • ref.watch(socketLifecycleProvider) (app/lib/app/app.dart:15) ignores the value (it is Provider<void>). Watching it is how the root keeps the provider alive. The provider itself, at app/lib/data/socket/socket_service.dart:24-38, listens to authStatusProvider and calls connect() on sign-in and disconnect() on sign-out. No screen ever opens the socket; it follows the session. On the Phoenix side that connect lands in backend/lib/footrace_web/channels/user_socket.ex, which routes race:* and waiting:* topics (backend/lib/footrace_web/channels/user_socket.ex:7-8). Details in Realtime.
  • ref.watch(routerProvider) (app/lib/app/app.dart:19) hands the GoRouter to MaterialApp.router. Note that the router is a provider, not a global. That is what lets it read auth state and lets tests build it against fakes.

MaterialApp.router is the variant of MaterialApp that takes a router object instead of a home: widget and a routes: map. Think of it as the window’s root view controller being a navigation controller whose stack is computed from a URL.

theme: footraceTheme() is called on every rebuild of FootraceApp. That is cheap because the root rebuilds only when the router provider changes, which in practice is never after startup.

Paths live in one place so screens never type a string literal:

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';
}

app/lib/app/routes.dart:2-21. abstract final class is Dart for “cannot be instantiated, cannot be subclassed”: a namespace of statics, the same job as a Kotlin object or a Swift caseless enum. Paths with parameters are functions, so context.push(AppRoutes.queue(type.slug)) (app/lib/features/race_tab/race_tab.dart:145) is checked by the compiler. The route definitions in router.dart still spell the patterns out as strings ('/queue/:raceType', '/race/:id'), so the two have to be kept in step by hand.

flowchart TD
    root(("GoRouter<br/>initial /home"))
    root --> splash["/splash<br/>SplashScreen"]
    root --> login["/login<br/>LoginScreen"]
    root --> register["/register<br/>RegisterScreen"]
    root --> shell{{"StatefulShellRoute.indexedStack<br/>HomeShell"}}
    shell --> b0["/home<br/>RaceTab"]
    shell --> b1["/activity<br/>ActivityTab"]
    shell --> b2["/history<br/>HistoryTab"]
    shell --> b3["/profile<br/>ProfileTab"]
    root --> lb["/leaderboard<br/>LeaderboardScreen"]
    root --> user["/users/:id<br/>OpponentProfileScreen"]
    root --> lobby["/lobby<br/>LobbyScreen"]
    root --> queue["/queue/:raceType<br/>QueueScreen"]
    root --> race["/race/:id<br/>RaceScreen"]
    race --> result["result<br/>ResultScreen"]
    root -.->|"only if config.devTools"| dev["/dev<br/>GpsDiagnosticsScreen"]
    root -.->|errorBuilder| nf["ComingSoonScreen<br/>'Page not found'"]

Everything except the four tabs is a top-level route, which means it is pushed over the shell and the bottom bar disappears. Queue, race, result, lobby and leaderboard are full-screen on purpose: you should not be able to tap over to History in the middle of a countdown.

The definitions are at app/lib/app/router.dart:41-122. A few details:

  • Path parameters come out of state.pathParameters, as strings, with a ! to assert they are present: RaceScreen(raceId: state.pathParameters['id']!) (app/lib/app/router.dart:106). The ! cannot fail because go_router would not have matched the route without the segment.
  • Nested route: result is a child of /race/:id (app/lib/app/router.dart:107-113), so its full path is /race/:id/result and it inherits the id parameter. Navigating there with go builds a stack of race screen then result screen.
  • Conditional route: the /dev route is inside a collection-if (app/lib/app/router.dart:117-121). Dart lets you put if (and for) directly inside a list literal. When devTools is off the route does not exist at all, so a deep link to /dev falls through to the errorBuilder. The Profile tab hides its “GPS diagnostics” row under the same flag (app/lib/features/profile/profile_tab.dart:32, app/lib/features/profile/profile_tab.dart:189-199). test/app_test.dart checks both halves in its dev tools group.
  • Unknown paths render ComingSoonScreen with “Page not found” (app/lib/app/router.dart:123-124, app/lib/features/shared/coming_soon_screen.dart:8-41) and a button back to the Race tab, rather than a red error screen.

Screens navigate with two calls from go_router’s BuildContext extension, and the difference matters:

Call Effect Where the app uses it
context.go(path) Replace the stack with whatever the route tree says that path implies Leaving the queue for the race (app/lib/features/queue/queue_screen.dart:82), the race for its result (app/lib/features/race/race_screen.dart:61), anything back to /home
context.push(path) Push onto the current stack; back returns here Opening the leaderboard, a user profile, the lobby, the queue, a past result

go is like setting window.location; push is navigationController.pushViewController. The race flow uses go so that once you are in a race, “back” cannot take you to a queue screen that no longer has a queue behind it.

This is the part of the shell worth reading twice. go_router calls a redirect function before every navigation, and again whenever its refreshListenable fires. The function returns null to allow the navigation or a path to send the user somewhere else.

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 is a pure function of two values, so it reads as a table:

Auth status On /splash On /login or /register Anywhere else
unknown stay to /splash to /splash
signedOut to /login stay to /login
signedIn to /home to /home stay

AuthStatus is a three-value enum (app/lib/features/auth/auth_state.dart:4-9), and the switch is exhaustive: add a fourth value and this function stops compiling until it handles it. That is the same guarantee as a Kotlin when over a sealed type or a Swift switch over an enum.

Because _redirect is private (the leading underscore makes it library-private in Dart), the file exposes it for tests as redirectFor (app/lib/app/router.dart:132-134), marked @visibleForTesting so the analyzer warns if app code calls it. test/app_test.dart checks the table directly in 'redirect table' and then end to end with widget tests.

authStatusProvider collapses the session notifier’s async state into the three values the router needs:

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. authProvider is an AsyncNotifier whose build() reads the stored token and calls /api/me (app/lib/features/auth/auth_notifier.dart:24-45). While that Future is pending, auth.value is null and the status is unknown, which is what puts the splash up. When it resolves, the status flips to signedIn or signedOut. The full story, including the offline case and the 401 path, is in the auth flow.

go_router knows nothing about Riverpod. It re-runs redirect when a Flutter Listenable it was given fires. So the router provider builds a bridge:

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: [
// ...
],
);
ref.onDispose(router.dispose);
return router;
});

app/lib/app/router.dart:27-40 and app/lib/app/router.dart:126-128. Step by step:

  1. A ValueNotifier<AuthStatus> is Flutter’s simplest observable box: set .value and listeners are told. It starts at the current status.
  2. ref.listen(authStatusProvider, ...) pushes every new status into the box. listen runs a callback on change without making this provider depend on auth.
  3. refreshListenable: authListenable tells go_router to re-run redirect on the current location whenever the box changes.
  4. redirect itself uses ref.read, a one-off read, to get the latest status.

The choice of listen and read over watch is deliberate. If the router provider watched authStatusProvider, every sign-in or sign-out would rebuild the provider and construct a brand new GoRouter, dropping the navigation stack and every tab’s state. With the bridge, one router lives for the life of the app and only its redirect re-runs. The only thing it does watch is appConfigProvider (app/lib/app/router.dart:28), which never changes at runtime except in tests.

ref.onDispose registers cleanup, the Riverpod equivalent of a deinit or a Kotlin awaitClose { } block. Both the ValueNotifier and the GoRouter are disposed when the provider is.

If you have written Phoenix, this is the on_mount hook or plug pipeline that decides whether a request reaches its LiveView. The backend has the same pair of rules for its web UI: :ensure_authenticated for protected live sessions (backend/lib/footrace_web/router.ex:188-191) and :redirect_if_user_is_authenticated for the login pages (backend/lib/footrace_web/router.ex:173-176). _redirect is both of those in one function, plus a third state, unknown, that a server never needs because it can check the session synchronously. A phone has to wait on a network call to know, so it shows a splash in the meantime.

Where the analogy breaks: a Phoenix pipeline runs once per request. go_router’s redirect also runs when nobody navigated, because refreshListenable fired. That is how signing out works. AuthNotifier.signOut() sets the state to signed out (app/lib/features/auth/auth_notifier.dart:67-71); the bridge fires; redirect runs against whatever screen is showing, returns /login, and the user lands there. No screen calls context.go(AppRoutes.login). The same path handles a 401 from any API call: the Dio interceptor calls sessionExpired() (app/lib/features/auth/auth_notifier.dart:74-78) and the router does the rest. See Data layer.

One consequence to know: after sign-in the user always lands on /home, never on the deep link they were originally heading to. _redirect does not remember where they came from.

The four tabs are a StatefulShellRoute.indexedStack (app/lib/app/router.dart:48-85): four branches, each with its own navigator, rendered into an IndexedStack so only the selected one is visible but all four stay alive. Switching tabs does not rebuild the History list or lose its scroll position. This is UITabBarController with a UINavigationController per tab, or Android’s bottom navigation with one back stack per destination.

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(
icon: Icons.directions_run_outlined,
selectedIcon: Icons.directions_run,
label: 'Race',
),
// ... Activity, History, Profile
],
),
);
}
}

app/lib/features/home/home_shell.dart:8-49. The shell is a plain StatelessWidget; it reads no providers. go_router hands it a StatefulNavigationShell, which is both the widget for the body (the indexed stack) and the controller for switching branches. goBranch(index, initialLocation: true) when the tab is already selected pops that branch back to its root, the iOS “tap the tab again to scroll to top” convention.

The bar is the design system’s FrNavigationBar, not Material’s NavigationBar (see Design system). The theme still configures navigationBarTheme (app/lib/app/theme.dart:155-179), which nothing in lib/ currently renders; it appears to be left from before the custom bar replaced the Material one.

The Race tab, at /home, is also where a killed race is recovered. RaceTab.build listens to pendingRaceProvider and calls context.go(AppRoutes.race(id)) if the server says a stored race is still on (app/lib/features/race_tab/race_tab.dart:31-35, app/lib/features/race/race_notifier.dart:403-421). Since every signed-in launch ends up at /home, that check runs on every cold start. See Failure modes.

footraceTheme() builds Material’s ThemeData entirely from tokens (app/lib/app/theme.dart:12-248). It has three jobs.

1. Map tokens onto Material’s colour roles. Material widgets (an AppBar, a TextField, a CircularProgressIndicator) read colours from a ColorScheme by role: primary, surface, onSurface, outline and so on. The theme fills every role from FrColors:

const scheme = ColorScheme.light(
primary: FrColors.accent,
onPrimary: FrColors.onAccent,
primaryContainer: FrColors.accentSoft,
// ...
surface: FrColors.surface,
onSurface: FrColors.ink,
// ...
outline: FrColors.hairline,
outlineVariant: FrColors.hairline,
shadow: Colors.black,
);

app/lib/app/theme.dart:13-37. This is why the splash screen can say colors.primary (app/lib/features/shared/splash_screen.dart:9-11) and get Footrace orange without importing the tokens.

2. Restyle stock Material widgets. Most of the file is per-widget theme data: appBarTheme (flat, canvas background, hairline bottom border), cardTheme, the three button themes at a 52 px minimum height, inputDecorationTheme with hairline borders that go ink on focus, snackBarTheme, bottomSheetTheme and more (app/lib/app/theme.dart:52-246). The comment at app/lib/app/theme.dart:9-11 explains why: screens written before the design-system components existed picked up the new look without per-screen edits. A plain FilledButton or TextField in a screen still looks right.

In UIKit terms this is UIAppearance proxies, set once and applied to every instance. In Android terms it is a theme in themes.xml with widget styles. The difference from both: Flutter’s theme is an ordinary immutable object passed down the tree, so a subtree can wrap itself in a different Theme and override any piece.

3. Register the Footrace extension. One line plugs in the roles Material has no slot for:

extensions: const [FrTheme.light],

app/lib/app/theme.dart:51. FrTheme is a ThemeExtension carrying text styles such as eyebrow, stat and raceLabel, plus the token colours. Widgets read it with context.fr (app/lib/design/typography.dart:309-313). The design system page covers it.

The theme also sets the default font family to Plus Jakarta Sans (app/lib/app/theme.dart:45) and the scaffold background to the canvas grey (app/lib/app/theme.dart:47). It is light only: there is no darkTheme: on MaterialApp.router and the comment says “Light theme only in v1” (app/lib/app/theme.dart:9).

theme.dart also re-exports the tokens and typography (app/lib/app/theme.dart:6-7), so importing package:footrace_flutter/app/theme.dart gives you FrColors, FrSpacing, FrTheme and the rest. The Widgetbook catalog uses that import to apply the same theme (app/widgetbook/lib/main.dart:2, app/widgetbook/lib/main.dart:32).

flowchart LR
    main["main.dart"] --> PS["ProviderScope"]
    PS --> FA["FootraceApp"]
    FA -->|watch| SL["socketLifecycleProvider"]
    FA -->|watch| RP["routerProvider"]
    FA --> TH["footraceTheme()"]
    RP -->|read + listen| AS["authStatusProvider"]
    AS -->|watch| AP["authProvider<br/>(AuthNotifier)"]
    SL -->|listen| AS
    RP --> HS["HomeShell"]
    HS --> Tabs["RaceTab · ActivityTab · HistoryTab · ProfileTab"]
    TH --> Tokens["lib/design tokens + FrTheme"]

Both the router and the socket hang off the same authStatusProvider, so signing in does two things at once with no code that coordinates them: the router redirects to /home, and the socket connects. Signing out reverses both.