Architecture overview
Footrace is “chess.com for running”: pick a distance, get matched with another runner, race them
live with the phone in your pocket. This app is the client. It talks to your Phoenix backend over
HTTP (/api/...) and Phoenix Channels (/socket), and it runs a second Dart isolate inside an
Android foreground service so GPS keeps counting with the screen locked.
Everything under lib/ falls into eight folders. This page shows how they depend on each other;
the directory map says what is in each one.
Layers and dependency direction
Section titled “Layers and dependency direction”flowchart TD
main["main.dart<br/>bootstrap"] --> app
app["app/<br/>FootraceApp, go_router, theme"] --> features
app --> design
app --> data
app --> core
features["features/*name*/<br/>screens + notifiers"] --> design
features --> data
features --> tracking
features --> core
design["design/<br/>tokens, typography, components"]
data["data/<br/>api, models, repositories,<br/>socket, storage"] --> core
tracking["tracking/<br/>GPS, foreground service,<br/>upload pipeline"] --> core
dev["dev/<br/>GPS diagnostics, sim GPS<br/>(debug only)"] --> tracking
dev --> core
app --> dev
core["core/<br/>config, clock, logging, units"]
data -. "HTTP /api" .-> BE[("Phoenix backend<br/>backend/lib/footrace_web")]
data -. "WebSocket /socket" .-> BE
tracking -. "POST /api/races/:id/positions<br/>(from the service isolate)" .-> BE
tracking -. plugins .-> OS[("Android / iOS<br/>GPS, keychain, service")]
data -. plugins .-> OS
Arrows point from the importer to what it imports. The general rule: screens depend on
everything below them, design/ depends on nothing in the app, and core/ is the floor.
Counted from the imports at d1b602a (relative imports between lib/ folders):
| From | To | Imports | Notes |
|---|---|---|---|
features |
data |
64 | repositories, models, socket clients |
features |
design |
45 | every screen uses the components and tokens |
features |
core |
28 | clock, units, config, logging |
app |
features |
16 | the router names every screen |
features |
app |
11 | all to app/lib/app/routes.dart, the path constants |
dev |
tracking |
7 | diagnostics drive the real tracker |
data |
core |
5 | config, logging |
features |
tracking |
5 | race, permissions |
tracking |
core |
1 | |
app |
design |
2 | theme.dart builds the Material theme from the tokens |
app |
core |
1 | the router reads devTools |
app |
data |
1 | app.dart watches the socket lifecycle |
app |
dev |
1 | the /dev route’s screen |
dev |
core |
1 | |
data |
features |
2 | upward, see below |
core |
features |
1 | upward, see below |
features |
dev |
1 | upward, see below |
design/ imports nothing from the rest of lib/, which is what lets Widgetbook render it on its
own. tracking/ imports only core/, which matters more than it looks: code that runs in the
service isolate cannot use Riverpod or app state anyway.
Where the arrows point the wrong way
Section titled “Where the arrows point the wrong way”Four imports go “up” the stack. None is a bug, but each is a coupling to know about:
app/lib/data/api/api_client.dart:5importsfeatures/auth/auth_notifier.dartso a 401 can callsessionExpired()(app/lib/data/api/api_client.dart:103). The data layer knows about the session owner.app/lib/data/socket/socket_service.dart:5imports the same file so the socket connects and disconnects with the session (app/lib/data/socket/socket_service.dart:24-38).app/lib/core/units.dart:3importsfeatures/profile/settings_notifier.dartso the units preference follows the server setting (app/lib/core/units.dart:12-15).app/lib/features/race/race_screen.dart:16importsdev/sim_gps_overlay.dart, the draggable simulated-GPS control, which only shows whenFAKE_GPSis on (app/lib/features/race/race_screen.dart:145).
The first three are Riverpod providers reading other providers, so at runtime they are edges in the provider graph, not direct calls. The Riverpod primer draws that graph.
What each layer is for
Section titled “What each layer is for”| Layer | One line | Read |
|---|---|---|
main.dart |
Error handlers, the foreground-task port, runApp(ProviderScope(FootraceApp())) (app/lib/main.dart:9-21) |
App shell |
app/ |
MaterialApp.router, the route table and the auth redirect (app/lib/app/router.dart:136-147), the Material theme |
App shell |
features/ |
One folder per screen or flow: widgets plus the notifier that owns their state | Features |
design/ |
Colour, spacing and type tokens, the FrTheme extension, Fr* components |
Design system |
data/ |
Dio client and ApiException, hand-written JSON models, repositories (the only code that knows URLs), Phoenix socket clients, local storage |
Data layer, Realtime |
tracking/ |
LocationService, the service isolate’s LocationTaskHandler, permissions, the durable fix queue and uploader |
Tracking |
core/ |
AppConfig from --dart-define, the injectable clock, logging, unit formatting |
Build and release for config |
dev/ |
GPS diagnostics screen and the simulated-GPS controller; route exists only with DEV_TOOLS (app/lib/app/router.dart:117-121) |
Tracking |
The app at runtime
Section titled “The app at runtime”Two isolates, two connections to the backend:
flowchart LR
subgraph UI["UI isolate"]
W[Widgets] --> N[Notifiers / providers]
N --> R[Repositories + Dio]
N --> S[PhoenixSocketTransport]
end
subgraph SVC["Service isolate (foreground service)"]
L[LocationTaskHandler] --> Q[(FixQueue<br/>sembast)]
Q --> U[PositionUploader]
end
R -- "HTTPS /api/*" --> P[Phoenix]
S -- "WSS /socket<br/>race:*, waiting:*" --> P
U -- "POST /api/races/:id/positions" --> P
L -- "sendDataToMain<br/>batches, upload reports" --> N
N -- "saveData<br/>race id, token, base URL" --> L
The Phoenix ends of those lines:
- HTTP routes:
backend/lib/footrace_web/router.ex, with the position upload atbackend/lib/footrace_web/router.ex:136(RaceController.put_position). - The socket:
backend/lib/footrace_web/endpoint.ex:15, channelsrace:*andwaiting:*inbackend/lib/footrace_web/channels/user_socket.ex:7-8.
During a race, positions go to the server by HTTP from the service isolate, not over the channel, because the service keeps running when the UI isolate is paused with the screen locked. The race channel carries the server’s view back (countdown, opponent progress, finish). The details are in Realtime, Tracking and the race flow.
How to read this site
Section titled “How to read this site”The site is built for reading with the code open next to it. Every claim names a file and line,
like app/lib/app/router.dart:136; click it to jump to GitHub or your editor.
- Primers teach the language and frameworks through this codebase. Read them first if Dart or Flutter is new: Dart for you, the Flutter mental model, Riverpod as used here, and the glossary to keep open.
- Structure (this section) explains how the app is built, one layer per page. Start with the directory map, then app shell and features.
- Flows follow one user journey end to end across layers and across the wire: the whole journey, auth, matchmaking, race, and failure modes.
- Dailies are 30 to 45 minute guided sessions, in order, from Day 1: Dart through lib/core to Day 13: integration test and release. Each one has exercises and questions.
If you only have an hour: the Flutter primer, this page, then the race flow.