Skip to content

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.

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.

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:5 imports features/auth/auth_notifier.dart so a 401 can call sessionExpired() (app/lib/data/api/api_client.dart:103). The data layer knows about the session owner.
  • app/lib/data/socket/socket_service.dart:5 imports 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:3 imports features/profile/settings_notifier.dart so the units preference follows the server setting (app/lib/core/units.dart:12-15).
  • app/lib/features/race/race_screen.dart:16 imports dev/sim_gps_overlay.dart, the draggable simulated-GPS control, which only shows when FAKE_GPS is 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.

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

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 at backend/lib/footrace_web/router.ex:136 (RaceController.put_position).
  • The socket: backend/lib/footrace_web/endpoint.ex:15, channels race:* and waiting:* in backend/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.

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.

  1. 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.
  2. Structure (this section) explains how the app is built, one layer per page. Start with the directory map, then app shell and features.
  3. Flows follow one user journey end to end across layers and across the wire: the whole journey, auth, matchmaking, race, and failure modes.
  4. 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.