Add run controller, run service, km cue

Foreground service (dataSync+location) started after access, numbers-only notification, kilometre buzz and chime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 16:07:25 +01:00
parent 25ee5ca9ad
commit 770a88454d
8 changed files with 706 additions and 1 deletions

View File

@ -34,7 +34,7 @@ double replaySpeedMps(RunSport sport) => switch (sport) {
/// Fixes for one lap of a [kLoopLengthM] circle starting at [start], one every
/// [kFixStepSeconds], 5 m accuracy.
List<GpsFix> sandboxLoopFixes(DateTime start, RunSport sport) {
final radius = kLoopLengthM / (2 * math.pi);
const radius = kLoopLengthM / (2 * math.pi);
final steps = (kLoopLengthM / replaySpeedMps(sport) / kFixStepSeconds)
.round();
const metresPerDegree = 111194.9;

View File

@ -6,6 +6,7 @@ import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:vibration/vibration.dart';
import 'package:workout_app/sandbox/sandbox_log.dart';
import 'package:workout_app/services/storage_service.dart';
@ -54,4 +55,25 @@ class FeedbackCues {
debugPrint('WorkoutApp: finish chime failed to play ($error).');
});
}
/// A whole kilometre of a run was just credited: a double buzz and, if
/// allowed, the chime.
///
/// The vibration plugin rather than [HapticFeedback]: a run happens with the
/// screen off in a pocket, where view haptics may not fire.
Future<void> kilometreReached(int km) async {
SandboxLog.event('cue kilometre', {'km': km, 'sound': soundOn});
if (await Vibration.hasVibrator()) {
// Android/iOS-only: hasVibrator() is false on the Linux test host.
// coverage:ignore-start
unawaited(Vibration.vibrate(pattern: [0, 150, 100, 150]));
// coverage:ignore-end
}
if (!soundOn) return;
await _player.play(AssetSource('sounds/workout_done.wav')).catchError((
Object error,
) {
debugPrint('WorkoutApp: kilometre chime failed to play ($error).');
});
}
}

View File

@ -0,0 +1,54 @@
/// The run screen's side of the foreground-service conversation.
library;
/// What the foreground service did when a recording asked for it.
enum RunServiceState {
/// Not started yet (or stopped).
none,
/// Running with its notification visible.
running,
/// Running, but the user denied notifications so nothing is on screen.
runningHidden,
/// The platform refused to start it: GPS may stop with the screen off.
failed,
/// This platform has no foreground services (Linux desktop, tests).
unsupported,
}
/// Starting, feeding and stopping the recording's foreground service.
///
/// The GPS stream itself stays in the UI isolate (the recorder writes to the
/// database, which only that isolate may do); the service holds the process,
/// the wake lock and the notification so the stream keeps flowing with the
/// screen off. An interface so the controller is testable without Android,
/// and so the one file that touches `flutter_foreground_task` stays small.
abstract class ForegroundRunClient {
/// False on platforms with no foreground service.
bool get isSupported;
/// True when the app may post notifications.
Future<bool> hasNotificationPermission();
/// Asks for notification permission, returning whether it was granted.
Future<bool> requestNotificationPermission();
/// Starts the service showing [title] and [text]. [withLocation] adds the
/// `location` service type, which Android 14 only allows once the location
/// permission is held (the sandbox's replayed GPS has none, so it asks for
/// the data-sync type alone).
Future<bool> start({
required bool withLocation,
required String title,
required String text,
});
/// Redraws the notification.
Future<void> update({required String title, required String text});
/// Stops the service and removes its notification.
Future<void> stop();
}

View File

@ -0,0 +1,116 @@
/// The real run foreground-service client, and the service's entry point.
library;
import 'dart:io' show Platform;
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:workout_app/services/foreground_run_client.dart';
/// Channel the ongoing run notification lives on.
///
/// Low importance (it is on screen for the whole run and must never buzz) and
/// versioned for the same reason as the workout one: channel settings freeze
/// on first creation. The name is a symbol, not a word (textgate): Android
/// shows it in system settings.
const kRunOngoingChannelId = 'run_ongoing_v1';
// coverage:ignore-start
/// Entry point the plugin calls in the background isolate.
///
/// The handler does nothing: the recorder lives in the UI isolate. The
/// service exists to keep that isolate's process alive, to hold the wake lock
/// and to own the notification.
@pragma('vm:entry-point')
void runServiceStartCallback() {
FlutterForegroundTask.setTaskHandler(_RunServiceTaskHandler());
}
class _RunServiceTaskHandler extends TaskHandler {
@override
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {}
@override
void onRepeatEvent(DateTime timestamp) {}
@override
void onNotificationPressed() => FlutterForegroundTask.launchApp();
@override
Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {}
}
// coverage:ignore-end
/// The real [ForegroundRunClient], driving `flutter_foreground_task`.
class FlutterForegroundRunClient implements ForegroundRunClient {
/// Creates a client. Cheap: nothing happens until [start].
FlutterForegroundRunClient();
@override
bool get isSupported => Platform.isAndroid;
// Platform-channel passthroughs. `flutter test` runs on Linux, where
// isSupported is false and none of this is reachable; the decisions that
// lead here live in run_controller.dart, covered against a fake.
// coverage:ignore-start
@override
Future<bool> hasNotificationPermission() async =>
await FlutterForegroundTask.checkNotificationPermission() ==
NotificationPermission.granted;
@override
Future<bool> requestNotificationPermission() async =>
await FlutterForegroundTask.requestNotificationPermission() ==
NotificationPermission.granted;
@override
Future<bool> start({
required bool withLocation,
required String title,
required String text,
}) async {
FlutterForegroundTask.init(
androidNotificationOptions: AndroidNotificationOptions(
channelId: kRunOngoingChannelId,
channelName: '\u{1F3C3}',
onlyAlertOnce: true,
),
iosNotificationOptions: const IOSNotificationOptions(),
foregroundTaskOptions: ForegroundTaskOptions(
eventAction: ForegroundTaskEventAction.nothing(),
// allowWakeLock stays at its default (true): it is why GPS fixes keep
// arriving with the screen off.
),
);
if (await FlutterForegroundTask.isRunningService) {
await FlutterForegroundTask.stopService();
}
final result = await FlutterForegroundTask.startService(
serviceTypes: [
ForegroundServiceTypes.dataSync,
if (withLocation) ForegroundServiceTypes.location,
],
notificationTitle: title,
notificationText: text,
callback: runServiceStartCallback,
);
return result is ServiceRequestSuccess;
}
@override
Future<void> update({required String title, required String text}) async {
await FlutterForegroundTask.updateService(
notificationTitle: title,
notificationText: text,
);
}
@override
Future<void> stop() async {
if (await FlutterForegroundTask.isRunningService) {
await FlutterForegroundTask.stopService();
}
}
// coverage:ignore-end
}

View File

@ -0,0 +1,167 @@
/// Wires one recording to the phone: permission, foreground service, cues,
/// notification and the save at the end.
library;
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/sandbox/sandbox.dart';
import 'package:workout_app/services/feedback_cues.dart';
import 'package:workout_app/services/foreground_run_client.dart';
import 'package:workout_app/services/position_source.dart';
import 'package:workout_app/services/run_format.dart';
import 'package:workout_app/services/run_recorder.dart';
import 'package:workout_app/services/run_saver.dart';
/// Owns a [RunRecorder] and everything that must happen around it.
///
/// Order matters at the start: location access first (the recorder asks), then
/// the foreground service (Android 14 refuses a `location` service without
/// the permission), then the first fix.
class RunController extends ChangeNotifier {
/// Creates a controller; it builds its own [recorder] over [sourceFor].
RunController({
required PositionSource Function(RunSport sport) sourceFor,
required this._service,
required this._cues,
required this._save,
}) {
recorder = RunRecorder(sourceFor: sourceFor, onKilometre: _onKilometre)
..addListener(_onRecorderChanged);
}
/// The state machine; the live map reads its points.
late final RunRecorder recorder;
final ForegroundRunClient _service;
final FeedbackCues _cues;
final Future<RunSaveResult> Function(RunTrack track) _save;
RunServiceState _serviceState = RunServiceState.none;
RunSaveResult? _saved;
bool _saving = false;
String _lastCopy = '';
/// Whether the foreground service is up (and with a notification).
RunServiceState get serviceState => _serviceState;
/// What saving the finished run did, once it ran.
RunSaveResult? get saved => _saved;
/// True while the finished run is being written.
bool get saving => _saving;
/// True from start to finish: the screen must not be left meanwhile.
bool get active =>
recorder.phase == RunPhase.recording || recorder.phase == RunPhase.paused;
/// Asks for access, starts the service, begins recording.
Future<void> start() async {
await _cues.load();
await recorder.start(afterAccess: _startService);
}
/// Pauses crediting.
void pause() => recorder.pause();
/// Resumes crediting.
void resume() => recorder.resume();
/// Ends the run, stops the service and saves the track.
Future<void> finish() async {
final track = await recorder.finish();
await _stopService();
if (track == null) return;
unawaited(_cues.workoutFinished());
_saving = true;
notifyListeners();
try {
_saved = await _save(track);
} on Exception catch (error, stack) {
log(
'RunController: saving ${track.id} failed ($error) -- the run is '
'lost unless it is recorded again.',
level: 1000,
error: error,
stackTrace: stack,
);
_saved = RunSaveResult(stored: false, reason: 'save failed: $error');
}
_saving = false;
notifyListeners();
}
Future<void> _startService() async {
if (!_service.isSupported) {
_serviceState = RunServiceState.unsupported;
return;
}
var shown = await _service.hasNotificationPermission();
if (!shown) shown = await _service.requestNotificationPermission();
if (!shown) {
log(
'RunController: notification permission denied. The run still '
'records, but the status-bar distance and time cannot be shown.',
level: 1000,
);
}
final copy = runNotificationCopy(metres: 0, movingSeconds: 0);
_lastCopy = '${copy.title}|${copy.body}';
final started = await _service.start(
withLocation: !Sandbox.enabled,
title: copy.title,
text: copy.body,
);
if (!started) {
_serviceState = RunServiceState.failed;
log(
'RunController: the foreground service refused to start. GPS fixes '
'may stop when the screen turns off -- keep the app open.',
level: 1000,
);
return;
}
_serviceState = shown
? RunServiceState.running
: RunServiceState.runningHidden;
}
Future<void> _stopService() async {
if (_serviceState != RunServiceState.running &&
_serviceState != RunServiceState.runningHidden) {
return;
}
await _service.stop();
_serviceState = RunServiceState.none;
notifyListeners();
}
void _onKilometre(int km) => unawaited(_cues.kilometreReached(km));
void _onRecorderChanged() {
if (_serviceState == RunServiceState.running) {
final copy = runNotificationCopy(
metres: recorder.distanceM,
movingSeconds: recorder.seconds,
);
// Only when the words changed: an update per fix would redraw twice a
// second for nothing.
if ('${copy.title}|${copy.body}' != _lastCopy) {
_lastCopy = '${copy.title}|${copy.body}';
unawaited(_service.update(title: copy.title, text: copy.body));
}
}
notifyListeners();
}
@override
void dispose() {
recorder
..removeListener(_onRecorderChanged)
..dispose();
if (active) unawaited(_service.stop());
super.dispose();
}
}

View File

@ -0,0 +1,73 @@
/// A recording fake for the run foreground service.
library;
import 'package:workout_app/services/foreground_run_client.dart';
/// Stands in for `flutter_foreground_task` on the run screen's side.
class FakeForegroundRunClient implements ForegroundRunClient {
/// Creates a fake describing a healthy Android device.
FakeForegroundRunClient({
this.isSupported = true,
this.permissionGranted = true,
this.grantOnRequest = true,
this.startSucceeds = true,
this.onStart,
});
@override
bool isSupported;
/// Whether notifications are already allowed.
bool permissionGranted;
/// Whether asking for them succeeds.
bool grantOnRequest;
/// Whether the service starts.
bool startSucceeds;
/// Runs inside [start], to record what had happened by then.
void Function()? onStart;
/// `withLocation` of each start.
final starts = <bool>[];
/// Notification redraws as `title|text`, the first being the start.
final drawn = <String>[];
/// Calls recorded in order.
final calls = <String>[];
@override
Future<bool> hasNotificationPermission() async {
calls.add('has');
return permissionGranted;
}
@override
Future<bool> requestNotificationPermission() async {
calls.add('request');
return permissionGranted = grantOnRequest;
}
@override
Future<bool> start({
required bool withLocation,
required String title,
required String text,
}) async {
calls.add('start');
onStart?.call();
starts.add(withLocation);
drawn.add('$title|$text');
return startSucceeds;
}
@override
Future<void> update({required String title, required String text}) async {
drawn.add('$title|$text');
}
@override
Future<void> stop() async => calls.add('stop');
}

View File

@ -0,0 +1,29 @@
// The kilometre cue of a run: a buzz, and the chime unless sound is off.
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/feedback_cues.dart';
import '../fake_audio_platform.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakeAudioRecorder audio;
setUp(() => audio = installFakeAudioPlatform());
test('chimes with the finish sound', () async {
await FeedbackCues(AudioPlayer()).kilometreReached(1);
expect(audio.resumedSources.single, contains('workout_done'));
});
test('stays silent when sound is off', () async {
await (FeedbackCues(AudioPlayer())..soundOn = false).kilometreReached(2);
expect(audio.resumedSources, isEmpty);
});
test('a chime that cannot play is logged, not thrown', () async {
audio.failNextPlay = true;
await FeedbackCues(AudioPlayer()).kilometreReached(3);
expect(audio.resumedSources, isEmpty);
});
}

View File

@ -0,0 +1,244 @@
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/sandbox/sandbox.dart';
import 'package:workout_app/services/feedback_cues.dart';
import 'package:workout_app/services/foreground_run_client.dart';
import 'package:workout_app/services/foreground_run_client_flutter.dart';
import 'package:workout_app/services/position_source.dart';
import 'package:workout_app/services/run_controller.dart';
import 'package:workout_app/services/run_recorder.dart';
import 'package:workout_app/services/run_saver.dart';
import 'package:workout_app/services/storage_service.dart';
import '../fake_audio_platform.dart';
import '../fake_run_service.dart';
import '../run_test_fixtures.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakeAudioRecorder audio;
late List<RunTrack> saved;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
StorageService.resetForTesting();
await StorageService.init();
audio = installFakeAudioPlatform();
saved = [];
});
tearDown(() => Sandbox.enabled = false);
RunController build(
ManualPositionSource source,
FakeForegroundRunClient service, {
Future<RunSaveResult> Function(RunTrack)? save,
}) => RunController(
sourceFor: (_) => source,
service: service,
cues: FeedbackCues(AudioPlayer()),
save:
save ??
(track) async {
saved.add(track);
return const RunSaveResult(stored: true, reason: 'saved');
},
);
Future<void> feed(ManualPositionSource source, List<int> northM) async {
for (final (i, m) in northM.indexed) {
source.controller.add(fixAt(i * 2, m.toDouble()));
}
await pumpEventQueue();
}
test('starts the service after access and before the first fix', () async {
final source = ManualPositionSource();
var subscribedAtStart = -1;
final service = FakeForegroundRunClient(
onStart: () => subscribedAtStart = source.subscriptions,
);
final controller = build(source, service);
await controller.start();
expect(subscribedAtStart, 0);
expect(source.subscriptions, 1);
expect(service.starts, [true]);
expect(controller.serviceState, RunServiceState.running);
expect(service.calls, ['has', 'start']);
expect(service.drawn.single, '0.00|0:00 --:--');
expect(controller.active, isTrue);
controller.dispose();
});
test('the notification follows the run, redrawing only on change', () async {
final source = ManualPositionSource();
final service = FakeForegroundRunClient();
final controller = build(source, service);
await controller.start();
await feed(source, [0, 6, 12, 18, 24, 30, 36]);
expect(service.drawn.last, '0.04|0:12 5:33');
expect(service.drawn.toSet(), hasLength(service.drawn.length));
controller.dispose();
});
test('a refused permission never reaches the service', () async {
final service = FakeForegroundRunClient();
final controller = build(
ManualPositionSource(access: LocationAccess.deniedForever),
service,
);
await controller.start();
expect(service.calls, isEmpty);
expect(controller.serviceState, RunServiceState.none);
expect(controller.recorder.issue, RunIssue.deniedForever);
expect(controller.active, isFalse);
controller.dispose();
});
test('denied notifications still record but draw nothing', () async {
final source = ManualPositionSource();
final service = FakeForegroundRunClient(
permissionGranted: false,
grantOnRequest: false,
);
final controller = build(source, service);
await controller.start();
await feed(source, [0, 6, 12]);
expect(service.calls, ['has', 'request', 'start']);
expect(controller.serviceState, RunServiceState.runningHidden);
expect(service.drawn, hasLength(1));
expect(controller.recorder.livePoints.value, hasLength(3));
controller.dispose();
});
test('a service that will not start is flagged, the run goes on', () async {
final source = ManualPositionSource();
final controller = build(
source,
FakeForegroundRunClient(startSucceeds: false),
);
await controller.start();
await feed(source, [0, 6]);
expect(controller.serviceState, RunServiceState.failed);
expect(controller.recorder.phase, RunPhase.recording);
expect(controller.recorder.livePoints.value, hasLength(2));
await controller.finish();
expect(controller.serviceState, RunServiceState.failed);
controller.dispose();
});
test('a platform without services records without one', () async {
final source = ManualPositionSource();
final service = FakeForegroundRunClient(isSupported: false);
final controller = build(source, service);
await controller.start();
await feed(source, [0, 6]);
await controller.finish();
expect(controller.serviceState, RunServiceState.unsupported);
expect(service.calls, isEmpty);
expect(saved, hasLength(1));
controller.dispose();
});
test('the sandbox asks for the data-sync service type alone', () async {
Sandbox.enabled = true;
final service = FakeForegroundRunClient();
final controller = build(ManualPositionSource(), service);
await controller.start();
expect(service.starts, [false]);
controller.dispose();
});
test('a kilometre buzzes and chimes once', () async {
final source = ManualPositionSource();
final controller = build(source, FakeForegroundRunClient());
await controller.start();
await feed(source, [for (var i = 0; i <= 170; i++) i * 6]);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(controller.recorder.distanceM, greaterThan(1000));
expect(
audio.resumedSources.where((s) => s.contains('workout_done')),
hasLength(1),
);
controller.dispose();
});
test('finish stops the service, saves the track and reports it', () async {
final source = ManualPositionSource();
final service = FakeForegroundRunClient();
final controller = build(source, service);
await controller.start();
await feed(source, [0, 6, 12, 18]);
controller
..pause()
..resume();
final notified = <bool>[];
controller.addListener(() => notified.add(controller.saving));
await controller.finish();
expect(service.calls.last, 'stop');
expect(controller.serviceState, RunServiceState.none);
expect(saved.single.points, hasLength(4));
expect(controller.saved!.stored, isTrue);
expect(controller.saving, isFalse);
expect(notified, contains(true));
expect(controller.active, isFalse);
controller.dispose();
});
test('a run too short to save is not saved', () async {
final source = ManualPositionSource();
final service = FakeForegroundRunClient();
final controller = build(source, service);
await controller.start();
await controller.finish();
expect(saved, isEmpty);
expect(controller.saved, isNull);
expect(controller.recorder.issue, RunIssue.tooShort);
expect(service.calls.last, 'stop');
controller.dispose();
});
test('a failing save is reported, not thrown', () async {
final source = ManualPositionSource();
final controller = build(
source,
FakeForegroundRunClient(),
save: (_) async => throw Exception('disk full'),
);
await controller.start();
await feed(source, [0, 6]);
await controller.finish();
expect(controller.saved!.stored, isFalse);
expect(controller.saved!.reason, contains('disk full'));
controller.dispose();
});
test('leaving mid-run stops the service', () async {
final source = ManualPositionSource();
final service = FakeForegroundRunClient();
final controller = build(source, service);
await controller.start();
controller.dispose();
expect(service.calls.last, 'stop');
});
test('the real client is unsupported off Android', () {
expect(FlutterForegroundRunClient().isSupported, isFalse);
expect(kRunOngoingChannelId, endsWith('_v1'));
});
}