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.
The convention
Section titled “The convention”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-widgetsThe rules the code follows, in practice:
- One routed widget per screen, named
<Name>Screenfor a pushed route or<Name>Tabfor 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-levelfinal <name>Provider = .... Screens never callsetStatefor data that came from the server; theyref.watchthe provider.setStateis kept for purely local UI state such as a_busyflag 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
FutureProviderbeside the widget:raceResultProviderinresult_screen.dart,opponentProfileProviderinopponent_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
authProviderto 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"]
Provider shapes you will see
Section titled “Provider shapes you will see”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.
Every feature folder
Section titled “Every feature folder”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.
race_tab/
Section titled “race_tab/”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.
activity/
Section titled “activity/”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).
history/
Section titled “history/”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.
profile/
Section titled “profile/”Three providers and two screens:
ProfileTabshows your identity, stats and settings, and the sign-out button. It readsauthProviderfor the user and stats,settingsProviderfor preferences, andappConfigProviderto decide whether to show the dev-tools row (app/lib/features/profile/profile_tab.dart:32-35). Pull to refresh callsAuthNotifier.refreshMe(); renaming callsAuthNotifier.updateName(); sign-out callsAuthNotifier.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).SettingsNotifierloadsGET /api/me/settingsand patches it on change (app/lib/features/profile/settings_notifier.dart:13-72). ItwatchesauthStatusProvider, 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) feedsunitsFormatterProviderinapp/lib/core/units.dart, which nearly every screen reads.OpponentProfileScreen, at/users/:id, is backed by twoFutureProvider.autoDispose.familys declared in the same file:opponentProfileProvider(GET /api/users/:id) andheadToHeadProvider, 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.dartholdsProfileIdentity,ProfileStats,ProfileSectionandRaceTypeStatsRow, shared by both profile screens.RaceTypeStatsRowalso has a Widgetbook story.
leaderboard/
Section titled “leaderboard/”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.
lobby/
Section titled “lobby/”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.
queue/
Section titled “queue/”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.buildjoinsrace:<id>throughRaceChannel.join, falls back toGET /api/races/:idif 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 toActiveRaceStoreand 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, aFutureProvider<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, aFutureProvider.autoDispose.familyinresult_screen.dartthat fetchesGET /api/races/:id/resultsand, if the result is not final yet, schedulesref.invalidateSelfin 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.
permissions/
Section titled “permissions/”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.
shared/
Section titled “shared/”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). |
Patterns that repeat
Section titled “Patterns that repeat”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.
Further reading
Section titled “Further reading”- App shell for how routes mount these screens.
- Design system for the
Fr*widgets the screens are built from. - Day 5: Riverpod through the auth feature end to end.