mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
Add sandbox GPS replay and speed setting
Synthetic 4 km loop generated in Dart, replayed at 1x/10x/60x; main imports the dufs login and retries pending uploads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
This commit is contained in:
parent
8a26387396
commit
25ee5ca9ad
@ -14,6 +14,8 @@ import 'package:workout_app/services/http_server_service.dart';
|
||||
import 'package:workout_app/services/lock_mode.dart';
|
||||
import 'package:workout_app/services/pc_pairing.dart';
|
||||
import 'package:workout_app/services/progression_sync_service.dart';
|
||||
import 'package:workout_app/services/run_upload_credentials.dart';
|
||||
import 'package:workout_app/services/run_uploader.dart';
|
||||
import 'package:workout_app/services/storage_service.dart';
|
||||
import 'package:workout_app/services/sync_device_id.dart';
|
||||
import 'package:workout_app/services/sync_service.dart';
|
||||
@ -80,7 +82,14 @@ void main(List<String> args) async {
|
||||
Sandbox.defaultRestSecs,
|
||||
);
|
||||
SandboxLog.event('rest override loaded', {'secs': Sandbox.restSecs});
|
||||
Sandbox.runReplayFactor = await StorageService.instance
|
||||
.getSandboxReplayFactor(Sandbox.defaultReplayFactor);
|
||||
}
|
||||
// Run uploads: absorb a login pushed by scripts/provision_run_upload.sh
|
||||
// (the sandbox never uploads, so it never imports one), then retry any run
|
||||
// still pending from an earlier launch.
|
||||
if (!Sandbox.enabled) await RunCredentialStore().importProvisionFile();
|
||||
unawaited(RunUploader.instance.uploadPending());
|
||||
// Before the UI exists: a service left over from a crashed or force-stopped
|
||||
// workout must not sit in the status bar counting down nothing.
|
||||
await stopStaleBreakService(
|
||||
@ -167,6 +176,8 @@ class _WorkoutAppState extends State<WorkoutApp> with WidgetsBindingObserver {
|
||||
unawaited(HttpServerService.instance.stop());
|
||||
case AppLifecycleState.resumed:
|
||||
unawaited(HttpServerService.instance.start());
|
||||
// The nearest thing to "connectivity came back" without a plugin.
|
||||
unawaited(RunUploader.instance.uploadPending());
|
||||
case AppLifecycleState.inactive:
|
||||
case AppLifecycleState.hidden:
|
||||
break;
|
||||
|
||||
@ -37,6 +37,16 @@ abstract final class Sandbox {
|
||||
/// the Settings screen, loaded by `main`.
|
||||
static int restSecs = defaultRestSecs;
|
||||
|
||||
/// Replay speed-ups the Settings control offers.
|
||||
static const List<int> replayFactors = [1, 10, 60];
|
||||
|
||||
/// Replay speed-up used when no override has been saved yet.
|
||||
static const int defaultReplayFactor = 60;
|
||||
|
||||
/// How many times faster than real time the sandbox replays a GPS run;
|
||||
/// persisted by the Settings screen, loaded by `main`.
|
||||
static int runReplayFactor = defaultReplayFactor;
|
||||
|
||||
/// Environment variable that turns the sandbox on where there is no
|
||||
/// flavor to ask — the Linux desktop build, run under Xvfb to check a
|
||||
/// change on the PC before it goes anywhere near the phone.
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
/// 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
|
||||
/// `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';
|
||||
import 'package:workout_app/services/position_source_geolocator.dart';
|
||||
import 'package:workout_app/services/position_source_scripted.dart';
|
||||
|
||||
/// Length of the synthetic loop in metres.
|
||||
const double kLoopLengthM = 4000;
|
||||
|
||||
/// Seconds between replayed fixes (the real request interval).
|
||||
const int kFixStepSeconds = 2;
|
||||
|
||||
// A neutral spot: nobody's home, any tile server has it.
|
||||
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,
|
||||
};
|
||||
|
||||
/// Fixes for one lap of a [kLoopLengthM] circle starting at [start], one every
|
||||
/// [kFixStepSeconds], 5 m accuracy.
|
||||
List<GpsFix> sandboxLoopFixes(DateTime start, RunSport sport) {
|
||||
final radius = kLoopLengthM / (2 * math.pi);
|
||||
final steps = (kLoopLengthM / replaySpeedMps(sport) / kFixStepSeconds)
|
||||
.round();
|
||||
const metresPerDegree = 111194.9;
|
||||
final lonScale = metresPerDegree * math.cos(_centreLat * math.pi / 180);
|
||||
return [
|
||||
for (var i = 0; i <= steps; i++)
|
||||
GpsFix(
|
||||
time: start.add(Duration(seconds: i * kFixStepSeconds)),
|
||||
latitude:
|
||||
_centreLat +
|
||||
radius * math.sin(2 * math.pi * i / steps) / metresPerDegree,
|
||||
longitude:
|
||||
_centreLon +
|
||||
radius * (1 - math.cos(2 * math.pi * i / steps)) / lonScale,
|
||||
accuracyM: 5,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 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,
|
||||
),
|
||||
);
|
||||
|
||||
/// The source a recording uses: the replay in the sandbox, the phone's GPS
|
||||
/// otherwise.
|
||||
PositionSource positionSourceFor(RunSport sport) =>
|
||||
Sandbox.enabled ? sandboxRunSource(sport) : GeolocatorPositionSource();
|
||||
@ -34,6 +34,7 @@ class _SandboxSectionState extends State<_SandboxSection> {
|
||||
Future<void> _wipe() async {
|
||||
await StorageService.instance.wipeAll();
|
||||
Sandbox.restSecs = Sandbox.defaultRestSecs;
|
||||
Sandbox.runReplayFactor = Sandbox.defaultReplayFactor;
|
||||
_rest.text = '${Sandbox.restSecs}';
|
||||
_report('Wiped — the sandbox is back to a fresh install.');
|
||||
}
|
||||
@ -60,6 +61,12 @@ class _SandboxSectionState extends State<_SandboxSection> {
|
||||
_report('Every rest now lasts $secs s.');
|
||||
}
|
||||
|
||||
Future<void> _saveReplay(int factor) async {
|
||||
await StorageService.instance.setSandboxReplayFactor(factor);
|
||||
setState(() => Sandbox.runReplayFactor = factor);
|
||||
_report('GPS runs replay at $factor×.');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@ -118,6 +125,27 @@ class _SandboxSectionState extends State<_SandboxSection> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 12),
|
||||
child: Icon(Icons.directions_run, semanticLabel: 'GPS replay'),
|
||||
),
|
||||
Expanded(
|
||||
child: SegmentedButton<int>(
|
||||
key: const Key('sandbox-replay-factor'),
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
for (final f in Sandbox.replayFactors)
|
||||
ButtonSegment(value: f, label: Text('$f×')),
|
||||
],
|
||||
selected: {Sandbox.runReplayFactor},
|
||||
onSelectionChanged: (s) => unawaited(_saveReplay(s.single)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_status != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
|
||||
@ -8,6 +8,9 @@ part of 'storage_service.dart';
|
||||
/// Settings key the sandbox's rest length is stored under.
|
||||
const String kSandboxRestSecsKey = 'sandbox_rest_secs';
|
||||
|
||||
/// Settings key the sandbox's GPS replay speed-up is stored under.
|
||||
const String kSandboxReplayFactorKey = 'sandbox_run_replay_x';
|
||||
|
||||
/// Sandbox-only maintenance on the (sandbox's own) database.
|
||||
///
|
||||
/// Nothing here checks `Sandbox.enabled`: the Settings screen only offers
|
||||
@ -54,4 +57,14 @@ extension StorageServiceSandbox on StorageService {
|
||||
/// Persists the rest override used by every sandbox break.
|
||||
Future<void> setSandboxRestSecs(int secs) =>
|
||||
_setSetting(kSandboxRestSecsKey, '$secs');
|
||||
|
||||
/// The saved GPS replay speed-up, or [fallback] when none has been saved.
|
||||
Future<int> getSandboxReplayFactor(int fallback) async {
|
||||
final raw = await _getSetting(kSandboxReplayFactorKey);
|
||||
return raw == null ? fallback : int.parse(raw);
|
||||
}
|
||||
|
||||
/// Persists the speed-up the sandbox replays its GPS run at.
|
||||
Future<void> setSandboxReplayFactor(int factor) =>
|
||||
_setSetting(kSandboxReplayFactorKey, '$factor');
|
||||
}
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:workout_app/models/run_track.dart';
|
||||
import 'package:workout_app/sandbox/sandbox.dart';
|
||||
import 'package:workout_app/sandbox/sandbox_run_replay.dart';
|
||||
import 'package:workout_app/services/gps_filter.dart';
|
||||
import 'package:workout_app/services/position_source_geolocator.dart';
|
||||
import 'package:workout_app/services/position_source_scripted.dart';
|
||||
import 'package:workout_app/services/run_math.dart';
|
||||
|
||||
void main() {
|
||||
tearDown(() {
|
||||
Sandbox.enabled = false;
|
||||
Sandbox.runReplayFactor = Sandbox.defaultReplayFactor;
|
||||
});
|
||||
|
||||
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('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('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,
|
||||
);
|
||||
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);
|
||||
expect(source, isA<ScriptedPositionSource>());
|
||||
expect(
|
||||
(source as ScriptedPositionSource).fixDelay,
|
||||
Duration(microseconds: micros),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the default speed-up replays a whole run in under a minute', () {
|
||||
expect(Sandbox.runReplayFactor, 60);
|
||||
final fixes = sandboxLoopFixes(start, RunSport.running).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>());
|
||||
Sandbox.enabled = false;
|
||||
expect(
|
||||
positionSourceFor(RunSport.running),
|
||||
isA<GeolocatorPositionSource>(),
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -24,10 +24,12 @@ void main() {
|
||||
installFakeSecureStorage();
|
||||
Sandbox.enabled = true;
|
||||
Sandbox.restSecs = Sandbox.defaultRestSecs;
|
||||
Sandbox.runReplayFactor = Sandbox.defaultReplayFactor;
|
||||
});
|
||||
tearDown(() {
|
||||
Sandbox.enabled = false;
|
||||
Sandbox.restSecs = Sandbox.defaultRestSecs;
|
||||
Sandbox.runReplayFactor = Sandbox.defaultReplayFactor;
|
||||
});
|
||||
|
||||
Widget wrap() => MaterialApp(
|
||||
@ -144,4 +146,24 @@ void main() {
|
||||
);
|
||||
expect(saved, 45);
|
||||
});
|
||||
|
||||
testWidgets('the GPS replay speed is chosen, saved and wiped', (
|
||||
tester,
|
||||
) async {
|
||||
await pump(tester);
|
||||
final tenX = find.descendant(
|
||||
of: find.byKey(const Key('sandbox-replay-factor')),
|
||||
matching: find.text('10×'),
|
||||
);
|
||||
await tapReal(tester, tenX);
|
||||
expect(find.text('GPS runs replay at 10×.'), findsOneWidget);
|
||||
expect(Sandbox.runReplayFactor, 10);
|
||||
final saved = await tester.runAsync(
|
||||
() => StorageService.instance.getSandboxReplayFactor(60),
|
||||
);
|
||||
expect(saved, 10);
|
||||
|
||||
await tapReal(tester, find.text('Wipe sandbox data'));
|
||||
expect(Sandbox.runReplayFactor, Sandbox.defaultReplayFactor);
|
||||
});
|
||||
}
|
||||
|
||||
@ -57,4 +57,11 @@ void main() {
|
||||
await storage.setSandboxRestSecs(42);
|
||||
expect(await storage.getSandboxRestSecs(5), 42);
|
||||
});
|
||||
|
||||
test('replay factor round-trips and falls back when unset', () async {
|
||||
final svc = StorageService.instance;
|
||||
expect(await svc.getSandboxReplayFactor(60), 60);
|
||||
await svc.setSandboxReplayFactor(10);
|
||||
expect(await svc.getSandboxReplayFactor(60), 10);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user