Race
This is the flow the app exists for. It crosses more boundaries than any other: two Dart
isolates, two network paths (the race channel and HTTP uploads), and the backend’s per-race
GenServer. It starts when the matchmaking flow navigates to
/race/:id and ends on /race/:id/result.
| Owner | Isolate | Kind | File |
|---|---|---|---|
RaceScreen |
main | ConsumerStatefulWidget |
app/lib/features/race/race_screen.dart:23-159 |
RaceNotifier |
main | FamilyAsyncNotifier<RaceState, String>, keyed by race id |
app/lib/features/race/race_notifier.dart:113-399 |
RaceChannel |
main | typed client for race:<id> |
app/lib/data/socket/race_channel.dart:80-113 |
ForegroundRaceTracker |
main | starts/stops the service, relays its messages | app/lib/tracking/race_tracker.dart:26-71 |
LocationService |
main | wraps flutter_foreground_task |
app/lib/tracking/location_service.dart:223-387 |
LocationTaskHandler |
service | samples GPS, flushes every 2 s | app/lib/tracking/location_service.dart:37-221 |
FixQueue + PositionUploader |
service | durable queue and HTTP drain | app/lib/tracking/upload/fix_queue.dart:38-145, app/lib/tracking/upload/position_uploader.dart:110-221 |
ResultScreen + raceResultProvider |
main | result card, self-refreshing | app/lib/features/race/result_screen.dart:21-79 |
On the backend: FootraceWeb.RaceChannel (backend/lib/footrace_web/channels/race_channel.ex),
FootraceWeb.Api.RaceController (backend/lib/footrace_web/controllers/api/race_controller.ex),
Racing.Positions.Ingest (backend/lib/racing/positions/ingest.ex) and Racing.RaceProcess
(backend/lib/racing/race_process.ex).
The sequence
Section titled “The sequence”sequenceDiagram
autonumber
participant RS as RaceScreen
participant RN as RaceNotifier
participant RC as RaceChannel
participant FT as ForegroundRaceTracker
participant SVC as service isolate<br/>(LocationTaskHandler)
participant Q as FixQueue + PositionUploader
participant BCh as backend RaceChannel
participant BC as backend RaceController
participant RP as backend RaceProcess
RS->>RN: ref.watch(raceProvider(id)), build()
RN->>RC: RaceChannel.join(socket, id)
RC->>BCh: join "race:id"
BCh-->>RN: {token, race, competitors} (status countdown)
RN->>RN: _applySnapshot, _schedule(startsAt)
Note over RN: startsAt - 3 s
RN->>FT: start(raceId, apiBaseUrl, token, fakeGps)
FT->>SVC: saveData(upload target), startService
RP-->>BCh: :race_started
BCh-->>RN: push raceStarted
RN->>RN: phase = racing
loop every ~1 s
SVC->>SVC: GPS fix into buffer
end
loop every 2 s (onRepeatEvent)
SVC-->>FT: sendDataToMain {type: batch, fixes}
FT-->>RN: messages stream, LocalDistance.add
SVC->>Q: enqueue, drain()
Q->>BC: POST /api/races/:id/positions (up to 50)
BC->>RP: Ingest.record, put_positions
RP-->>BCh: {:race_update, state}
BCh-->>RN: push raceUpdate (opponent's progress)
BC-->>Q: 200 {race, competitors, competitor}
SVC-->>FT: sendDataToMain {type: upload, reply}
FT-->>RN: _applySnapshot(reply)
end
RP-->>BCh: {:competitor_won, my id}
BCh-->>RN: push competitorWon
RN->>FT: stop()
FT->>SVC: stopService, onDestroy
RN->>RN: phase = finished
RS->>RS: context.go(/race/:id/result)
Step by step
Section titled “Step by step”1. The screen opens and joins the channel
Section titled “1. The screen opens and joins the channel”RaceScreen.initState (app/lib/features/race/race_screen.dart:42-48) starts a one-second
rebuild timer for the countdown and the elapsed clock, and enables the wakelock so the screen
does not dim on its own while the race screen is showing (WakelockPlus.enable(), line 47).
dispose releases both (lines 51-55).
build watches raceProvider(widget.raceId) (line 93). That is the first read, so Riverpod
runs RaceNotifier.build(arg) with the race id as arg. Unlike the queue, this provider is
not auto-disposed (app/lib/features/race/race_notifier.dart:397-399): the notifier for a
race outlives its screen.
build (app/lib/features/race/race_notifier.dart:129-166):
- Register
_teardownto run on dispose (line 130). - Join
race:<id>throughRaceChannel.join(lines 136-140,app/lib/data/socket/race_channel.dart:91-94). On the server,RaceChannel.join/3checks the user is authorised and a participant, refuses withrace_overif the race is finished or aborted, makes sure the race process is running, and replies with the current engine state plus a competitor token (backend/lib/footrace_web/channels/race_channel.ex:20-46). - Subscribe to typed events (
channel.events.listen(_onEvent), line 140) and to the socket’s status stream, which flipssocketDown(lines 141-143). - Work out which competitor is me by matching user ids (
_findMe, lines 168-174). - Subscribe to the tracker’s messages (line 160). The tracker is not running yet; this only attaches to its broadcast stream.
- Return the first state through
_applySnapshot(..., schedule: true)(lines 161-165).
If the join fails with not_a_competitor or not_found, the state is an error the screen
shows with its own copy (app/lib/features/race/race_notifier.dart:148-150,
app/lib/features/race/race_screen.dart:153-158). Any other join failure falls back to
GET /api/races/:id and marks the socket down (lines 151-156); the race still works, because
upload replies carry the server state (step 6).
While build is awaiting, raceProvider(id) is AsyncLoading and the screen shows a
spinner (app/lib/features/race/race_screen.dart:107-108).
2. The countdown and starting GPS early
Section titled “2. The countdown and starting GPS early”_applySnapshot (app/lib/features/race/race_notifier.dart:236-270) is the one function that
folds a server snapshot into RaceState. While the phase is loading or countdown, it maps
the server’s race status onto a phase. A countdown status sets RacePhase.countdown and,
on the first call, _schedule(startsAt) (lines 250-252).
_schedule (lines 274-289) sets two timers:
- GPS lead.
gpsLeadis 3 seconds (line 114). AtstartsAt - 3 sit calls_ensureTracking(), so the first fix is ready at the gun. - Start fallback. At
startsAtit calls_startRacing(). The comment on line 284 says this is a fallback; the server’sraceStartedpush is the primary signal.
The server side of the countdown: RaceProcess schedules :start_racing with
Process.send_after/3 (backend/lib/racing/race_process.ex:325-327) and broadcasts
{:countdown_started, starts_at} on start (line 267). When the timer fires,
handle_info(:start_racing, ...) sets status :racing, persists it, and broadcasts
:race_started (lines 330-336). The channel turns those into countdown and raceStarted
pushes (backend/lib/footrace_web/channels/race_channel.ex:99-109).
On the screen, RacePhase.countdown renders _Countdown
(app/lib/features/race/race_screen.dart:180-246): “Get ready”, “vs” and the opponent’s name (with “(bot)” for a bot), the
seconds left computed from startsAt against the clock on every tick, and under it either
“Starting the GPS…” or the tracking error in red (lines 235-241). At zero it fires one heavy
haptic (lines 126-131).
3. Starting the foreground service
Section titled “3. Starting the foreground service”_ensureTracking (app/lib/features/race/race_notifier.dart:300-317) is guarded by
_tracking so the GPS-lead timer, the start fallback and raceStarted can all call it
safely. It:
- Reads the token from
TokenStoreand the base URL andfakeGpsflag fromAppConfig. - Writes the race id to
ActiveRaceStore(SharedPreferences,app/lib/data/storage/active_race_store.dart:12-26), so a killed app can find its way back. - Calls
ForegroundRaceTracker.start(...).
On failure it records trackingError: 'GPS not available: ...' and resets _tracking.
ForegroundRaceTracker.start (app/lib/tracking/race_tracker.dart:41-58) registers
_onData as the receiver for messages from the service (addTaskDataCallback, line 48),
calls requestLocationPermissions() (which throws if location is off or denied,
app/lib/tracking/location_service.dart:232-251), and then
startBackgroundTracking(...).
startBackgroundTracking (app/lib/tracking/location_service.dart:318-362) is where the
isolate boundary is crossed:
_ensureInitialized()configures the plugin once: the notification channel and a repeat event everykFlushIntervalMs= 2000 ms (lines 289-308, constant on line 17).- Ask for notification permission (line 326).
- If a service is already running, return (line 327). Keep this line in mind for Failure modes.
- Write everything the service needs with
FlutterForegroundTask.saveData: the fake-GPS flag and pace, then aRaceUploadTargetof race id, base URL, token and the path of the sembast file (lines 329-348; keys inapp/lib/tracking/upload/race_upload_target.dart:19-23). FlutterForegroundTask.startService(callback: startLocationCallback)(lines 353-358).
Why saveData and not a constructor argument: the service runs in a separate isolate,
started by the plugin from a top-level entry point (startLocationCallback, lines 26-29,
annotated @pragma('vm:entry-point') so the AOT compiler keeps it). It shares no memory with
the main isolate and cannot read Riverpod, the keychain or AppConfig. saveData is a small
key-value store both isolates can reach. In BEAM terms, you cannot pass a PID’s state to a
new process by reference, only by copying it into the spawn arguments; this is the copy.
4. Inside the service isolate
Section titled “4. Inside the service isolate”LocationTaskHandler.onStart (app/lib/tracking/location_service.dart:54-73) reads the
fake-GPS flag, picks SimulatedLocationSource or RealLocationSource, opens the upload
pipeline, subscribes to fixes, and starts the source.
_openUploadPipeline (lines 77-108) reads the four RaceUploadTarget keys. If any is
missing (the GPS diagnostics screen starts the service without them) it returns and fixes
are only displayed, never uploaded. Otherwise it opens the FixQueue at the saved path,
prunes fixes uploaded more than a day ago, and builds an HttpPositionClient and a
PositionUploader. The queue is sembast, a pure-Dart database, because plugin-backed storage
such as SharedPreferences needs a platform channel the service isolate may not have
(app/lib/tracking/upload/fix_queue.dart:33-37). Likewise the uploader uses package:http,
not the app’s Dio, which lives in the main isolate with its interceptors
(app/lib/tracking/upload/position_uploader.dart:19-20).
Each fix lands in _onFix (lines 110-129). It records the gap since the previous fix, adds
the fix to _pendingEnqueue (for the durable queue) and a map version to _buffer (for
the UI).
5. Every two seconds: a batch to the UI, a drain to the backend
Section titled “5. Every two seconds: a batch to the UI, a drain to the backend”The plugin calls onRepeatEvent every 2 seconds (app/lib/tracking/location_service.dart:132-161).
That timer is a flush cadence, not the sample rate; GPS arrives at about 1 Hz on its own.
Each tick:
- Sends
{'type': 'batch', 'fixes': [...], 'totalFixes', 'maxGapMs', ...}to the main isolate withFlutterForegroundTask.sendDataToMain(lines 138-150). - Calls
_persistAndUpload()(line 151). - Rewrites the notification text with the current pace and fix count (lines 152-160).
_persistAndUpload (lines 165-202) moves _pendingEnqueue into the sembast queue and runs
one drain at a time, tracked by _uploadInFlight. After each drain it sends
{'type': 'upload', ...report} to the main isolate (lines 179-183). On raceOver it deletes
the race’s fixes from the queue (lines 184-186).
PositionUploader.drain() (app/lib/tracking/upload/position_uploader.dart:140-214) loops:
take up to 50 pending fixes, ordered by timestamp (FixQueue.pending,
app/lib/tracking/upload/fix_queue.dart:74-87), POST them as
{"positions": [{latitude, longitude, timestamp, accuracy, altitude, speed}, ...]}
(QueuedFix.toPosition, lines 23-30), and on 200 mark them uploaded and continue. Fixes are
marked only after a 200, so delivery is at-least-once. The non-200 cases (network failure,
409, 403, 401, 422, 5xx) are covered in Failure modes.
On the backend, RaceController.put_position/2
(backend/lib/footrace_web/controllers/api/race_controller.ex:50-89) checks you are a
participant and the race is open, parses the batch, and calls Ingest.record/2. During the
countdown that returns the state unchanged, dropping the fixes
(backend/lib/racing/positions/ingest.ex:143-145). While racing it inserts a marker per fix
and feeds them to the process (lines 147-153). RaceProcess.handle_call({:positions, ...})
applies each fix (backend/lib/racing/race_process.ex:353-360): apply_position adds the
haversine distance from the last position, checks for a winner, checks whether everyone is
done, persists, and broadcasts {:race_update, state} (lines 514-536). The reply is the race
state from your side: {race, competitors, competitor}.
There is a second, unused path: RaceChannel.pushPositions pushes the same body as a
position event (app/lib/data/socket/race_channel.dart:105-108,
backend/lib/footrace_web/channels/race_channel.ex:54-79). Nothing in lib/ calls it.
Uploads go over HTTP because only the service isolate has the fixes when the phone is
locked, and the service has no socket.
6. Fixes and snapshots reach the UI
Section titled “6. Fixes and snapshots reach the UI”Back in the main isolate, the plugin delivers each message to ForegroundRaceTracker._onData,
which adds it to a broadcast StreamController (app/lib/tracking/race_tracker.dart:36-38).
RaceNotifier._onTrackerMessage (app/lib/features/race/race_notifier.dart:201-234) handles
two types:
batch(lines 203-213): feed each fix toLocalDistance.addand copy its total and last speed into the state.LocalDistance(app/lib/features/race/local_distance.dart:9-58) mirrors the server’s haversine sum but drops fixes worse than 50 m accuracy or implying more than 12 m/s. This is what makes the distance tick every two seconds without waiting for a network round-trip.upload(lines 214-232): parsereplyas aRaceSnapshotand fold it in with_applySnapshot. If the snapshot says the race is over or won, call_finishFromSnapshot(lines 221-224; see the caution in step 7). AstopofunauthorizedorforbiddensetstrackingError.
The race channel delivers the other side of the picture. parseRaceEvent
(app/lib/data/socket/race_channel.dart:46-64) turns pushes into a sealed RaceEvent, and
returns null for events it does not know so a newer backend never breaks an older app.
_onEvent (app/lib/features/race/race_notifier.dart:178-199) switches over it:
| Event | Backend source | App reaction |
|---|---|---|
countdown |
backend/lib/footrace_web/channels/race_channel.ex:99-102 |
store startsAt, reschedule timers |
raceStarted |
backend/lib/footrace_web/channels/race_channel.ex:105-109 |
_startRacing(): ensure tracking, phase racing |
raceUpdate |
backend/lib/footrace_web/channels/race_channel.ex:119-122 |
_applySnapshot (this is how the opponent’s bar moves) |
competitorWon |
backend/lib/footrace_web/channels/race_channel.ex:131-134 |
if it is me, _finish(won); otherwise nothing |
competitorFinished |
backend/lib/footrace_web/channels/race_channel.ex:125-128 |
nothing; the following snapshot carries statuses |
competitorAborted |
backend/lib/footrace_web/channels/race_channel.ex:137-140 |
if it is the opponent, mark opponentAborted |
raceFinished |
backend/lib/footrace_web/channels/race_channel.ex:145-148 |
_applySnapshot, then _finishFromSnapshot |
Your own distance has two sources, and _applySnapshot reconciles them: if the server’s
distance for you is ahead of the local count, the local counter snaps up to it
(app/lib/features/race/race_notifier.dart:243-247). The display picks the larger of the two
(RaceState.displayDistance, lines 69-72). The local count never goes backwards because of a
stale server value. The server stays authoritative for the opponent, the race status and
the result; the phone is authoritative only for “how far have I gone right now”.
The _Racing view (app/lib/features/race/race_screen.dart:257-414) shows distance, current
and average pace, both progress bars and the gap (“+40 m AHEAD”), with banners above for a
down socket (“Reconnecting…”), a lost permission, a tracking error, or an opponent who left
(lines 309-318).
7. Finishing
Section titled “7. Finishing”When you cross the line, RaceProcess.check_for_winner/2 sets the race to :won, stamps you
:won, and broadcasts {:competitor_won, id} (backend/lib/racing/race_process.ex:441-448).
The app’s _onEvent sees its own id and calls _finish(RaceOutcome.won)
(app/lib/features/race/race_notifier.dart:187-188).
If the opponent crosses first, the server status becomes :won with them as winner and the
race goes on for you; when you cross, you become :finished, and once everyone is won,
finished or aborted the race is :finished (backend/lib/racing/race_process.ex:434-439,
496-507). maybe_stop/1 then broadcasts :race_finished and stops the GenServer
(lines 538-544). The app’s raceFinished handler works out the outcome from the final
snapshot in _finishFromSnapshot (app/lib/features/race/race_notifier.dart:321-336).
_finish (app/lib/features/race/race_notifier.dart:338-343) sets phase: finished and the
outcome, then _stopTracking() (lines 367-379), which calls ForegroundRaceTracker.stop()
and clears ActiveRaceStore. stop() calls stopBackgroundTracking(), which stops the
service and removes the upload target keys so the token does not outlive the race in the
plugin’s plain storage (app/lib/tracking/location_service.dart:364-376). In the service
isolate, onDestroy cancels the GPS subscription, awaits any drain in flight, and closes the
HTTP client and the queue (lines 212-220).
Leaving early goes through abort() (lines 347-365): cancel the timers, push abort on the
channel or fall back to POST /api/races/:id/abort, set phase: aborted, stop tracking. The
on-screen control is a slide-to-confirm (app/lib/features/race/race_screen.dart:385-389)
wired straight to abort(); the system back gesture asks first with a “Leave the race?”
dialog (lines 70-89, 98-102). On the server both paths reach Racing.abort_race/1
(backend/lib/racing.ex:46), which marks you aborted and awards the opponent a win by
forfeit if they are the last one running (backend/lib/racing/race_process.ex:380-396,
470-490).
8. The result screen
Section titled “8. The result screen”RaceScreen calls _maybeLeave(st) on every data build
(app/lib/features/race/race_screen.dart:57-63, called at line 114). Once st.isOver, it
navigates with context.go(AppRoutes.raceResult(id)) after the frame. Between the state
change and the navigation, _Racing shows “Fetching the result…” in place of the slider.
ResultScreen does not use RaceNotifier’s outcome. It watches raceResultProvider(id)
(app/lib/features/race/result_screen.dart:21-29), which calls GET /api/races/:id/results
(app/lib/data/repositories/race_repository.dart:39-46,
backend/lib/footrace_web/controllers/api/race_controller.ex:33-41). If the server says the
race is not finished yet (the opponent is still running), the provider schedules
ref.invalidateSelf in three seconds, so it re-fetches until it is. invalidateSelf is
Riverpod’s “throw away my value and rebuild me”; combined with autoDispose, the polling stops
when the screen goes away. While provisional, the card says “Your opponent is still running.
This updates by itself.” (lines 134-141).
The headline comes from outcomeFor (app/lib/data/models/race_result.dart:127-140), a
switch over your own competitor status in the server’s results: “You won”, “You lost”, “Tie”,
“You left the race”, “Opponent left the race”, or “Race in progress”
(app/lib/features/race/result_screen.dart:186-193). Because it reads server statuses, the
early-finish problem above would show “Race in progress” rather than “You lost” on this
screen.
“Done” goes to /home; “Race again” goes home and pushes the queue for the same distance
(lines 166-181).
9. History and profile afterwards
Section titled “9. History and profile afterwards”Nothing on the result screen refreshes other screens. historyProvider and authProvider
are both kept alive for the whole session, not auto-disposed
(app/lib/features/history/history_notifier.dart:109-110,
app/lib/features/auth/auth_notifier.dart:126-127), and nothing invalidates them after a
race:
- History: if the History tab has not been opened yet this session, its first build loads
page one and the new race is on top. If it was already loaded, the old list stays until
you pull to refresh (
app/lib/features/history/history_tab.dart:54, which callsHistoryNotifier.refresh,app/lib/features/history/history_notifier.dart:75-87).footrace-flutter/docs/use-cases/history-and-profile.mdstep 6 expects the new row without a refresh; that holds only in the first case. The integration test opens History for the first time after its race, so it does not catch the second. - Profile: stats come from
/api/me, fetched at sign-in. Pull to refresh on the Profile tab callsrefreshMe()(app/lib/features/profile/profile_tab.dart:50). The use case says this explicitly (step 7).
Further reading
Section titled “Further reading”The tracker’s internals, the permission gate and the simulator are in Tracking; the socket and channel layer in Realtime. Day 9, Day 10 and Day 11 walk the same code in sittings. What happens when any of this goes wrong is on Failure modes.