One-mode run, pace target, idle map

Remove the run/walk toggle: every recording uses the 25 km/h running
filter and writes a Running TCX; decodeTcx still reads walking files.
Add the optional pace target (stepper, 30 s current pace, band,
throttled cues, persisted). The idle map follows the user's fix; refused
location shows only the red icon, no map and no start.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 18:47:53 +01:00
parent d0bb8d700e
commit 48d21bfafc
59 changed files with 2675 additions and 402 deletions

View File

@ -16,12 +16,17 @@ Implementing design_v2.md improvements.
sync layer owns that database's ordering)
## GPS runs (foundation, 2026-11-10)
- The app records runs/walks itself and uploads a TCX the PC credits unchanged.
- The app records one mode: every GPS activity is a run (25 km/h jump cap,
`Sport="Running"`, `_Running.tcx`); the PC credits all with one rule. No
run/walk toggle. `decodeTcx` still reads old `_Walking.tcx` imports. Optional
pace target (`pace_band.dart`, picker `run_pace_picker.dart`); idle map follows
the user's fix (`idle_location.dart`, display-only); location refused = no
map and no start, only the red icon. The app uploads a TCX the PC credits unchanged.
Contract = the PC parser, pinned by `screen_locker/tests/fixtures/run_tcx/`
(Dart `test/services/tcx_golden_fixture_test.dart` <-> pytest
`screen_locker/tests/test_run_tcx_golden.py`). Regenerate after a deliberate
codec change: `UPDATE_GOLDEN=1 flutter test test/services/tcx_golden_fixture_test.dart`.
- File name `RunnerUp_YYYY-MM-DD-HH-MM-SS_<Running|Walking>.tcx`, stamp in
- File name `RunnerUp_YYYY-MM-DD-HH-MM-SS_<Running>.tcx` (historic: `Walking`), stamp in
LOCAL time; `<Id>`/`<Time>` UTC `Z`; source tag `kuhy workout_app` in
`Author/Name` + `Creator/Name`.
- Trackpoints must be <= 30 s apart with cumulative `DistanceMeters`, or the PC

View File

@ -1,13 +1,12 @@
/// The sandbox's GPS: a synthetic loop replayed faster than real time.
///
/// There is no bundled asset (that would need a pubspec entry): the track is a
/// pure function of the start time and sport, so every replay gets its own
/// pure function of the start time, so every replay gets its own
/// `RunTrack.id` and two replays never collide in the `runs` table.
library;
import 'dart:math' as math;
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/sandbox/sandbox.dart';
import 'package:workout_app/services/gps_filter.dart';
import 'package:workout_app/services/position_source.dart';
@ -24,19 +23,14 @@ const int kFixStepSeconds = 2;
const double _centreLat = 50;
const double _centreLon = 10;
/// Pace of the replay: 10 km/h for a run, a stroll for a walk (a run's speed
/// would trip the walking jump cap and credit nothing).
double replaySpeedMps(RunSport sport) => switch (sport) {
RunSport.running => 10 / 3.6,
RunSport.walking => 5 / 3.6,
};
/// Pace of the replay: 10 km/h.
const double kReplaySpeedMps = 10 / 3.6;
/// Fixes for one lap of a [kLoopLengthM] circle starting at [start], one every
/// [kFixStepSeconds], 5 m accuracy.
List<GpsFix> sandboxLoopFixes(DateTime start, RunSport sport) {
List<GpsFix> sandboxLoopFixes(DateTime start) {
const radius = kLoopLengthM / (2 * math.pi);
final steps = (kLoopLengthM / replaySpeedMps(sport) / kFixStepSeconds)
.round();
final steps = (kLoopLengthM / kReplaySpeedMps / kFixStepSeconds).round();
const metresPerDegree = 111194.9;
final lonScale = metresPerDegree * math.cos(_centreLat * math.pi / 180);
return [
@ -56,15 +50,21 @@ List<GpsFix> sandboxLoopFixes(DateTime start, RunSport sport) {
/// The sandbox's [PositionSource]: the loop at [Sandbox.runReplayFactor] times
/// real speed, starting now.
PositionSource sandboxRunSource(RunSport sport, {DateTime? now}) =>
ScriptedPositionSource(
sandboxLoopFixes(now ?? DateTime.now(), sport),
fixDelay: Duration(
microseconds: kFixStepSeconds * 1000000 ~/ Sandbox.runReplayFactor,
),
);
PositionSource sandboxRunSource({DateTime? now}) => ScriptedPositionSource(
sandboxLoopFixes(now ?? DateTime.now()),
fixDelay: Duration(
microseconds: kFixStepSeconds * 1000000 ~/ Sandbox.runReplayFactor,
),
);
/// The source a recording uses: the replay in the sandbox, the phone's GPS
/// otherwise.
PositionSource positionSourceFor(RunSport sport) =>
Sandbox.enabled ? sandboxRunSource(sport) : GeolocatorPositionSource();
PositionSource positionSourceFor() =>
Sandbox.enabled ? sandboxRunSource() : GeolocatorPositionSource();
/// The source the idle map watches: in the sandbox just the replay's first
/// fix (so the marker sits at the loop's start and does not walk the lap
/// before the run), the phone's GPS otherwise.
PositionSource idlePositionSourceFor({DateTime? now}) => Sandbox.enabled
? ScriptedPositionSource([sandboxLoopFixes(now ?? DateTime.now()).first])
: GeolocatorPositionSource();

View File

@ -1,8 +1,9 @@
/// The live run/walk screen: sport, start/pause/finish, and big numbers.
/// The live run screen: map, big numbers, pace target, start/pause/finish.
///
/// 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.
/// system settings. With location refused there is no map and no start, only
/// the red problem icon; coming back from settings with a grant recovers it.
library;
import 'dart:async';
@ -10,6 +11,7 @@ import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/sandbox/sandbox_log.dart';
@ -17,6 +19,8 @@ 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/pace_band.dart';
import 'package:workout_app/services/pace_target_store.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';
@ -25,14 +29,17 @@ 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_pace_picker.dart';
import 'package:workout_app/widgets/run_upload_badge.dart';
part 'run_screen_numbers.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,
idleSourceFor: idlePositionSourceFor,
service: FlutterForegroundRunClient(),
cues: FeedbackCues(AudioPlayer()),
save: (track) => saveRecordedRun(
@ -42,13 +49,23 @@ RunController buildRunController() => RunController(
),
);
/// Records one run or walk.
/// Builds the live map from the run's accepted points and the user's current
/// position (shown until the run has points).
typedef RunMapBuilder =
Widget Function(
BuildContext context,
ValueListenable<List<RunPoint>> points,
ValueListenable<LatLng?> position,
);
/// Records one GPS activity (always a run).
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.paceStore,
this.mapBuilder,
this.openSettings = openAppSettings,
});
@ -59,10 +76,11 @@ class RunScreen extends StatefulWidget {
/// 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;
/// Where the last pace target is kept; defaults to shared_preferences.
final PaceTargetStore? paceStore;
/// Builds the live map; defaults to [RunMapSlot].
final RunMapBuilder? mapBuilder;
/// Opens the system settings page for this app.
final Future<bool> Function() openSettings;
@ -71,27 +89,55 @@ class RunScreen extends StatefulWidget {
State<RunScreen> createState() => _RunScreenState();
}
class _RunScreenState extends State<RunScreen> {
class _RunScreenState extends State<RunScreen> with WidgetsBindingObserver {
late RunController _controller = widget.controllerFactory();
late final RunUploader _uploader = widget.uploader ?? RunUploader.instance;
late final PaceTargetStore _paceStore =
widget.paceStore ?? PrefsPaceTargetStore();
bool _starting = false;
bool _paceOn = false;
int _paceTarget = kDefaultPaceTarget;
@override
void initState() {
super.initState();
SandboxLog.event('open run');
WidgetsBinding.instance.addObserver(this);
unawaited(_controller.watchIdle());
unawaited(_loadPaceTarget());
// So the badge shows the real queue, not the pre-first-pass default.
unawaited(_uploader.uploadPending());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// A silent re-check: the permission prompt itself backgrounds the app, so
// asking again here would loop.
if (state == AppLifecycleState.resumed) {
unawaited(_controller.recheckAccess());
}
}
Future<void> _loadPaceTarget() async {
final stored = await _paceStore.load();
if (mounted) setState(() => _paceTarget = stored);
}
void _setPaceTarget(int seconds) {
setState(() => _paceTarget = seconds);
unawaited(_paceStore.save(seconds));
}
Future<void> _start() async {
setState(() => _starting = true);
_controller.recorder.setPaceTarget(_paceOn ? _paceTarget : null);
try {
await _controller.start();
} finally {
@ -102,15 +148,30 @@ class _RunScreenState extends State<RunScreen> {
void _again() {
final old = _controller;
setState(() => _controller = widget.controllerFactory());
unawaited(_controller.watchIdle());
old.dispose();
}
/// Fixable refusals open the system settings; a plain denial asks again.
VoidCallback? _issueTap(RunIssue issue) => switch (issue) {
RunIssue.deniedForever ||
RunIssue.serviceOff => () => unawaited(widget.openSettings()),
RunIssue.denied => () => unawaited(_controller.watchIdle()),
_ => null,
};
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final c = _controller;
final r = c.recorder;
final blocked = c.blocked;
final issue = _IssueButton(
issue: r.issue,
onTap: _issueTap(r.issue),
size: blocked ? 96 : 24,
);
return PopScope(
// A recording lives in this screen's controller: leaving would lose it.
canPop: !c.active,
@ -123,25 +184,38 @@ class _RunScreenState extends State<RunScreen> {
_StatusRow(
controller: c,
uploader: _uploader,
openSettings: widget.openSettings,
issue: blocked ? null : issue,
),
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(),
),
if (blocked)
Expanded(child: Center(child: issue))
else ...[
Expanded(
flex: 4,
child: KeyedSubtree(
key: ObjectKey(c),
child: (widget.mapBuilder ?? _defaultMap)(
context,
r.livePoints,
c.position,
),
),
),
const SizedBox(height: AppSpacing.md),
Expanded(flex: 3, child: _Numbers(recorder: r)),
const SizedBox(height: AppSpacing.md),
_Controls(
controller: c,
starting: _starting,
paceOn: _paceOn,
paceTarget: _paceTarget,
onPaceToggle: () => setState(() => _paceOn = !_paceOn),
onPaceChanged: _setPaceTarget,
onStart: () => unawaited(_start()),
onAgain: _again,
onClose: () => Navigator.of(context).maybePop(),
),
],
],
),
),
@ -151,3 +225,9 @@ class _RunScreenState extends State<RunScreen> {
},
);
}
Widget _defaultMap(
BuildContext context,
ValueListenable<List<RunPoint>> points,
ValueListenable<LatLng?> position,
) => RunMapSlot(points: points, position: position);

View File

@ -0,0 +1,87 @@
// The run screen's big numbers: moving time, distance, the run's average pace
// and, with a pace target on, the current pace coloured by the band.
part of 'run_screen.dart';
class _Numbers extends StatelessWidget {
const _Numbers({required this.recorder});
final RunRecorder recorder;
/// Green on pace, red off it, plain while there is no verdict yet.
Color _paceColour(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final colors = Theme.of(context).extension<AppStatusColors>()!;
final band = recorder.coach.band;
if (band == null) return scheme.onSurface;
return band == PaceBand.onPace ? colors.success : scheme.error;
}
@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, {Color? colour}) =>
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: scheme.onSurfaceVariant),
const SizedBox(width: AppSpacing.xs),
Flexible(child: big(key, text, 48, colour ?? scheme.onSurface)),
],
),
);
final current = recorder.coach.pace;
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'),
formatDistanceKm(recorder.distanceM),
),
small(
Icons.speed,
const Key('run_pace'),
formatPace(
seconds: recorder.seconds,
distanceM: recorder.distanceM,
),
),
if (recorder.coach.target != null)
small(
Icons.bolt,
const Key('run_pace_now'),
current == null ? '-:--' : formatPaceSeconds(current.round()),
colour: _paceColour(context),
),
],
),
],
);
}
}

View File

@ -1,11 +1,7 @@
// The run screen's three bands: status icons, the big numbers, the buttons.
// The run screen's bands: status icons and the buttons (the numbers have
// their own part).
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,
@ -17,26 +13,55 @@ IconData _sportIcon(RunSport sport) => switch (sport) {
RunIssue.tooShort => (icon: Icons.error_outline, danger: true),
};
/// The red problem icon; [size] 24 in the status row, large when it is the
/// only thing on the screen (location refused).
class _IssueButton extends StatelessWidget {
const _IssueButton({
required this.issue,
required this.onTap,
required this.size,
});
final RunIssue issue;
final VoidCallback? onTap;
final double size;
@override
Widget build(BuildContext context) {
final face = _issueFace(issue);
if (face == null) return const SizedBox.shrink();
final scheme = Theme.of(context).colorScheme;
final colors = Theme.of(context).extension<AppStatusColors>()!;
return IconButton(
key: Key('run_issue_${issue.name}'),
onPressed: onTap,
iconSize: size,
icon: Icon(
face.icon,
color: face.danger ? scheme.error : colors.warning,
semanticLabel: issue.name,
),
);
}
}
class _StatusRow extends StatelessWidget {
const _StatusRow({
required this.controller,
required this.uploader,
required this.openSettings,
required this.issue,
});
final RunController controller;
final RunUploader uploader;
final Future<bool> Function() openSettings;
/// The problem icon, or null when it is shown elsewhere (or not at all).
final Widget? issue;
@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),
@ -74,100 +99,21 @@ class _StatusRow extends StatelessWidget {
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,
),
),
?issue,
],
),
);
}
}
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'),
formatDistanceKm(recorder.distanceM),
),
small(
Icons.speed,
const Key('run_pace'),
formatPace(
seconds: recorder.seconds,
distanceM: recorder.distanceM,
),
),
],
),
],
);
}
}
class _Controls extends StatelessWidget {
const _Controls({
required this.controller,
required this.starting,
required this.paceOn,
required this.paceTarget,
required this.onPaceToggle,
required this.onPaceChanged,
required this.onStart,
required this.onAgain,
required this.onClose,
@ -175,6 +121,10 @@ class _Controls extends StatelessWidget {
final RunController controller;
final bool starting;
final bool paceOn;
final int paceTarget;
final VoidCallback onPaceToggle;
final ValueChanged<int> onPaceChanged;
final VoidCallback onStart;
final VoidCallback onAgain;
final VoidCallback onClose;
@ -204,20 +154,19 @@ class _Controls extends StatelessWidget {
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),
Flexible(
child: RunPacePicker(
on: paceOn,
seconds: paceTarget,
onToggle: onPaceToggle,
onChanged: onPaceChanged,
),
),
button(
'start',
Icons.play_arrow,
starting || !controller.canStart ? null : onStart,
),
button('start', Icons.play_arrow, starting ? null : onStart),
],
RunPhase.recording => [
button('pause', Icons.pause, controller.pause),

View File

@ -8,8 +8,23 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:vibration/vibration.dart';
import 'package:workout_app/sandbox/sandbox_log.dart';
import 'package:workout_app/services/pace_band.dart';
import 'package:workout_app/services/storage_service.dart';
/// The vibration pattern (pause, buzz, pause, buzz ... in ms) of a pace cue:
/// one long buzz to speed up, three short ones to ease off. Both differ from
/// the kilometre double buzz.
List<int> paceCuePattern(PaceCue cue) => switch (cue) {
PaceCue.tooSlow => const [0, 700],
PaceCue.tooFast => const [0, 90, 70, 90, 70, 90],
};
/// The tone of a pace cue: falling to speed up, rising to ease off.
String paceCueSound(PaceCue cue) => switch (cue) {
PaceCue.tooSlow => 'sounds/pace_slow.wav',
PaceCue.tooFast => 'sounds/pace_fast.wav',
};
/// Cues for recording a set and finishing a workout.
///
/// They reward the RECORDING, never the number recorded: a deloaded or failed
@ -76,4 +91,22 @@ class FeedbackCues {
debugPrint('WorkoutApp: kilometre chime failed to play ($error).');
});
}
/// The runner left the pace band: the cue's buzz pattern and, if allowed,
/// its tone. Silent while on pace (the caller only calls this off pace).
Future<void> paceOff(PaceCue cue) async {
SandboxLog.event('cue pace', {'cue': cue.name, 'sound': soundOn});
if (await Vibration.hasVibrator()) {
// Android/iOS-only: hasVibrator() is false on the Linux test host.
// coverage:ignore-start
unawaited(Vibration.vibrate(pattern: paceCuePattern(cue)));
// coverage:ignore-end
}
if (!soundOn) return;
await _player.play(AssetSource(paceCueSound(cue))).catchError((
Object error,
) {
debugPrint('WorkoutApp: pace tone failed to play ($error).');
});
}
}

View File

@ -58,7 +58,7 @@ enum FixVerdict {
/// Dropped: not strictly later (whole seconds) than the last point.
outOfOrder,
/// Dropped: implies a speed above the sport's cap, i.e. a GPS jump.
/// Dropped: implies a speed above [kRunningMaxSpeedMps], i.e. a GPS jump.
tooFast,
}
@ -66,13 +66,10 @@ enum FixVerdict {
///
/// A step between two accepted points adds distance only when it is a moving
/// step (see [isMovingStep]): a dropout longer than [kMaxCreditGapSeconds]
/// and standing-still jitter credit nothing, same as the PC reads a walk.
/// and standing-still jitter credit nothing, same as the PC reads an activity.
class GpsFilter {
/// Creates a filter for [sport] (which picks the speed cap).
GpsFilter({required this.sport});
/// The sport being recorded.
final RunSport sport;
/// Creates a filter; every recording uses the running jump cap.
GpsFilter();
final List<RunPoint> _points = [];
int _jumps = 0;
@ -121,7 +118,7 @@ class GpsFilter {
fix.latitude,
fix.longitude,
);
if (step / gap > maxSpeedMps(sport)) {
if (step / gap > kRunningMaxSpeedMps) {
_jumps++;
if (_jumps < kMaxConsecutiveJumps) return FixVerdict.tooFast;
// The anchor, not the fixes, was wrong: continue from here, no credit.

View File

@ -0,0 +1,98 @@
/// Where the user is before a run starts: display-only fixes for the map.
///
/// These fixes never touch the recorder, the filter or the track -- they only
/// move the idle map's camera and position marker. The watch ends when the run
/// starts (one location stream at a time), and the recorder's own accepted
/// points take over from there.
library;
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:workout_app/services/gps_filter.dart';
import 'package:workout_app/services/position_source.dart';
/// Watches the position while idle and reports the access it found.
class IdleLocation {
/// Creates a watcher over [_source]; [onAccess] hears every access check.
IdleLocation(this._source, {required this.onAccess});
final PositionSource _source;
/// Told the access after each [open] and [recheck].
final void Function(LocationAccess access) onAccess;
/// The latest fix, or null before the first one (or once access is lost).
final position = ValueNotifier<LatLng?>(null);
/// The last access found, or null before the first check resolves.
LocationAccess? access;
StreamSubscription<GpsFix>? _sub;
bool _closed = false;
/// Asks for access (this may show the system prompt) and, once granted,
/// starts watching.
Future<void> open() async {
_closed = false;
await _apply(await _source.requestAccess());
}
/// Re-reads the access without prompting (the app came back to the
/// foreground); a grant starts the watch, a revocation ends it.
Future<void> recheck() async {
if (_closed) return;
await _apply(await _source.checkAccess());
}
/// Lets [recheck] work again after [stop], without watching yet: for a start
/// that was refused, so coming back from settings can recover the screen.
void reopen() => _closed = false;
/// Ends the watch (a run took over, or the screen is going away).
Future<void> stop() async {
_closed = true;
await _unwatch();
}
Future<void> _apply(LocationAccess found) async {
if (_closed) return;
access = found;
onAccess(found);
if (found.isGranted) {
if (_sub != null) return;
_sub = _source.fixes().listen(_onFix, onError: _onError);
return;
}
position.value = null;
await _unwatch();
}
Future<void> _unwatch() async {
final cancelling = _sub?.cancel();
_sub = null;
await cancelling;
}
void _onFix(GpsFix fix) {
position.value = LatLng(fix.latitude, fix.longitude);
}
void _onError(Object error, StackTrace stack) {
log(
'IdleLocation: the location stream failed ($error) -- the idle map '
'keeps the last known position until fixes resume.',
level: 1000,
error: error,
stackTrace: stack,
);
}
/// Stops watching and releases the position notifier.
Future<void> dispose() async {
await stop();
position.dispose();
}
}

View File

@ -0,0 +1,114 @@
/// The optional pace target: current pace, the band around the target and the
/// cue throttle. Pure Dart, no clock of its own -- everything runs on fix time,
/// so it is deterministic in tests and right at the sandbox's replay speed.
library;
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/services/run_math.dart';
/// The trailing window, in seconds of fix time, the current pace is read over.
const int kPaceWindowSeconds = 30;
/// The window must span at least this long before a pace is reported: the
/// first fixes of a run (or after a resume) say nothing yet.
const int kPaceWarmupSeconds = 20;
/// Half-width of the band around the target, in seconds per kilometre.
const int kPaceToleranceSeconds = 10;
/// The least time between two cues, in seconds of fix time.
const int kPaceCueGapSeconds = 30;
/// Fastest target, 3:00 per kilometre.
const int kMinPaceTarget = 180;
/// Slowest target, 12:00 per kilometre.
const int kMaxPaceTarget = 720;
/// The picker's step, in seconds per kilometre.
const int kPaceStep = 5;
/// [seconds] rounded to a [kPaceStep] and held inside the target range.
int clampPaceTarget(int seconds) => ((seconds / kPaceStep).round() * kPaceStep)
.clamp(kMinPaceTarget, kMaxPaceTarget);
/// Pace in seconds per kilometre over the last [kPaceWindowSeconds] of
/// [points], reading only from index [from] on (the first point after a
/// resume, so the time spent paused never counts as slow running).
///
/// Null while the window spans under [kPaceWarmupSeconds], and null when it
/// moved slower than [kMinMovingSpeedMps]: standing still is not a pace, so it
/// earns neither a colour nor a cue (the pause button is for that).
double? currentPaceSeconds(List<RunPoint> points, {int from = 0}) {
if (points.length - from < 2) return null;
final last = points.last;
var oldest = points.length - 1;
while (oldest > from &&
last.time.difference(points[oldest - 1].time).inSeconds <=
kPaceWindowSeconds) {
oldest--;
}
final span = last.time.difference(points[oldest].time).inMilliseconds / 1000;
if (span < kPaceWarmupSeconds) return null;
final metres = last.distanceM - points[oldest].distanceM;
if (metres / span < kMinMovingSpeedMps) return null;
return span / (metres / 1000);
}
/// Where the current pace stands against the target.
enum PaceBand {
/// Within the tolerance, ends included.
onPace,
/// Slower than the band (more seconds per kilometre).
tooSlow,
/// Faster than the band (fewer seconds per kilometre).
tooFast,
}
/// The band [current] falls in around [target], or null with no pace yet.
PaceBand? paceBandOf({required double? current, required int target}) {
if (current == null) return null;
final off = current - target;
if (off > kPaceToleranceSeconds) return PaceBand.tooSlow;
if (off < -kPaceToleranceSeconds) return PaceBand.tooFast;
return PaceBand.onPace;
}
/// What the runner is told when off pace.
enum PaceCue {
/// Speed up.
tooSlow,
/// Ease off.
tooFast,
}
/// Lets a cue through at most once per [kPaceCueGapSeconds] of fix time.
///
/// On pace it is silent. Coming back into the band does not reset the gap, so
/// drifting in and out cannot nag; and a slow-to-fast flip inside the gap is
/// held back too.
class PaceCueThrottle {
DateTime? _last;
/// The cue for [band] at fix time [at], or null when silent or throttled.
PaceCue? cueFor(PaceBand? band, DateTime at) {
final cue = switch (band) {
PaceBand.tooSlow => PaceCue.tooSlow,
PaceBand.tooFast => PaceCue.tooFast,
PaceBand.onPace || null => null,
};
if (cue == null) return null;
final last = _last;
if (last != null && at.difference(last).inSeconds < kPaceCueGapSeconds) {
return null;
}
_last = at;
return cue;
}
/// Forgets the last cue, so the next off-pace fix speaks at once.
void reset() => _last = null;
}

View File

@ -0,0 +1,48 @@
/// The pace target's running state: current pace, band and throttled cues.
library;
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/services/pace_band.dart';
/// Tracks the current pace as accepted points arrive and fires [onCue] when
/// the runner is off the target (see [PaceCueThrottle]).
class PaceCoach {
/// Creates a coach with no target; [onCue] hears each cue that passes.
PaceCoach({this.onCue});
/// Called with the cue to give.
final void Function(PaceCue cue)? onCue;
final _throttle = PaceCueThrottle();
int _from = 0;
/// The target in seconds per kilometre, or null when off.
int? target;
/// Pace over the last 30 s of accepted fixes, once warmed up.
double? pace;
/// Where [pace] stands against [target]; null with no target or no pace.
PaceBand? get band =>
target == null ? null : paceBandOf(current: pace, target: target!);
/// Sets the target (null turns it off); the next off-pace fix cues at once.
void setTarget(int? seconds) {
target = seconds;
_throttle.reset();
}
/// Starts a fresh window at [index] (the first point after a resume), so
/// the time spent paused never reads as slow running.
void restartAt(int index) {
_from = index;
pace = null;
}
/// Folds in the newest of [points] (accepted, oldest first).
void update(List<RunPoint> points) {
pace = currentPaceSeconds(points, from: _from);
final cue = _throttle.cueFor(band, points.last.time);
if (cue != null) onCue?.call(cue);
}
}

View File

@ -0,0 +1,41 @@
/// Where the last pace target is kept between runs (shared_preferences).
library;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:workout_app/services/pace_band.dart';
/// The preferences key of the last pace target, in seconds per kilometre.
const String kPaceTargetKey = 'run_pace_target_v1';
/// The target the picker starts at when none was ever chosen, 6:00.
const int kDefaultPaceTarget = 360;
/// Reads and writes the last target; an interface so screen tests need no
/// platform channel.
abstract class PaceTargetStore {
/// The stored target, or [kDefaultPaceTarget].
Future<int> load();
/// Stores [seconds] as the last target.
Future<void> save(int seconds);
}
/// The real store, over `SharedPreferencesAsync` (resolved on first use, so
/// merely owning one costs nothing on the test host).
class PrefsPaceTargetStore implements PaceTargetStore {
/// Creates a store over [prefs], defaulting to a fresh async instance.
PrefsPaceTargetStore([SharedPreferencesAsync? prefs]) : _prefs = prefs;
SharedPreferencesAsync? _prefs;
SharedPreferencesAsync get _resolved => _prefs ??= SharedPreferencesAsync();
@override
Future<int> load() async {
final stored = await _resolved.getInt(kPaceTargetKey);
return clampPaceTarget(stored ?? kDefaultPaceTarget);
}
@override
Future<void> save(int seconds) => _resolved.setInt(kPaceTargetKey, seconds);
}

View File

@ -27,9 +27,15 @@ enum LocationAccess {
/// A stream of GPS fixes plus the permission handshake that guards it.
abstract class PositionSource {
/// Asks for (or re-checks) location permission; never throws for a refusal.
/// Asks for location permission (this may show the system prompt); never
/// throws for a refusal.
Future<LocationAccess> requestAccess();
/// Reads the access as it stands, without ever prompting. For re-checks
/// when the app comes back to the foreground: the permission dialog itself
/// backgrounds the app, so re-asking there would loop.
Future<LocationAccess> checkAccess();
/// Fixes roughly every [interval], best accuracy, until cancelled.
///
/// [interval] must stay well under the PC's 30 s credit gap

View File

@ -25,15 +25,26 @@ class GeolocatorPositionSource implements PositionSource {
if (permission == LocationPermission.denied) {
permission = await _platform.requestPermission();
}
return switch (permission) {
LocationPermission.always ||
LocationPermission.whileInUse => LocationAccess.granted,
LocationPermission.deniedForever => LocationAccess.deniedForever,
LocationPermission.denied ||
LocationPermission.unableToDetermine => LocationAccess.denied,
};
return _access(permission);
}
@override
Future<LocationAccess> checkAccess() async {
if (!await _platform.isLocationServiceEnabled()) {
return LocationAccess.serviceOff;
}
return _access(await _platform.checkPermission());
}
static LocationAccess _access(LocationPermission permission) =>
switch (permission) {
LocationPermission.always ||
LocationPermission.whileInUse => LocationAccess.granted,
LocationPermission.deniedForever => LocationAccess.deniedForever,
LocationPermission.denied ||
LocationPermission.unableToDetermine => LocationAccess.denied,
};
@override
Stream<GpsFix> fixes({Duration interval = const Duration(seconds: 2)}) =>
_platform

View File

@ -31,6 +31,9 @@ class ScriptedPositionSource implements PositionSource {
@override
Future<LocationAccess> requestAccess() async => access;
@override
Future<LocationAccess> checkAccess() async => access;
@override
Stream<GpsFix> fixes({
Duration interval = const Duration(seconds: 2),

View File

@ -6,10 +6,13 @@ import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/sandbox/sandbox.dart';
import 'package:workout_app/services/feedback_cues.dart';
import 'package:workout_app/services/foreground_run_client.dart';
import 'package:workout_app/services/idle_location.dart';
import 'package:workout_app/services/pace_band.dart';
import 'package:workout_app/services/position_source.dart';
import 'package:workout_app/services/run_notification.dart';
import 'package:workout_app/services/run_recorder.dart';
@ -22,19 +25,28 @@ import 'package:workout_app/services/run_saver.dart';
/// the permission), then the first fix.
class RunController extends ChangeNotifier {
/// Creates a controller; it builds its own [recorder] over [sourceFor].
///
/// [idleSourceFor] feeds the idle map before the run (display only); it
/// defaults to [sourceFor].
RunController({
required PositionSource Function(RunSport sport) sourceFor,
required PositionSource Function() sourceFor,
required this._service,
required this._cues,
required this._save,
PositionSource Function()? idleSourceFor,
}) {
recorder = RunRecorder(sourceFor: sourceFor, onKilometre: _onKilometre)
..addListener(_onRecorderChanged);
recorder = RunRecorder(
sourceFor: sourceFor,
onKilometre: _onKilometre,
onPaceCue: _onPaceCue,
)..addListener(_onRecorderChanged);
_idle = IdleLocation((idleSourceFor ?? sourceFor)(), onAccess: _onAccess);
}
/// The state machine; the live map reads its points.
late final RunRecorder recorder;
late final IdleLocation _idle;
final ForegroundRunClient _service;
final FeedbackCues _cues;
final Future<RunSaveResult> Function(RunTrack track) _save;
@ -56,10 +68,32 @@ class RunController extends ChangeNotifier {
/// True from start to finish: the screen must not be left meanwhile.
bool get active => recorder.phase.isActive;
/// Where the user is before the run starts (display only, never recorded).
ValueListenable<LatLng?> get position => _idle.position;
/// True while location is refused and the run has not started: the screen
/// shows only the problem icon, no map and no start.
bool get blocked =>
recorder.phase == RunPhase.idle && recorder.issue.isAccess;
/// Whether access has been found granted, so a start cannot race a prompt.
bool get canStart => _idle.access?.isGranted ?? false;
/// Asks for location (this may show the prompt) and starts watching the
/// position for the idle map. Also the retry after a refusal.
Future<void> watchIdle() => _idle.open();
/// Re-reads the access silently (the app came back to the foreground).
Future<void> recheckAccess() =>
recorder.phase == RunPhase.idle ? _idle.recheck() : Future.value();
/// Asks for access, starts the service, begins recording.
Future<void> start() async {
await _idle.stop();
await _cues.load();
await recorder.start(afterAccess: _startService);
final started = await recorder.start(afterAccess: _startService);
// A refused start leaves the screen blocked; let a resume recheck it.
if (!started && recorder.issue.isAccess) _idle.reopen();
}
/// Pauses crediting.
@ -136,11 +170,20 @@ class RunController extends ChangeNotifier {
void _onKilometre(int km) => unawaited(_cues.kilometreReached(km));
void _onPaceCue(PaceCue cue) => unawaited(_cues.paceOff(cue));
void _onAccess(LocationAccess access) {
recorder.noteAccess(access);
// canStart follows the access, which the recorder does not report.
notifyListeners();
}
void _onRecorderChanged() {
if (_serviceState == RunServiceState.running) {
final copy = runNotificationCopy(
metres: recorder.distanceM,
movingSeconds: recorder.seconds,
currentPace: recorder.coach.target == null ? null : recorder.coach.pace,
);
// Only when the words changed: an update per fix would redraw twice a
// second for nothing.
@ -154,6 +197,7 @@ class RunController extends ChangeNotifier {
@override
void dispose() {
unawaited(_idle.dispose());
recorder
..removeListener(_onRecorderChanged)
..dispose();

View File

@ -22,10 +22,12 @@ String formatDuration(int seconds) {
/// covered (the quotient is meaningless, and 0 would divide by zero).
String formatPace({required int seconds, required double distanceM}) {
if (distanceM < 10) return '-:--';
final perKm = (seconds / (distanceM / 1000)).round();
return '${perKm ~/ 60}:${_two(perKm % 60)}';
return formatPaceSeconds((seconds / (distanceM / 1000)).round());
}
/// A pace of [perKm] seconds per kilometre as `m:ss`.
String formatPaceSeconds(int perKm) => '${perKm ~/ 60}:${_two(perKm % 60)}';
/// `dd.MM` of [when] in the device's local time.
String formatRunDay(DateTime when) {
final l = when.toLocal();

View File

@ -22,18 +22,12 @@ const int kMaxCreditGapSeconds = 30;
/// Mirrors the lower bound of `WALK_SPEED_MPS` on the PC.
const double kMinMovingSpeedMps = 0.5;
/// Speed cap above which a fix is a GPS jump, 25 km/h for a run.
/// Speed cap above which a fix is a GPS jump, 25 km/h.
///
/// One cap for every recording: the PC credits every GPS activity with the
/// same rule, so the app no longer tells a run from a walk.
const double kRunningMaxSpeedMps = 25 / 3.6;
/// Speed cap above which a fix is a GPS jump, 8 km/h for a walk.
const double kWalkingMaxSpeedMps = 8 / 3.6;
/// The jump cap for [sport].
double maxSpeedMps(RunSport sport) => switch (sport) {
RunSport.running => kRunningMaxSpeedMps,
RunSport.walking => kWalkingMaxSpeedMps,
};
double _rad(double degrees) => degrees * math.pi / 180;
/// Great-circle distance in metres between two WGS-84 positions.
@ -62,11 +56,10 @@ bool isMovingStep(RunPoint a, RunPoint b, double maxSpeed) {
}
/// Moving seconds over [points]: the summed gaps of every moving step.
int movingSeconds(List<RunPoint> points, RunSport sport) {
final cap = maxSpeedMps(sport);
int movingSeconds(List<RunPoint> points) {
var total = 0.0;
for (var i = 1; i < points.length; i++) {
if (isMovingStep(points[i - 1], points[i], cap)) {
if (isMovingStep(points[i - 1], points[i], kRunningMaxSpeedMps)) {
total += points[i].time.difference(points[i - 1].time).inSeconds;
}
}

View File

@ -1,15 +1,23 @@
/// The two lines of the ongoing run notification (numbers only, textgate).
library;
import 'package:workout_app/services/pace_band.dart';
import 'package:workout_app/services/run_format.dart';
/// Distance as the title; moving time and average pace as the body.
/// Distance as the title; moving time and average pace as the body, plus the
/// current pace (to the nearest [kPaceStep], so GPS jitter does not redraw it
/// every fix) when a pace target is on.
({String title, String body}) runNotificationCopy({
required double metres,
required int movingSeconds,
}) => (
title: formatDistanceKm(metres),
body:
'${formatDuration(movingSeconds)} '
'${formatPace(seconds: movingSeconds, distanceM: metres)}',
);
double? currentPace,
}) {
final average = formatPace(seconds: movingSeconds, distanceM: metres);
final current = currentPace == null
? ''
: ' ${formatPaceSeconds((currentPace / kPaceStep).round() * kPaceStep)}';
return (
title: formatDistanceKm(metres),
body: '${formatDuration(movingSeconds)} $average$current',
);
}

View File

@ -1,9 +1,11 @@
/// The phases and problems of a GPS recording.
library;
import 'package:workout_app/services/position_source.dart';
/// Where a recording stands.
enum RunPhase {
/// Nothing recorded yet; the sport can still be chosen.
/// Nothing recorded yet.
idle,
/// Fixes are being kept and credited.
@ -40,5 +42,16 @@ enum RunIssue {
streamError,
/// Finished with too few usable fixes to make a track; nothing was saved.
tooShort,
tooShort;
/// The issue a refused [access] raises (never [none]; a grant has none).
static RunIssue forAccess(LocationAccess access) => switch (access) {
LocationAccess.deniedForever => deniedForever,
LocationAccess.serviceOff => serviceOff,
_ => denied,
};
/// Whether this is a refused location access (no map, no start).
bool get isAccess =>
this == denied || this == deniedForever || this == serviceOff;
}

View File

@ -1,4 +1,4 @@
/// The GPS run/walk state machine: idle, recording, paused, finished.
/// The GPS activity state machine: idle, recording, paused, finished.
library;
import 'dart:async';
@ -8,30 +8,40 @@ import 'package:flutter/foundation.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/sandbox/sandbox_log.dart';
import 'package:workout_app/services/gps_filter.dart';
import 'package:workout_app/services/pace_band.dart';
import 'package:workout_app/services/pace_coach.dart';
import 'package:workout_app/services/position_source.dart';
import 'package:workout_app/services/run_math.dart';
import 'package:workout_app/services/run_phase.dart';
export 'package:workout_app/services/run_phase.dart';
/// Records one run or walk from a [PositionSource].
/// Records one GPS activity from a [PositionSource].
///
/// Every activity is a run (one jump cap, one `Sport="Running"` TCX): the PC
/// credits every GPS activity with the same rule.
///
/// A pause ignores fixes and breaks the filter's segment, so the stretch
/// walked while paused and the first fix after it credit nothing. Moving time
/// is the PC's rule ([movingSeconds]) applied as fixes arrive, so the number
/// on screen is the number the PC will credit.
class RunRecorder extends ChangeNotifier {
/// Creates a recorder; [sourceFor] builds the fix stream for the chosen
/// sport when [start] is called (the sandbox's replay speed follows it).
RunRecorder({required this.sourceFor, this.onKilometre});
/// Creates a recorder; [sourceFor] builds the fix stream on [start].
RunRecorder({
required this.sourceFor,
this.onKilometre,
void Function(PaceCue cue)? onPaceCue,
}) : coach = PaceCoach(onCue: onPaceCue);
/// Builds the source for one recording.
final PositionSource Function(RunSport sport) sourceFor;
final PositionSource Function() sourceFor;
/// Called with 1, 2, 3 ... as each whole kilometre is credited.
final void Function(int km)? onKilometre;
RunSport _sport = RunSport.running;
/// The pace target's state: current pace, band and cues.
final PaceCoach coach;
RunPhase _phase = RunPhase.idle;
RunIssue _issue = RunIssue.none;
GpsFilter? _filter;
@ -42,9 +52,6 @@ class RunRecorder extends ChangeNotifier {
final _live = ValueNotifier<List<RunPoint>>(const []);
final _seenVerdicts = <FixVerdict>{};
/// Running or walking.
RunSport get sport => _sport;
/// The state machine's phase.
RunPhase get phase => _phase;
@ -60,12 +67,20 @@ class RunRecorder extends ChangeNotifier {
/// Credited distance in metres.
double get distanceM => _filter?.distanceM ?? 0;
/// Picks the sport; only possible while [RunPhase.idle].
bool selectSport(RunSport sport) {
if (_phase != RunPhase.idle) return false;
_sport = sport;
/// Sets the pace target ([seconds] per kilometre), or off with null.
void setPaceTarget(int? seconds) {
coach.setTarget(seconds);
notifyListeners();
}
/// Folds an access check in while idle: a refusal becomes the issue, a
/// grant clears it.
void noteAccess(LocationAccess access) {
if (_phase != RunPhase.idle) return;
if (!access.isGranted) return _refuse(access);
if (!_issue.isAccess) return;
_issue = RunIssue.none;
notifyListeners();
return true;
}
/// Asks for location access, runs [afterAccess] (where the caller starts the
@ -76,20 +91,20 @@ class RunRecorder extends ChangeNotifier {
if (_phase != RunPhase.idle || _starting) return false;
_starting = true;
try {
final source = sourceFor(_sport);
final source = sourceFor();
final access = await source.requestAccess();
if (!access.isGranted) {
_refuse(access);
return false;
}
await afterAccess?.call();
_filter = GpsFilter(sport: _sport);
_filter = GpsFilter();
_sub = source
.fixes() // the source's default 2 s, far under kMaxCreditGapSeconds
.listen(_onFix, onError: _onError, onDone: _onDone);
_issue = RunIssue.none;
_phase = RunPhase.recording;
SandboxLog.event('run started', {'sport': _sport.tcxName});
SandboxLog.event('run started');
notifyListeners();
return true;
} finally {
@ -110,6 +125,7 @@ class RunRecorder extends ChangeNotifier {
void resume() {
if (_phase != RunPhase.paused) return;
_phase = RunPhase.recording;
coach.restartAt(_filter!.points.length);
SandboxLog.event('run resumed', {'m': distanceM});
notifyListeners();
}
@ -122,7 +138,7 @@ class RunRecorder extends ChangeNotifier {
_sub = null;
_phase = RunPhase.finished;
final points = _filter!.points;
_moving = movingSeconds(points, _sport);
_moving = movingSeconds(points);
RunTrack? track;
if (points.length < 2) {
_issue = RunIssue.tooShort;
@ -133,7 +149,7 @@ class RunRecorder extends ChangeNotifier {
);
} else {
track = RunTrack.started(
sport: _sport,
sport: RunSport.running,
startedAt: points.first.time,
points: points,
totalSeconds: _moving,
@ -151,11 +167,7 @@ class RunRecorder extends ChangeNotifier {
}
void _refuse(LocationAccess access) {
_issue = switch (access) {
LocationAccess.deniedForever => RunIssue.deniedForever,
LocationAccess.serviceOff => RunIssue.serviceOff,
_ => RunIssue.denied,
};
_issue = RunIssue.forAccess(access);
final fix = access == LocationAccess.serviceOff
? 'Switch location on'
: 'Grant location to the app in system settings';
@ -178,10 +190,11 @@ class RunRecorder extends ChangeNotifier {
final points = _filter!.points;
final n = points.length;
if (n >= 2 &&
isMovingStep(points[n - 2], points[n - 1], maxSpeedMps(_sport))) {
isMovingStep(points[n - 2], points[n - 1], kRunningMaxSpeedMps)) {
_moving += points[n - 1].time.difference(points[n - 2].time).inSeconds;
}
_live.value = points;
coach.update(points);
while (distanceM ~/ 1000 > _km) {
_km++;
SandboxLog.event('run km', {'km': _km, 'secs': _moving});

View File

@ -8,9 +8,13 @@
/// screen. Pass a `ValueListenable<List<RunPoint>>` (the recorder's
/// accepted points, oldest first); the route
/// redraws on every change and, in follow mode (the default), the camera
/// keeps the last point centred. Dragging the map ends follow mode; the
/// recentre button (icon only) turns it back on. The listenable must stay
/// the same object for the widget's lifetime (give it a new `Key` to swap).
/// keeps the last point centred. With no points yet, an optional
/// `positionListenable` (where the user is right now) stands in: the map
/// centres on it, draws a position marker, and the recentre button jumps to
/// it. Once the run has points they win. Dragging the map ends follow
/// mode; the recentre button (icon only) turns it back on. The listenables
/// must stay the same objects for the widget's lifetime (give it a new
/// `Key` to swap).
///
/// Tiles are OSM raster tiles from tile.openstreetmap.org, cached on disk
/// (`map_tile_cache.dart`) so revisited areas draw offline. The OSM credit is
@ -48,11 +52,13 @@ class RunMap extends StatefulWidget {
super.key,
this.fitPadding = const EdgeInsets.all(40),
this.tileProviderOpener = openOsmTileProvider,
}) : pointsListenable = null;
}) : pointsListenable = null,
positionListenable = null;
/// A live run: follows the last point in [pointsListenable].
const RunMap.live({
required ValueListenable<List<RunPoint>> this.pointsListenable,
this.positionListenable,
super.key,
this.tileProviderOpener = openOsmTileProvider,
}) : points = null,
@ -64,6 +70,9 @@ class RunMap extends StatefulWidget {
/// The accepted points in live mode; null in detail mode.
final ValueListenable<List<RunPoint>>? pointsListenable;
/// Where the user is before the first run point; null in detail mode.
final ValueListenable<LatLng?>? positionListenable;
/// Space kept free around the fitted route (detail mode).
final EdgeInsets fitPadding;
@ -83,14 +92,21 @@ class _RunMapState extends State<RunMap> {
bool get _live => widget.pointsListenable != null;
List<RunPoint> get _points => _live
? widget.pointsListenable!.value
: widget.points!;
List<RunPoint> get _points =>
_live ? widget.pointsListenable!.value : widget.points!;
/// What the camera follows: the last run point, else the current position.
LatLng? get _target {
final points = _points;
if (points.isEmpty) return widget.positionListenable?.value;
return LatLng(points.last.latitude, points.last.longitude);
}
@override
void initState() {
super.initState();
widget.pointsListenable?.addListener(_followLast);
widget.positionListenable?.addListener(_followLast);
unawaited(
widget.tileProviderOpener().then((p) {
if (mounted) setState(() => _tiles = p);
@ -101,15 +117,16 @@ class _RunMapState extends State<RunMap> {
@override
void dispose() {
widget.pointsListenable?.removeListener(_followLast);
widget.positionListenable?.removeListener(_followLast);
_controller.dispose();
super.dispose();
}
void _followLast() {
final coords = toLatLngs(_points);
if (!_ready || !_follow || coords.isEmpty) return;
final target = _target;
if (!_ready || !_follow || target == null) return;
final zoom = _controller.camera.zoom;
_controller.move(coords.last, zoom < 12 ? _closeZoom : zoom);
_controller.move(target, zoom < 12 ? _closeZoom : zoom);
}
void _warnOnce(Object error) {
@ -121,7 +138,7 @@ class _RunMapState extends State<RunMap> {
);
}
MapOptions _options(List<LatLng> coords, ColorScheme scheme) {
MapOptions _options(List<LatLng> coords, ColorScheme scheme, LatLng? here) {
final fit = !_live && coords.length > 1
? CameraFit.coordinates(
coordinates: coords,
@ -130,8 +147,8 @@ class _RunMapState extends State<RunMap> {
)
: null;
return MapOptions(
initialCenter: coords.isEmpty ? _fallbackCenter : coords.last,
initialZoom: coords.isEmpty ? _fallbackZoom : _closeZoom,
initialCenter: coords.isEmpty ? here ?? _fallbackCenter : coords.last,
initialZoom: coords.isEmpty && here == null ? _fallbackZoom : _closeZoom,
initialCameraFit: fit,
backgroundColor: scheme.surfaceContainerHigh,
onMapReady: () {
@ -147,9 +164,10 @@ class _RunMapState extends State<RunMap> {
Widget _map(BuildContext context, List<RunPoint> points) {
final scheme = Theme.of(context).colorScheme;
final coords = toLatLngs(points);
final here = widget.positionListenable?.value;
return FlutterMap(
mapController: _controller,
options: _options(coords, scheme),
options: _options(coords, scheme, here),
children: [
if (_tiles != null)
TileLayer(
@ -159,6 +177,7 @@ class _RunMapState extends State<RunMap> {
errorTileCallback: (tile, error, stack) => _warnOnce(error),
),
if (coords.length > 1) routeLayer(coords, scheme),
if (coords.isEmpty && here != null) positionLayer(here, scheme),
if (coords.isNotEmpty)
endpointLayer(
coords,
@ -178,9 +197,9 @@ class _RunMapState extends State<RunMap> {
return Stack(
fit: StackFit.expand,
children: [
ValueListenableBuilder<List<RunPoint>>(
valueListenable: listenable,
builder: (context, points, _) => _map(context, points),
ListenableBuilder(
listenable: Listenable.merge([listenable, widget.positionListenable]),
builder: (context, _) => _map(context, listenable.value),
),
if (!_follow)
Positioned(

View File

@ -38,6 +38,11 @@ Marker _dot(LatLng at, Color fill, IconData icon, Color ink) => Marker(
),
);
/// The user's own position before any run point exists: the accent dot.
MarkerLayer positionLayer(LatLng at, ColorScheme scheme) => MarkerLayer(
markers: [_dot(at, scheme.primary, Icons.navigation, scheme.surface)],
);
/// Start marker (green play), and either the live position (accent dot) or
/// the finish flag (red); no end marker for a one-point track.
MarkerLayer endpointLayer(

View File

@ -3,19 +3,22 @@ library;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/services/map_tile_cache.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/run_map.dart';
/// The live map on the run screen: [RunMap.live] over the recorder's points,
/// clipped to the card shape.
/// The live map on the run screen: [RunMap.live] over the recorder's points
/// (and, before the first one, the user's current [position]), clipped to the
/// card shape.
///
/// `RunScreen` fills this space from `mapBuilder`, defaulting to this widget.
class RunMapSlot extends StatelessWidget {
/// Creates the slot; [points] updates with every accepted GPS fix.
const RunMapSlot({
required this.points,
this.position,
super.key,
this.tileProviderOpener = openOsmTileProvider,
});
@ -23,6 +26,9 @@ class RunMapSlot extends StatelessWidget {
/// The accepted points of the run so far, oldest first.
final ValueListenable<List<RunPoint>> points;
/// Where the user is now, shown until the run has points; null for none.
final ValueListenable<LatLng?>? position;
/// Where the map's tiles come from; tests pass a fake.
final TileProviderOpener tileProviderOpener;
@ -38,6 +44,7 @@ class RunMapSlot extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: RunMap.live(
pointsListenable: points,
positionListenable: position,
tileProviderOpener: tileProviderOpener,
),
);

View File

@ -0,0 +1,161 @@
/// The optional pace target before a run: a flag icon that switches it on,
/// and then `- m:ss +` in [kPaceStep] steps (holding a button repeats).
///
/// Icons and digits only (textgate). Deliberately not a wheel: a wheel is a
/// `Scrollable`, and the run screen must have nothing that scrolls (R7).
library;
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:workout_app/services/pace_band.dart';
import 'package:workout_app/services/run_format.dart';
import 'package:workout_app/ui/theme.dart';
/// How often a held step button repeats once it has started.
const Duration kPaceRepeatEvery = Duration(milliseconds: 120);
/// The pace-target control shown beside the start button.
class RunPacePicker extends StatelessWidget {
/// Creates the control; [seconds] is the target in seconds per kilometre.
const RunPacePicker({
required this.on,
required this.seconds,
required this.onToggle,
required this.onChanged,
super.key,
});
/// Whether a target is set.
final bool on;
/// The target while [on].
final int seconds;
/// Called when the flag is tapped.
final VoidCallback onToggle;
/// Called with the new target for each step.
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
key: const Key('run_pace_toggle'),
onPressed: onToggle,
color: on ? scheme.primary : scheme.onSurfaceVariant,
icon: Icon(
on ? Icons.flag : Icons.flag_outlined,
semanticLabel: 'pace target',
),
),
if (on) ...[
_StepButton(
id: 'run_pace_minus',
icon: Icons.remove,
onStep: seconds > kMinPaceTarget
? () => onChanged(seconds - kPaceStep)
: null,
),
Flexible(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
// Scales down rather than overflow at large system text sizes.
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
formatPaceSeconds(seconds),
key: const Key('run_pace_value'),
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w600,
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
),
),
_StepButton(
id: 'run_pace_plus',
icon: Icons.add,
onStep: seconds < kMaxPaceTarget
? () => onChanged(seconds + kPaceStep)
: null,
),
],
],
);
}
}
/// A step button: a tap steps once, holding steps on repeat. Null [onStep]
/// disables it (the end of the range), which also ends a hold.
class _StepButton extends StatefulWidget {
const _StepButton({required this.id, required this.icon, this.onStep});
final String id;
final IconData icon;
final VoidCallback? onStep;
@override
State<_StepButton> createState() => _StepButtonState();
}
class _StepButtonState extends State<_StepButton> {
Timer? _timer;
bool _held = false;
void _down() {
_held = false;
_timer = Timer(kLongPressTimeout, _startRepeating);
}
void _startRepeating() {
_held = true;
_step();
_timer = Timer.periodic(kPaceRepeatEvery, (_) => _step());
}
void _step() {
final onStep = widget.onStep;
if (onStep == null) return _release();
onStep();
}
void _release() {
_timer?.cancel();
_timer = null;
}
void _tap() {
// The release of a hold is not one more tap.
if (_held) {
_held = false;
return;
}
widget.onStep?.call();
}
@override
void dispose() {
_release();
super.dispose();
}
@override
Widget build(BuildContext context) => Listener(
onPointerDown: (_) => _down(),
onPointerUp: (_) => _release(),
onPointerCancel: (_) => _release(),
child: IconButton(
key: Key(widget.id),
onPressed: widget.onStep == null ? null : _tap,
icon: Icon(widget.icon, semanticLabel: widget.id),
),
);
}

View File

@ -91,6 +91,8 @@ flutter:
assets:
- assets/sounds/break_end.mp3
- assets/sounds/workout_done.wav
- assets/sounds/pace_slow.wav
- assets/sounds/pace_fast.wav
# Launcher icon generation. Source art is produced by the shared generator:
# PYTHONPATH=~/src/testsAndMisc python3 -m python_pkg.app_icons generate --app <key>

View File

@ -0,0 +1,27 @@
/// A geolocator platform that answers from fields, for tests that build the
/// real run controller (and so the real position source) without a device.
library;
import 'package:geolocator/geolocator.dart';
/// Location on, permission granted, no positions.
class FakeGeolocator extends GeolocatorPlatform {
/// Whether the device's location switch is on.
bool serviceOn = true;
/// The permission [checkPermission] reports.
LocationPermission permission = LocationPermission.whileInUse;
@override
Future<bool> isLocationServiceEnabled() async => serviceOn;
@override
Future<LocationPermission> checkPermission() async => permission;
@override
Future<LocationPermission> requestPermission() async => permission;
@override
Stream<Position> getPositionStream({LocationSettings? locationSettings}) =>
const Stream.empty();
}

View File

@ -4,6 +4,7 @@ library;
import 'package:audioplayers/audioplayers.dart';
import 'package:workout_app/services/feedback_cues.dart';
import 'package:workout_app/services/foreground_run_client.dart';
import 'package:workout_app/services/pace_band.dart';
/// Stands in for `flutter_foreground_task` on the run screen's side.
class FakeForegroundRunClient implements ForegroundRunClient {
@ -85,9 +86,15 @@ class QuietCues extends FeedbackCues {
/// How many times the finish cue fired.
int finishes = 0;
/// Pace cues given, in order.
final paceCues = <PaceCue>[];
@override
Future<void> load() async {}
@override
Future<void> paceOff(PaceCue cue) async => paceCues.add(cue);
@override
Future<void> kilometreReached(int km) async => kilometres.add(km);

View File

@ -36,8 +36,15 @@ class ManualPositionSource implements PositionSource {
/// Creates a source answering [access].
ManualPositionSource({this.access = LocationAccess.granted});
/// What [requestAccess] answers.
final LocationAccess access;
/// What [requestAccess] and [checkAccess] answer; tests flip it to play a
/// grant in system settings.
LocationAccess access;
/// How many times the permission prompt path ran.
int requests = 0;
/// How many times the silent re-check ran.
int checks = 0;
/// The stream the recorder listens to.
final controller = StreamController<GpsFix>.broadcast();
@ -46,7 +53,16 @@ class ManualPositionSource implements PositionSource {
int subscriptions = 0;
@override
Future<LocationAccess> requestAccess() async => access;
Future<LocationAccess> requestAccess() async {
requests++;
return access;
}
@override
Future<LocationAccess> checkAccess() async {
checks++;
return access;
}
@override
Stream<GpsFix> fixes({Duration interval = const Duration(seconds: 2)}) {

View File

@ -15,49 +15,42 @@ void main() {
final start = DateTime.utc(2026, 11, 10, 8);
for (final sport in RunSport.values) {
test('the ${sport.tcxName} loop is a legal ~4 km track for the filter', () {
final fixes = sandboxLoopFixes(start, sport);
final filter = GpsFilter(sport: sport);
for (final fix in fixes) {
expect(filter.add(fix), FixVerdict.accepted);
}
expect(filter.distanceM, closeTo(kLoopLengthM, 20));
expect(movingSeconds(filter.points, sport), fixes.length * 2 - 2);
expect(fixes.first.time, start);
// A closed loop: the lap ends where it began.
expect(
haversineM(
fixes.first.latitude,
fixes.first.longitude,
fixes.last.latitude,
fixes.last.longitude,
),
lessThan(5),
);
});
}
test('the loop is a legal ~4 km track for the filter', () {
final fixes = sandboxLoopFixes(start);
final filter = GpsFilter();
for (final fix in fixes) {
expect(filter.add(fix), FixVerdict.accepted);
}
expect(filter.distanceM, closeTo(kLoopLengthM, 20));
expect(movingSeconds(filter.points), fixes.length * 2 - 2);
expect(fixes.first.time, start);
// A closed loop: the lap ends where it began.
expect(
haversineM(
fixes.first.latitude,
fixes.first.longitude,
fixes.last.latitude,
fixes.last.longitude,
),
lessThan(5),
);
});
test('a run laps at 10 km/h (24 minutes of fixes), a walk at 5 km/h', () {
final run = sandboxLoopFixes(start, RunSport.running);
final walk = sandboxLoopFixes(start, RunSport.walking);
expect(run.last.time.difference(start), const Duration(minutes: 24));
expect(walk.last.time.difference(start), const Duration(minutes: 48));
test('the lap runs at 10 km/h (24 minutes of fixes)', () {
final lap = sandboxLoopFixes(start);
expect(lap.last.time.difference(start), const Duration(minutes: 24));
});
test('replay times follow the start, so two replays never share an id', () {
final a = sandboxLoopFixes(start, RunSport.running);
final b = sandboxLoopFixes(
start.add(const Duration(seconds: 1)),
RunSport.running,
);
final a = sandboxLoopFixes(start);
final b = sandboxLoopFixes(start.add(const Duration(seconds: 1)));
expect(RunTrack.idFor(a.first.time), isNot(RunTrack.idFor(b.first.time)));
});
test('the speed-up divides the 2 s fix interval', () async {
for (final (factor, micros) in [(1, 2000000), (10, 200000), (60, 33333)]) {
Sandbox.runReplayFactor = factor;
final source = sandboxRunSource(RunSport.running, now: start);
final source = sandboxRunSource(now: start);
expect(source, isA<ScriptedPositionSource>());
expect(
(source as ScriptedPositionSource).fixDelay,
@ -68,22 +61,28 @@ void main() {
test('the default speed-up replays a whole run in under a minute', () {
expect(Sandbox.runReplayFactor, 60);
final fixes = sandboxLoopFixes(start, RunSport.running).length;
final fixes = sandboxLoopFixes(start).length;
expect(fixes * 2 / Sandbox.runReplayFactor, lessThan(60));
expect(
sandboxLoopFixes(start, RunSport.walking).length * 2 / 60,
lessThan(60),
);
expect(Sandbox.replayFactors, [1, 10, 60]);
});
test('the sandbox replays; the daily build reads the real GPS', () async {
Sandbox.enabled = true;
expect(positionSourceFor(RunSport.running), isA<ScriptedPositionSource>());
expect(positionSourceFor(), isA<ScriptedPositionSource>());
Sandbox.enabled = false;
expect(
positionSourceFor(RunSport.running),
isA<GeolocatorPositionSource>(),
);
expect(positionSourceFor(), isA<GeolocatorPositionSource>());
});
test('the idle position is the replay first fix, and only that', () async {
Sandbox.enabled = true;
final source = idlePositionSourceFor(now: start);
final fixes = await source.fixes().toList();
expect(fixes, hasLength(1));
final first = sandboxLoopFixes(start).first;
expect(fixes.single.latitude, first.latitude);
expect(fixes.single.longitude, first.longitude);
expect((await source.requestAccess()).isGranted, isTrue);
Sandbox.enabled = false;
expect(idlePositionSourceFor(), isA<GeolocatorPositionSource>());
});
}

View File

@ -5,16 +5,49 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/screens/run_screen.dart';
import 'package:workout_app/services/pace_target_store.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 'package:workout_app/widgets/run_map_slot.dart';
import '../fake_run_service.dart';
import '../fake_run_uploader.dart';
import '../run_test_fixtures.dart';
import '../widgets/_run_map_fixtures.dart';
/// An in-memory [PaceTargetStore].
class MemoryPaceStore implements PaceTargetStore {
/// The stored target.
int value = kDefaultPaceTarget;
/// How many times [save] ran.
int saves = 0;
@override
Future<int> load() async => value;
@override
Future<void> save(int seconds) async {
saves++;
value = seconds;
}
}
/// The real map slot over fake tiles, so nothing reaches the network.
Widget fakeTileMap(
BuildContext context,
ValueListenable<List<RunPoint>> points,
ValueListenable<LatLng?> position,
) => RunMapSlot(
points: points,
position: position,
tileProviderOpener: fakeTiles(),
);
/// Everything a test needs to drive and inspect one screen.
class RunHarness {
@ -33,12 +66,13 @@ class RunHarness {
final Completer<void>? saveGate;
final uploader = FakeRunUploader();
final cues = QuietCues();
final paceStore = MemoryPaceStore();
final saved = <RunTrack>[];
final settingsOpened = <int>[];
final navigator = GlobalKey<NavigatorState>();
RunController build() => RunController(
sourceFor: (_) => source,
sourceFor: () => source,
service: service,
cues: cues,
save: (track) async {
@ -55,12 +89,11 @@ class RunHarness {
home: const SizedBox(),
);
RunScreen screen({
Widget Function(BuildContext, ValueListenable<List<RunPoint>>)? mapBuilder,
}) => RunScreen(
RunScreen screen({RunMapBuilder? mapBuilder}) => RunScreen(
controllerFactory: build,
uploader: uploader,
mapBuilder: mapBuilder,
paceStore: paceStore,
mapBuilder: mapBuilder ?? fakeTileMap,
openSettings: () async {
settingsOpened.add(1);
return true;
@ -68,10 +101,7 @@ class RunHarness {
);
/// 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 {
Future<void> pump(WidgetTester tester, {RunMapBuilder? mapBuilder}) async {
await tester.pumpWidget(app());
navigator.currentState!.push(
MaterialPageRoute<void>(builder: (_) => screen(mapBuilder: mapBuilder)),

View File

@ -1,11 +1,15 @@
// The run icon on the home screen opens the run screen.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/screens/run_screen.dart';
import 'package:workout_app/services/storage_service.dart';
import '../fake_audio_platform.dart';
import '../fake_geolocator.dart';
import '../fake_secure_storage.dart';
import '_home_test_fixtures.dart';
@ -16,10 +20,17 @@ void main() {
});
setUp(() async {
// The run screen reads its last pace target from shared_preferences.
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
StorageService.resetForTesting();
await StorageService.init();
installFakeSecureStorage();
installFakeAudioPlatform();
// The real run controller asks the real source where the user is.
final original = GeolocatorPlatform.instance;
GeolocatorPlatform.instance = FakeGeolocator();
addTearDown(() => GeolocatorPlatform.instance = original);
});
testWidgets('the run icon opens the run screen and back returns home', (

View File

@ -0,0 +1,160 @@
// Location refused: no map, no start, only the red icon; a grant later (the
// app coming back from settings) brings the screen back.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/position_source.dart';
import 'package:workout_app/widgets/run_map.dart';
import '../fake_audio_platform.dart';
import '_run_screen_fixtures.dart';
void _background(WidgetTester tester) {
for (final state in [
AppLifecycleState.inactive,
AppLifecycleState.hidden,
AppLifecycleState.paused,
]) {
tester.binding.handleAppLifecycleStateChanged(state);
}
}
Future<void> _resume(WidgetTester tester) async {
_background(tester);
for (final state in [
AppLifecycleState.hidden,
AppLifecycleState.inactive,
AppLifecycleState.resumed,
]) {
tester.binding.handleAppLifecycleStateChanged(state);
}
await tester.pumpAndSettle();
}
const _refusals = [
(LocationAccess.denied, 'run_issue_denied'),
(LocationAccess.deniedForever, 'run_issue_deniedForever'),
(LocationAccess.serviceOff, 'run_issue_serviceOff'),
];
void main() {
setUp(installFakeAudioPlatform);
for (final (access, id) in _refusals) {
testWidgets('${access.name}: no map, no start, just the red icon', (
tester,
) async {
final h = RunHarness(access: access);
await h.pump(tester);
expect(find.byType(RunMap), findsNothing);
expect(key('run_map_slot'), findsNothing);
expect(key('run_start'), findsNothing);
expect(key('run_pace_toggle'), findsNothing);
expect(key(id), findsOneWidget);
expect(find.byIcon(Icons.location_disabled), findsOneWidget);
expect(h.service.calls, isEmpty);
});
}
testWidgets('denied once: the icon asks again and can recover', (
tester,
) async {
final h = RunHarness(access: LocationAccess.denied);
await h.pump(tester);
expect(h.source.requests, 1);
await tester.tap(key('run_issue_denied'));
await tester.pumpAndSettle();
expect(h.source.requests, 2);
expect(h.settingsOpened, isEmpty);
expect(key('run_start'), findsNothing);
h.source.access = LocationAccess.granted;
await tester.tap(key('run_issue_denied'));
await tester.pumpAndSettle();
expect(key('run_start'), findsOneWidget);
expect(find.byType(RunMap), findsOneWidget);
expect(find.byIcon(Icons.location_disabled), findsNothing);
});
for (final access in [
LocationAccess.deniedForever,
LocationAccess.serviceOff,
]) {
testWidgets('${access.name}: the icon opens the app settings', (
tester,
) async {
final h = RunHarness(access: access);
await h.pump(tester);
await tester.tap(key('run_issue_${access.name}'));
expect(h.settingsOpened, [1]);
expect(h.source.requests, 1);
});
}
testWidgets('a grant in settings recovers the screen on resume', (
tester,
) async {
final h = RunHarness(access: LocationAccess.deniedForever);
await h.pump(tester);
expect(key('run_start'), findsNothing);
// Still refused: coming back changes nothing and never prompts.
await _resume(tester);
expect(key('run_start'), findsNothing);
expect(key('run_issue_deniedForever'), findsOneWidget);
h.source.access = LocationAccess.granted;
await _resume(tester);
expect(key('run_start'), findsOneWidget);
expect(find.byType(RunMap), findsOneWidget);
expect(find.byIcon(Icons.location_disabled), findsNothing);
// The recheck is silent: only the open-time request ever prompted.
expect(h.source.requests, 1);
expect(h.source.checks, greaterThanOrEqualTo(2));
});
testWidgets('revoking while idle takes the map and start away on resume', (
tester,
) async {
final h = RunHarness();
await h.pump(tester);
expect(key('run_start'), findsOneWidget);
h.source.access = LocationAccess.deniedForever;
await _resume(tester);
expect(key('run_start'), findsNothing);
expect(find.byType(RunMap), findsNothing);
expect(key('run_issue_deniedForever'), findsOneWidget);
});
testWidgets('resuming during a recording never re-checks access', (
tester,
) async {
final h = RunHarness();
await h.pump(tester);
await tapKey(tester, 'run_start');
final before = h.source.checks;
await _resume(tester);
expect(h.source.checks, before);
expect(key('run_pause'), findsOneWidget);
});
testWidgets('a start refused by a switched-off service recovers on resume', (
tester,
) async {
final h = RunHarness();
await h.pump(tester);
// Location goes off between the idle grant and the tap on start.
h.source.access = LocationAccess.serviceOff;
await tapKey(tester, 'run_start');
expect(key('run_start'), findsNothing);
expect(find.byType(RunMap), findsNothing);
expect(key('run_issue_serviceOff'), findsOneWidget);
h.source.access = LocationAccess.granted;
await _resume(tester);
expect(key('run_start'), findsOneWidget);
expect(find.byType(RunMap), findsOneWidget);
expect(find.byIcon(Icons.location_disabled), findsNothing);
});
}

View File

@ -0,0 +1,102 @@
// Before a run the map shows and follows where the user is; those display-only
// fixes never reach the recorded track.
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/widgets/run_map.dart';
import '../fake_audio_platform.dart';
import '../run_test_fixtures.dart';
import '_run_screen_fixtures.dart';
MapCamera _camera(WidgetTester tester) =>
MapCamera.of(tester.element(find.byType(RichAttributionWidget)));
Future<void> _idleFix(
WidgetTester tester,
RunHarness h,
int second,
double northM,
) async {
h.source.controller.add(fixAt(second, northM));
await tester.pump();
await tester.pump();
}
void main() {
setUp(installFakeAudioPlatform);
testWidgets('opening the screen asks for location and shows the map', (
tester,
) async {
final h = RunHarness();
await h.pump(tester);
expect(h.source.requests, 1);
expect(find.byType(RunMap), findsOneWidget);
expect(key('run_start'), findsOneWidget);
});
testWidgets('the idle map is centred on the current fix, with a marker', (
tester,
) async {
final h = RunHarness();
await h.pump(tester);
// Zoomed-out Poland until the first fix arrives.
expect(_camera(tester).zoom, lessThan(8));
await _idleFix(tester, h, 0, 0);
final fix = fixAt(0, 0);
final camera = _camera(tester);
expect(camera.center.latitude, closeTo(fix.latitude, 1e-6));
expect(camera.center.longitude, closeTo(fix.longitude, 1e-6));
expect(camera.zoom, greaterThan(15));
expect(
tester.widget<MarkerLayer>(find.byType(MarkerLayer)).markers,
hasLength(1),
);
expect(find.byType(PolylineLayer), findsNothing);
await _idleFix(tester, h, 2, 40);
expect(
_camera(tester).center.latitude,
closeTo(fixAt(2, 40).latitude, 1e-6),
);
});
testWidgets('recentre jumps to the latest fix with zero run points', (
tester,
) async {
final h = RunHarness();
await h.pump(tester);
await _idleFix(tester, h, 0, 0);
await tester.drag(find.byType(FlutterMap), const Offset(150, 0));
await tester.pump();
final dragged = _camera(tester).center.longitude;
expect(find.byIcon(Icons.my_location), findsOneWidget);
// A newer fix does not drag the camera while the user is looking around.
await _idleFix(tester, h, 2, 40);
expect(_camera(tester).center.longitude, closeTo(dragged, 1e-9));
await tester.tap(find.byIcon(Icons.my_location));
await tester.pump();
final center = _camera(tester).center;
expect(center.latitude, closeTo(fixAt(2, 40).latitude, 1e-6));
expect(center.longitude, closeTo(fixAt(2, 40).longitude, 1e-6));
expect(find.byIcon(Icons.my_location), findsNothing);
});
testWidgets('idle fixes are never recorded into the track', (tester) async {
final h = RunHarness();
await h.pump(tester);
for (var i = 0; i < 4; i++) {
await _idleFix(tester, h, i * 2, i * 6.0);
}
await tapKey(tester, 'run_start');
expect(find.byType(PolylineLayer), findsNothing);
await h.run(tester, 5, from: 10);
await tapKey(tester, 'run_finish');
expect(h.saved.single.points, hasLength(5));
});
}

View File

@ -48,6 +48,12 @@ void main() {
expect(find.byType(Scrollable), findsNothing);
_expectFits(tester);
// With the pace picker open: still nothing scrolls, nothing overflows.
await tapKey(tester, 'run_pace_toggle');
expect(find.byKey(const Key('run_pace_value')), findsOneWidget);
expect(find.byType(Scrollable), findsNothing);
_expectFits(tester);
await tapKey(tester, 'run_start');
await h.run(tester, 400);
_expectFits(tester);
@ -67,8 +73,12 @@ void main() {
addTearDown(tester.platformDispatcher.clearAllTestValues);
final h = RunHarness();
await h.pump(tester);
await tapKey(tester, 'run_pace_toggle');
_expectFits(tester);
await tapKey(tester, 'run_start');
await h.run(tester, 3000);
// Three numbers in the row (distance, average pace, current pace).
expect(find.byKey(const Key('run_pace_now')), findsOneWidget);
_expectFits(tester);
});
}

View File

@ -0,0 +1,43 @@
// One mode: every GPS activity is recorded with the running filter and written
// as a Running TCX. There is no run/walk switch left on the screen.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/services/tcx_codec.dart';
import '../fake_audio_platform.dart';
import '_run_screen_fixtures.dart';
void main() {
setUp(installFakeAudioPlatform);
testWidgets('no sport toggle and no sport icon anywhere', (tester) async {
final h = RunHarness();
await h.pump(tester);
expect(key('run_sport'), findsNothing);
expect(find.byType(SegmentedButton<RunSport>), findsNothing);
expect(find.byIcon(Icons.directions_walk), findsNothing);
expect(find.byIcon(Icons.directions_run), findsNothing);
await tapKey(tester, 'run_start');
expect(find.byIcon(Icons.directions_walk), findsNothing);
expect(find.byIcon(Icons.directions_run), findsNothing);
});
testWidgets('the saved track is a Running TCX with a _Running file name', (
tester,
) async {
final h = RunHarness();
await h.pump(tester);
await tapKey(tester, 'run_start');
// 3 m/s is 10.8 km/h: over any walking cap, fine for the running filter.
await h.run(tester, 30);
await tapKey(tester, 'run_finish');
final track = h.saved.single;
expect(track.sport, RunSport.running);
expect(track.distanceM, greaterThan(150));
expect(tcxFilename(track), endsWith('_Running.tcx'));
expect(encodeTcx(track), contains('Sport="Running"'));
expect(encodeTcx(track), isNot(contains('Walking')));
});
}

View File

@ -0,0 +1,117 @@
// The optional pace target on the run screen: the picker before the start, the
// coloured current pace while recording, the cues, the notification.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/pace_band.dart';
import 'package:workout_app/ui/theme.dart';
import '../fake_audio_platform.dart';
import '_run_screen_fixtures.dart';
String _text(String id) =>
(find.byKey(Key(id)).evaluate().single.widget as Text).data!;
Color? _colour(WidgetTester tester, String id) =>
tester.widget<Text>(key(id)).style?.color;
Future<void> _pickTarget(WidgetTester tester, RunHarness h, int target) async {
h.paceStore.value = target;
await h.pump(tester);
await tapKey(tester, 'run_pace_toggle');
}
void main() {
setUp(installFakeAudioPlatform);
group('picker', () {
testWidgets('off by default: only the pace icon, no numbers', (
tester,
) async {
final h = RunHarness();
await h.pump(tester);
expect(key('run_pace_toggle'), findsOneWidget);
expect(key('run_pace_value'), findsNothing);
expect(key('run_pace_minus'), findsNothing);
});
testWidgets('on: shows the last target and steps it by 0:05', (
tester,
) async {
final h = RunHarness();
h.paceStore.value = 300;
await h.pump(tester);
await tapKey(tester, 'run_pace_toggle');
expect(_text('run_pace_value'), '5:00');
await tester.tap(key('run_pace_plus'));
await tester.pump();
expect(_text('run_pace_value'), '5:05');
await tester.tap(key('run_pace_minus'));
await tester.pump();
await tester.tap(key('run_pace_minus'));
await tester.pump();
expect(_text('run_pace_value'), '4:55');
expect(h.paceStore.value, 295);
await tapKey(tester, 'run_pace_toggle');
expect(key('run_pace_value'), findsNothing);
});
});
group('recording with a target', () {
testWidgets('no target: no second number, no cues', (tester) async {
final h = RunHarness();
await h.pump(tester);
await tapKey(tester, 'run_start');
await h.run(tester, 40);
expect(key('run_pace_now'), findsNothing);
expect(h.cues.paceCues, isEmpty);
});
testWidgets('on pace: green number, silence, average stays big', (
tester,
) async {
final h = RunHarness();
await _pickTarget(tester, h, 335); // 3 m/s is 5:33
await tapKey(tester, 'run_start');
await h.run(tester, 40);
final colors = Theme.of(
tester.element(key('run_pace')),
).extension<AppStatusColors>()!;
expect(_text('run_pace_now'), '5:33');
expect(_colour(tester, 'run_pace_now'), colors.success);
expect(_text('run_pace'), '5:33'); // the run's average
expect(h.cues.paceCues, isEmpty);
});
testWidgets('too slow: red number and one slow cue per 30 s', (
tester,
) async {
final h = RunHarness();
await _pickTarget(tester, h, 300); // wants 5:00, runs 5:33
await tapKey(tester, 'run_start');
final scheme = Theme.of(tester.element(key('run_pace'))).colorScheme;
await h.run(tester, 40); // 78 s
expect(_colour(tester, 'run_pace_now'), scheme.error);
expect(h.cues.paceCues, [PaceCue.tooSlow, PaceCue.tooSlow]);
});
testWidgets('too fast: red number and the other cue', (tester) async {
final h = RunHarness();
await _pickTarget(tester, h, 420); // wants 7:00, runs 5:33
await tapKey(tester, 'run_start');
await h.run(tester, 14); // 26 s: one cue, then throttled
expect(h.cues.paceCues, [PaceCue.tooFast]);
});
testWidgets('the notification carries the current pace as numbers', (
tester,
) async {
final h = RunHarness();
await _pickTarget(tester, h, 335);
await tapKey(tester, 'run_start');
await h.run(tester, 40);
expect(h.service.drawn.last, endsWith('5:35'));
});
});
}

View File

@ -16,31 +16,6 @@ void main() {
setUp(installFakeAudioPlatform);
group('problems are icons', () {
for (final (access, id, fixable) in [
(LocationAccess.denied, 'run_issue_denied', false),
(LocationAccess.deniedForever, 'run_issue_deniedForever', true),
(LocationAccess.serviceOff, 'run_issue_serviceOff', true),
]) {
testWidgets('${access.name}: red icon, start stays available', (
tester,
) async {
final h = RunHarness(access: access);
await h.pump(tester);
await tapKey(tester, 'run_start');
expect(key(id), findsOneWidget);
expect(find.byIcon(Icons.location_disabled), findsOneWidget);
expect(key('run_start'), findsOneWidget);
expect(h.service.calls, isEmpty);
final button = tester.widget<IconButton>(key(id));
expect(button.onPressed != null, fixable);
if (fixable) {
await tester.tap(key(id));
expect(h.settingsOpened, [1]);
}
});
}
testWidgets('a mock fix shows the wrong-location icon', (tester) async {
final h = RunHarness();
await h.pump(tester);
@ -150,7 +125,7 @@ void main() {
List<RunPoint>? seen;
await h.pump(
tester,
mapBuilder: (context, points) => ValueListenableBuilder(
mapBuilder: (context, points, _) => ValueListenableBuilder(
valueListenable: points,
builder: (context, list, _) {
seen = list;

View File

@ -0,0 +1,63 @@
// In the sandbox flavor the idle map sits on the replay's first fix: one dot
// at the loop's start, not the whole lap walked before the run.
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:workout_app/sandbox/sandbox.dart';
import 'package:workout_app/sandbox/sandbox_run_replay.dart';
import 'package:workout_app/screens/run_screen.dart';
import 'package:workout_app/ui/theme.dart';
import '../fake_audio_platform.dart';
import '../fake_run_uploader.dart';
import '_run_screen_fixtures.dart';
MapCamera _camera(WidgetTester tester) =>
MapCamera.of(tester.element(find.byType(RichAttributionWidget)));
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
installFakeAudioPlatform();
Sandbox.enabled = true;
// Fast enough that a walking replay would show within the test.
Sandbox.runReplayFactor = 1000000;
});
tearDown(() {
Sandbox.enabled = false;
Sandbox.runReplayFactor = Sandbox.defaultReplayFactor;
});
testWidgets('idle, the map sits on the replay first fix and stays there', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: RunScreen(uploader: FakeRunUploader(), mapBuilder: fakeTileMap),
),
);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 200)),
);
await tester.pump();
await tester.pump();
final first = sandboxLoopFixes(DateTime.now()).first;
expect(_camera(tester).center.latitude, closeTo(first.latitude, 1e-6));
expect(_camera(tester).center.longitude, closeTo(first.longitude, 1e-6));
expect(find.byKey(const Key('run_start')), findsOneWidget);
// Not walking the lap before the run: a single dot, no route.
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 300)),
);
await tester.pump();
expect(find.byType(PolylineLayer), findsNothing);
expect(_camera(tester).center.latitude, closeTo(first.latitude, 1e-6));
});
}

View File

@ -4,6 +4,8 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/models/run_record.dart';
import 'package:workout_app/models/run_track.dart';
@ -26,6 +28,8 @@ void main() {
});
setUp(() async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
StorageService.resetForTesting();
await StorageService.init();
installFakeAudioPlatform();
@ -52,9 +56,13 @@ void main() {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: RunScreen(uploader: uploader),
home: RunScreen(uploader: uploader, mapBuilder: fakeTileMap),
),
);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 100)),
);
await tester.pump();
List<RunRecord>? runs;
await tester.runAsync(() async {

View File

@ -29,14 +29,14 @@ class _GatedSource extends ManualPositionSource {
void main() {
setUp(installFakeAudioPlatform);
testWidgets('idle: sport toggle, start, zeros and the map slot', (
testWidgets('idle: 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_sport'), findsNothing);
expect(key('run_pause'), findsNothing);
expect(key('run_map_slot'), findsOneWidget);
expect(find.byType(RunMapSlot), findsOneWidget);
@ -47,26 +47,6 @@ void main() {
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 {
@ -157,20 +137,21 @@ void main() {
expect(find.byType(RunScreen), findsNothing);
});
testWidgets('start is disabled while the permission prompt is open', (
testWidgets('start stays disabled until the access answer lands', (
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();
// The open-time request is still showing its prompt: no start yet, so a
// second request can never race it.
expect(_enabled(tester, 'run_start'), isFalse);
source.gate.complete();
await tester.pumpAndSettle();
expect(_enabled(tester, 'run_start'), isTrue);
await tapKey(tester, 'run_start');
expect(key('run_pause'), findsOneWidget);
});

View File

@ -0,0 +1,50 @@
// The pace cues: two distinct buzz patterns and tones, silent sound switch.
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/feedback_cues.dart';
import 'package:workout_app/services/pace_band.dart';
import '../fake_audio_platform.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakeAudioRecorder audio;
setUp(() => audio = installFakeAudioPlatform());
test('too slow is one long buzz, too fast three short ones', () {
expect(paceCuePattern(PaceCue.tooSlow), [0, 700]);
expect(paceCuePattern(PaceCue.tooFast), [0, 90, 70, 90, 70, 90]);
});
test('both differ from each other and from the kilometre double buzz', () {
const kilometre = [0, 150, 100, 150];
final slow = paceCuePattern(PaceCue.tooSlow);
final fast = paceCuePattern(PaceCue.tooFast);
expect(slow, isNot(fast));
expect(slow, isNot(kilometre));
expect(fast, isNot(kilometre));
});
test('each cue plays its own tone', () async {
await FeedbackCues(AudioPlayer()).paceOff(PaceCue.tooSlow);
await FeedbackCues(AudioPlayer()).paceOff(PaceCue.tooFast);
expect(audio.resumedSources, hasLength(2));
expect(audio.resumedSources.first, contains('pace_slow'));
expect(audio.resumedSources.last, contains('pace_fast'));
expect(paceCueSound(PaceCue.tooSlow), isNot(paceCueSound(PaceCue.tooFast)));
});
test('stays silent when sound is off (the buzz still carries it)', () async {
await (FeedbackCues(
AudioPlayer(),
)..soundOn = false).paceOff(PaceCue.tooFast);
expect(audio.resumedSources, isEmpty);
});
test('a tone that cannot play is logged, not thrown', () async {
audio.failNextPlay = true;
await FeedbackCues(AudioPlayer()).paceOff(PaceCue.tooSlow);
expect(audio.resumedSources, isEmpty);
});
}

View File

@ -1,5 +1,4 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/services/gps_filter.dart';
import 'package:workout_app/services/run_math.dart';
@ -24,13 +23,13 @@ GpsFix _fix(
void main() {
group('rejections', () {
test('mocked fixes are dropped', () {
final f = GpsFilter(sport: RunSport.running);
final f = GpsFilter();
expect(f.add(_fix(0, 0, mocked: true)), FixVerdict.mocked);
expect(f.points, isEmpty);
});
test('accuracy above 25 m, negative or NaN is dropped', () {
final f = GpsFilter(sport: RunSport.running);
final f = GpsFilter();
expect(f.add(_fix(0, 0, accuracy: 25.1)), FixVerdict.inaccurate);
expect(f.add(_fix(0, 0, accuracy: -1)), FixVerdict.inaccurate);
expect(f.add(_fix(0, 0, accuracy: double.nan)), FixVerdict.inaccurate);
@ -38,26 +37,28 @@ void main() {
});
test('same-second and older fixes are out of order', () {
final f = GpsFilter(sport: RunSport.running)..add(_fix(10, 0));
final f = GpsFilter()..add(_fix(10, 0));
expect(f.add(_fix(10, 5, millis: 900)), FixVerdict.outOfOrder);
expect(f.add(_fix(9, 5)), FixVerdict.outOfOrder);
expect(f.points, hasLength(1));
});
test('a jump above the sport cap is dropped, below it kept', () {
final run = GpsFilter(sport: RunSport.running)..add(_fix(0, 0));
test('a jump above the 25 km/h cap is dropped, below it kept', () {
final f = GpsFilter()..add(_fix(0, 0));
// 7.0 m/s > 25 km/h (6.94 m/s)
expect(run.add(_fix(10, 70)), FixVerdict.tooFast);
expect(run.add(_fix(20, 60)), FixVerdict.accepted);
expect(f.add(_fix(10, 70)), FixVerdict.tooFast);
expect(f.add(_fix(20, 60)), FixVerdict.accepted);
});
final walk = GpsFilter(sport: RunSport.walking)..add(_fix(0, 0));
// 3 m/s is fine for a run but over the 8 km/h walking cap (2.22 m/s)
expect(walk.add(_fix(10, 30)), FixVerdict.tooFast);
expect(walk.add(_fix(20, 20)), FixVerdict.accepted);
test('one cap for everything: a 3 m/s stroll or sprint is no jump', () {
// 3 m/s was over the old 8 km/h walking cap; it is a plain run now.
final f = GpsFilter()..add(_fix(0, 0));
expect(f.add(_fix(10, 30)), FixVerdict.accepted);
expect(f.distanceM, closeTo(30, 0.5));
});
test('five consecutive jumps re-anchor without crediting distance', () {
final f = GpsFilter(sport: RunSport.walking)..add(_fix(0, 0));
final f = GpsFilter()..add(_fix(0, 0));
for (var i = 1; i < kMaxConsecutiveJumps; i++) {
expect(f.add(_fix(i * 5, 5000)), FixVerdict.tooFast);
}
@ -68,7 +69,7 @@ void main() {
});
test('an accepted fix resets the jump counter', () {
final f = GpsFilter(sport: RunSport.walking)..add(_fix(0, 0));
final f = GpsFilter()..add(_fix(0, 0));
for (var i = 1; i < kMaxConsecutiveJumps; i++) {
f.add(_fix(i * 5, 5000));
}
@ -79,7 +80,7 @@ void main() {
group('distance credit', () {
test('moving steps accumulate haversine distance', () {
final f = GpsFilter(sport: RunSport.running);
final f = GpsFilter();
for (var i = 0; i < 4; i++) {
f.add(_fix(i * 10, i * 27.0));
}
@ -90,7 +91,7 @@ void main() {
});
test('standing-still jitter and a long dropout earn no distance', () {
final f = GpsFilter(sport: RunSport.running)
final f = GpsFilter()
..add(_fix(0, 0))
..add(_fix(10, 2)); // 0.2 m/s: standing
expect(f.distanceM, 0);
@ -102,13 +103,12 @@ void main() {
});
test('times are truncated to whole UTC seconds', () {
final f = GpsFilter(sport: RunSport.running)
..add(_fix(3, 0, millis: 750));
final f = GpsFilter()..add(_fix(3, 0, millis: 750));
expect(f.points.single.time, DateTime.utc(2026, 11, 9, 12, 0, 3));
});
test('points is a read-only view and distance starts at zero', () {
final f = GpsFilter(sport: RunSport.running);
final f = GpsFilter();
expect(f.distanceM, 0);
f.add(_fix(0, 0));
expect(() => f.points.clear(), throwsUnsupportedError);
@ -117,7 +117,7 @@ void main() {
group('consistency with movingSeconds', () {
test('every credited step is a moving step and vice versa', () {
final f = GpsFilter(sport: RunSport.walking);
final f = GpsFilter();
var north = 0.0;
for (var s = 0; s <= 300; s += 10) {
// walk 1.4 m/s, stand 40-70 s, resume
@ -125,13 +125,13 @@ void main() {
if (!standing) north += 14;
f.add(_fix(s, north));
}
expect(movingSeconds(f.points, RunSport.walking), 260);
expect(movingSeconds(f.points), 260);
});
});
group('breakSegment', () {
test('the fix after a pause earns nothing, then crediting resumes', () {
final f = GpsFilter(sport: RunSport.walking)
final f = GpsFilter()
..add(_fix(0, 0))
..add(_fix(10, 14))
..breakSegment();
@ -143,7 +143,7 @@ void main() {
});
test('a pause jump is re-anchored, not rejected as a GPS jump', () {
final f = GpsFilter(sport: RunSport.walking)
final f = GpsFilter()
..add(_fix(0, 0))
..breakSegment();
expect(f.add(_fix(5, 5000)), FixVerdict.accepted);

View File

@ -0,0 +1,127 @@
// The display-only position watch behind the idle map.
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/idle_location.dart';
import 'package:workout_app/services/position_source.dart';
import '../run_test_fixtures.dart';
/// A source whose permission answer the test releases by hand.
class _SlowSource extends ManualPositionSource {
final gate = Completer<LocationAccess>();
@override
Future<LocationAccess> requestAccess() => gate.future;
}
void main() {
late ManualPositionSource source;
late List<LocationAccess> heard;
late IdleLocation idle;
IdleLocation build(PositionSource s) => IdleLocation(s, onAccess: heard.add);
setUp(() {
source = ManualPositionSource();
heard = [];
idle = build(source);
});
tearDown(() => idle.dispose());
test('granted: reports it, watches, and follows each fix', () async {
expect(idle.access, isNull);
await idle.open();
expect(heard, [LocationAccess.granted]);
expect(idle.access, LocationAccess.granted);
expect(source.subscriptions, 1);
source.controller.add(fixAt(0, 0));
await pumpEventQueue();
expect(idle.position.value!.latitude, closeTo(fixAt(0, 0).latitude, 1e-9));
source.controller.add(fixAt(2, 30));
await pumpEventQueue();
expect(idle.position.value!.latitude, closeTo(fixAt(2, 30).latitude, 1e-9));
});
test('opening twice keeps a single stream', () async {
await idle.open();
await idle.open();
expect(source.subscriptions, 1);
});
test('refused: reports it, no stream, no position', () async {
source.access = LocationAccess.deniedForever;
await idle.open();
expect(heard, [LocationAccess.deniedForever]);
expect(source.subscriptions, 0);
expect(idle.position.value, isNull);
});
test('recheck never prompts; a grant starts the watch', () async {
source.access = LocationAccess.denied;
await idle.open();
source.access = LocationAccess.granted;
await idle.recheck();
expect(source.requests, 1);
expect(source.checks, 1);
expect(source.subscriptions, 1);
expect(heard.last, LocationAccess.granted);
});
test(
'recheck after a revocation ends the watch and clears the dot',
() async {
await idle.open();
source.controller.add(fixAt(0, 0));
await pumpEventQueue();
expect(source.controller.hasListener, isTrue);
source.access = LocationAccess.deniedForever;
await idle.recheck();
expect(idle.position.value, isNull);
expect(source.controller.hasListener, isFalse);
expect(heard.last, LocationAccess.deniedForever);
},
);
test('stop ends the watch and later rechecks are ignored', () async {
await idle.open();
await idle.stop();
expect(source.controller.hasListener, isFalse);
await idle.recheck();
expect(source.checks, 0);
});
test('open after stop watches again (the retry after a refusal)', () async {
await idle.open();
await idle.stop();
await idle.open();
expect(source.subscriptions, 2);
expect(source.controller.hasListener, isTrue);
});
test('an answer that lands after stop is dropped', () async {
final slow = _SlowSource();
idle = build(slow);
final opening = idle.open();
await idle.stop();
slow.gate.complete(LocationAccess.granted);
await opening;
expect(heard, isEmpty);
expect(slow.subscriptions, 0);
});
test('a stream error is logged, not thrown, and the dot stays', () async {
await idle.open();
source.controller.add(fixAt(0, 0));
await pumpEventQueue();
source.controller.addError(StateError('gps off'));
await pumpEventQueue();
expect(idle.position.value, isNotNull);
source.controller.add(fixAt(2, 6));
await pumpEventQueue();
expect(idle.position.value!.latitude, closeTo(fixAt(2, 6).latitude, 1e-9));
});
}

View File

@ -0,0 +1,168 @@
// The pace band: current pace over the last 30 s, the +-10 s band around the
// target, and the cue throttle. Pure Dart, no recorder.
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/services/pace_band.dart';
final _t0 = DateTime.utc(2026, 11, 9, 12);
/// Points 2 s apart moving at [mps] for [count] fixes, cumulative distance.
List<RunPoint> _points(double mps, int count, {int startSecond = 0}) => [
for (var i = 0; i < count; i++)
RunPoint(
time: _t0.add(Duration(seconds: startSecond + i * 2)),
latitude: 48,
longitude: 2,
altitudeM: 0,
distanceM: i * 2 * mps,
),
];
void main() {
group('currentPaceSeconds', () {
test('is the pace over the last 30 s of fixes', () {
// 3 m/s is 333.3 s/km.
final pace = currentPaceSeconds(_points(3, 40))!;
expect(pace, closeTo(1000 / 3, 0.01));
});
test('only the trailing 30 s count: a slow start is forgotten', () {
final slow = _points(2, 20); // 40 s at 2 m/s
final last = slow.last;
final fast = [
for (var i = 1; i <= 20; i++)
RunPoint(
time: last.time.add(Duration(seconds: i * 2)),
latitude: 48,
longitude: 2,
altitudeM: 0,
distanceM: last.distanceM + i * 2 * 4.0,
),
];
expect(
currentPaceSeconds([...slow, ...fast])!,
closeTo(250, 0.01), // 4 m/s
);
});
test('warm-up: null until the window spans 20 s', () {
expect(currentPaceSeconds(_points(3, 1)), isNull);
expect(currentPaceSeconds(_points(3, 10)), isNull); // 18 s
expect(currentPaceSeconds(_points(3, 11)), isNotNull); // 20 s
});
test('standing still is no pace (null), not "too slow"', () {
expect(currentPaceSeconds(_points(0.1, 30)), isNull);
});
test('from skips points before a resume', () {
final before = _points(3, 20);
final after = [
for (var i = 0; i < 20; i++)
RunPoint(
time: _t0.add(Duration(seconds: 44 + i * 2)),
latitude: 48,
longitude: 2,
altitudeM: 0,
// A pause adds time but no distance.
distanceM: before.last.distanceM + i * 2 * 3.0,
),
];
final all = [...before, ...after];
final naive = currentPaceSeconds(all);
final resumed = currentPaceSeconds(all, from: before.length);
expect(resumed, closeTo(1000 / 3, 0.01));
// The window is wholly after the pause here, so both agree...
expect(naive, closeTo(1000 / 3, 0.01));
// ...but a window straddling the pause does not.
final straddle = [...before, ...after.take(3)];
expect(
currentPaceSeconds(straddle, from: before.length),
isNull, // 3 points = 4 s, still warming up
);
expect(currentPaceSeconds(straddle)!, greaterThan(400));
});
test('a gap over 30 s leaves a single point: warm-up again', () {
final pts = [..._points(3, 20), ..._points(3, 1, startSecond: 200)];
expect(currentPaceSeconds(pts), isNull);
});
});
group('paceBandOf', () {
test('inside +-10 s inclusive is on pace', () {
expect(paceBandOf(current: 300, target: 300), PaceBand.onPace);
expect(paceBandOf(current: 310, target: 300), PaceBand.onPace);
expect(paceBandOf(current: 290, target: 300), PaceBand.onPace);
});
test('over the band is too slow, under it too fast', () {
expect(paceBandOf(current: 310.01, target: 300), PaceBand.tooSlow);
expect(paceBandOf(current: 289.99, target: 300), PaceBand.tooFast);
});
test('no pace yet means no verdict', () {
expect(paceBandOf(current: null, target: 300), isNull);
});
});
group('PaceCueThrottle', () {
test('silent while on pace or unknown', () {
final throttle = PaceCueThrottle();
expect(throttle.cueFor(PaceBand.onPace, _t0), isNull);
expect(throttle.cueFor(null, _t0), isNull);
});
test('too slow and too fast give distinct cues', () {
expect(PaceCueThrottle().cueFor(PaceBand.tooSlow, _t0), PaceCue.tooSlow);
expect(PaceCueThrottle().cueFor(PaceBand.tooFast, _t0), PaceCue.tooFast);
});
test('at most once per 30 s while off pace', () {
final throttle = PaceCueThrottle();
expect(throttle.cueFor(PaceBand.tooSlow, _t0), PaceCue.tooSlow);
expect(
throttle.cueFor(PaceBand.tooSlow, _t0.add(const Duration(seconds: 29))),
isNull,
);
expect(
throttle.cueFor(PaceBand.tooSlow, _t0.add(const Duration(seconds: 30))),
PaceCue.tooSlow,
);
});
test('flipping from slow to fast inside 30 s is still throttled', () {
final throttle = PaceCueThrottle()..cueFor(PaceBand.tooSlow, _t0);
expect(
throttle.cueFor(PaceBand.tooFast, _t0.add(const Duration(seconds: 10))),
isNull,
);
});
test('returning to the band stays silent and does not reset the gap', () {
final throttle = PaceCueThrottle()..cueFor(PaceBand.tooSlow, _t0);
final at = _t0.add(const Duration(seconds: 12));
expect(throttle.cueFor(PaceBand.onPace, at), isNull);
expect(
throttle.cueFor(PaceBand.tooSlow, at.add(const Duration(seconds: 5))),
isNull,
);
});
test('reset forgets the last cue', () {
final throttle = PaceCueThrottle()
..cueFor(PaceBand.tooSlow, _t0)
..reset();
expect(throttle.cueFor(PaceBand.tooSlow, _t0), PaceCue.tooSlow);
});
});
test('the target range is 3:00..12:00 in 5 s steps', () {
expect(kMinPaceTarget, 180);
expect(kMaxPaceTarget, 720);
expect(kPaceStep, 5);
expect(clampPaceTarget(100), 180);
expect(clampPaceTarget(9999), 720);
expect(clampPaceTarget(303), 305);
});
}

View File

@ -0,0 +1,34 @@
// The last pace target survives a restart (shared_preferences).
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:workout_app/services/pace_target_store.dart';
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
test('nothing stored yet reads as the default 6:00', () async {
expect(await PrefsPaceTargetStore().load(), kDefaultPaceTarget);
expect(kDefaultPaceTarget, 360);
});
test('saves and reloads the last target', () async {
await PrefsPaceTargetStore().save(295);
expect(await PrefsPaceTargetStore().load(), 295);
});
test('an out-of-range stored value is clamped on load', () async {
await SharedPreferencesAsync().setInt(kPaceTargetKey, 5);
expect(await PrefsPaceTargetStore().load(), 180);
});
test('the default store is usable without an injected instance', () async {
final store = PrefsPaceTargetStore(SharedPreferencesAsync());
await store.save(400);
expect(await store.load(), 400);
});
}

View File

@ -108,6 +108,18 @@ void main() {
);
});
test('checkAccess reads the state and never prompts', () async {
final source = GeolocatorPositionSource();
expect(await source.checkAccess(), LocationAccess.denied);
platform.current = LocationPermission.whileInUse;
expect(await source.checkAccess(), LocationAccess.granted);
platform.current = LocationPermission.deniedForever;
expect(await source.checkAccess(), LocationAccess.deniedForever);
platform.serviceOn = false;
expect(await source.checkAccess(), LocationAccess.serviceOff);
expect(platform.requests, 0);
});
test('fixes maps positions and asks for navigation accuracy', () async {
platform.positions = [_position(), _position(mocked: true)];
final fixes = await GeolocatorPositionSource(
@ -149,6 +161,7 @@ void main() {
access: LocationAccess.deniedForever,
);
expect(await source.requestAccess(), LocationAccess.deniedForever);
expect(await source.checkAccess(), LocationAccess.deniedForever);
expect(await source.fixes().toList(), script);
});

View File

@ -0,0 +1,131 @@
// The controller's idle side (display-only position, blocked state) and the
// pace cue wiring.
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/feedback_cues.dart';
import 'package:workout_app/services/pace_band.dart';
import 'package:workout_app/services/position_source.dart';
import 'package:workout_app/services/run_controller.dart';
import 'package:workout_app/services/run_recorder.dart';
import 'package:workout_app/services/run_saver.dart';
import '../fake_audio_platform.dart';
import '../fake_run_service.dart';
import '../run_test_fixtures.dart';
class _CueSpy extends FeedbackCues {
_CueSpy() : super(AudioPlayer());
final paced = <PaceCue>[];
@override
Future<void> load() async {}
@override
Future<void> paceOff(PaceCue cue) async => paced.add(cue);
}
RunController _build(
ManualPositionSource source, {
PositionSource Function()? idleSourceFor,
FeedbackCues? cues,
FakeForegroundRunClient? service,
}) => RunController(
sourceFor: () => source,
idleSourceFor: idleSourceFor,
service: service ?? FakeForegroundRunClient(),
cues: cues ?? _CueSpy(),
save: (_) async => const RunSaveResult(stored: true, reason: 'saved'),
);
void main() {
setUp(installFakeAudioPlatform);
group('idle location', () {
test('watching asks for access and tracks the position', () async {
final source = ManualPositionSource();
final controller = _build(source);
expect(controller.canStart, isFalse);
await controller.watchIdle();
expect(controller.canStart, isTrue);
expect(controller.blocked, isFalse);
source.controller.add(fixAt(0, 0));
await pumpEventQueue();
expect(controller.position.value, isNotNull);
controller.dispose();
});
test('a refusal blocks until a silent recheck finds a grant', () async {
final source = ManualPositionSource(access: LocationAccess.deniedForever);
final controller = _build(source);
await controller.watchIdle();
expect(controller.blocked, isTrue);
expect(controller.canStart, isFalse);
expect(controller.recorder.issue, RunIssue.deniedForever);
source.access = LocationAccess.granted;
await controller.recheckAccess();
expect(controller.blocked, isFalse);
expect(controller.canStart, isTrue);
expect(source.requests, 1);
controller.dispose();
});
test(
'starting ends the idle watch; a recording is not rechecked',
() async {
final source = ManualPositionSource();
final controller = _build(source);
await controller.watchIdle();
await controller.start();
source.controller.add(fixAt(0, 0));
await pumpEventQueue();
// Only the recorder listens now, and the display dot did not move.
expect(controller.position.value, isNull);
final checks = source.checks;
await controller.recheckAccess();
expect(source.checks, checks);
controller.dispose();
},
);
test('a separate idle source feeds the idle map only', () async {
final run = ManualPositionSource();
final idle = ManualPositionSource();
final controller = _build(run, idleSourceFor: () => idle);
await controller.watchIdle();
expect(idle.requests, 1);
expect(run.requests, 0);
controller.dispose();
});
});
test('pace cues reach the cue player and the notification', () async {
final source = ManualPositionSource();
final service = FakeForegroundRunClient();
final cues = _CueSpy();
final controller = _build(source, cues: cues, service: service);
controller.recorder.setPaceTarget(300);
await controller.start();
for (var i = 0; i < 12; i++) {
source.controller.add(fixAt(i * 2, i * 6.0));
}
await pumpEventQueue();
expect(cues.paced, [PaceCue.tooSlow]);
expect(service.drawn.last, endsWith('5:35'));
controller.dispose();
});
test('without a target the notification carries no current pace', () async {
final source = ManualPositionSource();
final service = FakeForegroundRunClient();
final controller = _build(source, service: service);
await controller.start();
for (var i = 0; i < 12; i++) {
source.controller.add(fixAt(i * 2, i * 6.0));
}
await pumpEventQueue();
expect(service.drawn.last.split(' '), hasLength(2));
controller.dispose();
});
}

View File

@ -40,7 +40,7 @@ void main() {
FakeForegroundRunClient service, {
Future<RunSaveResult> Function(RunTrack)? save,
}) => RunController(
sourceFor: (_) => source,
sourceFor: () => source,
service: service,
cues: FeedbackCues(AudioPlayer()),
save:

View File

@ -28,4 +28,10 @@ void main() {
expect(formatRunDay(when.toUtc()), '03.11');
expect(formatFullDate(when.toUtc()), '2026-11-03 07:05');
});
test('formatPaceSeconds is m:ss', () {
expect(formatPaceSeconds(335), '5:35');
expect(formatPaceSeconds(720), '12:00');
expect(formatPaceSeconds(65), '1:05');
});
}

View File

@ -30,10 +30,9 @@ void main() {
});
});
group('maxSpeedMps', () {
test('caps are 25 km/h for a run and 8 km/h for a walk', () {
expect(maxSpeedMps(RunSport.running), closeTo(25 / 3.6, 1e-9));
expect(maxSpeedMps(RunSport.walking), closeTo(8 / 3.6, 1e-9));
group('kRunningMaxSpeedMps', () {
test('the one jump cap is 25 km/h', () {
expect(kRunningMaxSpeedMps, closeTo(25 / 3.6, 1e-9));
});
});
@ -64,8 +63,8 @@ void main() {
_p(55, 60), // 35 s gap, not credited
_p(65, 73), // moving, 10 s
];
expect(movingSeconds(points, RunSport.walking), 20);
expect(movingSeconds(const [], RunSport.running), 0);
expect(movingSeconds(points), 20);
expect(movingSeconds(const []), 0);
});
});
}

View File

@ -13,4 +13,17 @@ void main() {
expect(copy.title, '0.00');
expect(copy.body, '0:00 -:--');
});
test('a pace target adds the current pace, to the nearest 5 s', () {
final copy = runNotificationCopy(
metres: 3524,
movingSeconds: 723,
currentPace: 333.3,
);
expect(copy.body, '12:03 3:25 5:35');
expect(
runNotificationCopy(metres: 1, movingSeconds: 1, currentPace: 332.4).body,
endsWith('5:30'),
);
});
}

View File

@ -0,0 +1,108 @@
// The recorder's side of the pace target: the current pace, the band, the cues
// (throttled on fix time) and the reset around a pause.
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/pace_band.dart';
import 'package:workout_app/services/run_recorder.dart';
import '../run_test_fixtures.dart';
void main() {
late ManualPositionSource source;
late List<PaceCue> cues;
late RunRecorder recorder;
setUp(() {
source = ManualPositionSource();
cues = [];
recorder = RunRecorder(sourceFor: () => source, onPaceCue: cues.add);
});
tearDown(() => recorder.dispose());
Future<void> feed(int count, {int from = 0, double mps = 3}) async {
for (var i = 0; i < count; i++) {
final s = from + i * 2;
source.controller.add(fixAt(s, s * mps));
}
await pumpEventQueue();
}
test(
'no target: a pace is tracked but there is no band and no cue',
() async {
await recorder.start();
await feed(30);
expect(recorder.coach.pace, closeTo(1000 / 3, 0.01));
expect(recorder.coach.target, isNull);
expect(recorder.coach.band, isNull);
expect(cues, isEmpty);
},
);
test('warm-up: no band until 20 s of fixes', () async {
recorder.setPaceTarget(300);
await recorder.start();
await feed(5);
expect(recorder.coach.pace, isNull);
expect(recorder.coach.band, isNull);
expect(cues, isEmpty);
});
test('too slow cues once, then every 30 s of fix time', () async {
recorder.setPaceTarget(300);
await recorder.start();
await feed(11); // t = 20 s
expect(recorder.coach.band, PaceBand.tooSlow);
expect(cues, [PaceCue.tooSlow]);
await feed(14, from: 22); // up to t = 48 s
expect(cues, hasLength(1));
await feed(2, from: 50); // t = 52 s, 32 s after the first
expect(cues, [PaceCue.tooSlow, PaceCue.tooSlow]);
});
test('too fast uses the other cue; on pace is silent', () async {
recorder.setPaceTarget(420);
await recorder.start();
await feed(15);
expect(cues, [PaceCue.tooFast]);
cues.clear();
recorder.setPaceTarget(335); // within +-10 of 5:33
await feed(5, from: 30);
expect(recorder.coach.band, PaceBand.onPace);
expect(cues, isEmpty);
});
test('clearing the target drops the band', () async {
recorder.setPaceTarget(300);
await recorder.start();
await feed(15);
recorder.setPaceTarget(null);
expect(recorder.coach.band, isNull);
expect(recorder.coach.target, isNull);
});
test('a pause does not read as too slow when the run resumes', () async {
recorder.setPaceTarget(335);
await recorder.start();
await feed(15);
recorder.pause();
recorder.resume();
// 12 s later, same speed: the stretch standing still is not in the window.
await feed(8, from: 42);
expect(recorder.coach.pace, isNull); // warming up again
await feed(8, from: 58);
expect(recorder.coach.band, PaceBand.onPace);
expect(cues, isEmpty);
});
test('a new target forgets the last cue so it can speak at once', () async {
recorder.setPaceTarget(300);
await recorder.start();
await feed(12);
expect(cues, hasLength(1));
recorder.setPaceTarget(420);
await feed(1, from: 24);
expect(cues, [PaceCue.tooSlow, PaceCue.tooFast]);
});
}

View File

@ -6,7 +6,7 @@ import 'package:workout_app/services/run_recorder.dart';
import '../run_test_fixtures.dart';
RunRecorder _recorder(ManualPositionSource source, {List<int>? km}) =>
RunRecorder(sourceFor: (_) => source, onKilometre: km?.add);
RunRecorder(sourceFor: () => source, onKilometre: km?.add);
void main() {
group('start', () {
@ -56,23 +56,55 @@ void main() {
recorder.dispose();
});
test('the sport is chosen while idle only, and picks the source', () async {
final seen = <RunSport>[];
final recorder = RunRecorder(
sourceFor: (sport) {
seen.add(sport);
return ManualPositionSource();
},
);
expect(recorder.selectSport(RunSport.walking), isTrue);
test('every recording is a run, whatever the speed', () async {
final source = ManualPositionSource();
final recorder = _recorder(source);
await recorder.start();
expect(recorder.selectSport(RunSport.running), isFalse);
expect(recorder.sport, RunSport.walking);
expect(seen, [RunSport.walking]);
// 6.5 m/s (23 km/h) is over any walking cap, under the 25 km/h one.
for (var i = 0; i < 6; i++) {
source.controller.add(fixAt(i * 2, i * 13.0));
}
await pumpEventQueue();
final track = await recorder.finish();
expect(track!.sport, RunSport.running);
expect(recorder.distanceM, closeTo(65, 1));
recorder.dispose();
});
});
group('noteAccess', () {
test('a refusal while idle becomes the issue, a grant clears it', () {
final recorder = _recorder(ManualPositionSource());
recorder.noteAccess(LocationAccess.deniedForever);
expect(recorder.issue, RunIssue.deniedForever);
expect(recorder.issue.isAccess, isTrue);
recorder.noteAccess(LocationAccess.granted);
expect(recorder.issue, RunIssue.none);
// A grant with nothing to clear leaves other issues alone.
recorder.noteAccess(LocationAccess.granted);
expect(recorder.issue, RunIssue.none);
recorder.dispose();
});
test('is ignored once a recording has begun', () async {
final recorder = _recorder(ManualPositionSource());
await recorder.start();
recorder.noteAccess(LocationAccess.denied);
expect(recorder.issue, RunIssue.none);
recorder.dispose();
});
test('every refusal maps to its issue', () {
expect(RunIssue.forAccess(LocationAccess.denied), RunIssue.denied);
expect(
RunIssue.forAccess(LocationAccess.serviceOff),
RunIssue.serviceOff,
);
expect(RunIssue.none.isAccess, isFalse);
expect(RunIssue.tooShort.isAccess, isFalse);
});
});
group('trouble', () {
test('a stream error is flagged and clears with the next fix', () async {
final source = ManualPositionSource();

View File

@ -8,14 +8,14 @@ import 'package:workout_app/services/run_recorder.dart';
import '../run_test_fixtures.dart';
RunRecorder _recorder(ManualPositionSource source, {List<int>? km}) =>
RunRecorder(sourceFor: (_) => source, onKilometre: km?.add);
RunRecorder(sourceFor: () => source, onKilometre: km?.add);
void main() {
group('a scripted run', () {
test('records points, moving time and distance, then finishes', () async {
final kms = <int>[];
final recorder = RunRecorder(
sourceFor: (_) => ScriptedPositionSource(straightRun(340)),
sourceFor: () => ScriptedPositionSource(straightRun(340)),
onKilometre: kms.add,
);
expect(await recorder.start(), isTrue);
@ -43,7 +43,7 @@ void main() {
'a stream that ends early is logged, the run is still savable',
() async {
final recorder = RunRecorder(
sourceFor: (_) => ScriptedPositionSource(straightRun(5)),
sourceFor: () => ScriptedPositionSource(straightRun(5)),
);
await recorder.start();
await pumpEventQueue();
@ -63,7 +63,7 @@ void main() {
Sandbox.enabled = false;
});
final recorder = RunRecorder(
sourceFor: (_) => ScriptedPositionSource(straightRun(340)),
sourceFor: () => ScriptedPositionSource(straightRun(340)),
);
await recorder.start();
await pumpEventQueue();

View File

@ -0,0 +1,125 @@
// RunMap.live with a current-position listenable: the idle map before a run.
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/widgets/run_map.dart';
import '_run_map_fixtures.dart';
const _here = LatLng(50.06, 19.94);
const _there = LatLng(50.07, 19.95);
Future<void> _pump(WidgetTester tester, Widget map) async {
tester.view.physicalSize = const Size(400, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(themed(map));
await tester.pump();
await tester.pump();
}
MapCamera _camera(WidgetTester tester) =>
MapCamera.of(tester.element(find.byType(RichAttributionWidget)));
Widget _map(
ValueNotifier<List<RunPoint>> points,
ValueNotifier<LatLng?> position,
) => RunMap.live(
pointsListenable: points,
positionListenable: position,
tileProviderOpener: fakeTiles(),
);
void main() {
late ValueNotifier<List<RunPoint>> points;
late ValueNotifier<LatLng?> position;
setUp(() {
points = ValueNotifier(const []);
position = ValueNotifier(null);
});
tearDown(() {
points.dispose();
position.dispose();
});
testWidgets('no fix yet: the zoomed-out fallback, no marker', (tester) async {
await _pump(tester, _map(points, position));
expect(_camera(tester).zoom, lessThan(8));
expect(find.byType(MarkerLayer), findsNothing);
});
testWidgets('a fix before the map is built starts centred on it', (
tester,
) async {
position.value = _here;
await _pump(tester, _map(points, position));
expect(_camera(tester).center.latitude, closeTo(_here.latitude, 1e-6));
expect(_camera(tester).zoom, greaterThan(15));
expect(
tester.widget<MarkerLayer>(find.byType(MarkerLayer)).markers,
hasLength(1),
);
});
testWidgets('a later fix moves the camera and the marker', (tester) async {
await _pump(tester, _map(points, position));
position.value = _here;
await tester.pump();
expect(_camera(tester).center.longitude, closeTo(_here.longitude, 1e-6));
position.value = _there;
await tester.pump();
expect(_camera(tester).center.latitude, closeTo(_there.latitude, 1e-6));
expect(
tester.widget<MarkerLayer>(find.byType(MarkerLayer)).markers.single.point,
_there,
);
});
testWidgets('recentre jumps to the position with zero run points', (
tester,
) async {
position.value = _here;
await _pump(tester, _map(points, position));
await tester.drag(find.byType(FlutterMap), const Offset(120, 0));
await tester.pump();
expect(find.byIcon(Icons.my_location), findsOneWidget);
expect(
_camera(tester).center.longitude,
isNot(closeTo(_here.longitude, 1e-4)),
);
await tester.tap(find.byIcon(Icons.my_location));
await tester.pump();
expect(_camera(tester).center.latitude, closeTo(_here.latitude, 1e-6));
expect(_camera(tester).center.longitude, closeTo(_here.longitude, 1e-6));
});
testWidgets('once the run has points they win over the position', (
tester,
) async {
position.value = _here;
await _pump(tester, _map(points, position));
final track = buildTrack(count: 4);
points.value = track.points;
await tester.pump();
final last = track.points.last;
expect(_camera(tester).center.latitude, closeTo(last.latitude, 1e-6));
// The route's own start and live markers; no extra position marker.
expect(
tester.widget<MarkerLayer>(find.byType(MarkerLayer)).markers,
hasLength(2),
);
});
testWidgets('detail mode ignores any position', (tester) async {
await _pump(
tester,
RunMap.route(points: const [], tileProviderOpener: fakeTiles()),
);
expect(find.byType(MarkerLayer), findsNothing);
});
}

View File

@ -0,0 +1,116 @@
// The pace picker: a pace icon, and when on, - m:ss + in 0:05 steps. No
// Scrollable, so it fits the no-scroll run screen.
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/pace_band.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/run_pace_picker.dart';
Future<List<int>> _pump(
WidgetTester tester, {
required bool on,
int seconds = 360,
}) async {
final picked = <int>[];
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) => RunPacePicker(
on: on,
seconds: seconds,
onToggle: () => picked.add(-1),
onChanged: (v) {
picked.add(v);
setState(() => seconds = v);
},
),
),
),
),
);
return picked;
}
void main() {
testWidgets('off: the icon only, and no scrollable', (tester) async {
await _pump(tester, on: false);
expect(find.byKey(const Key('run_pace_toggle')), findsOneWidget);
expect(find.byKey(const Key('run_pace_value')), findsNothing);
expect(find.byType(Scrollable), findsNothing);
});
testWidgets('the toggle reports a tap', (tester) async {
final picked = await _pump(tester, on: false);
await tester.tap(find.byKey(const Key('run_pace_toggle')));
expect(picked, [-1]);
});
testWidgets('on: m:ss between minus and plus, 0:05 per tap', (tester) async {
final picked = await _pump(tester, on: true);
expect(find.text('6:00'), findsOneWidget);
expect(find.byType(Scrollable), findsNothing);
for (final id in ['run_pace_plus', 'run_pace_plus', 'run_pace_minus']) {
await tester.tap(find.byKey(Key(id)));
await tester.pump();
}
expect(picked, [365, 370, 365]);
});
testWidgets('holding a button repeats, and stops on release', (tester) async {
final picked = await _pump(tester, on: true);
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(const Key('run_pace_plus'))),
);
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 10));
for (var i = 0; i < 5; i++) {
await tester.pump(kPaceRepeatEvery);
}
await gesture.up();
await tester.pump();
final steps = picked.length;
expect(steps, greaterThan(3));
await tester.pump(const Duration(seconds: 2));
expect(picked, hasLength(steps));
expect(picked, orderedEquals([...picked]..sort()));
});
testWidgets('a cancelled touch stops the repeat too', (tester) async {
final picked = await _pump(tester, on: true);
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(const Key('run_pace_minus'))),
);
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 10));
await gesture.cancel();
final steps = picked.length;
await tester.pump(const Duration(seconds: 2));
expect(picked, hasLength(steps));
});
testWidgets('a hold stops by itself at the end of the range', (tester) async {
final picked = await _pump(tester, on: true, seconds: 710);
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(const Key('run_pace_plus'))),
);
await tester.pump(kLongPressTimeout + const Duration(milliseconds: 10));
for (var i = 0; i < 20; i++) {
await tester.pump(kPaceRepeatEvery);
}
await gesture.up();
await tester.pump(const Duration(seconds: 1));
expect(picked, [715, 720]);
expect(find.text('12:00'), findsOneWidget);
});
testWidgets('clamps at 3:00 and 12:00', (tester) async {
final low = await _pump(tester, on: true, seconds: kMinPaceTarget);
await tester.tap(find.byKey(const Key('run_pace_minus')));
expect(low, isEmpty);
final high = await _pump(tester, on: true, seconds: kMaxPaceTarget);
await tester.tap(find.byKey(const Key('run_pace_plus')));
expect(high, isEmpty);
expect(find.text('12:00'), findsOneWidget);
});
}

View File

@ -0,0 +1,63 @@
"""Render the two pace-cue tones with the standard library only.
Falling tone (``pace_slow.wav``): speed up. Rising tone (``pace_fast.wav``):
ease off. Each is two soft sine notes with a fast attack and a linear fade so
nothing clicks. They sit apart in pitch and direction, so they can be told
apart with the screen off and the phone in a pocket (the vibration pattern is
the second channel).
Usage: ``render_pace_tones.py OUT_DIR`` writes both files into OUT_DIR.
"""
from __future__ import annotations
import math
from pathlib import Path
import struct
import sys
import wave
SAMPLE_RATE = 44100
PEAK = 0.6
ATTACK_SECONDS = 0.01
# (frequency in Hz, seconds) per note.
SLOW_NOTES = ((494.0, 0.22), (370.0, 0.34))
FAST_NOTES = ((880.0, 0.11), (1319.0, 0.17))
ARG_COUNT = 2
def _note(freq: float, seconds: float) -> list[float]:
"""One sine note with an attack ramp and a linear fade to zero."""
count = int(SAMPLE_RATE * seconds)
attack = int(SAMPLE_RATE * ATTACK_SECONDS)
samples = []
for i in range(count):
gain = min(1.0, i / attack) * (1 - i / count)
samples.append(PEAK * gain * math.sin(2 * math.pi * freq * i / SAMPLE_RATE))
return samples
def render(path: Path, notes: tuple[tuple[float, float], ...]) -> None:
"""Write ``notes`` back to back as a 16-bit mono WAV at ``path``."""
samples = [s for freq, seconds in notes for s in _note(freq, seconds)]
with wave.open(str(path), "wb") as out:
out.setnchannels(1)
out.setsampwidth(2)
out.setframerate(SAMPLE_RATE)
out.writeframes(b"".join(struct.pack("<h", int(s * 32767)) for s in samples))
def main(argv: list[str]) -> int:
"""Render both tones into the directory named by ``argv[1]``."""
if len(argv) != ARG_COUNT:
sys.stderr.write("usage: render_pace_tones.py OUT_DIR\n")
return 2
out = Path(argv[1])
out.mkdir(parents=True, exist_ok=True)
render(out / "pace_slow.wav", SLOW_NOTES)
render(out / "pace_fast.wav", FAST_NOTES)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))