mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-15 05:30:59 +01:00
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:
parent
97d73b31f5
commit
bd12b8711d
@ -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,
|
||||
}
|
||||
@ -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].
|
||||
///
|
||||
|
||||
107
stronglift_replacement/workout_app/scripts/provision_run_upload.sh
Executable file
107
stronglift_replacement/workout_app/scripts/provision_run_upload.sh
Executable 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 "$@"
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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));
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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));
|
||||
});
|
||||
}
|
||||
@ -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'));
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user