Add run screen, home entry, provisioning

One-screen live run UI (icons and digits), map slot via mapBuilder, upload badge, sandbox e2e, Pixel 6a inset test, dufs provisioning script.

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:24:25 +01:00
parent 97d73b31f5
commit bd12b8711d
10 changed files with 561 additions and 187 deletions

View File

@ -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,
}

View File

@ -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].
///

View File

@ -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/<file>) 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 "$@"

View File

@ -40,7 +40,7 @@ class ManualPositionSource implements PositionSource {
final LocationAccess access;
/// The stream the recorder listens to.
final controller = StreamController<GpsFix>();
final controller = StreamController<GpsFix>.broadcast();
/// How many times [fixes] was called.
int subscriptions = 0;

View File

@ -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<void>? saveGate;
final uploader = FakeRunUploader();
final cues = QuietCues();
final saved = <RunTrack>[];
@ -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<void> 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<void>.delayed(const Duration(milliseconds: 10)),
);
await tester.pumpAndSettle();
}

View File

@ -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<NavigatorState>(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<void>.delayed(const Duration(milliseconds: 300)),
);
await tester.pump(const Duration(minutes: 2));
});
}

View File

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

View File

@ -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<IconButton>(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<RunPoint>? 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));
});
}

View File

@ -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<void>.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<RunRecord>? 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<void>.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<void>.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'));
});
}

View File

@ -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<IconButton>(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<void>();
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<RunPoint>? 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<void>.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<void>.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);
});
}