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:
115
lib/src/notification/ride_notification_controller.dart
Normal file
115
lib/src/notification/ride_notification_controller.dart
Normal 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);
|
||||
}
|
||||
62
lib/src/notification/ride_notification_coordinator.dart
Normal file
62
lib/src/notification/ride_notification_coordinator.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
19
lib/src/notification/ride_notification_text.dart
Normal file
19
lib/src/notification/ride_notification_text.dart
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user