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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXcjiPpgRfxzrRfoky
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 19:05:53 +01:00
parent cd32c0a79e
commit 9641906818
12 changed files with 336 additions and 32 deletions

View File

@ -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,

View File

@ -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;

View File

@ -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();

View File

@ -38,4 +38,14 @@ class PrefsPaceTargetStore implements PaceTargetStore {
@override
Future<void> 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<int?> stored() => _resolved.getInt(kPaceTargetKey);
/// Puts a backed-up target back; null means none chosen, so a leftover
/// value is removed rather than kept.
Future<void> restore(int? seconds) => seconds == null
? _resolved.remove(kPaceTargetKey)
: save(clampPaceTarget(seconds));
}

View File

@ -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;

View File

@ -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({

View File

@ -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

View File

@ -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<Map<String, Object?>> _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<void> _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']);
}
}

View File

@ -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<void> pump(WidgetTester tester, {RunMapBuilder? mapBuilder}) async {
Future<void> pump(
WidgetTester tester, {
RunMapBuilder? mapBuilder,
bool realMap = false,
}) async {
await tester.pumpWidget(app());
navigator.currentState!.push(
MaterialPageRoute<void>(builder: (_) => screen(mapBuilder: mapBuilder)),
MaterialPageRoute<void>(
builder: (_) => screen(mapBuilder: mapBuilder, realMap: realMap),
),
);
await tester.pumpAndSettle();
}

View File

@ -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);
});
}

View File

@ -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<int?> getInt(String key, SharedPreferencesOptions options) =>
throw StateError('prefs unavailable');
@override
Future<bool> 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<Map<String, dynamic>> _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<void>.delayed(const Duration(milliseconds: 20));
}
fail('backup.json never written');
}
/// A reinstall: new DB, preferences holding [stale] (null: nothing), restore.
Future<void> _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': <Object>[],
'workout_history': <Object>[],
'settings': <Object>[],
});
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<String, dynamic> 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));
});
}

View File

@ -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("<h", int(s * 32767)) for s in samples))
return {
"file": path.name,
"frames": len(samples),
"recipe": {
"attack_seconds": ATTACK_SECONDS,
"notes": [list(note) for note in notes],
"peak": PEAK,
"sample_rate": SAMPLE_RATE,
},
"sample_rate": SAMPLE_RATE,
"seconds": round(len(samples) / SAMPLE_RATE, 2),
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
}
def update_manifest(path: Path, entries: dict[str, dict[str, object]]) -> 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