From 9641906818d0181ac7a35c5c6da4d961a243d4c0 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 10 Nov 2026 19:05:53 +0100 Subject: [PATCH] Pace target in backup, run rule comments Back up and restore the pace target (null or absent = off, a stale target is cleared). Mirror comments in run_math and friends now state the PC's merged rule and where the app is stricter. Pace tones get MANIFEST entries; default map and backup get tests. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWXcjiPpgRfxzrRfoky --- .../workout_app/assets/sounds/MANIFEST.json | 44 ++++++ .../workout_app/lib/models/run_track.dart | 7 +- .../workout_app/lib/services/gps_filter.dart | 4 +- .../lib/services/pace_target_store.dart | 10 ++ .../workout_app/lib/services/run_math.dart | 30 ++-- .../lib/services/run_recorder.dart | 4 +- .../lib/services/storage_service.dart | 1 + .../lib/services/storage_service_backup.dart | 37 +++++ .../test/screens/_run_screen_fixtures.dart | 33 +++-- .../screens/run_screen_default_map_test.dart | 25 ++++ .../storage_service_backup_pace_test.dart | 129 ++++++++++++++++++ .../workout_app/tools/render_pace_tones.py | 44 +++++- 12 files changed, 336 insertions(+), 32 deletions(-) create mode 100644 stronglift_replacement/workout_app/test/screens/run_screen_default_map_test.dart create mode 100644 stronglift_replacement/workout_app/test/services/storage_service_backup_pace_test.dart diff --git a/stronglift_replacement/workout_app/assets/sounds/MANIFEST.json b/stronglift_replacement/workout_app/assets/sounds/MANIFEST.json index 7c75a7a..1e115d5 100644 --- a/stronglift_replacement/workout_app/assets/sounds/MANIFEST.json +++ b/stronglift_replacement/workout_app/assets/sounds/MANIFEST.json @@ -1,4 +1,48 @@ { + "pace_fast.wav": { + "file": "pace_fast.wav", + "frames": 12348, + "recipe": { + "attack_seconds": 0.01, + "notes": [ + [ + 880.0, + 0.11 + ], + [ + 1319.0, + 0.17 + ] + ], + "peak": 0.6, + "sample_rate": 44100 + }, + "sample_rate": 44100, + "seconds": 0.28, + "sha256": "9d9297b0f681cf00490cbbb0b4316b9607002ca7e0cb2ef3d33622b52091349e" + }, + "pace_slow.wav": { + "file": "pace_slow.wav", + "frames": 24696, + "recipe": { + "attack_seconds": 0.01, + "notes": [ + [ + 494.0, + 0.22 + ], + [ + 370.0, + 0.34 + ] + ], + "peak": 0.6, + "sample_rate": 44100 + }, + "sample_rate": 44100, + "seconds": 0.56, + "sha256": "5bea2e6c64b57065fb72436f8246ea6ded4e58f2c8f070a0aef3b135fb871bf6" + }, "workout_done.wav": { "file": "workout_done.wav", "frames": 24255, diff --git a/stronglift_replacement/workout_app/lib/models/run_track.dart b/stronglift_replacement/workout_app/lib/models/run_track.dart index 40676ab..0be76e1 100644 --- a/stronglift_replacement/workout_app/lib/models/run_track.dart +++ b/stronglift_replacement/workout_app/lib/models/run_track.dart @@ -6,10 +6,11 @@ import 'package:flutter/foundation.dart'; /// The sports the app records. enum RunSport { - /// A run; credited on distance OR duration by the PC. + /// A run. The PC credits every GPS activity alike: a day's distance OR + /// moving time, whatever the sport. running('Running', 'Running'), - /// A walk; the PC sums a day's walks and credits their moving time. + /// A walk; read from old files only (the app records everything as a run). /// /// Written as `Sport="Other"` exactly like RunnerUp does (the TCX schema /// only knows Running, Biking and Other); the `_Walking.tcx` file-name @@ -79,7 +80,7 @@ class RunPoint { /// Cumulative credited distance from the start, in metres. /// - /// The PC derives walking speed from the difference between two points' + /// The PC derives speed from the difference between two points' /// values, so this is the field the recorder must keep honest. final double distanceM; diff --git a/stronglift_replacement/workout_app/lib/services/gps_filter.dart b/stronglift_replacement/workout_app/lib/services/gps_filter.dart index e2e8745..f6fe82f 100644 --- a/stronglift_replacement/workout_app/lib/services/gps_filter.dart +++ b/stronglift_replacement/workout_app/lib/services/gps_filter.dart @@ -66,7 +66,9 @@ 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 an activity. +/// and standing-still jitter credit nothing. The PC judges windows of 10 s or +/// more and keeps a dropout moving at 1 m/s or faster, so it can credit more +/// than this does (see `run_math.dart`). class GpsFilter { /// Creates a filter; every recording uses the running jump cap. GpsFilter(); diff --git a/stronglift_replacement/workout_app/lib/services/pace_target_store.dart b/stronglift_replacement/workout_app/lib/services/pace_target_store.dart index a5c6502..cb49f47 100644 --- a/stronglift_replacement/workout_app/lib/services/pace_target_store.dart +++ b/stronglift_replacement/workout_app/lib/services/pace_target_store.dart @@ -38,4 +38,14 @@ class PrefsPaceTargetStore implements PaceTargetStore { @override Future save(int seconds) => _resolved.setInt(kPaceTargetKey, seconds); + + /// The target as stored, or null when none was ever chosen (the "off" + /// state the /sdcard backup carries). + Future stored() => _resolved.getInt(kPaceTargetKey); + + /// Puts a backed-up target back; null means none chosen, so a leftover + /// value is removed rather than kept. + Future restore(int? seconds) => seconds == null + ? _resolved.remove(kPaceTargetKey) + : save(clampPaceTarget(seconds)); } diff --git a/stronglift_replacement/workout_app/lib/services/run_math.dart b/stronglift_replacement/workout_app/lib/services/run_math.dart index b21a571..ef27e4d 100644 --- a/stronglift_replacement/workout_app/lib/services/run_math.dart +++ b/stronglift_replacement/workout_app/lib/services/run_math.dart @@ -1,4 +1,19 @@ /// Distance and moving-time maths for recorded tracks (pure, no I/O). +/// +/// The PC's one crediting rule (`screen_locker/_activity_day.py`, `_walk_tcx.py`), +/// for every GPS activity whatever its sport: a day's activities are summed and +/// count once the distance reaches 3.5 km (5 % tolerance) OR the moving time +/// reaches 25 min. Only the PC decides that; the app has no "qualifies" test. +/// +/// The PC judges speed over windows of at least 10 s (`TRACK_WINDOW_SECONDS`). +/// A window faster than 25 km/h earns neither distance nor time; a window of +/// 30 s or less earns moving time at 0.5 m/s or faster; a longer one (a GPS +/// dropout) at 1 m/s or faster; paused time earns nothing. +/// +/// The app mirrors only the bounds, per pair of its 2 s fixes, and is stricter: +/// a pair over 30 s credits nothing, where the PC keeps a dropout at 1 m/s or +/// faster. Its on-screen moving time is therefore an estimate, not the +/// number the PC will credit. library; import 'dart:math' as math; @@ -12,20 +27,18 @@ const double kEarthRadiusM = 6371008.8; /// nor moving time: a GPS dropout whose motion is unknown. /// /// Mirrors `WALK_MAX_GAP_SECONDS` in the PC's `screen_locker/_constants.py`. -/// This is a mirror of the rule the PC applies to a walk's trackpoints, so the -/// app never reports moving time the PC would discard -- it is NOT a crediting -/// threshold (those stay on the PC). const int kMaxCreditGapSeconds = 30; /// A pair slower than this is standing still, not moving. /// -/// Mirrors the lower bound of `WALK_SPEED_MPS` on the PC. +/// Mirrors `MIN_MOVING_SPEED_MPS` on the PC. const double kMinMovingSpeedMps = 0.5; /// 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. +/// Mirrors `MAX_MOVING_SPEED_MPS` on the PC, which applies it to each window +/// of 10 s or more rather than to each fix. One cap for every recording: the +/// PC credits every GPS activity with the same rule. const double kRunningMaxSpeedMps = 25 / 3.6; double _rad(double degrees) => degrees * math.pi / 180; @@ -46,8 +59,9 @@ double haversineM(double lat1, double lon1, double lat2, double lon2) { /// [kMaxCreditGapSeconds] and travelled at a speed in /// `[kMinMovingSpeedMps, maxSpeed]`. /// -/// Speed comes from the cumulative `distanceM` difference, exactly as the PC -/// reads a TCX, so app and PC agree on which steps count. +/// Speed comes from the cumulative `distanceM` difference, as the PC reads a +/// TCX, but over one pair of fixes rather than a window of 10 s or more, so the +/// two can disagree on a jittery or stop-and-go stretch. bool isMovingStep(RunPoint a, RunPoint b, double maxSpeed) { final gap = b.time.difference(a.time).inMilliseconds / 1000; if (gap <= 0 || gap > kMaxCreditGapSeconds) return false; diff --git a/stronglift_replacement/workout_app/lib/services/run_recorder.dart b/stronglift_replacement/workout_app/lib/services/run_recorder.dart index 233616f..a35e54a 100644 --- a/stronglift_replacement/workout_app/lib/services/run_recorder.dart +++ b/stronglift_replacement/workout_app/lib/services/run_recorder.dart @@ -23,8 +23,8 @@ export 'package:workout_app/services/run_phase.dart'; /// /// 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. +/// is [movingSeconds] applied as fixes arrive: an estimate that mirrors the +/// PC's bounds per pair of fixes, not its 10 s windows (see `run_math.dart`). class RunRecorder extends ChangeNotifier { /// Creates a recorder; [sourceFor] builds the fix stream on [start]. RunRecorder({ diff --git a/stronglift_replacement/workout_app/lib/services/storage_service.dart b/stronglift_replacement/workout_app/lib/services/storage_service.dart index e096516..634f03f 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service.dart @@ -19,6 +19,7 @@ import 'package:workout_app/models/run_track.dart'; import 'package:workout_app/models/workout_plan.dart'; import 'package:workout_app/services/backup_service.dart'; import 'package:workout_app/services/manual_workout_port.dart'; +import 'package:workout_app/services/pace_target_store.dart'; import 'package:workout_app/services/progression_sync_service.dart'; // Re-exported: ExerciseState was defined here until it moved to models/, and diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart b/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart index 21208d9..80a61b3 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart @@ -51,9 +51,45 @@ extension StorageServiceBackup on StorageService { 'settings': settingsRows, 'progression_events': eventRows, 'runs': runRows, + ...await _paceTargetForBackup(), }); } + /// The pace target for the backup: `pace_target` is seconds per kilometre, + /// or null when none was ever chosen (off). Lives in shared_preferences, not + /// the DB, so it is added by hand. An unreadable store leaves the field out + /// (a restore then reads "off") and says so. + Future> _paceTargetForBackup() async { + try { + return {'pace_target': await PrefsPaceTargetStore().stored()}; + } on Object catch (error) { + debugPrint( + 'WorkoutApp: pace target NOT in this backup -- could not read it ' + '($error). A restore will bring the pace target back as off.', + ); + return const {}; + } + } + + /// Puts the backup's `pace_target` back. Absent (an older backup), null or + /// unusable all mean off, so a restore never keeps a stale target. + Future _restorePaceTarget(Object? raw) async { + if (raw != null && raw is! int) { + debugPrint( + 'WorkoutApp: backup pace_target is not a number ($raw) -- restoring ' + 'the pace target as off.', + ); + } + try { + await PrefsPaceTargetStore().restore(raw is int ? raw : null); + } on Object catch (error) { + debugPrint( + 'WorkoutApp: pace target NOT restored -- could not write it ($error). ' + 'It stays as it was; pick the target again on the run screen.', + ); + } + } + /// Whether this database has never recorded a workout on this install. /// /// "Empty" means no workout history and no [last_workout_type] setting — @@ -184,5 +220,6 @@ extension StorageServiceBackup on StorageService { // Absent from older backups; null merges as nothing. await _mergeRuns(txn, backup['runs']); }); + await _restorePaceTarget(backup['pace_target']); } } diff --git a/stronglift_replacement/workout_app/test/screens/_run_screen_fixtures.dart b/stronglift_replacement/workout_app/test/screens/_run_screen_fixtures.dart index 887d299..edcd6ab 100644 --- a/stronglift_replacement/workout_app/test/screens/_run_screen_fixtures.dart +++ b/stronglift_replacement/workout_app/test/screens/_run_screen_fixtures.dart @@ -89,22 +89,31 @@ class RunHarness { home: const SizedBox(), ); - RunScreen screen({RunMapBuilder? mapBuilder}) => RunScreen( - controllerFactory: build, - uploader: uploader, - paceStore: paceStore, - mapBuilder: mapBuilder ?? fakeTileMap, - openSettings: () async { - settingsOpened.add(1); - return true; - }, - ); + /// The screen over [mapBuilder], fake tiles by default; [realMap] leaves + /// the screen's own default map in place. + RunScreen screen({RunMapBuilder? mapBuilder, bool realMap = false}) => + RunScreen( + controllerFactory: build, + uploader: uploader, + paceStore: paceStore, + mapBuilder: realMap ? null : mapBuilder ?? fakeTileMap, + openSettings: () async { + settingsOpened.add(1); + return true; + }, + ); /// Pumps the app with the run screen pushed on top of an empty route. - Future pump(WidgetTester tester, {RunMapBuilder? mapBuilder}) async { + Future pump( + WidgetTester tester, { + RunMapBuilder? mapBuilder, + bool realMap = false, + }) async { await tester.pumpWidget(app()); navigator.currentState!.push( - MaterialPageRoute(builder: (_) => screen(mapBuilder: mapBuilder)), + MaterialPageRoute( + builder: (_) => screen(mapBuilder: mapBuilder, realMap: realMap), + ), ); await tester.pumpAndSettle(); } diff --git a/stronglift_replacement/workout_app/test/screens/run_screen_default_map_test.dart b/stronglift_replacement/workout_app/test/screens/run_screen_default_map_test.dart new file mode 100644 index 0000000..0d08cf6 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/run_screen_default_map_test.dart @@ -0,0 +1,25 @@ +// Without an injected map builder the run screen shows the real map slot +// (over the disk-cached OSM source). +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/map_tile_cache.dart'; +import 'package:workout_app/widgets/run_map.dart'; + +import '../fake_audio_platform.dart'; +import '../fake_path_provider.dart'; +import '_run_screen_fixtures.dart'; + +void main() { + setUp(() { + installFakeAudioPlatform(); + resetOsmTileProvider(); + final dir = installFakePathProvider(); + addTearDown(() => dir.deleteSync(recursive: true)); + }); + + testWidgets('the default map is the run map slot', (tester) async { + final h = RunHarness(); + await h.pump(tester, realMap: true); + expect(key('run_map_slot'), findsOneWidget); + expect(find.byType(RunMap), findsOneWidget); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/storage_service_backup_pace_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_backup_pace_test.dart new file mode 100644 index 0000000..d8e4a27 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/storage_service_backup_pace_test.dart @@ -0,0 +1,129 @@ +// The pace target rides in the /sdcard backup and comes back on a restore. +@TestOn('vm') +library; + +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:shared_preferences_platform_interface/types.dart'; +import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/pace_band.dart'; +import 'package:workout_app/services/pace_target_store.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import '../widgets/_run_map_fixtures.dart' show capturingDebugPrint; +import 'storage_service_runs_fixtures.dart'; + +/// A preferences platform that can be neither read nor written. +base class _BrokenPrefs extends InMemorySharedPreferencesAsync { + _BrokenPrefs() : super.empty(); + + @override + Future getInt(String key, SharedPreferencesOptions options) => + throw StateError('prefs unavailable'); + + @override + Future setInt( + String key, + int value, + SharedPreferencesOptions options, + ) => throw StateError('prefs unavailable'); +} + +void _freshPrefs() => SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + +/// Writes a backup (a write triggers one) and returns it once it is on disk. +Future> _backup() async { + await svc.setLastWorkoutType('A'); + for (var i = 0; i < 150; i++) { + final backup = await BackupService.instance.readBackup(); + if (backup != null && (backup['settings'] as List).isNotEmpty) { + return backup; + } + await Future.delayed(const Duration(milliseconds: 20)); + } + fail('backup.json never written'); +} + +/// A reinstall: new DB, preferences holding [stale] (null: nothing), restore. +Future _reinstall({int? stale}) async { + await freshInstall(); + _freshPrefs(); + if (stale != null) await PrefsPaceTargetStore().save(stale); + await svc.restoreFromBackupIfNeeded(); +} + +void main() { + useRunsTransferSandbox(); + setUp(_freshPrefs); + + test('a set target round-trips through backup and restore', () async { + await PrefsPaceTargetStore().save(295); + expect((await _backup())['pace_target'], 295); + + await _reinstall(stale: 400); + + expect(await PrefsPaceTargetStore().stored(), 295); + }); + + test('"off" round-trips and replaces a stale target', () async { + final backup = await _backup(); + expect(backup.containsKey('pace_target'), isTrue); + expect(backup['pace_target'], isNull); + + await _reinstall(stale: 400); + + expect(await PrefsPaceTargetStore().stored(), isNull); + expect(await PrefsPaceTargetStore().load(), kDefaultPaceTarget); + }); + + test('an older backup without the field restores as off', () async { + await BackupService.instance.export({ + 'exercise_state': [], + 'workout_history': [], + 'settings': [], + }); + + await _reinstall(stale: 400); + + expect(await PrefsPaceTargetStore().stored(), isNull); + }); + + test('a target that is not a number restores as off, loudly', () async { + await BackupService.instance.export({'pace_target': 'fast'}); + final lines = await capturingDebugPrint(() => _reinstall(stale: 400)); + expect(await PrefsPaceTargetStore().stored(), isNull); + expect(lines.where((l) => l.contains('not a number')), hasLength(1)); + }); + + test('an out-of-range target is clamped on restore', () async { + await BackupService.instance.export({'pace_target': 60}); + await _reinstall(); + expect(await PrefsPaceTargetStore().stored(), kMinPaceTarget); + }); + + test('unreadable preferences leave the field out and say so', () async { + SharedPreferencesAsyncPlatform.instance = _BrokenPrefs(); + late Map backup; + final lines = await capturingDebugPrint( + () async => backup = await _backup(), + ); + expect(backup.containsKey('pace_target'), isFalse); + expect(lines.where((l) => l.contains('NOT in this backup')), hasLength(1)); + }); + + test('unwritable preferences do not stop the rest of the restore', () async { + await BackupService.instance.export({ + 'settings': [ + {'key': 'last_workout_type', 'value': 'B', 'updated_at': 1}, + ], + 'pace_target': 295, + }); + await freshInstall(); + SharedPreferencesAsyncPlatform.instance = _BrokenPrefs(); + final lines = await capturingDebugPrint(svc.restoreFromBackupIfNeeded); + expect(await svc.getNextWorkoutType(), 'A'); // last was B: restored + expect(lines.where((l) => l.contains('NOT restored')), hasLength(1)); + }); +} diff --git a/stronglift_replacement/workout_app/tools/render_pace_tones.py b/stronglift_replacement/workout_app/tools/render_pace_tones.py index 062bd90..f244452 100644 --- a/stronglift_replacement/workout_app/tools/render_pace_tones.py +++ b/stronglift_replacement/workout_app/tools/render_pace_tones.py @@ -6,11 +6,16 @@ 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. +Usage: ``render_pace_tones.py OUT_DIR`` writes both files into OUT_DIR and +adds their entries (recipe, frame count, SHA-256) to OUT_DIR/MANIFEST.json, +leaving the other entries (the finish chime's) as they are. Standard library +only, so there is no toolkit to pin and no ``.sh`` wrapper. """ from __future__ import annotations +import hashlib +import json import math from pathlib import Path import struct @@ -37,14 +42,38 @@ def _note(freq: float, seconds: float) -> list[float]: 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``.""" +def render(path: Path, notes: tuple[tuple[float, float], ...]) -> dict[str, object]: + """Write ``notes`` back to back as a 16-bit mono WAV; return its manifest entry.""" samples = [s for freq, seconds in notes for s in _note(freq, seconds)] - with wave.open(str(path), "wb") as out: + with wave.Wave_write(str(path)) as out: out.setnchannels(1) out.setsampwidth(2) out.setframerate(SAMPLE_RATE) out.writeframes(b"".join(struct.pack(" None: + """Merge ``entries`` into the JSON manifest at ``path``, keeping the rest.""" + manifest: dict[str, object] = ( + json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + ) + manifest.update(entries) + path.write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) def main(argv: list[str]) -> int: @@ -54,8 +83,11 @@ def main(argv: list[str]) -> int: 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) + entries = { + "pace_slow.wav": render(out / "pace_slow.wav", SLOW_NOTES), + "pace_fast.wav": render(out / "pace_fast.wav", FAST_NOTES), + } + update_manifest(out / "MANIFEST.json", entries) return 0