diff --git a/stronglift_replacement/workout_app/lib/screens/home_screen.dart b/stronglift_replacement/workout_app/lib/screens/home_screen.dart index 07ea1f3..da8c424 100644 --- a/stronglift_replacement/workout_app/lib/screens/home_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/home_screen.dart @@ -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 { 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', diff --git a/stronglift_replacement/workout_app/lib/screens/home_screen_navigation.dart b/stronglift_replacement/workout_app/lib/screens/home_screen_navigation.dart index 75eb694..6bfe4c6 100644 --- a/stronglift_replacement/workout_app/lib/screens/home_screen_navigation.dart +++ b/stronglift_replacement/workout_app/lib/screens/home_screen_navigation.dart @@ -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 _openRun() async { + SandboxLog.event('open run screen'); + await Navigator.of( + context, + ).push(MaterialPageRoute(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, diff --git a/stronglift_replacement/workout_app/lib/screens/run_screen.dart b/stronglift_replacement/workout_app/lib/screens/run_screen.dart new file mode 100644 index 0000000..3013b92 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/run_screen.dart @@ -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>)? + mapBuilder; + + /// Opens the system settings page for this app. + final Future Function() openSettings; + + @override + State createState() => _RunScreenState(); +} + +class _RunScreenState extends State { + 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 _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(), + ), + ], + ), + ), + ), + ), + ); + }, + ); +} diff --git a/stronglift_replacement/workout_app/lib/screens/run_screen_parts.dart b/stronglift_replacement/workout_app/lib/screens/run_screen_parts.dart new file mode 100644 index 0000000..883ccb3 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/run_screen_parts.dart @@ -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 Function() openSettings; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final colors = Theme.of(context).extension()!; + 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( + 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, + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/widgets/run_map_slot.dart b/stronglift_replacement/workout_app/lib/widgets/run_map_slot.dart new file mode 100644 index 0000000..112d645 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/run_map_slot.dart @@ -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> 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', + ), + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/widgets/run_upload_badge.dart b/stronglift_replacement/workout_app/lib/widgets/run_upload_badge.dart new file mode 100644 index 0000000..d4a19d4 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/run_upload_badge.dart @@ -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()!; + return ValueListenableBuilder( + 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), + ), + ], + ], + ), + ), + ); + }, + ); + } +} diff --git a/stronglift_replacement/workout_app/test/fake_run_service.dart b/stronglift_replacement/workout_app/test/fake_run_service.dart index df68651..c2f74ed 100644 --- a/stronglift_replacement/workout_app/test/fake_run_service.dart +++ b/stronglift_replacement/workout_app/test/fake_run_service.dart @@ -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 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 = []; + + /// How many times the finish cue fired. + int finishes = 0; + + @override + Future load() async {} + + @override + Future kilometreReached(int km) async => kilometres.add(km); + + @override + Future workoutFinished() async => finishes++; +} diff --git a/stronglift_replacement/workout_app/test/fake_run_uploader.dart b/stronglift_replacement/workout_app/test/fake_run_uploader.dart new file mode 100644 index 0000000..02dd852 --- /dev/null +++ b/stronglift_replacement/workout_app/test/fake_run_uploader.dart @@ -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 state; + + /// How many times a pass was requested. + int passes = 0; + + @override + ValueListenable get status => state; + + @override + Future uploadPending() async { + passes++; + return state.value; + } +} diff --git a/stronglift_replacement/workout_app/test/screens/_run_screen_fixtures.dart b/stronglift_replacement/workout_app/test/screens/_run_screen_fixtures.dart new file mode 100644 index 0000000..0becb3e --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/_run_screen_fixtures.dart @@ -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 = []; + final settingsOpened = []; + final navigator = GlobalKey(); + + 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>)? 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 pump( + WidgetTester tester, { + Widget Function(BuildContext, ValueListenable>)? mapBuilder, + }) async { + await tester.pumpWidget(app()); + navigator.currentState!.push( + MaterialPageRoute(builder: (_) => screen(mapBuilder: mapBuilder)), + ); + await tester.pumpAndSettle(); + } + + /// Feeds [count] fixes 2 s apart at 3 m/s and lets the screen redraw. + Future 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 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(); +} diff --git a/stronglift_replacement/workout_app/test/screens/run_screen_test.dart b/stronglift_replacement/workout_app/test/screens/run_screen_test.dart new file mode 100644 index 0000000..429bf07 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/run_screen_test.dart @@ -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(key(id)).onPressed != null; + +class _GatedSource extends ManualPositionSource { + final gate = Completer(); + + @override + Future 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(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)); + }); +}