Skip to content

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.

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.

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.

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(); // dispose

wakelock_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, with FrRacePhase (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 a List<FrRunner> but asserts at least two (line 42). The race screen builds it at app/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 past threshold (0.85) or springs back with an AnimationController; 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.

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).

  1. Race the bot on an emulator. Start the backend with bots enabled (PORT=4001 mix phx.server in ../footrace), then from footrace-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=true

    Race 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).

  2. Drive the notifier from a test. Run:

    Terminal window
    flutter test test/features/race/race_notifier_test.dart

    Open the test at line 151 (“local fixes add distance; server snapshot snaps it”). It emits a batch from the fake tracker, then a raceUpdate from the fake channel, and checks that the server’s 12 m replaces the local 6.6 m. Add one line before the raceUpdate: a fix with accuracy 80 (copy fix(4, 9.9) and change the field in the map). Predict whether localDistance changes, then run it. Then make a fix 100 m away one second later and check the teleport rule at app/lib/features/race/local_distance.dart:42.

  1. List the three inputs RaceNotifier merges, and say which one keeps the race state moving when the socket is down.
  2. Why is _startRacing also on a timer when the server pushes raceStarted?
  3. What does WakelockPlus.enable() guarantee, and what does it not?
  4. In _finishFromSnapshot, your status is finished and the opponent aborted earlier. What outcome do you get, and which line decides it?
  5. With FAKE_GPS=true, which code is different from a real race, and which is the same?
Answers
  1. 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).
  2. 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 _startRacing ignores a second call because it only acts in the countdown phase (line 293).
  3. 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.
  4. opponentAborted, from the guard _ when st.outcome == RaceOutcome.opponentAborted (lines 330-331), set earlier by the CompetitorAborted event at lines 191-194. It is checked before CompetitorStatus.finished => RaceOutcome.lost.
  5. Only the LocationSource built in the service isolate differs: SimulatedLocationSource instead of RealLocationSource (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.

Day 12: The test harness, fakes, fixture contracts.