# V3-06 — Live stats in the notification **Phase** Live map · **Depends on** nothing · **Size** S · **Status** Done ## Goal Distance and duration readable from the notification shade without unlocking. ## Context The ongoing notification currently says "Rippr is recording / Tracking your ride" — static text. During a pocketed ride that is a wasted surface. **Constraint that shapes this whole ticket:** the notification is owned by `geolocator`'s `ForegroundNotificationConfig`, which takes fixed strings at stream-subscription time and offers no update path and no actions. That is the parity gap recorded in [../port/PARITY-AUDIT.md](../port/PARITY-AUDIT.md). ## Design Two honest options: **A. Restart the position stream with new text.** Cheap to write, but it tears down and re-establishes location updates every time — unacceptable during recording. **B. Take the notification back with `flutter_local_notifications`,** and let geolocator raise a silent minimal one. More moving parts, but it also **restores Pause/Resume actions** — closing the one capability lost in the port. **Recommend B**, precisely because it buys back the parity gap as well. ## Implementation 1. Add `flutter_local_notifications` 2. Own a notification on the same channel, updated on each writer flush (~2 s), not per fix 3. Pause/Resume actions routed back into `RecordingEngine` 4. Android 13+ notification permission is already requested ## Acceptance criteria - [ ] Distance and elapsed update while riding, without unlocking - [ ] Pause and Resume work from the shade - [ ] Location updates are **not** interrupted when the notification changes - [ ] The notification cannot be swiped away mid-ride ## Tests - Unit: notification text formatting from a trip - Integration on a device: start, confirm the text advances, pause from the shade, confirm the engine actually paused (check the database, do not trust the UI) ## Risks - Two notification sources fighting is the obvious failure. Verify only one is visible. - An update per fix would be a battery and jank problem; drive it from the flush. ## Out of scope iOS. There is no equivalent live notification surface; a Live Activity is a much larger piece of work and belongs in its own ticket. ## Outcome Shipped as designed (option B), with one honestly-unresolved risk carried forward. `RideNotificationController` is a seam over `flutter_local_notifications` (`FakeRideNotificationController` for tests), the same shape as `LocationSource` and `WakelockController`. `RideNotificationCoordinator` owns the wiring: it subscribes to `TripRepository.watchActiveTrip()` (the same stream `activeTripProvider` exposes, updated once per writer flush) and calls `show`/`cancel`; it subscribes to the controller's action stream and routes `pause`/`resume` back into `RecordingEngine`, guarded so a Resume can't fire against a trip that isn't actually paused. `rideNotificationText(Trip, {unit})` is pure and unit-tested directly — distance/elapsed formatting, the `Paused ·` prefix, and unit-system handling. **Not screen-owned, deliberately.** Unlike the live map (V3-04) and mounted mode (V3-05), which are fed from `RecordScreen`'s widget tree, the coordinator is instantiated eagerly from `main.dart`'s `RipprApp.build` via a bare `ref.watch(rideNotificationCoordinatorProvider)` — a pocketed ride has no visible widget tree, but the notification and Pause/Resume both still have to work. **Unresolved risk, flagged rather than papered over:** the ticket's own risk section names "two notification sources fighting" as the obvious failure mode, and it is real. geolocator's `ForegroundNotificationConfig` is what satisfies Android's foreground-service requirement and cannot be suppressed; `flutter_local_notifications` raises a second, independent notification. There is no documented way to merge or guarantee only one is visible — the geolocator notification was made minimal and silent (`geolocator_location_source.dart`) on the assumption that an `ongoing: true` notification on the same-ish surface might collapse or de-prioritise it, but that assumption is unverified without a device. This is exactly the kind of claim the project's testing philosophy refuses to accept on faith — see the real-ride checklist (V3-13) and this ticket's own "Integration on a device" test, neither of which could run here. 6 new tests, all in `test/ride_notification_test.dart`: three for `rideNotificationText` (recording, paused-prefix, unit system), three for the coordinator using a real `RecordingEngine` + in-memory `TripRepository` (not a mock) so pause/resume are checked in the database per the project's standing rule, not by trusting the notification state. `flutter analyze` clean; full suite green (237 tests, up from 231). Not attempted: the device-only acceptance criteria (text updates without unlocking, Pause/Resume from the shade, the notification resisting swipe-away, and the two-source visibility question above) — all require a real Android device, per the ticket's own Tests section.