mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
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:
parent
770a88454d
commit
66e9948777
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
153
stronglift_replacement/workout_app/lib/screens/run_screen.dart
Normal file
153
stronglift_replacement/workout_app/lib/screens/run_screen.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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++;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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));
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user