Day 11: RaceNotifier, the race screen, the result
This is the game itself. RaceNotifier joins the race:<id> channel, schedules the GPS
three seconds before the gun, merges three inputs (channel events, the service’s fix batches,
upload replies) into one RaceState, and decides when the race is over. The race screen
renders that state once a second, keeps the screen awake, and hands over to the result
screen. By the end you should be able to run a whole race against the bot on your desk.
Before you start
Section titled “Before you start”Day 7 covered SocketTransport,
ChannelTransport and how a typed channel client wraps them.
Day 8 ended with context.go('/race/<id>').
Day 9 gave you RaceTracker, the
service isolate and its {'type': 'batch'} messages, and
Day 10 the {'type': 'upload'} reports
whose reply is the server’s race state. The flow-level view of today is
The race flow; what happens when things go wrong is in
Failure modes.
1. The model and the events
Section titled “1. The model and the events”RaceSnapshot (app/lib/data/models/race_snapshot.dart:165-212) is
{race, competitors, competitor?}, the shape your Footrace.Race.Data.process_state_to_channel/2
builds (backend/lib/footrace/race/data.ex:256-278). It arrives four ways: in the channel
join reply, in raceUpdate and raceFinished, in a channel position reply, and in the 200
body of POST /positions. fromRaceJson (lines 180-186) adapts the fifth, GET /api/races/:id, which nests competitors inside race.
Two enums parse the server’s strings. RaceStatus.parse (lines 13-20) maps both countdown
and not_started to countdown; CompetitorStatus.parse (lines 39-46) maps racing,
started and not_started to racing. Those are the backend inconsistencies that
test/fixtures/README.md documents, absorbed in one place. Unknown strings become unknown
rather than throwing, so a new backend status does not crash an old app. Enums in Dart 3 can
have methods and getters (isOver on line 22, isDone on line 48), like Kotlin enum classes.
Events. app/lib/data/socket/race_channel.dart:5-42 declares sealed class RaceEvent
with seven subclasses. parseRaceEvent (lines 46-64) turns each server push into one of them,
returning null for unknown events. Put it next to the Phoenix side:
Phoenix pushes (backend/lib/footrace_web/channels/race_channel.ex) |
Dart type |
|---|---|
countdown (line 100) |
RaceCountdown(startsAt) |
raceStarted (line 107) |
RaceStarted(race) |
raceUpdate (lines 114, 120) |
RaceUpdated(snapshot) |
competitorFinished (line 126) |
CompetitorFinished(id) |
competitorWon (line 132) |
CompetitorWon(id) |
competitorAborted (line 138) |
CompetitorAborted(id) |
raceFinished (line 146) |
RaceFinished(snapshot) |
sealed means every subclass is in this file, so a switch over RaceEvent is checked for
exhaustiveness. That is Kotlin’s sealed class + when, and close to an Elixir case over
tagged tuples, except the compiler enforces that you handled all of them.
2. RaceNotifier: one race, three inputs
Section titled “2. RaceNotifier: one race, three inputs”Open app/lib/features/race/race_notifier.dart.
State. RacePhase (line 20) is the screen’s state machine: loading, countdown,
racing, finished, aborted, error. RaceState (lines 24-105) is an immutable class
with a hand-written copyWith. Its getters are where display logic lives: me and
opponent (lines 55-63), and displayDistance (lines 69-72), which shows the larger of the
server’s distance and the phone’s local count.
The provider. Lines 397-399:
final raceProvider = AsyncNotifierProvider.family<RaceNotifier, RaceState, String>( RaceNotifier.new);.family makes one notifier per argument; raceProvider('abc') and raceProvider('xyz')
are separate instances, and inside the notifier the argument is arg. That is close to your
RaceProcess registered via {:race, race_id}: one stateful thing per race id, found by key.
Unlike that process, this one is not supervised and, since the provider is not
.autoDispose, it is not torn down when the screen goes away. As far as I can find, nothing
invalidates it, so _teardown (lines 387-394) and the channel leave there run only when
the ProviderScope is disposed.
build (lines 129-166) is the join. Line 137 joins the channel; line 139 takes the
snapshot from the join reply; line 140 subscribes to events; lines 141-143 watch the socket
status to drive the “Reconnecting…” banner. If the join throws a ChannelException, lines
144-157 decide: not_a_competitor or not_found is a hard error, anything else falls back to
GET /api/races/:id and marks the socket down. Line 160 subscribes to the tracker’s message
stream. Line 161 applies the first snapshot with schedule: true.
_applySnapshot (lines 236-270) is the single place a server snapshot changes state. Two
things happen. Lines 243-247 snap the local distance up to the server’s if the server is
ahead. Lines 248-268 move the phase, but only out of loading or countdown: a countdown
snapshot schedules the start; a racing or won snapshot either resumes (and starts
tracking) or, if your own competitor is already done, goes straight to finished, which is
the “rejoined after crossing the line” case.
Timing. _schedule (lines 274-289) sets two Timers from the server’s startsAt and the
injected clock (clockProvider, app/lib/core/clock.dart:7): one gpsLead (3 s, line 114)
before the start to call _ensureTracking, one at the start to call _startRacing as a
fallback in case the raceStarted push is missed. The backend drops positions during the
countdown (backend/lib/racing/positions/ingest.ex:143-145), so the early fixes only warm up
the GPS. They do still reach LocalDistance on the phone, since _onTrackerMessage does not
look at the phase; if you stand still during the countdown that is harmless.
_ensureTracking (lines 300-317) reads the token, writes the race id to
ActiveRaceStore (so a killed app can find its way back, see pendingRaceProvider at lines
403-421), and calls _tracker.start with the race id, base URL, token and fakeGps. A throw
here, typically the permission check from Day 9, becomes trackingError, shown under the
countdown and as a banner during the race.
Inputs. _onEvent (lines 178-199) is the channel. Note the destructuring patterns:
switch (event) { case RaceCountdown(:final startsAt): _update((st) => st.copyWith(startsAt: startsAt)); _schedule(startsAt); case RaceStarted(): _startRacing(); case RaceUpdated(:final snapshot): _update((st) => _applySnapshot(st, snapshot)); case CompetitorWon(:final competitorId): if (competitorId == state.value?.myId) _finish(RaceOutcome.won); // ...}RaceCountdown(:final startsAt) matches the type and binds its startsAt field to a local
of the same name: %RaceCountdown{starts_at: starts_at} in Elixir. _onTrackerMessage (lines
201-234) is the service: batch fixes feed LocalDistance, upload replies go through
_applySnapshot like a channel update, which is how the race keeps working with no socket.
LocalDistance (app/lib/features/race/local_distance.dart:9-58) mirrors the server’s
haversine sum (app/lib/tracking/geo.dart:21-29, against Geocalc on the backend) but drops
fixes worse than 50 m accuracy (line 29), out-of-order fixes (line 37) and jumps faster than
12 m/s (line 42). The server wins whenever it is ahead: snapTo (line 51) adopts its number
and keeps the last fix, so the next local delta continues from there.
Ending. _finishFromSnapshot (lines 321-336) turns a final snapshot into a
RaceOutcome with a switch expression that uses guards:
final outcome = switch (mine?.status) { CompetitorStatus.won => RaceOutcome.won, CompetitorStatus.aborted => RaceOutcome.aborted, _ when winnerId != null && winnerId == st.myId => RaceOutcome.won, _ when st.outcome == RaceOutcome.opponentAborted => RaceOutcome.opponentAborted, CompetitorStatus.finished => RaceOutcome.lost, _ => winnerId == null ? RaceOutcome.tie : RaceOutcome.lost,};_ when ... is a wildcard with a guard, the same as _ when ... in an Elixir case. Cases
are tried top to bottom. _finish (lines 338-343) sets the phase and stops the tracker.
abort (lines 347-365) pushes abort on the channel (app/lib/data/socket/race_channel.dart:110,
handled at backend/lib/footrace_web/channels/race_channel.ex:82), or falls back to POST /api/races/:id/abort, then stops tracking either way.
stateDiagram-v2
[*] --> loading
loading --> error: join refused
loading --> countdown: snapshot countdown
loading --> racing: snapshot racing (rejoin)
loading --> finished: already done
countdown --> racing: raceStarted or start timer
racing --> finished: competitorWon (me) / raceFinished / upload reply over
racing --> aborted: abort()
3. The race screen, its components, and the screen lock
Section titled “3. The race screen, its components, and the screen lock”Open app/lib/features/race/race_screen.dart. RaceScreen is a ConsumerStatefulWidget
(lines 23-34): a stateful widget that can also ref.watch. Its State holds three things:
a one-second Timer (lines 44-46) whose only job is setState, so elapsed time and the
countdown redraw even when no new state arrives; _buzzed, so the haptic at zero fires once;
and _navigated, so the jump to the result happens once.
Screen lock. Lines 47 and 53:
WakelockPlus.enable(); // initState// ...WakelockPlus.disable(); // disposewakelock_plus keeps the display on while this widget is mounted: on iOS it is
UIApplication.shared.isIdleTimerDisabled = true, on Android FLAG_KEEP_SCREEN_ON on the
window. It is not what makes tracking survive a lock. If the runner presses the power button
anyway, the screen goes off, the UI stops drawing, and the foreground service from Day 9
(with its own CPU wake lock, allowWakeLock: true) keeps sampling and uploading. When the
screen comes back, the notifier catches up from the next channel update or upload reply. The
real-device check for exactly this is docs/use-cases/locked-phone-race.md in the app repo.
Leaving. PopScope (lines 98-102) blocks the back gesture while the race is on and asks
for confirmation instead (_confirmAbort, lines 70-89): the iOS equivalent is disabling the
interactive pop gesture and showing an alert. _maybeLeave (lines 57-63) runs during build
when the race is over, and defers context.go to after the frame with
addPostFrameCallback, because navigating in the middle of building a widget is not allowed.
Phases to widgets. Lines 113-143 map RacePhase to a widget with a switch expression.
racing, finished and aborted share _Racing (the || pattern on lines 133-135), which
shows “Fetching the result…” in place of the slider once it is over. Line 145 stacks the
simulator overlay on top when fakeGps is on.
_Racing.build (lines 271-405) is a good example of “build is a pure render”: it computes
elapsed time, remaining distance, pace, average pace and the gap from st and now, then
returns widgets. Line 289 destructures a record: final (paceValue, paceUnit) = _split(pace);
where _split (lines 409-413) returns (String, String?), a Dart 3 record type, like a
Kotlin Pair or an Elixir tuple with types.
The race-specific design components (exported from app/lib/design/components/race/race.dart):
FrLivePill(app/lib/design/components/race/fr_live_pill.dart:13-21): the status pill at the top, withFrRacePhase(line 8) deciding whether it shows as live.FrHeadToHead(app/lib/design/components/race/fr_head_to_head.dart:34-102): the gap tag and two runners on a track. It takes aList<FrRunner>but asserts at least two (line 42). The race screen builds it atapp/lib/features/race/race_screen.dart:323-344.FrSlideToConfirm(app/lib/design/components/race/fr_slide_to_confirm.dart:11-31): the “Slide to leave race” control._drag(lines 60-66) moves the handle;_release(lines 68-76) confirms pastthreshold(0.85) or springs back with anAnimationController;Semantics(onTap: ...)on lines 93-99 gives VoiceOver and TalkBack a plain tap instead of a drag.RaceScreen.leaveKey(line 30 of the race screen) is the key the integration test drags.
PermissionBanner (line 311) is the one from Day 9.
The result. app/lib/features/race/result_screen.dart:21-29:
final raceResultProvider = FutureProvider.autoDispose.family<RaceResult, String>((ref, raceId) async { final result = await ref.read(raceRepositoryProvider).results(raceId); if (!result.finished) { final timer = Timer(const Duration(seconds: 3), ref.invalidateSelf); ref.onDispose(timer.cancel); } return result;});This is polling built from provider parts: fetch GET /api/races/:id/results
(backend/lib/footrace_web/controllers/api/race_controller.ex:33); if the other runner is
still out, set a timer that invalidates the provider, which re-runs this function. The screen
shows the provisional result meanwhile, with “Your opponent is still running. This updates by
itself.” outcomeFor (app/lib/data/models/race_result.dart:127-140) decides the headline
from the server’s ranking, independently of RaceNotifier’s outcome. The route is nested
under the race (app/lib/app/router.dart:104-114), so the URL is /race/<id>/result.
4. Racing on a desk: FAKE_GPS
Section titled “4. Racing on a desk: FAKE_GPS”AppConfig.fakeGps (app/lib/core/config/app_config.dart:29) is
!kReleaseMode && _fakeGps, so a release build ignores the flag whatever it says. When it is
on, RaceNotifier passes fakeGps: true to the tracker, the start payload carries it, and
the service isolate builds a SimulatedLocationSource instead of RealLocationSource
(app/lib/tracking/location_service.dart:62-65). Everything downstream is unchanged: same
batches, same queue, same uploads, same server. Only the source of coordinates differs.
SimulatedLocationSource (app/lib/tracking/simulated_location_source.dart:69-244)
implements the same three-member LocationSource. A Timer.periodic (line 109) calls
_tick once a second. _tick (lines 177-208) advances distance at the current pace (doubled
while sprinting), then emits a fix at that distance east of Tulln (line 12; heading 90 on
line 72) with a little Gaussian jitter on position, accuracy and speed. The controls mimic
real failures: pause, sprint, loseSignal (the runner keeps moving but no fixes arrive,
line 185, so the server sees a jump), badAccuracy (80 m fixes, line 188, which
LocalDistance drops).
The controls cross the isolate boundary as messages. The overlay
(app/lib/dev/sim_gps_overlay.dart) calls SimGpsController (app/lib/dev/sim_gps_controller.dart:10-34),
which sends {'type': 'sim', 'cmd': ...} with sendDataToTask
(app/lib/tracking/location_service.dart:380-386); the service’s onReceiveData hands it to
applyCommand (app/lib/tracking/simulated_location_source.dart:155-173). The simulator’s
state comes back piggybacked on each batch as 'sim' (app/lib/tracking/location_service.dart:144).
Try it
Section titled “Try it”-
Race the bot on an emulator. Start the backend with bots enabled (
PORT=4001 mix phx.serverin../footrace), then fromfootrace-flutter/:Terminal window flutter run --dart-define=API_BASE_URL=http://10.0.2.2:4001 \--dart-define=DEV_TOOLS=true --dart-define=FAKE_GPS=trueRace tab → 100 m → Find opponent → “Race a bot (dev)”. Watch the countdown, the “SIMULATED GPS” panel, the distance climbing. Mid-race, tap the panel’s “Lose signal 10 s” chip and watch the distance stall locally, then jump when fixes resume and the server update arrives. You need location permission granted on the emulator even with fake GPS (Day 9 explains why).
-
Drive the notifier from a test. Run:
Terminal window flutter test test/features/race/race_notifier_test.dartOpen the test at line 151 (“local fixes add distance; server snapshot snaps it”). It emits a
batchfrom the fake tracker, then araceUpdatefrom the fake channel, and checks that the server’s 12 m replaces the local 6.6 m. Add one line before theraceUpdate: a fix withaccuracy80 (copyfix(4, 9.9)and change the field in the map). Predict whetherlocalDistancechanges, then run it. Then make a fix 100 m away one second later and check the teleport rule atapp/lib/features/race/local_distance.dart:42.
Check yourself
Section titled “Check yourself”- List the three inputs
RaceNotifiermerges, and say which one keeps the race state moving when the socket is down. - Why is
_startRacingalso on a timer when the server pushesraceStarted? - What does
WakelockPlus.enable()guarantee, and what does it not? - In
_finishFromSnapshot, your status isfinishedand the opponent aborted earlier. What outcome do you get, and which line decides it? - With
FAKE_GPS=true, which code is different from a real race, and which is the same?
Answers
- Channel events (
_onEvent), the service’s fix batches (_onTrackerMessage,batch), and upload reports (_onTrackerMessage,upload). Upload replies carry the server snapshot every two seconds, so they keep the race going without a socket (app/lib/features/race/race_notifier.dart:214-224). - A missed push (a reconnect at the wrong moment) would otherwise leave the screen in the
countdown forever. The timer is the fallback (lines 284-288), and
_startRacingignores a second call because it only acts in thecountdownphase (line 293). - It keeps the display from sleeping while the race screen is mounted. It does not keep tracking alive if the screen is turned off by hand or the app is backgrounded; that is the foreground service’s job.
opponentAborted, from the guard_ when st.outcome == RaceOutcome.opponentAborted(lines 330-331), set earlier by theCompetitorAbortedevent at lines 191-194. It is checked beforeCompetitorStatus.finished => RaceOutcome.lost.- Only the
LocationSourcebuilt in the service isolate differs:SimulatedLocationSourceinstead ofRealLocationSource(app/lib/tracking/location_service.dart:62-65), plus the overlay on screen. Batches, the queue, uploads, the server, channel events and the result are the same code.