WIP run screen

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:12:59 +01:00
parent 770a88454d
commit 66e9948777
10 changed files with 999 additions and 0 deletions

View File

@ -8,6 +8,7 @@ import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/sandbox/sandbox_log.dart';
import 'package:workout_app/screens/history_screen.dart';
import 'package:workout_app/screens/manual_workout_screen.dart';
import 'package:workout_app/screens/run_screen.dart';
import 'package:workout_app/screens/settings_screen.dart';
import 'package:workout_app/screens/workout_screen.dart';
import 'package:workout_app/services/done_today.dart';
@ -153,6 +154,15 @@ class _HomeScreenState extends State<HomeScreen> {
style: TextStyle(color: colorScheme.onSurface),
),
actions: [
IconButton(
key: const Key('home_run'),
icon: Icon(
Icons.directions_run,
color: colorScheme.onSurface,
semanticLabel: 'run',
),
onPressed: _openRun,
),
IconButton(
icon: Icon(Icons.edit_note, color: colorScheme.onSurface),
tooltip: 'Log manual workout',

View File

@ -14,6 +14,16 @@ extension _HomeScreenNavigation on _HomeScreenState {
unawaited(_load());
}
/// Pushes the GPS run screen, then reloads (a finished run can change
/// today's credit once the PC has it).
Future<void> _openRun() async {
SandboxLog.event('open run screen');
await Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => const RunScreen()));
unawaited(_load());
}
/// Pushes the workout, resuming the saved session when [resume].
///
/// [auto] is the launch-time open: it replaces home without a transition,

View File

@ -0,0 +1,153 @@
/// The live run/walk screen: sport, start/pause/finish, and big numbers.
///
/// One screen, no scrolling (R7), and no words (textgate): icons, colours and
/// digits only. Icons that report a problem are tappable where the fix is in
/// system settings.
library;
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/sandbox/sandbox_log.dart';
import 'package:workout_app/sandbox/sandbox_run_replay.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/run_controller.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';
import 'package:workout_app/services/run_uploader.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/run_map_slot.dart';
import 'package:workout_app/widgets/run_upload_badge.dart';
part 'run_screen_parts.dart';
/// The controller the app uses: real GPS (or the sandbox replay), the real
/// foreground service, the real database and the app-wide upload queue.
RunController buildRunController() => RunController(
sourceFor: positionSourceFor,
service: FlutterForegroundRunClient(),
cues: FeedbackCues(AudioPlayer()),
save: (track) => saveRecordedRun(
track,
storage: StorageService.instance,
enqueue: RunUploader.instance.uploadPending,
),
);
/// Records one run or walk.
class RunScreen extends StatefulWidget {
/// Creates the screen; every argument is a test or map seam.
const RunScreen({
super.key,
this.controllerFactory = buildRunController,
this.uploader,
this.mapBuilder,
this.openSettings = openAppSettings,
});
/// Builds the controller for a recording (called again for "new run").
final RunController Function() controllerFactory;
/// The upload queue shown in the badge; defaults to the app-wide one.
final RunUploader? uploader;
/// Builds the live map from the run's accepted points; defaults to the
/// [RunMapSlot] placeholder. This is where the map widget goes.
final Widget Function(BuildContext, ValueListenable<List<RunPoint>>)?
mapBuilder;
/// Opens the system settings page for this app.
final Future<bool> Function() openSettings;
@override
State<RunScreen> createState() => _RunScreenState();
}
class _RunScreenState extends State<RunScreen> {
late RunController _controller = widget.controllerFactory();
late final RunUploader _uploader = widget.uploader ?? RunUploader.instance;
bool _starting = false;
@override
void initState() {
super.initState();
SandboxLog.event('open run');
// So the badge shows the real queue, not the pre-first-pass default.
unawaited(_uploader.uploadPending());
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _start() async {
setState(() => _starting = true);
try {
await _controller.start();
} finally {
if (mounted) setState(() => _starting = false);
}
}
void _again() {
final old = _controller;
setState(() => _controller = widget.controllerFactory());
old.dispose();
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final c = _controller;
final r = c.recorder;
return PopScope(
// A recording lives in this screen's controller: leaving would lose it.
canPop: !c.active,
child: Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
children: [
_StatusRow(
controller: c,
uploader: _uploader,
openSettings: widget.openSettings,
),
const SizedBox(height: AppSpacing.sm),
Expanded(
flex: 4,
child:
widget.mapBuilder?.call(context, r.livePoints) ??
RunMapSlot(points: r.livePoints),
),
const SizedBox(height: AppSpacing.md),
Expanded(flex: 3, child: _Numbers(recorder: r)),
const SizedBox(height: AppSpacing.md),
_Controls(
controller: c,
starting: _starting,
onStart: () => unawaited(_start()),
onAgain: _again,
onClose: () => Navigator.of(context).maybePop(),
),
],
),
),
),
),
);
},
);
}

View File

@ -0,0 +1,247 @@
// The run screen's three bands: status icons, the big numbers, the buttons.
part of 'run_screen.dart';
IconData _sportIcon(RunSport sport) => switch (sport) {
RunSport.running => Icons.directions_run,
RunSport.walking => Icons.directions_walk,
};
/// Problems with the recording, as an icon (null when there is none).
({IconData icon, bool danger})? _issueFace(RunIssue issue) => switch (issue) {
RunIssue.none => null,
RunIssue.denied ||
RunIssue.deniedForever ||
RunIssue.serviceOff => (icon: Icons.location_disabled, danger: true),
RunIssue.mockedLocation => (icon: Icons.wrong_location, danger: true),
RunIssue.streamError => (icon: Icons.gps_off, danger: false),
RunIssue.tooShort => (icon: Icons.error_outline, danger: true),
};
class _StatusRow extends StatelessWidget {
const _StatusRow({
required this.controller,
required this.uploader,
required this.openSettings,
});
final RunController controller;
final RunUploader uploader;
final Future<bool> Function() openSettings;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final colors = Theme.of(context).extension<AppStatusColors>()!;
final recorder = controller.recorder;
final issue = _issueFace(recorder.issue);
final fixable =
recorder.issue == RunIssue.deniedForever ||
recorder.issue == RunIssue.serviceOff;
final serviceIcon = switch (controller.serviceState) {
RunServiceState.runningHidden => (Icons.notifications_off, false),
RunServiceState.failed => (Icons.warning_amber, true),
_ => null,
};
final saved = controller.saved;
return SizedBox(
height: 48,
child: Row(
children: [
RunUploadBadge(uploader: uploader),
const Spacer(),
if (controller.saving)
const SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(
key: Key('run_saving'),
strokeWidth: 2,
),
),
if (saved != null)
Icon(
saved.stored ? Icons.check_circle : Icons.error,
key: Key(saved.stored ? 'run_saved_ok' : 'run_saved_failed'),
color: saved.stored ? colors.success : scheme.error,
semanticLabel: saved.stored ? 'saved' : 'not saved',
),
if (serviceIcon != null)
Padding(
padding: const EdgeInsets.only(left: AppSpacing.sm),
child: Icon(
serviceIcon.$1,
key: const Key('run_service_issue'),
color: serviceIcon.$2 ? scheme.error : colors.warning,
semanticLabel: controller.serviceState.name,
),
),
if (issue != null)
IconButton(
key: Key('run_issue_${recorder.issue.name}'),
onPressed: fixable ? () => unawaited(openSettings()) : null,
icon: Icon(
issue.icon,
color: issue.danger ? scheme.error : colors.warning,
semanticLabel: recorder.issue.name,
),
),
Padding(
padding: const EdgeInsets.only(left: AppSpacing.sm),
child: Icon(
_sportIcon(recorder.sport),
color: scheme.onSurfaceVariant,
semanticLabel: recorder.sport.name,
),
),
],
),
);
}
}
class _Numbers extends StatelessWidget {
const _Numbers({required this.recorder});
final RunRecorder recorder;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
const figures = [FontFeature.tabularFigures()];
Widget big(Key key, String text, double size, Color color) => FittedBox(
fit: BoxFit.scaleDown,
child: Text(
text,
key: key,
style: TextStyle(
fontSize: size,
fontWeight: FontWeight.w600,
color: color,
fontFeatures: figures,
),
),
);
Widget small(IconData icon, Key key, String text) => Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: scheme.onSurfaceVariant),
const SizedBox(width: AppSpacing.sm),
Flexible(child: big(key, text, 48, scheme.onSurface)),
],
),
);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: big(
const Key('run_time'),
formatDuration(recorder.seconds),
96,
scheme.onSurface,
),
),
const SizedBox(height: AppSpacing.sm),
Row(
children: [
small(
Icons.straighten,
const Key('run_distance'),
formatKm(recorder.distanceM),
),
small(
Icons.speed,
const Key('run_pace'),
formatPace(recorder.pace),
),
],
),
],
);
}
}
class _Controls extends StatelessWidget {
const _Controls({
required this.controller,
required this.starting,
required this.onStart,
required this.onAgain,
required this.onClose,
});
final RunController controller;
final bool starting;
final VoidCallback onStart;
final VoidCallback onAgain;
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
final recorder = controller.recorder;
final scheme = Theme.of(context).colorScheme;
Widget button(
String id,
IconData icon,
VoidCallback? onTap, {
Color? tint,
}) {
return IconButton.filled(
key: Key('run_$id'),
onPressed: onTap,
iconSize: 40,
style: IconButton.styleFrom(
fixedSize: const Size.square(88),
backgroundColor: tint ?? scheme.primary,
foregroundColor: scheme.onPrimary,
),
icon: Icon(icon, semanticLabel: id),
);
}
final buttons = switch (recorder.phase) {
RunPhase.idle => [
SegmentedButton<RunSport>(
key: const Key('run_sport'),
showSelectedIcon: false,
segments: [
for (final sport in RunSport.values)
ButtonSegment(
value: sport,
icon: Icon(_sportIcon(sport), semanticLabel: sport.name),
),
],
selected: {recorder.sport},
onSelectionChanged: (s) => recorder.selectSport(s.single),
),
button('start', Icons.play_arrow, starting ? null : onStart),
],
RunPhase.recording => [
button('pause', Icons.pause, controller.pause),
button(
'finish',
Icons.stop,
() => unawaited(controller.finish()),
tint: scheme.error,
),
],
RunPhase.paused => [
button('resume', Icons.play_arrow, controller.resume),
button(
'finish',
Icons.stop,
() => unawaited(controller.finish()),
tint: scheme.error,
),
],
RunPhase.finished => [
button('again', Icons.refresh, controller.saving ? null : onAgain),
button('close', Icons.check, controller.saving ? null : onClose),
],
};
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: buttons,
);
}
}

View File

@ -0,0 +1,40 @@
/// The place on the run screen where the live map goes.
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/ui/theme.dart';
/// Placeholder for the live map: replace the body of [build] with the map
/// widget, keeping the [ValueListenable] as its source of points.
///
/// `RunScreen` fills this space from `mapBuilder`, defaulting to this widget,
/// so a map can also be dropped in by passing a builder with no edit here.
class RunMapSlot extends StatelessWidget {
/// Creates the slot; [points] updates with every accepted GPS fix.
const RunMapSlot({required this.points, super.key});
/// The accepted points of the run so far, oldest first.
final ValueListenable<List<RunPoint>> points;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
key: const Key('run_map_slot'),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Center(
child: Icon(
Icons.map_outlined,
size: 48,
color: scheme.onSurfaceVariant,
semanticLabel: 'Map',
),
),
);
}
}

View File

@ -0,0 +1,69 @@
/// The upload queue as an icon (and, while runs wait, a count).
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:workout_app/services/run_uploader.dart';
import 'package:workout_app/ui/theme.dart';
/// The icon for a queue state; shared by the badge and its tests.
IconData uploadBadgeIcon(UploadHealth health) => switch (health) {
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,
};
/// Shows [uploader]'s state; a tap runs the queue again right now.
class RunUploadBadge extends StatelessWidget {
/// Creates a badge over [uploader].
const RunUploadBadge({required this.uploader, super.key});
/// The queue shown.
final RunUploader uploader;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
return ValueListenableBuilder<RunUploadStatus>(
valueListenable: uploader.status,
builder: (context, s, _) {
final color = switch (s.health) {
UploadHealth.idle => status.success,
UploadHealth.uploading => scheme.primary,
UploadHealth.retrying => status.warning,
UploadHealth.notConfigured => scheme.error,
UploadHealth.sandbox => scheme.onSurfaceVariant,
};
return InkResponse(
key: const Key('run_upload_badge'),
onTap: () => unawaited(uploader.uploadPending()),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.sm),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
uploadBadgeIcon(s.health),
color: color,
semanticLabel: s.health.name,
),
if (s.pending > 0) ...[
const SizedBox(width: AppSpacing.xs),
Text(
'${s.pending}',
key: const Key('run_upload_pending'),
style: TextStyle(color: color, fontSize: AppTextSize.body),
),
],
],
),
),
);
},
);
}
}

View File

@ -1,6 +1,8 @@
/// A recording fake for the run foreground service.
library;
import 'package:audioplayers/audioplayers.dart';
import 'package:workout_app/services/feedback_cues.dart';
import 'package:workout_app/services/foreground_run_client.dart';
/// Stands in for `flutter_foreground_task` on the run screen's side.
@ -71,3 +73,24 @@ class FakeForegroundRunClient implements ForegroundRunClient {
@override
Future<void> stop() async => calls.add('stop');
}
/// Cues that touch neither the database nor a speaker, and count kilometres.
class QuietCues extends FeedbackCues {
/// Creates cues over a player (needs the fake audio platform installed).
QuietCues() : super(AudioPlayer());
/// Kilometres announced.
final kilometres = <int>[];
/// How many times the finish cue fired.
int finishes = 0;
@override
Future<void> load() async {}
@override
Future<void> kilometreReached(int km) async => kilometres.add(km);
@override
Future<void> workoutFinished() async => finishes++;
}

View File

@ -0,0 +1,28 @@
/// A queue whose state the test sets by hand.
library;
import 'package:flutter/foundation.dart';
import 'package:workout_app/services/run_uploader.dart';
/// Stands in for [RunUploader] where a widget only reads its state.
class FakeRunUploader extends RunUploader {
/// Creates a fake queue at [initial].
FakeRunUploader([
RunUploadStatus initial = const RunUploadStatus(UploadHealth.idle, 0),
]) : state = ValueNotifier(initial);
/// The state the badge listens to; assign `.value` to change it.
final ValueNotifier<RunUploadStatus> state;
/// How many times a pass was requested.
int passes = 0;
@override
ValueListenable<RunUploadStatus> get status => state;
@override
Future<RunUploadStatus> uploadPending() async {
passes++;
return state.value;
}
}

View File

@ -0,0 +1,90 @@
// 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 'package:flutter/foundation.dart';
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_controller.dart';
import 'package:workout_app/services/run_saver.dart';
import 'package:workout_app/ui/theme.dart';
import '../fake_run_service.dart';
import '../fake_run_uploader.dart';
import '../run_test_fixtures.dart';
/// Everything a test needs to drive and inspect one screen.
class RunHarness {
RunHarness({
LocationAccess access = LocationAccess.granted,
ManualPositionSource? source,
FakeForegroundRunClient? service,
this.saveThrows = false,
}) : source = source ?? ManualPositionSource(access: access),
service = service ?? FakeForegroundRunClient();
final ManualPositionSource source;
final FakeForegroundRunClient service;
final bool saveThrows;
final uploader = FakeRunUploader();
final cues = QuietCues();
final saved = <RunTrack>[];
final settingsOpened = <int>[];
final navigator = GlobalKey<NavigatorState>();
RunController build() => RunController(
sourceFor: (_) => source,
service: service,
cues: cues,
save: (track) async {
if (saveThrows) throw Exception('disk full');
saved.add(track);
return const RunSaveResult(stored: true, reason: 'saved');
},
);
Widget app() => MaterialApp(
navigatorKey: navigator,
theme: buildAppTheme(),
home: const SizedBox(),
);
RunScreen screen({
Widget Function(BuildContext, ValueListenable<List<RunPoint>>)? mapBuilder,
}) => RunScreen(
controllerFactory: build,
uploader: uploader,
mapBuilder: mapBuilder,
openSettings: () async {
settingsOpened.add(1);
return true;
},
);
/// Pumps the app with the run screen pushed on top of an empty route.
Future<void> pump(
WidgetTester tester, {
Widget Function(BuildContext, ValueListenable<List<RunPoint>>)? mapBuilder,
}) async {
await tester.pumpWidget(app());
navigator.currentState!.push(
MaterialPageRoute<void>(builder: (_) => screen(mapBuilder: mapBuilder)),
);
await tester.pumpAndSettle();
}
/// Feeds [count] fixes 2 s apart at 3 m/s and lets the screen redraw.
Future<void> run(WidgetTester tester, int count, {int from = 0}) async {
straightRun(count, startSecond: from * 2).forEach(source.controller.add);
await tester.pumpAndSettle();
}
}
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.
await tester.pumpAndSettle();
}

View File

@ -0,0 +1,329 @@
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';
String _text(String id) =>
(find.byKey(Key(id)).evaluate().single.widget as Text).data!;
bool _enabled(WidgetTester tester, String id) =>
tester.widget<IconButton>(key(id)).onPressed != null;
class _GatedSource extends ManualPositionSource {
final gate = Completer<void>();
@override
Future<LocationAccess> requestAccess() async {
await gate.future;
return LocationAccess.granted;
}
}
void main() {
setUp(installFakeAudioPlatform);
testWidgets('idle: sport toggle, start, zeros and the map slot', (
tester,
) async {
final h = RunHarness();
await h.pump(tester);
expect(key('run_start'), findsOneWidget);
expect(key('run_sport'), findsOneWidget);
expect(key('run_pause'), findsNothing);
expect(key('run_map_slot'), findsOneWidget);
expect(find.byType(RunMapSlot), findsOneWidget);
expect(_text('run_time'), '0:00');
expect(_text('run_distance'), '0.00');
expect(_text('run_pace'), '--:--');
expect(key('run_upload_badge'), findsOneWidget);
expect(h.uploader.passes, 1);
});
testWidgets('the sport can be switched until the run starts', (tester) async {
final h = RunHarness();
await h.pump(tester);
await tester.tap(find.byIcon(Icons.directions_walk));
await tester.pumpAndSettle();
expect(
find.descendant(
of: key('run_sport'),
matching: find.byIcon(Icons.directions_walk),
),
findsOneWidget,
);
await tapKey(tester, 'run_start');
expect(find.byIcon(Icons.directions_walk), findsOneWidget);
expect(key('run_sport'), findsNothing);
expect(h.service.starts, [true]);
});
testWidgets('a run shows live numbers and can pause, resume, finish', (
tester,
) async {
final h = RunHarness();
await h.pump(tester);
await tapKey(tester, 'run_start');
expect(key('run_start'), findsNothing);
expect(key('run_pause'), findsOneWidget);
await h.run(tester, 8);
expect(_text('run_time'), '0:14');
expect(_text('run_distance'), '0.04');
expect(_text('run_pace'), '5:33');
await tapKey(tester, 'run_pause');
expect(key('run_resume'), findsOneWidget);
expect(key('run_pause'), findsNothing);
await h.run(tester, 3, from: 8);
expect(_text('run_time'), '0:14');
await tapKey(tester, 'run_resume');
expect(key('run_pause'), findsOneWidget);
await tapKey(tester, 'run_finish');
await tester.pumpAndSettle();
expect(key('run_saved_ok'), findsOneWidget);
expect(h.saved.single.points, hasLength(8));
expect(h.cues.finishes, 1);
expect(h.service.calls.last, 'stop');
expect(key('run_again'), findsOneWidget);
expect(key('run_close'), findsOneWidget);
expect(key('run_finish'), findsNothing);
});
testWidgets('finishing from pause works too', (tester) async {
final h = RunHarness();
await h.pump(tester);
await tapKey(tester, 'run_start');
await h.run(tester, 4);
await tapKey(tester, 'run_pause');
await tapKey(tester, 'run_finish');
expect(key('run_saved_ok'), findsOneWidget);
});
testWidgets('"again" starts a fresh recording, "close" leaves', (
tester,
) async {
final h = RunHarness();
await h.pump(tester);
await tapKey(tester, 'run_start');
await h.run(tester, 4);
await tapKey(tester, 'run_finish');
await tapKey(tester, 'run_again');
expect(key('run_start'), findsOneWidget);
expect(_text('run_distance'), '0.00');
expect(key('run_saved_ok'), findsNothing);
await tapKey(tester, 'run_start');
await tapKey(tester, 'run_finish');
await tester.pumpAndSettle();
await tapKey(tester, 'run_close');
await tester.pumpAndSettle();
expect(find.byType(RunScreen), findsNothing);
});
testWidgets('back does nothing while recording, works before and after', (
tester,
) async {
final h = RunHarness();
await h.pump(tester);
await tapKey(tester, 'run_start');
await h.run(tester, 4);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(RunScreen), findsOneWidget);
await tapKey(tester, 'run_pause');
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(RunScreen), findsOneWidget);
await tapKey(tester, 'run_finish');
await tester.pumpAndSettle();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(RunScreen), findsNothing);
});
testWidgets('start is disabled while the permission prompt is open', (
tester,
) async {
final source = _GatedSource();
final h = RunHarness(source: source);
await h.pump(tester);
expect(_enabled(tester, 'run_start'), isTrue);
await tester.tap(key('run_start'));
await tester.pumpAndSettle();
expect(_enabled(tester, 'run_start'), isFalse);
source.gate.complete();
await tester.pumpAndSettle();
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', (
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));
});
}