diff --git a/stronglift_replacement/workout_app/lib/services/run_phase.dart b/stronglift_replacement/workout_app/lib/services/run_phase.dart new file mode 100644 index 0000000..e993480 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/run_phase.dart @@ -0,0 +1,41 @@ +/// The phases and problems of a GPS recording. +library; + +/// Where a recording stands. +enum RunPhase { + /// Nothing recorded yet; the sport can still be chosen. + idle, + + /// Fixes are being kept and credited. + recording, + + /// Fixes are ignored; nothing is credited until [RunRecorder.resume]. + paused, + + /// Over; [RunRecorder.finish] has returned the track. + finished, +} + +/// What is wrong with the recording right now (shown as an icon, logged). +enum RunIssue { + /// Nothing. + none, + + /// Location permission refused; asking again may work. + denied, + + /// Location permission refused for good; only system settings can fix it. + deniedForever, + + /// The device's location switch is off. + serviceOff, + + /// The newest fix came from a mock-location provider and was dropped. + mockedLocation, + + /// The location stream reported an error. + streamError, + + /// Finished with too few usable fixes to make a track; nothing was saved. + tooShort, +} diff --git a/stronglift_replacement/workout_app/lib/services/run_recorder.dart b/stronglift_replacement/workout_app/lib/services/run_recorder.dart index 00b840c..99c0e82 100644 --- a/stronglift_replacement/workout_app/lib/services/run_recorder.dart +++ b/stronglift_replacement/workout_app/lib/services/run_recorder.dart @@ -10,45 +10,9 @@ import 'package:workout_app/sandbox/sandbox_log.dart'; import 'package:workout_app/services/gps_filter.dart'; import 'package:workout_app/services/position_source.dart'; import 'package:workout_app/services/run_math.dart'; +import 'package:workout_app/services/run_phase.dart'; -/// Where a recording stands. -enum RunPhase { - /// Nothing recorded yet; the sport can still be chosen. - idle, - - /// Fixes are being kept and credited. - recording, - - /// Fixes are ignored; nothing is credited until [RunRecorder.resume]. - paused, - - /// Over; [RunRecorder.finish] has returned the track. - finished, -} - -/// What is wrong with the recording right now (shown as an icon, logged). -enum RunIssue { - /// Nothing. - none, - - /// Location permission refused; asking again may work. - denied, - - /// Location permission refused for good; only system settings can fix it. - deniedForever, - - /// The device's location switch is off. - serviceOff, - - /// The newest fix came from a mock-location provider and was dropped. - mockedLocation, - - /// The location stream reported an error. - streamError, - - /// Finished with too few usable fixes to make a track; nothing was saved. - tooShort, -} +export 'package:workout_app/services/run_phase.dart'; /// Records one run or walk from a [PositionSource]. /// diff --git a/stronglift_replacement/workout_app/scripts/provision_run_upload.sh b/stronglift_replacement/workout_app/scripts/provision_run_upload.sh new file mode 100755 index 0000000..d621321 --- /dev/null +++ b/stronglift_replacement/workout_app/scripts/provision_run_upload.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# provision_run_upload.sh — give the workout app its own dufs login for run +# uploads, without anyone typing a password. +# +# 1. creates (idempotently) a dufs login `workout_app` scoped `/RunnerUp rw` +# via ~/src/dufs-cloud/scripts/add_dufs_login.sh. That script needs sudo +# (it restarts dufs.service the first time, and may `pacman -S` openssl/ +# curl/xclip); an existing login is left alone. It never touches the +# `runnerup` login the stock RunnerUp app depends on, and this script +# never passes --rotate. +# 2. writes {"url","user","password"} (url = the BARE origin, the app +# appends /RunnerUp/) to a 0600 temp file, +# 3. adb-pushes it to the app's external files dir, where the app imports it +# into secure storage on the next start and deletes it. +# +# The app uploads each finished run as a TCX; a PC-side watcher credits it. +# Run only when the phone is attached and the app has been started once. +# +# Usage: provision_run_upload.sh [--print-only] +# ADB_SERIAL phone serial (default 23181JEGR08034) + +set -euo pipefail + +readonly LOGIN_USER="workout_app" +readonly LOGIN_PATH="/RunnerUp" +readonly ADD_LOGIN="${ADD_DUFS_LOGIN:-${HOME}/src/dufs-cloud/scripts/add_dufs_login.sh}" +readonly ENV_FILE="${DUFS_LOGINS_DIR:-${HOME}/.config/dufs/logins}/${LOGIN_USER}.env" +readonly SERIAL="${ADB_SERIAL:-23181JEGR08034}" +readonly APP_ID="com.kuhy.workout_app" +readonly REMOTE_DIR="/sdcard/Android/data/${APP_ID}/files" +readonly REMOTE_FILE="${REMOTE_DIR}/dufs_login.json" + +PRINT_ONLY=0 +TMP_JSON="" + +die() { + printf '[FAIL] %s\n' "$*" >&2 + exit 1 +} + +cleanup() { + if [[ -n ${TMP_JSON} && -f ${TMP_JSON} ]]; then + rm -f "${TMP_JSON}" + fi +} +trap cleanup EXIT + +# Reads KEY from the login's .env without sourcing it (no code execution). +env_value() { + local key="$1" line + line="$(grep -m1 "^${key}=" "${ENV_FILE}")" || die "${key} missing in ${ENV_FILE}" + printf '%s' "${line#*=}" +} + +check_tools() { + local tool + for tool in jq adb; do + command -v "${tool}" >/dev/null || die "${tool} is required (sudo pacman -S ${tool})" + done + [[ -x ${ADD_LOGIN} ]] || die "missing ${ADD_LOGIN}" +} + +ensure_login() { + "${ADD_LOGIN}" "${LOGIN_USER}" "${LOGIN_PATH}" rw + [[ -f ${ENV_FILE} ]] || die "${ENV_FILE} was not written" +} + +write_json() { + TMP_JSON="$(mktemp)" + chmod 600 "${TMP_JSON}" + jq -n \ + --arg url "$(env_value DUFS_URL)" \ + --arg user "$(env_value DUFS_USER)" \ + --arg password "$(env_value DUFS_PASSWORD)" \ + '{url: $url, user: $user, password: $password}' >"${TMP_JSON}" + jq -e '.url | test("^https?://[^/]+$")' "${TMP_JSON}" >/dev/null || + die "url must be a bare origin without a path" +} + +push_json() { + adb -s "${SERIAL}" get-state >/dev/null || die "phone ${SERIAL} is not attached" + adb -s "${SERIAL}" shell mkdir -p "${REMOTE_DIR}" + adb -s "${SERIAL}" push "${TMP_JSON}" "${REMOTE_FILE}" >/dev/null + adb -s "${SERIAL}" shell ls -l "${REMOTE_FILE}" + printf '[ OK ] pushed; open the app once: it imports the login and deletes the file\n' +} + +main() { + if [[ ${1:-} == --print-only ]]; then + PRINT_ONLY=1 + elif [[ $# -gt 0 ]]; then + die "usage: provision_run_upload.sh [--print-only]" + fi + check_tools + # --print-only must not need sudo: reuse an existing login's .env. + if [[ ${PRINT_ONLY} -eq 0 || ! -f ${ENV_FILE} ]]; then + ensure_login + fi + write_json + if [[ ${PRINT_ONLY} -eq 1 ]]; then + jq '.password = "***"' "${TMP_JSON}" + return 0 + fi + push_json +} + +main "$@" diff --git a/stronglift_replacement/workout_app/test/run_test_fixtures.dart b/stronglift_replacement/workout_app/test/run_test_fixtures.dart index 6c2a94e..881e6e4 100644 --- a/stronglift_replacement/workout_app/test/run_test_fixtures.dart +++ b/stronglift_replacement/workout_app/test/run_test_fixtures.dart @@ -40,7 +40,7 @@ class ManualPositionSource implements PositionSource { final LocationAccess access; /// The stream the recorder listens to. - final controller = StreamController(); + final controller = StreamController.broadcast(); /// How many times [fixes] was called. int subscriptions = 0; diff --git a/stronglift_replacement/workout_app/test/screens/_run_screen_fixtures.dart b/stronglift_replacement/workout_app/test/screens/_run_screen_fixtures.dart index 0becb3e..73e1932 100644 --- a/stronglift_replacement/workout_app/test/screens/_run_screen_fixtures.dart +++ b/stronglift_replacement/workout_app/test/screens/_run_screen_fixtures.dart @@ -1,5 +1,7 @@ // Shared harness for the run screen tests: every dependency is an in-memory // fake, so nothing here needs the real event loop or the database. +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -21,12 +23,14 @@ class RunHarness { ManualPositionSource? source, FakeForegroundRunClient? service, this.saveThrows = false, + this.saveGate, }) : source = source ?? ManualPositionSource(access: access), service = service ?? FakeForegroundRunClient(); final ManualPositionSource source; final FakeForegroundRunClient service; final bool saveThrows; + final Completer? saveGate; final uploader = FakeRunUploader(); final cues = QuietCues(); final saved = []; @@ -38,6 +42,7 @@ class RunHarness { service: service, cues: cues, save: (track) async { + await saveGate?.future; if (saveThrows) throw Exception('disk full'); saved.add(track); return const RunSaveResult(stored: true, reason: 'saved'); @@ -85,6 +90,10 @@ Finder key(String id) => find.byKey(Key(id)); Future tapKey(WidgetTester tester, String id) async { await tester.tap(key(id)); - // Settles the async hops of start/finish (stream cancel, save) as well. + // Stream cancel only completes on the real event loop, so give it a turn + // before settling the frames. + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); await tester.pumpAndSettle(); } diff --git a/stronglift_replacement/workout_app/test/screens/home_run_entry_test.dart b/stronglift_replacement/workout_app/test/screens/home_run_entry_test.dart new file mode 100644 index 0000000..aecc09c --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/home_run_entry_test.dart @@ -0,0 +1,47 @@ +// The run icon on the home screen opens the run screen. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/screens/run_screen.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import '../fake_audio_platform.dart'; +import '../fake_secure_storage.dart'; +import '_home_test_fixtures.dart'; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakeSecureStorage(); + installFakeAudioPlatform(); + }); + + testWidgets('the run icon opens the run screen and back returns home', ( + tester, + ) async { + await pumpHome(tester, wrapHome()); + expect(find.byKey(const Key('home_run')), findsOneWidget); + + await tester.tap(find.byKey(const Key('home_run'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(RunScreen), findsOneWidget); + expect(find.byKey(const Key('run_start')), findsOneWidget); + + tester.state(find.byType(Navigator)).pop(); + await tester.pumpAndSettle(); + expect(find.byType(RunScreen), findsNothing); + expect(find.byKey(const Key('home_run')), findsOneWidget); + // Home reloads on return; let its database reads finish on the real loop. + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 300)), + ); + await tester.pump(const Duration(minutes: 2)); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/run_screen_insets_test.dart b/stronglift_replacement/workout_app/test/screens/run_screen_insets_test.dart new file mode 100644 index 0000000..1668f11 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/run_screen_insets_test.dart @@ -0,0 +1,74 @@ +// R7: the run screen fits one Pixel 6a screen with no scrolling, clear of the +// status bar and the gesture bar, in every phase. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../fake_audio_platform.dart'; +import '_run_screen_fixtures.dart'; + +const _dpr = 2.625; +const _physical = Size(1080, 2400); +const _topPx = 132.0; +const _bottomPx = 63.0; + +void _pixel6a(WidgetTester tester) { + tester.view + ..physicalSize = _physical + ..devicePixelRatio = _dpr + ..padding = const FakeViewPadding(top: _topPx, bottom: _bottomPx); + addTearDown(tester.view.reset); +} + +/// Every scrollable has nowhere to scroll, nothing overflowed, and every +/// control sits between the status bar and the gesture bar. +void _expectFits(WidgetTester tester) { + expect(tester.takeException(), isNull, reason: 'a layout overflow'); + for (final element in find.byType(Scrollable).evaluate()) { + final position = Scrollable.of(element).position; + expect(position.maxScrollExtent, 0); + } + final top = _topPx / _dpr; + final bottom = (_physical.height - _bottomPx) / _dpr; + for (final button in find.byType(IconButton).evaluate()) { + final rect = tester.getRect(find.byWidget(button.widget)); + expect(rect.top, greaterThanOrEqualTo(top)); + expect(rect.bottom, lessThanOrEqualTo(bottom)); + } + final slot = tester.getRect(find.byKey(const Key('run_map_slot'))); + expect(slot.top, greaterThanOrEqualTo(top)); +} + +void main() { + setUp(installFakeAudioPlatform); + + testWidgets('idle, recording, paused and finished all fit', (tester) async { + _pixel6a(tester); + final h = RunHarness(); + await h.pump(tester); + expect(find.byType(Scrollable), findsNothing); + _expectFits(tester); + + await tapKey(tester, 'run_start'); + await h.run(tester, 400); + _expectFits(tester); + + await tapKey(tester, 'run_pause'); + _expectFits(tester); + + await tapKey(tester, 'run_finish'); + _expectFits(tester); + }); + + testWidgets('a long run (hours, tens of km) still fits at 2x text', ( + tester, + ) async { + _pixel6a(tester); + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.platformDispatcher.clearAllTestValues); + final h = RunHarness(); + await h.pump(tester); + await tapKey(tester, 'run_start'); + await h.run(tester, 3000); + _expectFits(tester); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/run_screen_problems_test.dart b/stronglift_replacement/workout_app/test/screens/run_screen_problems_test.dart new file mode 100644 index 0000000..e55daa0 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/run_screen_problems_test.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/services/position_source.dart'; +import 'package:workout_app/services/run_uploader.dart'; + +import '../fake_audio_platform.dart'; +import '../fake_run_service.dart'; +import '../run_test_fixtures.dart'; +import '_run_screen_fixtures.dart'; + +String _text(String id) => + (find.byKey(Key(id)).evaluate().single.widget as Text).data!; + +void main() { + setUp(installFakeAudioPlatform); + + group('problems are icons', () { + for (final (access, id, fixable) in [ + (LocationAccess.denied, 'run_issue_denied', false), + (LocationAccess.deniedForever, 'run_issue_deniedForever', true), + (LocationAccess.serviceOff, 'run_issue_serviceOff', true), + ]) { + testWidgets('${access.name}: red icon, start stays available', ( + tester, + ) async { + final h = RunHarness(access: access); + await h.pump(tester); + await tapKey(tester, 'run_start'); + + expect(key(id), findsOneWidget); + expect(find.byIcon(Icons.location_disabled), findsOneWidget); + expect(key('run_start'), findsOneWidget); + expect(h.service.calls, isEmpty); + final button = tester.widget(key(id)); + expect(button.onPressed != null, fixable); + if (fixable) { + await tester.tap(key(id)); + expect(h.settingsOpened, [1]); + } + }); + } + + testWidgets('a mock fix shows the wrong-location icon', (tester) async { + final h = RunHarness(); + await h.pump(tester); + await tapKey(tester, 'run_start'); + h.source.controller.add(fixAt(0, 0, mocked: true)); + await tester.pumpAndSettle(); + expect(key('run_issue_mockedLocation'), findsOneWidget); + expect(find.byIcon(Icons.wrong_location), findsOneWidget); + }); + + testWidgets('a location stream error shows the gps-off icon', ( + tester, + ) async { + final h = RunHarness(); + await h.pump(tester); + await tapKey(tester, 'run_start'); + h.source.controller.addError(StateError('gps off')); + await tester.pumpAndSettle(); + expect(key('run_issue_streamError'), findsOneWidget); + expect(find.byIcon(Icons.gps_off), findsOneWidget); + }); + + testWidgets('a run with no fixes says so and saves nothing', ( + tester, + ) async { + final h = RunHarness(); + await h.pump(tester); + await tapKey(tester, 'run_start'); + await tapKey(tester, 'run_finish'); + expect(key('run_issue_tooShort'), findsOneWidget); + expect(h.saved, isEmpty); + expect(key('run_saved_ok'), findsNothing); + }); + + testWidgets('denied notifications and a dead service are flagged', ( + tester, + ) async { + final hidden = RunHarness( + service: FakeForegroundRunClient( + permissionGranted: false, + grantOnRequest: false, + ), + ); + await hidden.pump(tester); + await tapKey(tester, 'run_start'); + expect(key('run_service_issue'), findsOneWidget); + expect(find.byIcon(Icons.notifications_off), findsOneWidget); + + final dead = RunHarness( + service: FakeForegroundRunClient(startSucceeds: false), + ); + await dead.pump(tester); + await tapKey(tester, 'run_start'); + expect(find.byIcon(Icons.warning_amber), findsOneWidget); + }); + + testWidgets('a save that throws shows the failed icon', (tester) async { + final h = RunHarness(saveThrows: true); + await h.pump(tester); + await tapKey(tester, 'run_start'); + await h.run(tester, 4); + await tapKey(tester, 'run_finish'); + await tester.pumpAndSettle(); + expect(key('run_saved_failed'), findsOneWidget); + expect(key('run_saved_ok'), findsNothing); + }); + }); + + testWidgets('the upload badge follows the queue and retries on tap', ( + tester, + ) async { + final h = RunHarness(); + await h.pump(tester); + final cases = { + UploadHealth.idle: Icons.cloud_done, + UploadHealth.uploading: Icons.cloud_upload, + UploadHealth.retrying: Icons.cloud_off, + UploadHealth.notConfigured: Icons.link_off, + UploadHealth.sandbox: Icons.cloud_queue, + }; + for (final entry in cases.entries) { + h.uploader.state.value = RunUploadStatus(entry.key, 2); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: key('run_upload_badge'), + matching: find.byIcon(entry.value), + ), + findsOneWidget, + reason: entry.key.name, + ); + expect(_text('run_upload_pending'), '2'); + } + h.uploader.state.value = const RunUploadStatus(UploadHealth.idle, 0); + await tester.pumpAndSettle(); + expect(key('run_upload_pending'), findsNothing); + + final before = h.uploader.passes; + await tapKey(tester, 'run_upload_badge'); + expect(h.uploader.passes, before + 1); + }); + + testWidgets('a map builder replaces the placeholder and gets the points', ( + tester, + ) async { + final h = RunHarness(); + List? seen; + await h.pump( + tester, + mapBuilder: (context, points) => ValueListenableBuilder( + valueListenable: points, + builder: (context, list, _) { + seen = list; + return Text('${list.length}', key: const Key('custom_map')); + }, + ), + ); + expect(key('run_map_slot'), findsNothing); + await tapKey(tester, 'run_start'); + await h.run(tester, 5); + expect(_text('custom_map'), '5'); + expect(seen, hasLength(5)); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/run_screen_sandbox_test.dart b/stronglift_replacement/workout_app/test/screens/run_screen_sandbox_test.dart new file mode 100644 index 0000000..45ded30 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/run_screen_sandbox_test.dart @@ -0,0 +1,90 @@ +// A whole run in the sandbox flavor, end to end with the real wiring: the +// replayed GPS loop, the recorder, the database and the no-op uploader. +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/sandbox/sandbox.dart'; +import 'package:workout_app/screens/run_screen.dart'; +import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/ui/theme.dart'; + +import '../fake_audio_platform.dart'; +import '../fake_run_uploader.dart'; +import '_run_screen_fixtures.dart'; + +void main() { + late Directory backupDir; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakeAudioPlatform(); + backupDir = Directory.systemTemp.createTempSync('run_sandbox_backup_'); + BackupService.baseDir = backupDir.path; + Sandbox.enabled = true; + // 2 s of track per 2 microseconds: the lap replays in about a second. + Sandbox.runReplayFactor = 1000000; + }); + + tearDown(() async { + Sandbox.enabled = false; + Sandbox.runReplayFactor = Sandbox.defaultReplayFactor; + // insertRun fires an unawaited backup; let it land before the dir goes. + await Future.delayed(const Duration(milliseconds: 300)); + BackupService.baseDir = '/sdcard/WorkoutTracker'; + backupDir.deleteSync(recursive: true); + }); + + testWidgets('a replayed lap is recorded, saved pending, never uploaded', ( + tester, + ) async { + final uploader = FakeRunUploader(); + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: RunScreen(uploader: uploader), + ), + ); + + List? runs; + await tester.runAsync(() async { + await tester.tap(key('run_start')); + final deadline = DateTime.now().add(const Duration(seconds: 40)); + // The lap is done when the distance stops growing near 4 km. + var last = -1.0; + while (DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pump(); + final shown = double.parse( + (find.byKey(const Key('run_distance')).evaluate().single.widget + as Text) + .data!, + ); + if (shown >= 3.99 && shown == last) break; + last = shown; + } + await tester.tap(key('run_finish')); + await Future.delayed(const Duration(milliseconds: 500)); + runs = await StorageService.instance.listRuns(); + }); + await tester.pump(); + + expect(key('run_saved_ok'), findsOneWidget); + final run = runs!.single; + expect(run.track.sport, RunSport.running); + expect(run.track.source, RunSource.app); + expect(run.track.distanceM, closeTo(4000, 20)); + expect(run.uploadState, RunUploadState.pending); + expect(run.tcxFilename, endsWith('_Running.tcx')); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/run_screen_test.dart b/stronglift_replacement/workout_app/test/screens/run_screen_test.dart index ecccd9e..912aab8 100644 --- a/stronglift_replacement/workout_app/test/screens/run_screen_test.dart +++ b/stronglift_replacement/workout_app/test/screens/run_screen_test.dart @@ -2,14 +2,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:workout_app/models/run_track.dart'; import 'package:workout_app/screens/run_screen.dart'; import 'package:workout_app/services/position_source.dart'; -import 'package:workout_app/services/run_uploader.dart'; import 'package:workout_app/widgets/run_map_slot.dart'; import '../fake_audio_platform.dart'; -import '../fake_run_service.dart'; import '../run_test_fixtures.dart'; import '_run_screen_fixtures.dart'; @@ -177,153 +174,31 @@ void main() { expect(key('run_pause'), findsOneWidget); }); - group('problems are icons', () { - for (final (access, id, fixable) in [ - (LocationAccess.denied, 'run_issue_denied', false), - (LocationAccess.deniedForever, 'run_issue_deniedForever', true), - (LocationAccess.serviceOff, 'run_issue_serviceOff', true), - ]) { - testWidgets('${access.name}: red icon, start stays available', ( - tester, - ) async { - final h = RunHarness(access: access); - await h.pump(tester); - await tapKey(tester, 'run_start'); - - expect(key(id), findsOneWidget); - expect(find.byIcon(Icons.location_disabled), findsOneWidget); - expect(key('run_start'), findsOneWidget); - expect(h.service.calls, isEmpty); - final button = tester.widget(key(id)); - expect(button.onPressed != null, fixable); - if (fixable) { - await tester.tap(key(id)); - expect(h.settingsOpened, [1]); - } - }); - } - - testWidgets('a mock fix shows the wrong-location icon', (tester) async { - final h = RunHarness(); - await h.pump(tester); - await tapKey(tester, 'run_start'); - h.source.controller.add(fixAt(0, 0, mocked: true)); - await tester.pumpAndSettle(); - expect(key('run_issue_mockedLocation'), findsOneWidget); - expect(find.byIcon(Icons.wrong_location), findsOneWidget); - }); - - testWidgets('a location stream error shows the gps-off icon', ( - tester, - ) async { - final h = RunHarness(); - await h.pump(tester); - await tapKey(tester, 'run_start'); - h.source.controller.addError(StateError('gps off')); - await tester.pumpAndSettle(); - expect(key('run_issue_streamError'), findsOneWidget); - expect(find.byIcon(Icons.gps_off), findsOneWidget); - }); - - testWidgets('a run with no fixes says so and saves nothing', ( - tester, - ) async { - final h = RunHarness(); - await h.pump(tester); - await tapKey(tester, 'run_start'); - await tapKey(tester, 'run_finish'); - expect(key('run_issue_tooShort'), findsOneWidget); - expect(h.saved, isEmpty); - expect(key('run_saved_ok'), findsNothing); - }); - - testWidgets('denied notifications and a dead service are flagged', ( - tester, - ) async { - final hidden = RunHarness( - service: FakeForegroundRunClient( - permissionGranted: false, - grantOnRequest: false, - ), - ); - await hidden.pump(tester); - await tapKey(tester, 'run_start'); - expect(key('run_service_issue'), findsOneWidget); - expect(find.byIcon(Icons.notifications_off), findsOneWidget); - - final dead = RunHarness( - service: FakeForegroundRunClient(startSucceeds: false), - ); - await dead.pump(tester); - await tapKey(tester, 'run_start'); - expect(find.byIcon(Icons.warning_amber), findsOneWidget); - }); - - testWidgets('a save that throws shows the failed icon', (tester) async { - final h = RunHarness(saveThrows: true); - await h.pump(tester); - await tapKey(tester, 'run_start'); - await h.run(tester, 4); - await tapKey(tester, 'run_finish'); - await tester.pumpAndSettle(); - expect(key('run_saved_failed'), findsOneWidget); - expect(key('run_saved_ok'), findsNothing); - }); - }); - - testWidgets('the upload badge follows the queue and retries on tap', ( + testWidgets('while the run is being saved a spinner shows and exits wait', ( tester, ) async { - final h = RunHarness(); + final gate = Completer(); + final h = RunHarness(saveGate: gate); await h.pump(tester); - final cases = { - UploadHealth.idle: Icons.cloud_done, - UploadHealth.uploading: Icons.cloud_upload, - UploadHealth.retrying: Icons.cloud_off, - UploadHealth.notConfigured: Icons.link_off, - UploadHealth.sandbox: Icons.cloud_queue, - }; - for (final entry in cases.entries) { - h.uploader.state.value = RunUploadStatus(entry.key, 2); - await tester.pumpAndSettle(); - expect( - find.descendant( - of: key('run_upload_badge'), - matching: find.byIcon(entry.value), - ), - findsOneWidget, - reason: entry.key.name, - ); - expect(_text('run_upload_pending'), '2'); - } - h.uploader.state.value = const RunUploadStatus(UploadHealth.idle, 0); - await tester.pumpAndSettle(); - expect(key('run_upload_pending'), findsNothing); - - final before = h.uploader.passes; - await tapKey(tester, 'run_upload_badge'); - expect(h.uploader.passes, before + 1); - }); - - testWidgets('a map builder replaces the placeholder and gets the points', ( - tester, - ) async { - final h = RunHarness(); - List? seen; - await h.pump( - tester, - mapBuilder: (context, points) => ValueListenableBuilder( - valueListenable: points, - builder: (context, list, _) { - seen = list; - return Text('${list.length}', key: const Key('custom_map')); - }, - ), - ); - expect(key('run_map_slot'), findsNothing); await tapKey(tester, 'run_start'); - await h.run(tester, 5); - expect(_text('custom_map'), '5'); - expect(seen, hasLength(5)); + await h.run(tester, 4); + + await tester.tap(key('run_finish')); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(); + expect(key('run_saving'), findsOneWidget); + expect(_enabled(tester, 'run_again'), isFalse); + expect(_enabled(tester, 'run_close'), isFalse); + + gate.complete(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pumpAndSettle(); + expect(key('run_saving'), findsNothing); + expect(key('run_saved_ok'), findsOneWidget); + expect(_enabled(tester, 'run_again'), isTrue); }); }