mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 13:07:43 +01:00
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:
parent
d0bb8d700e
commit
48d21bfafc
@ -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
|
||||
|
||||
BIN
stronglift_replacement/workout_app/assets/sounds/pace_fast.wav
Normal file
BIN
stronglift_replacement/workout_app/assets/sounds/pace_fast.wav
Normal file
Binary file not shown.
BIN
stronglift_replacement/workout_app/assets/sounds/pace_slow.wav
Normal file
BIN
stronglift_replacement/workout_app/assets/sounds/pace_slow.wav
Normal file
Binary file not shown.
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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).');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
114
stronglift_replacement/workout_app/lib/services/pace_band.dart
Normal file
114
stronglift_replacement/workout_app/lib/services/pace_band.dart
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
27
stronglift_replacement/workout_app/test/fake_geolocator.dart
Normal file
27
stronglift_replacement/workout_app/test/fake_geolocator.dart
Normal 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();
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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)}) {
|
||||
|
||||
@ -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>());
|
||||
});
|
||||
}
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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', (
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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));
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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')));
|
||||
});
|
||||
}
|
||||
@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
});
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
@ -40,7 +40,7 @@ void main() {
|
||||
FakeForegroundRunClient service, {
|
||||
Future<RunSaveResult> Function(RunTrack)? save,
|
||||
}) => RunController(
|
||||
sourceFor: (_) => source,
|
||||
sourceFor: () => source,
|
||||
service: service,
|
||||
cues: FeedbackCues(AudioPlayer()),
|
||||
save:
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
});
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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))
|
||||
Loading…
Reference in New Issue
Block a user