Skip to content

Features

Everything a user can see lives under app/lib/features/, one folder per screen or flow. A feature folder holds the screen widgets and the Riverpod notifier that owns the screen’s state. It does not hold HTTP code, JSON models or storage: those sit below it in lib/data/, and a feature reaches them only through repository providers. This page explains the convention, then lists every folder with what it exposes and what it depends on.

lib/features/queue/
queue_notifier.dart state type(s) + Notifier + the provider that exposes it
queue_screen.dart the routed widget, and its private sub-widgets

The rules the code follows, in practice:

  • One routed widget per screen, named <Name>Screen for a pushed route or <Name>Tab for a branch of the tab shell. Private sub-widgets (_Waiting, _Matched, _Row) live in the same file, below the public one. The leading underscore makes them private to the file.
  • State lives in a notifier in <name>_notifier.dart, together with its state class and a top-level final <name>Provider = .... Screens never call setState for data that came from the server; they ref.watch the provider. setState is kept for purely local UI state such as a _busy flag on the login form (app/lib/features/auth/login_screen.dart:24-25).
  • Small read-only screens skip the notifier. When all a screen needs is “fetch this once”, the file declares a FutureProvider beside the widget: raceResultProvider in result_screen.dart, opponentProfileProvider in opponent_profile_screen.dart.
  • Features talk to data through providers. A notifier gets its repository with a getter like MatchmakingRepository get _repo => ref.read(matchmakingRepositoryProvider); (app/lib/features/queue/queue_notifier.dart:80). Repositories are the only code that knows URLs (Data layer).
  • Features use other features’ providers freely. Many screens read authProvider to know who “me” is. There is no rule against it, and no feature-to-feature imports of widgets except the permissions sheet and the profile widgets.
  • Navigation is by path, through AppRoutes (app/lib/app/routes.dart), not by importing another screen.

If you have built Android apps with Jetpack, a feature folder is a Fragment plus its ViewModel, and the notifier’s state is the ViewModel’s StateFlow. In UIKit terms it is a view controller with its data source pulled out into a separate object the controller observes. The difference from both: there is no lifecycle object to subclass. A Riverpod notifier’s lifetime is decided by its provider (kept for the app’s life, or autoDisposed when no widget watches it), not by the screen.

flowchart TD
    subgraph feature["lib/features/queue/"]
        QS["QueueScreen<br/>(ConsumerStatefulWidget)"]
        QN["QueueNotifier<br/>queueProvider(slug)"]
    end
    QS -->|"ref.watch / ref.read(.notifier)"| QN
    QN -->|ref.read| MR["matchmakingRepositoryProvider<br/>lib/data/repositories"]
    QN -->|"WaitingChannel.join"| ST["socketTransportProvider<br/>lib/data/socket"]
    QN -->|ref.read| AP["authProvider<br/>features/auth"]
    MR --> API["Dio ApiClient"]
    ST --> PH["Phoenix socket"]

Riverpod 2 gives each provider a type that says what it holds and how long it lives. The app uses these (StateNotifier is banned by the repo’s conventions, see app/CLAUDE.md):

Declaration Meaning Example
AsyncNotifierProvider<N, T> Async state with methods; lives for the app’s life authProvider, historyProvider
AsyncNotifierProvider.autoDispose Same, but torn down when no widget watches it lobbyProvider
AsyncNotifierProvider.family<N, T, Arg> One instance per argument raceProvider(raceId)
NotifierProvider.autoDispose.family Synchronous state, per argument, disposed when unwatched queueProvider(slug)
FutureProvider.autoDispose.family One read-only async value per argument raceResultProvider(raceId)
Provider<T> A derived value, recomputed when what it watches changes authStatusProvider
StateProvider<T> A single mutable cell leaderboardFilterProvider

family is the one with no direct Kotlin or Swift equivalent. raceProvider('abc') and raceProvider('xyz') are two separate notifiers with separate state, created on first use and keyed by the argument. If you think of Phoenix channel topics, race:abc and race:xyz are two processes behind one module; a family is the same idea on the client. The notifier sees its key as arg. The Riverpod primer covers all of these properly.

Routes are from app/lib/app/router.dart. Repositories are under app/lib/data/repositories/; channels under app/lib/data/socket/.

Folder Screens Notifiers Providers exposed Route(s) Uses
auth/ LoginScreen, RegisterScreen AuthNotifier authProvider, authStatusProvider /login, /register AuthRepository, ProfileRepository, TokenStore, UserCache
home/ HomeShell none none shell around the four tabs FrNavigationBar only
race_tab/ RaceTab, RaceTypeCard RaceTypesNotifier raceTypesProvider /home (tab 0) RaceTypeRepository; reads authProvider, pendingRaceProvider
activity/ ActivityTab, ActivityRow ActivityNotifier activityProvider /activity (tab 1) ActivityRepository
history/ HistoryTab HistoryNotifier historyProvider /history (tab 2) RaceHistoryRepository (with its cached first page)
profile/ ProfileTab, OpponentProfileScreen, profile_widgets.dart SettingsNotifier settingsProvider, opponentProfileProvider, headToHeadProvider /profile (tab 3), /users/:id ProfileRepository, UserRepository, RaceHistoryRepository; calls AuthNotifier
leaderboard/ LeaderboardScreen LeaderboardNotifier leaderboardProvider(slug), leaderboardFilterProvider /leaderboard LeaderboardRepository; reads raceTypesProvider, authProvider
lobby/ LobbyScreen LobbyNotifier lobbyProvider /lobby LobbyRepository (polled every 2 s)
queue/ QueueScreen QueueNotifier queueProvider(slug) /queue/:raceType MatchmakingRepository, WaitingChannel (waiting:<slug>)
race/ RaceScreen, ResultScreen RaceNotifier raceProvider(raceId), pendingRaceProvider, raceResultProvider(raceId) /race/:id, /race/:id/result RaceChannel (race:<id>), RaceRepository, RaceTracker, ActiveRaceStore, TokenStore
permissions/ PermissionsSheet (modal), PermissionBanner none of its own none not routed permissionGateProvider from lib/tracking/
shared/ SplashScreen, ComingSoonScreen, MessageState none none /splash, error page design components only

The dev-only GPS diagnostics screen at /dev is not a feature. It lives in app/lib/dev/ and the route exists only when DEV_TOOLS is on (app/lib/app/router.dart:117-121). See Directory map.

The rest of this page goes folder by folder, with the line that makes each claim true.

AuthNotifier is the session owner: its build() restores the session from the stored token, and it has login, register, signOut, sessionExpired, updateName and refreshMe (app/lib/features/auth/auth_notifier.dart:18-124). authProvider is declared at app/lib/features/auth/auth_notifier.dart:126-127, and authStatusProvider, the three-value view the router uses, at app/lib/features/auth/auth_notifier.dart:131-134. AuthState and the AuthStatus enum are in their own file, app/lib/features/auth/auth_state.dart, re-exported by the notifier file.

The two screens are thin. LoginScreen._submit validates the form, calls ref.read(authProvider.notifier).login(...), and shows the ApiException message on failure. On success it does nothing:

await ref.read(authProvider.notifier).login(
email: _email.text.trim(),
password: _password.text,
);
// The router's auth gate moves us to the home tab.

app/lib/features/auth/login_screen.dart:41-45. The router redirect sees the status change and navigates (App shell). auth_form.dart holds the shared validators, which mirror the backend’s rules (password 12 to 72 characters, name 2 to 32 after trimming; app/lib/features/auth/auth_form.dart:17-33), and FormErrorBanner. Backend side: backend/lib/footrace_web/controllers/api/user_session_controller.ex and me_controller.ex. Walkthrough: the auth flow.

authProvider is the most-read provider in the app. Six files in other features read it to find out who “me” is.

Only HomeShell (app/lib/features/home/home_shell.dart:8-49): a Scaffold whose body is go_router’s StatefulNavigationShell and whose bottom bar is FrNavigationBar. It reads no providers. Covered on App shell.

The first tab, at /home. RaceTypesNotifier loads the race types (distances) from GET /api/dashboard through RaceTypeRepository.dashboard() (app/lib/features/race_tab/race_types_notifier.dart:7-28). RaceTab shows them as RaceTypeCards with your stats per distance, which it gets from authProvider’s stats rather than a second request (app/lib/features/race_tab/race_tab.dart:37-40).

Tapping a card opens a confirm sheet, then the permissions check, then pushes the queue:

// A race is worthless without tracking, so ask before queueing rather
// than three system prompts deep into a countdown.
final granted = await PermissionsSheet.ensureGranted(context, ref);
if (granted && context.mounted) {
context.push(AppRoutes.queue(type.slug));
}

app/lib/features/race_tab/race_tab.dart:141-146. The tab also hosts the killed-race recovery: it listens to pendingRaceProvider and jumps straight into a race the server says is still running (app/lib/features/race_tab/race_tab.dart:31-35). Pull to refresh reloads both the race types and /api/me (app/lib/features/race_tab/race_tab.dart:58-59). Buttons in the app bar and body push /leaderboard and /lobby.

context.mounted deserves a word. After an await, the widget may have left the tree; using its context then is a bug. Checking mounted after every await is the Flutter equivalent of checking isAdded on a Fragment or viewIfLoaded != nil after an async callback in UIKit.

The global feed of recent races, GET /api/activity. ActivityNotifier is a paged list: build() loads page one, refresh() reloads it keeping the old list on failure, loadMore() appends the next page and ignores the call when one is already in flight (app/lib/features/activity/activity_notifier.dart:42-90). ActivityTab calls loadMore when the scroll position nears the bottom (app/lib/features/activity/activity_tab.dart:46). Rows push the result screen or a runner’s profile. No offline cache, by design: the doc comment says other people’s races are not worth keeping (app/lib/features/activity/activity_notifier.dart:40-41).

Your own races, GET /api/me/races. HistoryNotifier has the same paging shape as Activity, with one addition: when the first page fails, it falls back to the repository’s cached first page and flags the state fromCache if the failure was a network error (app/lib/features/history/history_notifier.dart:53-72). HistoryTab groups rows under headers with a small sealed class hierarchy (_Row, _HeaderRow, _RaceRow; app/lib/features/history/history_tab.dart:144-158), the Dart equivalent of a Kotlin sealed class used as a list-item type for a RecyclerView with two view types.

Three providers and two screens:

  • ProfileTab shows your identity, stats and settings, and the sign-out button. It reads authProvider for the user and stats, settingsProvider for preferences, and appConfigProvider to decide whether to show the dev-tools row (app/lib/features/profile/profile_tab.dart:32-35). Pull to refresh calls AuthNotifier.refreshMe(); renaming calls AuthNotifier.updateName(); sign-out calls AuthNotifier.signOut() (app/lib/features/profile/profile_tab.dart:50, app/lib/features/profile/profile_tab.dart:274, app/lib/features/profile/profile_tab.dart:294).
  • SettingsNotifier loads GET /api/me/settings and patches it on change (app/lib/features/profile/settings_notifier.dart:13-72). It watches authStatusProvider, so it rebuilds on sign-in and sign-out, and returns defaults while signed out or on error (app/lib/features/profile/settings_notifier.dart:17-28). The units setting (km or miles) feeds unitsFormatterProvider in app/lib/core/units.dart, which nearly every screen reads.
  • OpponentProfileScreen, at /users/:id, is backed by two FutureProvider.autoDispose.familys declared in the same file: opponentProfileProvider (GET /api/users/:id) and headToHeadProvider, which counts your wins and losses against that runner from the history page already cached on the device, with no request (app/lib/features/profile/opponent_profile_screen.dart:18-44).
  • profile_widgets.dart holds ProfileIdentity, ProfileStats, ProfileSection and RaceTypeStatsRow, shared by both profile screens. RaceTypeStatsRow also has a Widgetbook story.

LeaderboardNotifier is an AutoDisposeFamilyAsyncNotifier keyed by race-type slug, with the empty string meaning global (app/lib/features/leaderboard/leaderboard_notifier.dart:13-26). The doc comment explains the family: switching the filter back and forth does not refetch what is already loaded. Which slug is selected is held in a separate StateProvider<String?>:

final leaderboardFilterProvider = StateProvider<String?>((_) => null);

app/lib/features/leaderboard/leaderboard_notifier.dart:7. The screen watches the filter, then watches the leaderboard for that slug (app/lib/features/leaderboard/leaderboard_screen.dart:27-28). Filter chips come from raceTypesProvider, the same list the Race tab loaded. Endpoints: GET /api/leaderboard and GET /api/leaderboard/:slug.

StateProvider is the older, simpler Riverpod API for a single settable value. It is not StateNotifier, which the conventions forbid, but it is the one place in features/ that does not use a Notifier.

Open races other runners have posted, which you can join directly. LobbyNotifier polls GET /api/lobby every two seconds with a Timer.periodic, skips a tick if the previous request is still out, keeps the last good list on failure, and marks it stale after two misses in a row (app/lib/features/lobby/lobby_notifier.dart:33-95). It is autoDispose, and its build registers ref.onDispose(stopPolling) (app/lib/features/lobby/lobby_notifier.dart:44), so leaving the screen stops the timer.

Joining is the one place where a screen calls a repository directly instead of going through its notifier:

ref.read(lobbyProvider.notifier).stopPolling();
try {
final race = await ref.read(lobbyRepositoryProvider).join(entry.id);
if (mounted) context.go(AppRoutes.race(race.id));

app/lib/features/lobby/lobby_screen.dart:69-72. POST /api/lobby/:id/join answers with the race, and the screen goes straight to /race/:id, skipping the queue. Backend: backend/lib/footrace_web/controllers/api/lobby_controller.ex.

Matchmaking for one distance, at /queue/:raceType. QueueNotifier is a synchronous Notifier (not async) whose state is a sealed class: QueueIdle, QueueJoining, QueueWaiting, QueueMatched, QueueCancelled, QueueError (app/lib/features/queue/queue_notifier.dart:15-62). The screen switches on it. It is a family keyed by race-type slug and is autoDispose (app/lib/features/queue/queue_notifier.dart:266-267).

It uses both transports. It joins the queue over HTTP (POST /api/matchmaking/join), then joins the waiting:<slug> channel to hear the match push, falling back to polling GET /api/competitors/:id/poll every two seconds only if the channel will not join (app/lib/features/queue/queue_notifier.dart:64-160). The backend side of the channel is backend/lib/footrace_web/channels/waiting_channel.ex:21.

QueueScreen starts matchmaking from initState with Future.microtask (app/lib/features/queue/queue_screen.dart:49), watches the app lifecycle to pause polling after a grace period in the background (app/lib/features/queue/queue_screen.dart:61-71), and hands off to the race screen with context.go shortly before the start time (app/lib/features/queue/queue_screen.dart:73-85). With dev tools on, a button cancels and re-queues against a bot (app/lib/features/queue/queue_screen.dart:122-127). Full sequence: Matchmaking.

The biggest feature. Three providers in two files:

  • raceProvider, AsyncNotifierProvider.family<RaceNotifier, RaceState, String> keyed by race id (app/lib/features/race/race_notifier.dart:397-399). RaceNotifier.build joins race:<id> through RaceChannel.join, falls back to GET /api/races/:id if the channel refuses, and subscribes to the tracker’s messages (app/lib/features/race/race_notifier.dart:128-166). It schedules the countdown, starts the GPS foreground service three seconds before the start (gpsLead, app/lib/features/race/race_notifier.dart:114), and stops it at the end. Starting the service also writes the race id to ActiveRaceStore and passes the token and base URL in the start payload (app/lib/features/race/race_notifier.dart:300-317), because the service isolate cannot read providers. Aborting goes over the channel first, HTTP second (app/lib/features/race/race_notifier.dart:347-365).
  • pendingRaceProvider, a FutureProvider<String?> that returns a stored race id if the server still reports it in countdown or racing (app/lib/features/race/race_notifier.dart:403-421). The Race tab listens to it.
  • raceResultProvider, a FutureProvider.autoDispose.family in result_screen.dart that fetches GET /api/races/:id/results and, if the result is not final yet, schedules ref.invalidateSelf in three seconds so it polls until it is (app/lib/features/race/result_screen.dart:21-29).

local_distance.dart computes your distance from the phone’s own fixes for instant feedback, dropping inaccurate fixes and implausible jumps; the server’s distance wins when it arrives (app/lib/features/race/local_distance.dart:4-19).

RaceScreen keeps the screen awake with WakelockPlus while mounted (app/lib/features/race/race_screen.dart:47, app/lib/features/race/race_screen.dart:53) and blocks the system back gesture with PopScope (app/lib/features/race/race_screen.dart:98); leaving is a slide-to-confirm. ResultScreen shows a “you won / you lost” card for a runner and a neutral two-runner card for a spectator coming from the Activity feed (app/lib/features/race/result_screen.dart:64).

Backend side: backend/lib/footrace_web/channels/race_channel.ex:20 for the join, race_controller.ex for show, abort and results. Positions are not sent from this feature: the foreground service uploads them over HTTP. See Tracking and the race flow.

Not routed. PermissionsSheet is a modal bottom sheet that explains why background location is needed, then walks through what is missing. Callers use the static helper, which shows nothing when everything is granted:

static Future<bool> ensureGranted(BuildContext context, WidgetRef ref) async {
final state = await ref.read(permissionGateProvider.future);
if (state.canTrack) return true;
if (!context.mounted) return false;
return show(context);
}

app/lib/features/permissions/permissions_screen.dart:24-29. It is called before queueing (app/lib/features/race_tab/race_tab.dart:143) and before joining a lobby race (app/lib/features/lobby/lobby_screen.dart:64). PermissionBanner shows on the race screen when something was revoked mid-race (app/lib/features/race/race_screen.dart:311). The state comes from permissionGateProvider in app/lib/tracking/permission_gate.dart:196. Note the file is named permissions_screen.dart but contains no screen.

Three widgets with no state of their own, used across features:

Widget Used for
SplashScreen The /splash route while auth is unknown. Reads the theme’s primary colour and nothing else (app/lib/features/shared/splash_screen.dart:4-30).
ComingSoonScreen The router’s errorBuilder (“Page not found”) with a button back to /home (app/lib/features/shared/coming_soon_screen.dart:8-41). The name dates from when unbuilt routes pointed at it.
MessageState The empty or failed state for a list screen: a card with a title, a line of explanation and an optional button. It is a scrolling ListView so pull-to-refresh still works around it (app/lib/features/shared/message_state.dart:7-69). Its constructor asserts that a button label and a handler come together (app/lib/features/shared/message_state.dart:18-21).

Error and loading states. Screens watch an AsyncValue and branch on it. Activity, History and the others show MessageState with a retry button that calls ref.invalidate(provider), which throws the provider’s state away and runs build again (app/lib/features/activity/activity_tab.dart:65, app/lib/features/history/history_tab.dart:61).

Keep the list on refresh failure. Every paged or polled notifier (Activity, History, Lobby, Leaderboard) keeps the previous data on screen when a refresh fails, rather than flipping to an error. The pattern is to read state.value before the request and put it back on failure.

Socket first, HTTP fallback. Both realtime features (queue and race) try the Phoenix channel and fall back to HTTP when it will not join, and record which one they are on in their state (polling: true in QueueWaiting, socketDown in RaceState) so the UI can say so.

Clock as a provider. Anything time-dependent reads clockProvider (app/lib/core/clock.dart:7) instead of calling DateTime.now() directly, so tests can fix the time.

Each feature has a mirror under test/features/<name>/ (activity, auth, history, leaderboard, lobby, permissions, profile, queue, race, race_tab). home/ and shared/ are covered by test/app_test.dart. Widget tests boot the screens through testApp(...) in test/support/fakes.dart with every repository faked. See Testing.