mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
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:
parent
25ee5ca9ad
commit
770a88454d
@ -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;
|
||||
|
||||
@ -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).');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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'));
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user