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

@@ -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';
}