V3-06: live stats in the notification

RideNotificationController seam over flutter_local_notifications; RideNotificationCoordinator wires TripRepository.watchActiveTrip() to it and routes Pause/Resume actions back into RecordingEngine. Instantiated eagerly at app root rather than screen-owned, since a pocketed ride has no visible widget tree. Documents an unresolved risk: geolocator's own foreground-service notification cannot be suppressed, so two notifications may be visible until verified on a device.
This commit is contained in:
2026-08-17 15:04:34 -05:00
parent fbdb55258c
commit 5013b7002f
11 changed files with 471 additions and 13 deletions

View File

@@ -14,6 +14,8 @@ import '../config/config.dart';
import '../data/database.dart';
import '../data/trip_repository.dart';
import '../domain/models.dart';
import '../notification/ride_notification_controller.dart';
import '../notification/ride_notification_coordinator.dart';
import '../recording/geolocator_location_source.dart';
import '../recording/location_source.dart';
import '../recording/recording_engine.dart';
@@ -122,6 +124,25 @@ final wakelockControllerProvider = Provider<WakelockController>(
(ref) => PlusWakelockController(),
);
/// Overridden in tests with [FakeRideNotificationController].
final rideNotificationControllerProvider = Provider<RideNotificationController>(
(ref) => PlusRideNotificationController(),
);
/// V3-06: eager, not screen-owned -- watched once from app root ([main.dart]) purely to
/// force creation, so it keeps running (and the notification keeps updating, and
/// Pause/Resume keeps working) regardless of which screen is on top or whether the app is
/// backgrounded at all.
final rideNotificationCoordinatorProvider = Provider<RideNotificationCoordinator>((ref) {
final coordinator = RideNotificationCoordinator(
controller: ref.watch(rideNotificationControllerProvider),
engine: ref.watch(recordingEngineProvider),
tripStream: ref.watch(tripRepositoryProvider).watchActiveTrip(),
);
ref.onDispose(coordinator.dispose);
return coordinator;
});
/// Live path for the recording screen's map (V3-04). `.family` + `autoDispose` so the
/// stream tears down the moment the record screen stops watching it — no leftover
/// subscription ticking away once a ride ends or the map toggle goes off.

View File

@@ -0,0 +1,115 @@
/// V3-06: this app's own notification, taking the shade back from geolocator's
/// `ForegroundNotificationConfig` -- which takes fixed strings at stream-subscription
/// time and offers no update path and no actions (see
/// `geolocator_location_source.dart`'s "Known parity gap" note). geolocator still raises
/// its own notification to satisfy Android's foreground-service requirement; it is
/// configured elsewhere to be minimal and silent, and this one is what the rider actually
/// reads and taps.
///
/// Wrapped behind a seam -- the same reasoning as [LocationSource] and
/// [WakelockController] -- so the acquire/update/cancel path and the action-routing path
/// are both things a test can assert, not just plausible claims about a plugin.
library;
import 'dart:async';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
enum RideNotificationAction { pause, resume }
abstract class RideNotificationController {
Stream<RideNotificationAction> get actions;
/// Shown once at the start of a ride and updated in place from then on -- a fresh
/// `show` with the same id replaces the previous one rather than stacking, which is
/// also what keeps "two notification sources fighting" from being possible on our side.
Future<void> show({required String text, required bool paused});
/// Must run on stop, discard, and completion -- an ongoing notification that outlives
/// the ride it described is the notification equivalent of V3-05's leaked wake lock.
Future<void> cancel();
}
const _channelId = 'ride_recording';
const _notificationId = 1001;
class PlusRideNotificationController implements RideNotificationController {
PlusRideNotificationController() {
_plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('ic_stat_rippr'),
),
onDidReceiveNotificationResponse: (response) {
final action = switch (response.actionId) {
'pause_action' => RideNotificationAction.pause,
'resume_action' => RideNotificationAction.resume,
_ => null,
};
if (action != null) _actions.add(action);
},
);
}
final _plugin = FlutterLocalNotificationsPlugin();
final _actions = StreamController<RideNotificationAction>.broadcast();
@override
Stream<RideNotificationAction> get actions => _actions.stream;
@override
Future<void> show({required String text, required bool paused}) => _plugin.show(
id: _notificationId,
title: 'Rippr',
body: text,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
'Ride recording',
// Cannot be swiped away mid-ride -- see the ticket's acceptance criteria.
ongoing: true,
autoCancel: false,
onlyAlertOnce: true,
icon: 'ic_stat_rippr',
actions: [
if (paused)
const AndroidNotificationAction('resume_action', 'Resume')
else
const AndroidNotificationAction('pause_action', 'Pause'),
],
),
),
);
@override
Future<void> cancel() => _plugin.cancel(id: _notificationId);
}
class FakeRideNotificationController implements RideNotificationController {
final _controller = StreamController<RideNotificationAction>.broadcast();
String? lastText;
bool? lastPaused;
bool visible = false;
int showCalls = 0;
int cancelCalls = 0;
@override
Stream<RideNotificationAction> get actions => _controller.stream;
@override
Future<void> show({required String text, required bool paused}) async {
lastText = text;
lastPaused = paused;
visible = true;
showCalls++;
}
@override
Future<void> cancel() async {
visible = false;
cancelCalls++;
}
/// Test helper: simulates the rider tapping Pause/Resume in the shade.
void simulateAction(RideNotificationAction action) => _controller.add(action);
}

View File

@@ -0,0 +1,62 @@
/// V3-06: the piece that makes the notification live rather than static. Deliberately not
/// owned by a screen -- a pocketed ride has no visible widget tree, but the notification
/// still has to update and Pause/Resume still has to work. Instantiated once, eagerly, at
/// app root (see `main.dart`) and lives for the app's lifetime, the same shape as
/// [RecordingEngine] itself.
library;
import 'dart:async';
import '../domain/models.dart';
import '../recording/recording_engine.dart';
import 'ride_notification_controller.dart';
import 'ride_notification_text.dart';
class RideNotificationCoordinator {
RideNotificationCoordinator({
required this._controller,
required this._engine,
required Stream<Trip?> tripStream,
}) {
// The trip stream is backed by the same Drift query the record screen watches,
// updated once per writer flush (~2 s) -- never per fix.
_tripSub = tripStream.listen(_onTrip);
_actionSub = _controller.actions.listen(_onAction);
}
final RideNotificationController _controller;
final RecordingEngine _engine;
late final StreamSubscription<Trip?> _tripSub;
late final StreamSubscription<RideNotificationAction> _actionSub;
Trip? _lastTrip;
void _onTrip(Trip? trip) {
_lastTrip = trip;
if (trip == null) {
_controller.cancel();
return;
}
_controller.show(
text: rideNotificationText(trip),
paused: trip.state == TripState.paused,
);
}
void _onAction(RideNotificationAction action) {
final trip = _lastTrip;
if (trip == null) return;
switch (action) {
// start() both begins a new ride and resumes a paused one -- see RecordingEngine.
case RideNotificationAction.resume:
if (trip.state == TripState.paused) _engine.start();
case RideNotificationAction.pause:
if (trip.state == TripState.recording) _engine.pause();
}
}
Future<void> dispose() async {
await _tripSub.cancel();
await _actionSub.cancel();
}
}

View File

@@ -0,0 +1,19 @@
/// V3-06: pure text formatting for the live ride notification, kept free of Flutter and
/// of the notification plugin so it is trivially unit-testable -- the plugin call itself
/// cannot be exercised without a device, but the string it is given can be.
library;
import '../domain/models.dart';
import '../telemetry/telemetry.dart' show formatDuration;
import '../ui/format.dart';
/// "12.3 km · 00:45:12", or "Paused · 12.3 km · 00:45:12" while paused. Driven from the
/// same aggregate columns the record screen reads, so this updates on the same ~2 s
/// writer flush -- never per fix.
String rideNotificationText(Trip trip, {UnitSystem unit = UnitSystem.metric}) {
final distance = formatDistance(trip.distanceM, unit: unit);
final elapsed = formatDuration(trip.movingMillis);
return trip.state == TripState.paused
? 'Paused · $distance · $elapsed'
: '$distance · $elapsed';
}

View File

@@ -13,9 +13,17 @@
/// `flutter_foreground_task` was dropped — along with its two deprecation warnings (no
/// Swift Package Manager support, and it applies KGP).
///
/// **Known parity gap:** this notification cannot carry *actions*, so the native app's
/// Pause/Resume buttons in the shade are not reproduced. Tapping the notification opens
/// the app instead. Recorded in `docs/port/PROGRESS.md` for the T27 parity audit.
/// **Former parity gap, partially closed by V3-06:** this notification cannot carry
/// *actions* and offers no update path, so the notification the rider actually reads and
/// taps is meant to be `RideNotificationCoordinator`'s own, which restores live text and
/// Pause/Resume. **Unresolved risk, needs a real device:** geolocator's
/// `ForegroundNotificationConfig` is what satisfies Android's foreground-service
/// requirement and there is no documented way to suppress or merge it with a second,
/// independently-raised `flutter_local_notifications` notification -- it is entirely
/// possible both are visible in the shade at once. Kept minimal and silent here on the
/// assumption that the OS may collapse or de-prioritise it next to an `ongoing: true`
/// notification on the same channel, but that assumption is unverified. See V3-06's
/// Outcome section and the real-ride checklist (V3-13).
///
/// ## iOS: the app stays awake only while location flows
///
@@ -49,8 +57,10 @@ const Duration _interval = Duration(seconds: 1);
class GeolocatorLocationSource implements LocationSource {
GeolocatorLocationSource({
this.notificationTitle = 'Rippr is recording',
this.notificationText = 'Tracking your ride',
// Deliberately quiet: the notification the rider reads is
// RideNotificationCoordinator's, not this one. See the file doc comment.
this.notificationTitle = 'Rippr',
this.notificationText = '',
});
final String notificationTitle;