From 6685aa2a82243662344d477184f0c43e1ee949bc Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 10:32:45 +0200 Subject: [PATCH 01/17] chore(mobile): add package_info_plus for settings version row --- mobile/pubspec.lock | 26 +++++++++----------------- mobile/pubspec.yaml | 1 + 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index a7c8d5b..b4e4cd2 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -288,14 +288,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - ffi_leak_tracker: - dependency: transitive - description: - name: ffi_leak_tracker - sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" - url: "https://pub.dev" - source: hosted - version: "0.1.2" file: dependency: transitive description: @@ -732,21 +724,21 @@ packages: source: hosted version: "2.2.0" package_info_plus: - dependency: transitive + dependency: "direct main" description: name: package_info_plus - sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852" + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" url: "https://pub.dev" source: hosted - version: "10.1.0" + version: "8.3.1" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "3.2.1" path: dependency: transitive description: @@ -1212,10 +1204,10 @@ packages: dependency: "direct main" description: name: wakelock_plus - sha256: "2b09acadd7a2862d33c3577e77e7a2aabb684f47ccca1711f1413bd7307a6a72" + sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b" url: "https://pub.dev" source: hosted - version: "1.6.0" + version: "1.3.3" wakelock_plus_platform_interface: dependency: transitive description: @@ -1300,10 +1292,10 @@ packages: dependency: transitive description: name: win32 - sha256: ba7d5750e3441caa1bbe31d9e516348fcf8dfcb32aa29ef87a844a59f4d1f1d0 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "5.15.0" xdg_directories: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 60412b3..fc6d529 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: haptic_feedback: ^0.6.4 sentry_flutter: ^9.19.0 wakelock_plus: ^1.2.8 + package_info_plus: ^8.1.0 ferrostar_flutter: path: ../packages/ferrostar_flutter From 0c5019aa3c33386314d38d6a02bb089fc0989ed4 Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 10:36:20 +0200 Subject: [PATCH 02/17] chore(mobile): bump package_info_plus constraint to avoid peer downgrades --- mobile/pubspec.lock | 24 ++++++++++++++++-------- mobile/pubspec.yaml | 2 +- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index b4e4cd2..fe5b2f4 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -288,6 +288,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -727,18 +735,18 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852" url: "https://pub.dev" source: hosted - version: "8.3.1" + version: "10.1.0" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "4.1.0" path: dependency: transitive description: @@ -1204,10 +1212,10 @@ packages: dependency: "direct main" description: name: wakelock_plus - sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b" + sha256: "2b09acadd7a2862d33c3577e77e7a2aabb684f47ccca1711f1413bd7307a6a72" url: "https://pub.dev" source: hosted - version: "1.3.3" + version: "1.6.0" wakelock_plus_platform_interface: dependency: transitive description: @@ -1292,10 +1300,10 @@ packages: dependency: transitive description: name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: ba7d5750e3441caa1bbe31d9e516348fcf8dfcb32aa29ef87a844a59f4d1f1d0 url: "https://pub.dev" source: hosted - version: "5.15.0" + version: "6.1.0" xdg_directories: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index fc6d529..f92948e 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: haptic_feedback: ^0.6.4 sentry_flutter: ^9.19.0 wakelock_plus: ^1.2.8 - package_info_plus: ^8.1.0 + package_info_plus: ^10.1.0 ferrostar_flutter: path: ../packages/ferrostar_flutter From 6b64a97e3b02b3f7df2e445b1a0693c4213a2881 Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 10:38:23 +0200 Subject: [PATCH 03/17] feat(mobile): add PolylineWalker for GPS playback interpolation --- mobile/lib/dev/polyline_walker.dart | 82 +++++++++++++++++++++++ mobile/test/dev/polyline_walker_test.dart | 53 +++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 mobile/lib/dev/polyline_walker.dart create mode 100644 mobile/test/dev/polyline_walker_test.dart diff --git a/mobile/lib/dev/polyline_walker.dart b/mobile/lib/dev/polyline_walker.dart new file mode 100644 index 0000000..e750afc --- /dev/null +++ b/mobile/lib/dev/polyline_walker.dart @@ -0,0 +1,82 @@ +import 'package:geolocator/geolocator.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +class WalkerPoint { + const WalkerPoint(this.latLng, this.bearingDeg); + final LatLng latLng; + final double bearingDeg; +} + +class PolylineWalker { + PolylineWalker(List points) + : _points = List.unmodifiable(points), + _cumulative = _buildCumulative(points), + _bearings = _buildBearings(points) { + if (points.length < 2) { + throw ArgumentError('PolylineWalker requires >= 2 points'); + } + } + + final List _points; + final List _cumulative; // length == _points.length + final List _bearings; // length == _points.length - 1 + + double get totalLengthM => _cumulative.last; + + WalkerPoint positionAt(double cumulativeM) { + if (cumulativeM <= 0) { + return WalkerPoint(_points.first, _bearings.first); + } + if (cumulativeM >= totalLengthM) { + return WalkerPoint(_points.last, _bearings.last); + } + // Find segment whose end-cumulative >= cumulativeM. + int lo = 1, hi = _cumulative.length - 1; + while (lo < hi) { + final mid = (lo + hi) >> 1; + if (_cumulative[mid] < cumulativeM) { + lo = mid + 1; + } else { + hi = mid; + } + } + final segIdx = lo - 1; + final segStart = _cumulative[segIdx]; + final segEnd = _cumulative[segIdx + 1]; + final t = (cumulativeM - segStart) / (segEnd - segStart); + final a = _points[segIdx]; + final b = _points[segIdx + 1]; + final lat = a.latitude + (b.latitude - a.latitude) * t; + final lng = a.longitude + (b.longitude - a.longitude) * t; + return WalkerPoint(LatLng(lat, lng), _bearings[segIdx]); + } + + static List _buildCumulative(List points) { + final out = [0.0]; + for (var i = 1; i < points.length; i++) { + final d = Geolocator.distanceBetween( + points[i - 1].latitude, + points[i - 1].longitude, + points[i].latitude, + points[i].longitude, + ); + out.add(out.last + d); + } + return out; + } + + static List _buildBearings(List points) { + final out = []; + for (var i = 1; i < points.length; i++) { + final b = Geolocator.bearingBetween( + points[i - 1].latitude, + points[i - 1].longitude, + points[i].latitude, + points[i].longitude, + ); + // Normalize to [0, 360). + out.add((b % 360 + 360) % 360); + } + return out; + } +} diff --git a/mobile/test/dev/polyline_walker_test.dart b/mobile/test/dev/polyline_walker_test.dart new file mode 100644 index 0000000..b1b509a --- /dev/null +++ b/mobile/test/dev/polyline_walker_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:beebeebike/dev/polyline_walker.dart'; + +void main() { + group('PolylineWalker', () { + // ~111 km per degree of latitude. These three points form a 2-leg polyline + // running due east along the equator, so each leg's bearing is 90. + final points = [ + const LatLng(0.0, 0.0), + const LatLng(0.0, 0.001), // ~111 m east + const LatLng(0.0, 0.003), // ~222 m further east + ]; + + test('totalLengthM ≈ sum of segment lengths', () { + final w = PolylineWalker(points); + // Segment 1 ≈ 111.32 m; segment 2 ≈ 222.64 m; total ≈ 333.96 m. + expect(w.totalLengthM, closeTo(333.96, 1.0)); + }); + + test('positionAt(0) returns first point with bearing of first segment', () { + final w = PolylineWalker(points); + final p = w.positionAt(0); + expect(p.latLng.latitude, closeTo(0.0, 1e-9)); + expect(p.latLng.longitude, closeTo(0.0, 1e-9)); + expect(p.bearingDeg, closeTo(90.0, 0.5)); + }); + + test('positionAt(totalLength) returns last point', () { + final w = PolylineWalker(points); + final p = w.positionAt(w.totalLengthM); + expect(p.latLng.latitude, closeTo(0.0, 1e-9)); + expect(p.latLng.longitude, closeTo(0.003, 1e-9)); + }); + + test('positionAt midpoint of segment 1 interpolates lng', () { + final w = PolylineWalker(points); + final p = w.positionAt(55.66); // half of leg 1 + expect(p.latLng.longitude, closeTo(0.0005, 1e-5)); + expect(p.bearingDeg, closeTo(90.0, 0.5)); + }); + + test('positionAt clamps below 0 and above totalLength', () { + final w = PolylineWalker(points); + expect(w.positionAt(-100).latLng.longitude, closeTo(0.0, 1e-9)); + expect(w.positionAt(99999).latLng.longitude, closeTo(0.003, 1e-9)); + }); + + test('throws on polyline with fewer than 2 points', () { + expect(() => PolylineWalker(const [LatLng(0, 0)]), throwsArgumentError); + }); + }); +} From ff825884f5612aef3dfe70ada0aa7dea255ffc4f Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 10:41:27 +0200 Subject: [PATCH 04/17] fix(mobile): guard PolylineWalker against zero-length segments --- mobile/lib/dev/polyline_walker.dart | 4 ++++ mobile/test/dev/polyline_walker_test.dart | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/mobile/lib/dev/polyline_walker.dart b/mobile/lib/dev/polyline_walker.dart index e750afc..05aeac8 100644 --- a/mobile/lib/dev/polyline_walker.dart +++ b/mobile/lib/dev/polyline_walker.dart @@ -43,6 +43,10 @@ class PolylineWalker { final segIdx = lo - 1; final segStart = _cumulative[segIdx]; final segEnd = _cumulative[segIdx + 1]; + // Guard against zero-length segments: duplicate points produce div-by-zero in t. + if (segEnd - segStart < 1e-9) { + return WalkerPoint(_points[segIdx + 1], _bearings[segIdx]); + } final t = (cumulativeM - segStart) / (segEnd - segStart); final a = _points[segIdx]; final b = _points[segIdx + 1]; diff --git a/mobile/test/dev/polyline_walker_test.dart b/mobile/test/dev/polyline_walker_test.dart index b1b509a..18e0a81 100644 --- a/mobile/test/dev/polyline_walker_test.dart +++ b/mobile/test/dev/polyline_walker_test.dart @@ -49,5 +49,21 @@ void main() { test('throws on polyline with fewer than 2 points', () { expect(() => PolylineWalker(const [LatLng(0, 0)]), throwsArgumentError); }); + + test('handles zero-length segment without NaN', () { + final points = [ + const LatLng(0.0, 0.0), + const LatLng(0.0, 0.0), // duplicate of previous + const LatLng(0.0, 0.001), + ]; + final w = PolylineWalker(points); + // Asking for position right at the zero-length boundary must return finite coords. + final p = w.positionAt(0.0); + expect(p.latLng.latitude.isFinite, isTrue); + expect(p.latLng.longitude.isFinite, isTrue); + // And mid-route still works. + final mid = w.positionAt(w.totalLengthM / 2); + expect(mid.latLng.longitude.isFinite, isTrue); + }); }); } From 4802c063f79f718e6a31ca860effada43bcb9821 Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 10:49:34 +0200 Subject: [PATCH 05/17] feat(mobile): add PlaybackEngine for GPS simulation Timer-driven 4 Hz engine with play/pause/seek/override and a broadcast UserLocation stream; freezed PlaybackState holds all simulation fields. --- mobile/lib/dev/playback_constants.dart | 6 + mobile/lib/dev/playback_engine.dart | 142 ++++++++++ mobile/lib/dev/playback_state.dart | 16 ++ mobile/lib/dev/playback_state.freezed.dart | 291 +++++++++++++++++++++ mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + mobile/test/dev/playback_engine_test.dart | 141 ++++++++++ 7 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 mobile/lib/dev/playback_constants.dart create mode 100644 mobile/lib/dev/playback_engine.dart create mode 100644 mobile/lib/dev/playback_state.dart create mode 100644 mobile/lib/dev/playback_state.freezed.dart create mode 100644 mobile/test/dev/playback_engine_test.dart diff --git a/mobile/lib/dev/playback_constants.dart b/mobile/lib/dev/playback_constants.dart new file mode 100644 index 0000000..baf3d26 --- /dev/null +++ b/mobile/lib/dev/playback_constants.dart @@ -0,0 +1,6 @@ +const int kPlaybackTickHz = 4; +const Duration kPlaybackTickInterval = Duration(milliseconds: 250); +const double kPlaybackStepMeters = 100.0; +const List kPlaybackSpeedMultipliers = [0.5, 1.0, 2.0, 5.0, 10.0]; +const double kPlaybackDefaultMultiplier = 1.0; +const double kPlaybackFakeAccuracyM = 5.0; diff --git a/mobile/lib/dev/playback_engine.dart b/mobile/lib/dev/playback_engine.dart new file mode 100644 index 0000000..ed2ceac --- /dev/null +++ b/mobile/lib/dev/playback_engine.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +import 'package:ferrostar_flutter/ferrostar_flutter.dart'; +import 'package:maplibre_gl/maplibre_gl.dart' hide UserLocation; + +import 'playback_constants.dart'; +import 'playback_state.dart'; +import 'polyline_walker.dart'; + +class PlaybackEngine { + PlaybackEngine(); + + final _controller = StreamController.broadcast(); + Timer? _timer; + PolylineWalker? _walker; + PlaybackState _state = const PlaybackState(); + double _lastBearing = 0.0; + + Stream get stream => _controller.stream; + PlaybackState get state => _state; + + void setRoute({ + required PolylineWalker walker, + required double routeBaseSpeedMps, + }) { + _walker = walker; + _state = _state.copyWith( + routeLengthM: walker.totalLengthM, + routeBaseSpeedMps: routeBaseSpeedMps, + cumulativeM: 0.0, + isPlaying: false, + overrideLatLng: null, + ); + _stopTimer(); + } + + void clearRoute() { + _walker = null; + _state = const PlaybackState(); + _stopTimer(); + } + + void play() { + if (_walker == null) return; + if (_state.isPlaying) return; + _state = _state.copyWith(isPlaying: true); + _startTimer(); + } + + void pause() { + _state = _state.copyWith(isPlaying: false); + _stopTimer(); + } + + void seek(double cumulativeM) { + final clamped = cumulativeM.clamp(0.0, _state.routeLengthM); + _state = _state.copyWith(cumulativeM: clamped); + _emitCurrent(); + } + + void step(double meters) => seek(_state.cumulativeM + meters); + + void setSpeedMultiplier(double m) { + _state = _state.copyWith(speedMultiplier: m); + } + + void setOverridePosition(LatLng latLng) { + _state = _state.copyWith(overrideLatLng: latLng); + if (_timer == null) _startTimer(); + _emitCurrent(); + } + + void clearOverride() { + _state = _state.copyWith(overrideLatLng: null); + if (!_state.isPlaying) _stopTimer(); + _emitCurrent(); + } + + void dispose() { + _stopTimer(); + _controller.close(); + } + + void _startTimer() { + _timer ??= Timer.periodic(kPlaybackTickInterval, _onTick); + } + + void _stopTimer() { + _timer?.cancel(); + _timer = null; + } + + void _onTick(Timer _) { + final walker = _walker; + if (walker == null) return; + if (_state.overrideLatLng != null) { + _emitCurrent(); + return; + } + if (!_state.isPlaying) return; + final dt = 1.0 / kPlaybackTickHz; + final advance = _state.routeBaseSpeedMps * _state.speedMultiplier * dt; + final next = (_state.cumulativeM + advance).clamp(0.0, _state.routeLengthM); + final reachedEnd = next >= _state.routeLengthM; + _state = _state.copyWith( + cumulativeM: next, + isPlaying: !reachedEnd, + ); + if (reachedEnd) _stopTimer(); + _emitCurrent(); + } + + void _emitCurrent() { + final walker = _walker; + if (walker == null) return; + final override = _state.overrideLatLng; + if (override != null) { + _controller.add(UserLocation( + lat: override.latitude, + lng: override.longitude, + horizontalAccuracyM: kPlaybackFakeAccuracyM, + courseDeg: _lastBearing, + speedMps: 0.0, + timestampMs: DateTime.now().millisecondsSinceEpoch, + )); + return; + } + final p = walker.positionAt(_state.cumulativeM); + _lastBearing = p.bearingDeg; + final speed = _state.isPlaying + ? _state.routeBaseSpeedMps * _state.speedMultiplier + : 0.0; + _controller.add(UserLocation( + lat: p.latLng.latitude, + lng: p.latLng.longitude, + horizontalAccuracyM: kPlaybackFakeAccuracyM, + courseDeg: p.bearingDeg, + speedMps: speed, + timestampMs: DateTime.now().millisecondsSinceEpoch, + )); + } +} diff --git a/mobile/lib/dev/playback_state.dart b/mobile/lib/dev/playback_state.dart new file mode 100644 index 0000000..fb4810e --- /dev/null +++ b/mobile/lib/dev/playback_state.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +part 'playback_state.freezed.dart'; + +@freezed +class PlaybackState with _$PlaybackState { + const factory PlaybackState({ + @Default(false) bool isPlaying, + @Default(0.0) double cumulativeM, + @Default(1.0) double speedMultiplier, + LatLng? overrideLatLng, + @Default(0.0) double routeLengthM, + @Default(0.0) double routeBaseSpeedMps, + }) = _PlaybackState; +} diff --git a/mobile/lib/dev/playback_state.freezed.dart b/mobile/lib/dev/playback_state.freezed.dart new file mode 100644 index 0000000..92fc2b5 --- /dev/null +++ b/mobile/lib/dev/playback_state.freezed.dart @@ -0,0 +1,291 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'playback_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +/// @nodoc +mixin _$PlaybackState { + bool get isPlaying => throw _privateConstructorUsedError; + double get cumulativeM => throw _privateConstructorUsedError; + double get speedMultiplier => throw _privateConstructorUsedError; + LatLng? get overrideLatLng => throw _privateConstructorUsedError; + double get routeLengthM => throw _privateConstructorUsedError; + double get routeBaseSpeedMps => throw _privateConstructorUsedError; + + /// Create a copy of PlaybackState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $PlaybackStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $PlaybackStateCopyWith<$Res> { + factory $PlaybackStateCopyWith( + PlaybackState value, + $Res Function(PlaybackState) then, + ) = _$PlaybackStateCopyWithImpl<$Res, PlaybackState>; + @useResult + $Res call({ + bool isPlaying, + double cumulativeM, + double speedMultiplier, + LatLng? overrideLatLng, + double routeLengthM, + double routeBaseSpeedMps, + }); +} + +/// @nodoc +class _$PlaybackStateCopyWithImpl<$Res, $Val extends PlaybackState> + implements $PlaybackStateCopyWith<$Res> { + _$PlaybackStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of PlaybackState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? isPlaying = null, + Object? cumulativeM = null, + Object? speedMultiplier = null, + Object? overrideLatLng = freezed, + Object? routeLengthM = null, + Object? routeBaseSpeedMps = null, + }) { + return _then( + _value.copyWith( + isPlaying: + null == isPlaying + ? _value.isPlaying + : isPlaying // ignore: cast_nullable_to_non_nullable + as bool, + cumulativeM: + null == cumulativeM + ? _value.cumulativeM + : cumulativeM // ignore: cast_nullable_to_non_nullable + as double, + speedMultiplier: + null == speedMultiplier + ? _value.speedMultiplier + : speedMultiplier // ignore: cast_nullable_to_non_nullable + as double, + overrideLatLng: + freezed == overrideLatLng + ? _value.overrideLatLng + : overrideLatLng // ignore: cast_nullable_to_non_nullable + as LatLng?, + routeLengthM: + null == routeLengthM + ? _value.routeLengthM + : routeLengthM // ignore: cast_nullable_to_non_nullable + as double, + routeBaseSpeedMps: + null == routeBaseSpeedMps + ? _value.routeBaseSpeedMps + : routeBaseSpeedMps // ignore: cast_nullable_to_non_nullable + as double, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$PlaybackStateImplCopyWith<$Res> + implements $PlaybackStateCopyWith<$Res> { + factory _$$PlaybackStateImplCopyWith( + _$PlaybackStateImpl value, + $Res Function(_$PlaybackStateImpl) then, + ) = __$$PlaybackStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + bool isPlaying, + double cumulativeM, + double speedMultiplier, + LatLng? overrideLatLng, + double routeLengthM, + double routeBaseSpeedMps, + }); +} + +/// @nodoc +class __$$PlaybackStateImplCopyWithImpl<$Res> + extends _$PlaybackStateCopyWithImpl<$Res, _$PlaybackStateImpl> + implements _$$PlaybackStateImplCopyWith<$Res> { + __$$PlaybackStateImplCopyWithImpl( + _$PlaybackStateImpl _value, + $Res Function(_$PlaybackStateImpl) _then, + ) : super(_value, _then); + + /// Create a copy of PlaybackState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? isPlaying = null, + Object? cumulativeM = null, + Object? speedMultiplier = null, + Object? overrideLatLng = freezed, + Object? routeLengthM = null, + Object? routeBaseSpeedMps = null, + }) { + return _then( + _$PlaybackStateImpl( + isPlaying: + null == isPlaying + ? _value.isPlaying + : isPlaying // ignore: cast_nullable_to_non_nullable + as bool, + cumulativeM: + null == cumulativeM + ? _value.cumulativeM + : cumulativeM // ignore: cast_nullable_to_non_nullable + as double, + speedMultiplier: + null == speedMultiplier + ? _value.speedMultiplier + : speedMultiplier // ignore: cast_nullable_to_non_nullable + as double, + overrideLatLng: + freezed == overrideLatLng + ? _value.overrideLatLng + : overrideLatLng // ignore: cast_nullable_to_non_nullable + as LatLng?, + routeLengthM: + null == routeLengthM + ? _value.routeLengthM + : routeLengthM // ignore: cast_nullable_to_non_nullable + as double, + routeBaseSpeedMps: + null == routeBaseSpeedMps + ? _value.routeBaseSpeedMps + : routeBaseSpeedMps // ignore: cast_nullable_to_non_nullable + as double, + ), + ); + } +} + +/// @nodoc + +class _$PlaybackStateImpl implements _PlaybackState { + const _$PlaybackStateImpl({ + this.isPlaying = false, + this.cumulativeM = 0.0, + this.speedMultiplier = 1.0, + this.overrideLatLng, + this.routeLengthM = 0.0, + this.routeBaseSpeedMps = 0.0, + }); + + @override + @JsonKey() + final bool isPlaying; + @override + @JsonKey() + final double cumulativeM; + @override + @JsonKey() + final double speedMultiplier; + @override + final LatLng? overrideLatLng; + @override + @JsonKey() + final double routeLengthM; + @override + @JsonKey() + final double routeBaseSpeedMps; + + @override + String toString() { + return 'PlaybackState(isPlaying: $isPlaying, cumulativeM: $cumulativeM, speedMultiplier: $speedMultiplier, overrideLatLng: $overrideLatLng, routeLengthM: $routeLengthM, routeBaseSpeedMps: $routeBaseSpeedMps)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaybackStateImpl && + (identical(other.isPlaying, isPlaying) || + other.isPlaying == isPlaying) && + (identical(other.cumulativeM, cumulativeM) || + other.cumulativeM == cumulativeM) && + (identical(other.speedMultiplier, speedMultiplier) || + other.speedMultiplier == speedMultiplier) && + (identical(other.overrideLatLng, overrideLatLng) || + other.overrideLatLng == overrideLatLng) && + (identical(other.routeLengthM, routeLengthM) || + other.routeLengthM == routeLengthM) && + (identical(other.routeBaseSpeedMps, routeBaseSpeedMps) || + other.routeBaseSpeedMps == routeBaseSpeedMps)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + isPlaying, + cumulativeM, + speedMultiplier, + overrideLatLng, + routeLengthM, + routeBaseSpeedMps, + ); + + /// Create a copy of PlaybackState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$PlaybackStateImplCopyWith<_$PlaybackStateImpl> get copyWith => + __$$PlaybackStateImplCopyWithImpl<_$PlaybackStateImpl>(this, _$identity); +} + +abstract class _PlaybackState implements PlaybackState { + const factory _PlaybackState({ + final bool isPlaying, + final double cumulativeM, + final double speedMultiplier, + final LatLng? overrideLatLng, + final double routeLengthM, + final double routeBaseSpeedMps, + }) = _$PlaybackStateImpl; + + @override + bool get isPlaying; + @override + double get cumulativeM; + @override + double get speedMultiplier; + @override + LatLng? get overrideLatLng; + @override + double get routeLengthM; + @override + double get routeBaseSpeedMps; + + /// Create a copy of PlaybackState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$PlaybackStateImplCopyWith<_$PlaybackStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index fe5b2f4..6bbb4b5 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -266,7 +266,7 @@ packages: source: hosted version: "2.1.2" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f92948e..f2e30f6 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -50,6 +50,7 @@ dev_dependencies: http_mock_adapter: ^0.6.1 mocktail: ^1.0.4 flutter_launcher_icons: ^0.14.4 + fake_async: ^1.3.0 flutter_launcher_icons: ios: true diff --git a/mobile/test/dev/playback_engine_test.dart b/mobile/test/dev/playback_engine_test.dart new file mode 100644 index 0000000..7727fb4 --- /dev/null +++ b/mobile/test/dev/playback_engine_test.dart @@ -0,0 +1,141 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:ferrostar_flutter/ferrostar_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maplibre_gl/maplibre_gl.dart' hide UserLocation; +import 'package:beebeebike/dev/playback_engine.dart'; +import 'package:beebeebike/dev/polyline_walker.dart'; + +void main() { + // East-running 333 m polyline along the equator. + final points = [ + const LatLng(0.0, 0.0), + const LatLng(0.0, 0.001), + const LatLng(0.0, 0.003), + ]; + + PolylineWalker walker() => PolylineWalker(points); + + group('PlaybackEngine', () { + test('idle engine emits nothing', () { + fakeAsync((async) { + final e = PlaybackEngine(); + final got = []; + final sub = e.stream.listen(got.add); + async.elapse(const Duration(seconds: 1)); + expect(got, isEmpty); + sub.cancel(); + e.dispose(); + }); + }); + + test('play advances cumulative by baseSpeed * multiplier * dt', () { + fakeAsync((async) { + final e = PlaybackEngine() + ..setRoute(walker: walker(), routeBaseSpeedMps: 10.0); + final got = []; + final sub = e.stream.listen(got.add); + e.play(); + async.elapse(const Duration(seconds: 1)); // 4 ticks + expect(got, isNotEmpty); + expect(got.last.lng, closeTo(9.0e-5, 1e-5)); + expect(got.last.speedMps, closeTo(10.0, 1e-9)); + expect(e.state.cumulativeM, closeTo(10.0, 0.01)); + sub.cancel(); + e.dispose(); + }); + }); + + test('pause halts advance', () { + fakeAsync((async) { + final e = PlaybackEngine() + ..setRoute(walker: walker(), routeBaseSpeedMps: 10.0); + e.play(); + async.elapse(const Duration(seconds: 1)); + final atPause = e.state.cumulativeM; + e.pause(); + async.elapse(const Duration(seconds: 1)); + expect(e.state.cumulativeM, closeTo(atPause, 1e-9)); + e.dispose(); + }); + }); + + test('override emits override latlng with speed=0; advance halts', () { + fakeAsync((async) { + final e = PlaybackEngine() + ..setRoute(walker: walker(), routeBaseSpeedMps: 10.0); + final got = []; + final sub = e.stream.listen(got.add); + e.play(); + async.elapse(const Duration(milliseconds: 500)); + e.setOverridePosition(const LatLng(1.234, 5.678)); + async.elapse(const Duration(seconds: 1)); + expect(got.last.lat, closeTo(1.234, 1e-9)); + expect(got.last.lng, closeTo(5.678, 1e-9)); + expect(got.last.speedMps, 0.0); + sub.cancel(); + e.dispose(); + }); + }); + + test('clearOverride snaps back to polyline at cumulative', () { + fakeAsync((async) { + final e = PlaybackEngine() + ..setRoute(walker: walker(), routeBaseSpeedMps: 10.0); + e.play(); + async.elapse(const Duration(seconds: 1)); + final beforeOverrideCum = e.state.cumulativeM; + e.setOverridePosition(const LatLng(1.0, 1.0)); + async.elapse(const Duration(milliseconds: 500)); + expect(e.state.cumulativeM, closeTo(beforeOverrideCum, 1e-9)); + e.clearOverride(); + async.elapse(const Duration(milliseconds: 250)); + expect(e.state.overrideLatLng, isNull); + e.dispose(); + }); + }); + + test('reaching end pauses and clamps cumulative', () { + fakeAsync((async) { + final e = PlaybackEngine() + ..setRoute(walker: walker(), routeBaseSpeedMps: 10.0); + e.play(); + async.elapse(const Duration(seconds: 60)); + expect(e.state.isPlaying, isFalse); + expect(e.state.cumulativeM, closeTo(e.state.routeLengthM, 0.01)); + e.dispose(); + }); + }); + + test('seek sets cumulative and clamps', () { + final e = PlaybackEngine() + ..setRoute(walker: walker(), routeBaseSpeedMps: 10.0); + e.seek(50.0); + expect(e.state.cumulativeM, 50.0); + e.seek(-10); + expect(e.state.cumulativeM, 0.0); + e.seek(99999); + expect(e.state.cumulativeM, e.state.routeLengthM); + e.dispose(); + }); + + test('setSpeedMultiplier updates state', () { + final e = PlaybackEngine() + ..setRoute(walker: walker(), routeBaseSpeedMps: 10.0); + e.setSpeedMultiplier(5.0); + expect(e.state.speedMultiplier, 5.0); + e.dispose(); + }); + + test('setRoute resets cumulative and stops playback', () { + final e = PlaybackEngine() + ..setRoute(walker: walker(), routeBaseSpeedMps: 10.0); + e.play(); + e.seek(100); + e.setRoute(walker: walker(), routeBaseSpeedMps: 5.0); + expect(e.state.cumulativeM, 0.0); + expect(e.state.isPlaying, isFalse); + expect(e.state.routeBaseSpeedMps, 5.0); + e.dispose(); + }); + }); +} From ab7c2e8c5bb9f013e0fcf3ae9b5040ccb1361971 Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 10:56:47 +0200 Subject: [PATCH 06/17] fix(mobile): re-emit on pause and guard PlaybackEngine override on no-route --- mobile/lib/dev/playback_engine.dart | 8 +++++++- mobile/test/dev/playback_engine_test.dart | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/mobile/lib/dev/playback_engine.dart b/mobile/lib/dev/playback_engine.dart index ed2ceac..b28a366 100644 --- a/mobile/lib/dev/playback_engine.dart +++ b/mobile/lib/dev/playback_engine.dart @@ -49,6 +49,7 @@ class PlaybackEngine { void pause() { _state = _state.copyWith(isPlaying: false); + _emitCurrent(); _stopTimer(); } @@ -65,6 +66,7 @@ class PlaybackEngine { } void setOverridePosition(LatLng latLng) { + if (_walker == null) return; _state = _state.copyWith(overrideLatLng: latLng); if (_timer == null) _startTimer(); _emitCurrent(); @@ -97,7 +99,11 @@ class PlaybackEngine { _emitCurrent(); return; } - if (!_state.isPlaying) return; + if (!_state.isPlaying) { + _emitCurrent(); + _stopTimer(); + return; + } final dt = 1.0 / kPlaybackTickHz; final advance = _state.routeBaseSpeedMps * _state.speedMultiplier * dt; final next = (_state.cumulativeM + advance).clamp(0.0, _state.routeLengthM); diff --git a/mobile/test/dev/playback_engine_test.dart b/mobile/test/dev/playback_engine_test.dart index 7727fb4..3daa735 100644 --- a/mobile/test/dev/playback_engine_test.dart +++ b/mobile/test/dev/playback_engine_test.dart @@ -59,6 +59,24 @@ void main() { }); }); + test('pause emits last position once with speed=0', () { + fakeAsync((async) { + final e = PlaybackEngine() + ..setRoute(walker: walker(), routeBaseSpeedMps: 10.0); + final got = []; + final sub = e.stream.listen(got.add); + e.play(); + async.elapse(const Duration(seconds: 1)); + final beforePause = got.length; + e.pause(); + async.flushMicrotasks(); + expect(got.length, greaterThan(beforePause)); + expect(got.last.speedMps, 0.0); + sub.cancel(); + e.dispose(); + }); + }); + test('override emits override latlng with speed=0; advance halts', () { fakeAsync((async) { final e = PlaybackEngine() @@ -90,6 +108,10 @@ void main() { e.clearOverride(); async.elapse(const Duration(milliseconds: 250)); expect(e.state.overrideLatLng, isNull); + // Cumulative should now be advancing again. + final afterClearCum = e.state.cumulativeM; + async.elapse(const Duration(milliseconds: 250)); + expect(e.state.cumulativeM, greaterThan(afterClearCum)); e.dispose(); }); }); From 02211367e219a90701fdfbf6e760afb8d80da451 Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 10:58:23 +0200 Subject: [PATCH 07/17] feat(mobile): add dev-mode unlock + persisted flags --- mobile/lib/dev/dev_mode.dart | 83 ++++++++++++++++++++++++++++++ mobile/test/dev/dev_mode_test.dart | 62 ++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 mobile/lib/dev/dev_mode.dart create mode 100644 mobile/test/dev/dev_mode_test.dart diff --git a/mobile/lib/dev/dev_mode.dart b/mobile/lib/dev/dev_mode.dart new file mode 100644 index 0000000..c53ea9a --- /dev/null +++ b/mobile/lib/dev/dev_mode.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const String kPrefDevModeUnlocked = 'dev.modeUnlocked'; +const String kPrefPlaybackEnabled = 'dev.playbackEnabled'; +const int kDevTapsRequired = 5; +const Duration kDevTapWindow = Duration(seconds: 3); + +class TapUnlockCounter { + TapUnlockCounter({ + required this.tapsRequired, + required this.window, + required this.onUnlock, + }); + + final int tapsRequired; + final Duration window; + final void Function() onUnlock; + + int _count = 0; + Timer? _resetTimer; + + void tap() { + _count++; + _resetTimer?.cancel(); + if (_count >= tapsRequired) { + _count = 0; + onUnlock(); + return; + } + _resetTimer = Timer(window, () => _count = 0); + } + + void dispose() { + _resetTimer?.cancel(); + } +} + +class DevModeController extends AsyncNotifier { + @override + Future build() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(kPrefDevModeUnlocked) ?? false; + } + + Future setUnlocked(bool value) async { + state = AsyncData(value); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(kPrefDevModeUnlocked, value); + if (!value) { + // Locking returns the app to a clean prod-equivalent state. + await prefs.setBool(kPrefPlaybackEnabled, false); + } + } + + Future toggle() async { + final current = state.valueOrNull ?? false; + await setUnlocked(!current); + } +} + +final devModeProvider = + AsyncNotifierProvider(DevModeController.new); + +class PlaybackEnabledController extends AsyncNotifier { + @override + Future build() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(kPrefPlaybackEnabled) ?? false; + } + + Future set(bool value) async { + state = AsyncData(value); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(kPrefPlaybackEnabled, value); + } +} + +final playbackEnabledProvider = + AsyncNotifierProvider( + PlaybackEnabledController.new); diff --git a/mobile/test/dev/dev_mode_test.dart b/mobile/test/dev/dev_mode_test.dart new file mode 100644 index 0000000..4eaed6a --- /dev/null +++ b/mobile/test/dev/dev_mode_test.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:beebeebike/dev/dev_mode.dart'; + +void main() { + group('TapUnlockCounter', () { + test('5 taps within 3 s fires once', () { + fakeAsync((async) { + var fired = 0; + final c = TapUnlockCounter( + tapsRequired: 5, + window: const Duration(seconds: 3), + onUnlock: () => fired++, + ); + for (var i = 0; i < 5; i++) { + c.tap(); + async.elapse(const Duration(milliseconds: 200)); + } + expect(fired, 1); + }); + }); + + test('4 taps then 4 s gap then 1 tap does not fire', () { + fakeAsync((async) { + var fired = 0; + final c = TapUnlockCounter( + tapsRequired: 5, + window: const Duration(seconds: 3), + onUnlock: () => fired++, + ); + for (var i = 0; i < 4; i++) { + c.tap(); + async.elapse(const Duration(milliseconds: 200)); + } + async.elapse(const Duration(seconds: 4)); + c.tap(); + expect(fired, 0); + }); + }); + + test('counter resets after firing — second 5-tap series fires again', () { + fakeAsync((async) { + var fired = 0; + final c = TapUnlockCounter( + tapsRequired: 5, + window: const Duration(seconds: 3), + onUnlock: () => fired++, + ); + for (var i = 0; i < 5; i++) { + c.tap(); + } + async.elapse(const Duration(milliseconds: 100)); + for (var i = 0; i < 5; i++) { + c.tap(); + } + expect(fired, 2); + }); + }); + }); +} From 8116a7f3a17ec7d2a4ff3ade890f9ee448f985a8 Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 11:01:30 +0200 Subject: [PATCH 08/17] feat(mobile): add PlaybackController wiring engine to route preview --- mobile/lib/dev/playback_controller.dart | 108 +++++++++++++++++ mobile/test/dev/playback_controller_test.dart | 114 ++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 mobile/lib/dev/playback_controller.dart create mode 100644 mobile/test/dev/playback_controller_test.dart diff --git a/mobile/lib/dev/playback_controller.dart b/mobile/lib/dev/playback_controller.dart new file mode 100644 index 0000000..31cfe02 --- /dev/null +++ b/mobile/lib/dev/playback_controller.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:ferrostar_flutter/ferrostar_flutter.dart' show UserLocation; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:maplibre_gl/maplibre_gl.dart' hide UserLocation; + +import '../models/route_preview.dart'; +import '../providers/route_provider.dart'; +import 'playback_constants.dart'; +import 'playback_engine.dart'; +import 'playback_state.dart'; +import 'polyline_walker.dart'; + +final playbackEngineProvider = Provider((ref) { + final engine = PlaybackEngine(); + ref.onDispose(engine.dispose); + return engine; +}); + +class PlaybackController extends Notifier { + StreamSubscription? _engineSub; + + @override + PlaybackState build() { + final engine = ref.watch(playbackEngineProvider); + final preview = ref.watch(routeControllerProvider.select((s) => s.preview)); + if (preview != null) { + _seedFrom(preview, engine); + } else { + engine.clearRoute(); + } + _engineSub?.cancel(); + _engineSub = engine.stream.listen((_) { + state = engine.state; + }); + ref.onDispose(() => _engineSub?.cancel()); + return engine.state; + } + + void _seedFrom(RoutePreview preview, PlaybackEngine engine) { + final coords = (preview.geometry['coordinates'] as List) + .map((c) => LatLng((c as List)[1] as double, c[0] as double)) + .toList(); + if (coords.length < 2) { + engine.clearRoute(); + return; + } + final baseSpeed = preview.time > 0 ? preview.distance / preview.time : 1.0; + engine.setRoute( + walker: PolylineWalker(coords), + routeBaseSpeedMps: baseSpeed, + ); + } + + PlaybackEngine get _engine => ref.read(playbackEngineProvider); + + void play() { + _engine.play(); + state = _engine.state; + } + + void pause() { + _engine.pause(); + state = _engine.state; + } + + void restart() { + _engine.seek(0); + state = _engine.state; + } + + void stepForward() { + _engine.step(kPlaybackStepMeters); + state = _engine.state; + } + + void stepBack() { + _engine.step(-kPlaybackStepMeters); + state = _engine.state; + } + + void seek(double cumulativeM) { + _engine.seek(cumulativeM); + state = _engine.state; + } + + void cycleSpeed() { + final list = kPlaybackSpeedMultipliers; + final current = state.speedMultiplier; + final idx = list.indexOf(current); + final next = list[(idx + 1) % list.length]; + _engine.setSpeedMultiplier(next); + state = _engine.state; + } + + void setOverride(LatLng latLng) { + _engine.setOverridePosition(latLng); + state = _engine.state; + } + + void clearOverride() { + _engine.clearOverride(); + state = _engine.state; + } +} + +final playbackControllerProvider = + NotifierProvider(PlaybackController.new); diff --git a/mobile/test/dev/playback_controller_test.dart b/mobile/test/dev/playback_controller_test.dart new file mode 100644 index 0000000..3e503e9 --- /dev/null +++ b/mobile/test/dev/playback_controller_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:beebeebike/dev/playback_controller.dart'; +import 'package:beebeebike/dev/playback_engine.dart'; +import 'package:beebeebike/dev/playback_state.dart'; +import 'package:beebeebike/models/route_preview.dart'; +import 'package:beebeebike/models/route_state.dart'; +import 'package:beebeebike/providers/route_provider.dart'; + +RoutePreview _fixturePreview() => const RoutePreview( + geometry: { + 'type': 'LineString', + 'coordinates': [ + [0.0, 0.0], + [0.001, 0.0], + [0.003, 0.0], + ], + }, + distance: 333.96, + time: 33.4, // ~10 m/s + ); + +class _FakeRouteController extends Notifier + implements RouteController { + _FakeRouteController(this._initial); + final RouteState _initial; + @override + RouteState build() => _initial; + + void emit(RouteState s) => state = s; + + @override + noSuchMethod(Invocation invocation) => + throw UnimplementedError(invocation.memberName.toString()); +} + +void main() { + group('PlaybackController', () { + test('seeds engine when route preview becomes available', () { + final container = ProviderContainer(overrides: [ + routeControllerProvider.overrideWith( + () => _FakeRouteController(RouteState(preview: _fixturePreview())), + ), + ]); + addTearDown(container.dispose); + final state = container.read(playbackControllerProvider); + expect(state.routeLengthM, closeTo(333.96, 1.0)); + expect(state.routeBaseSpeedMps, closeTo(10.0, 0.1)); + }); + + test('returns empty state when no route', () { + final container = ProviderContainer(overrides: [ + routeControllerProvider.overrideWith( + () => _FakeRouteController(const RouteState()), + ), + ]); + addTearDown(container.dispose); + final state = container.read(playbackControllerProvider); + expect(state.routeLengthM, 0.0); + }); + + test('play / pause / seek mutate state', () { + final container = ProviderContainer(overrides: [ + routeControllerProvider.overrideWith( + () => _FakeRouteController(RouteState(preview: _fixturePreview())), + ), + ]); + addTearDown(container.dispose); + final notifier = container.read(playbackControllerProvider.notifier); + notifier.play(); + expect(container.read(playbackControllerProvider).isPlaying, isTrue); + notifier.pause(); + expect(container.read(playbackControllerProvider).isPlaying, isFalse); + notifier.seek(50); + expect(container.read(playbackControllerProvider).cumulativeM, 50.0); + }); + + test('cycleSpeed walks through preset multipliers', () { + final container = ProviderContainer(overrides: [ + routeControllerProvider.overrideWith( + () => _FakeRouteController(RouteState(preview: _fixturePreview())), + ), + ]); + addTearDown(container.dispose); + final notifier = container.read(playbackControllerProvider.notifier); + notifier.cycleSpeed(); + expect(container.read(playbackControllerProvider).speedMultiplier, 2.0); + notifier.cycleSpeed(); + expect(container.read(playbackControllerProvider).speedMultiplier, 5.0); + notifier.cycleSpeed(); + expect(container.read(playbackControllerProvider).speedMultiplier, 10.0); + notifier.cycleSpeed(); + expect(container.read(playbackControllerProvider).speedMultiplier, 0.5); + notifier.cycleSpeed(); + expect(container.read(playbackControllerProvider).speedMultiplier, 1.0); + }); + + test('setOverride / clearOverride round-trip', () { + final container = ProviderContainer(overrides: [ + routeControllerProvider.overrideWith( + () => _FakeRouteController(RouteState(preview: _fixturePreview())), + ), + ]); + addTearDown(container.dispose); + final notifier = container.read(playbackControllerProvider.notifier); + notifier.setOverride(const LatLng(1, 2)); + expect(container.read(playbackControllerProvider).overrideLatLng, + isNotNull); + notifier.clearOverride(); + expect(container.read(playbackControllerProvider).overrideLatLng, isNull); + }); + }); +} From 3fbb0d1d3de3ee7a982b7c81d744861790d48b7c Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 11:05:38 +0200 Subject: [PATCH 09/17] feat(mobile): switchable location source for GPS playback --- .../lib/dev/switchable_location_source.dart | 35 ++++++++ mobile/lib/providers/navigation_provider.dart | 21 ++++- .../dev/switchable_location_source_test.dart | 81 +++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 mobile/lib/dev/switchable_location_source.dart create mode 100644 mobile/test/dev/switchable_location_source_test.dart diff --git a/mobile/lib/dev/switchable_location_source.dart b/mobile/lib/dev/switchable_location_source.dart new file mode 100644 index 0000000..3409833 --- /dev/null +++ b/mobile/lib/dev/switchable_location_source.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import 'package:ferrostar_flutter/ferrostar_flutter.dart'; + +class SwitchableLocationSource { + SwitchableLocationSource({ + required this.usePlayback, + required this.realStream, + required this.playbackStream, + }) { + _resubscribe(); + } + + final bool Function() usePlayback; + final Stream Function() realStream; + final Stream Function() playbackStream; + + final _out = StreamController.broadcast(); + StreamSubscription? _sub; + + Stream get stream => _out.stream; + + void notifySourceChanged() => _resubscribe(); + + void dispose() { + _sub?.cancel(); + _out.close(); + } + + void _resubscribe() { + _sub?.cancel(); + final src = usePlayback() ? playbackStream() : realStream(); + _sub = src.listen(_out.add, onError: _out.addError); + } +} diff --git a/mobile/lib/providers/navigation_provider.dart b/mobile/lib/providers/navigation_provider.dart index 261c6e5..2d0c877 100644 --- a/mobile/lib/providers/navigation_provider.dart +++ b/mobile/lib/providers/navigation_provider.dart @@ -10,12 +10,15 @@ import 'package:wakelock_plus/wakelock_plus.dart'; import '../api/client.dart'; import '../api/routing_api.dart'; +import '../dev/dev_mode.dart'; +import '../dev/playback_controller.dart'; +import '../dev/switchable_location_source.dart'; import '../navigation/location_converter.dart'; import '../navigation/navigation_service.dart'; import '../services/error_reporter.dart'; import 'locale_provider.dart'; -Stream _buildLocationStream() async* { +Stream _buildRealLocationStream() async* { var permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); @@ -55,6 +58,20 @@ Stream _buildLocationStream() async* { .map(positionToUserLocation); } +final locationSourceProvider = Provider((ref) { + final source = SwitchableLocationSource( + usePlayback: () => + ref.read(playbackEnabledProvider).valueOrNull ?? false, + realStream: _buildRealLocationStream, + playbackStream: () => ref.read(playbackEngineProvider).stream, + ); + ref.listen>(playbackEnabledProvider, (_, __) { + source.notifySourceChanged(); + }); + ref.onDispose(source.dispose); + return source; +}); + final ttsFactoryProvider = Provider((_) => FlutterTts.new); /// Whether turn-by-turn voice is currently enabled. Toggled by the voice FAB @@ -139,7 +156,7 @@ final navigationServiceProvider = Provider((ref) { ), loadNavigationRoute: ({required origin, required destination}) => routingApi.computeNavigationRoute(origin, destination), - locationStreamFactory: _buildLocationStream, + locationStreamFactory: () => ref.read(locationSourceProvider).stream, setWakelock: (enabled) => WakelockPlus.toggle(enable: enabled), speakInstruction: (text) async { if (!ref.read(ttsEnabledProvider)) return; diff --git a/mobile/test/dev/switchable_location_source_test.dart b/mobile/test/dev/switchable_location_source_test.dart new file mode 100644 index 0000000..7b4b0e8 --- /dev/null +++ b/mobile/test/dev/switchable_location_source_test.dart @@ -0,0 +1,81 @@ +import 'dart:async'; + +import 'package:ferrostar_flutter/ferrostar_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:beebeebike/dev/switchable_location_source.dart'; + +UserLocation _u(double lng) => UserLocation( + lat: 0, + lng: lng, + horizontalAccuracyM: 5, + timestampMs: 0, + ); + +void main() { + group('SwitchableLocationSource', () { + test('forwards real-source events while not playback', () async { + final real = StreamController.broadcast(); + final playback = StreamController.broadcast(); + final source = SwitchableLocationSource( + usePlayback: () => false, + realStream: () => real.stream, + playbackStream: () => playback.stream, + ); + final got = []; + final sub = source.stream.listen(got.add); + real.add(_u(1)); + await Future.delayed(Duration.zero); + expect(got.map((u) => u.lng), [1]); + await sub.cancel(); + source.dispose(); + await real.close(); + await playback.close(); + }); + + test('switches to playback events when notifySourceChanged()', () async { + final real = StreamController.broadcast(); + final playback = StreamController.broadcast(); + var usePlayback = false; + final source = SwitchableLocationSource( + usePlayback: () => usePlayback, + realStream: () => real.stream, + playbackStream: () => playback.stream, + ); + final got = []; + final sub = source.stream.listen(got.add); + real.add(_u(1)); + await Future.delayed(Duration.zero); + usePlayback = true; + source.notifySourceChanged(); + playback.add(_u(2)); + await Future.delayed(Duration.zero); + expect(got.map((u) => u.lng), [1, 2]); + await sub.cancel(); + source.dispose(); + await real.close(); + await playback.close(); + }); + + test('after switch, ignores events from previous source', () async { + final real = StreamController.broadcast(); + final playback = StreamController.broadcast(); + var usePlayback = false; + final source = SwitchableLocationSource( + usePlayback: () => usePlayback, + realStream: () => real.stream, + playbackStream: () => playback.stream, + ); + final got = []; + final sub = source.stream.listen(got.add); + usePlayback = true; + source.notifySourceChanged(); + real.add(_u(99)); + await Future.delayed(Duration.zero); + expect(got, isEmpty); + await sub.cancel(); + source.dispose(); + await real.close(); + await playback.close(); + }); + }); +} From 526507545e3e2e4781fd214525dbe04d6f7b90ff Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 11:06:43 +0200 Subject: [PATCH 10/17] chore(mobile): drop unused imports in dev tests --- mobile/test/dev/dev_mode_test.dart | 2 -- mobile/test/dev/playback_controller_test.dart | 2 -- 2 files changed, 4 deletions(-) diff --git a/mobile/test/dev/dev_mode_test.dart b/mobile/test/dev/dev_mode_test.dart index 4eaed6a..a13580d 100644 --- a/mobile/test/dev/dev_mode_test.dart +++ b/mobile/test/dev/dev_mode_test.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:beebeebike/dev/dev_mode.dart'; diff --git a/mobile/test/dev/playback_controller_test.dart b/mobile/test/dev/playback_controller_test.dart index 3e503e9..155f8ea 100644 --- a/mobile/test/dev/playback_controller_test.dart +++ b/mobile/test/dev/playback_controller_test.dart @@ -2,8 +2,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:beebeebike/dev/playback_controller.dart'; -import 'package:beebeebike/dev/playback_engine.dart'; -import 'package:beebeebike/dev/playback_state.dart'; import 'package:beebeebike/models/route_preview.dart'; import 'package:beebeebike/models/route_state.dart'; import 'package:beebeebike/providers/route_provider.dart'; From af6a09ec719dca81817456710c9d864c29bd1631 Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 11:14:32 +0200 Subject: [PATCH 11/17] feat(mobile): settings version row + Developer mode unlock --- mobile/lib/screens/settings_screen.dart | 91 +++++++++++++++++++ .../screens/settings_screen_dev_test.dart | 83 +++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 mobile/test/screens/settings_screen_dev_test.dart diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 7f6f670..b56c5d7 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -1,9 +1,11 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import '../app.dart'; +import '../dev/dev_mode.dart'; import '../l10n/generated/app_localizations.dart'; import '../providers/auth_provider.dart'; import '../providers/location_provider.dart'; @@ -42,6 +44,8 @@ class SettingsScreen extends ConsumerWidget { _SectionDivider(), _DangerSection(), _CreditsSection(), + _DevModeSection(), + _VersionRow(), ], ), ); @@ -487,3 +491,90 @@ class _CreditLinkState extends State<_CreditLink> { ); } } + +class _DevModeSection extends ConsumerWidget { + const _DevModeSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final unlocked = ref.watch(devModeProvider).valueOrNull ?? false; + if (!unlocked) return const SizedBox.shrink(); + final playbackOn = + ref.watch(playbackEnabledProvider).valueOrNull ?? false; + return Column( + key: const ValueKey('settings-dev-section'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionHeader('Developer mode'), + SwitchListTile( + key: const ValueKey('settings-playback-toggle'), + title: const Text('Playback drawer'), + subtitle: const Text('Simulate GPS along the active route'), + value: playbackOn, + onChanged: (v) => + ref.read(playbackEnabledProvider.notifier).set(v), + ), + ], + ); + } +} + +class _VersionRow extends ConsumerStatefulWidget { + const _VersionRow(); + + @override + ConsumerState<_VersionRow> createState() => _VersionRowState(); +} + +class _VersionRowState extends ConsumerState<_VersionRow> { + String _label = ''; + late final TapUnlockCounter _counter; + + @override + void initState() { + super.initState(); + _counter = TapUnlockCounter( + tapsRequired: kDevTapsRequired, + window: kDevTapWindow, + onUnlock: _handleUnlock, + ); + _loadVersion(); + } + + Future _loadVersion() async { + final info = await PackageInfo.fromPlatform(); + if (!mounted) return; + setState(() => _label = 'Version ${info.version} (${info.buildNumber})'); + } + + Future _handleUnlock() async { + await ref.read(devModeProvider.notifier).toggle(); + if (!mounted) return; + final unlocked = ref.read(devModeProvider).valueOrNull ?? false; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + unlocked ? 'Developer mode enabled' : 'Developer mode disabled', + ), + ), + ); + } + + @override + void dispose() { + _counter.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final config = ref.watch(appConfigProvider); + final host = Uri.tryParse(config.apiBaseUrl)?.host ?? ''; + return ListTile( + key: const ValueKey('settings-version-row'), + title: Text(_label.isEmpty ? ' ' : _label, style: BbbText.label()), + subtitle: Text(host, style: BbbText.monoSub()), + onTap: _counter.tap, + ); + } +} diff --git a/mobile/test/screens/settings_screen_dev_test.dart b/mobile/test/screens/settings_screen_dev_test.dart new file mode 100644 index 0000000..b426a5a --- /dev/null +++ b/mobile/test/screens/settings_screen_dev_test.dart @@ -0,0 +1,83 @@ +import 'package:beebeebike/dev/dev_mode.dart'; +import 'package:beebeebike/l10n/generated/app_localizations.dart'; +import 'package:beebeebike/screens/settings_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/test_helpers.dart'; + +Future _scrollTo(WidgetTester tester, Finder finder) async { + await tester.scrollUntilVisible( + finder, + 100, + scrollable: find.byType(Scrollable).first, + ); + // Ensure the widget is in the visible viewport after scrolling. + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('version row visible; dev section hidden until unlocked', + (tester) async { + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + buildTestWidget(const SettingsScreen(), prefs: prefs), + ); + await tester.pumpAndSettle(); + await _scrollTo(tester, find.byKey(const ValueKey('settings-version-row'))); + expect(find.byKey(const ValueKey('settings-version-row')), findsOneWidget); + expect(find.byKey(const ValueKey('settings-dev-section')), findsNothing); + }); + + testWidgets('5 taps on version row unlocks dev section', (tester) async { + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + buildTestWidget(const SettingsScreen(), prefs: prefs), + ); + await tester.pumpAndSettle(); + final row = find.byKey(const ValueKey('settings-version-row')); + await _scrollTo(tester, row); + for (var i = 0; i < 5; i++) { + await tester.tap(row); + await tester.pump(const Duration(milliseconds: 100)); + } + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('settings-dev-section')), findsOneWidget); + expect(find.byKey(const ValueKey('settings-playback-toggle')), + findsOneWidget); + }); + + testWidgets('toggling playback switch updates provider', (tester) async { + SharedPreferences.setMockInitialValues({kPrefDevModeUnlocked: true}); + final prefs = await SharedPreferences.getInstance(); + final container = ProviderContainer( + overrides: testProviderOverrides(prefs: prefs), + ); + addTearDown(container.dispose); + // Pre-warm devModeProvider so it reads the mock prefs value (true). + await container.read(devModeProvider.future); + + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: + AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const SettingsScreen(), + ), + )); + await tester.pumpAndSettle(); + final toggle = find.byKey(const ValueKey('settings-playback-toggle')); + await _scrollTo(tester, toggle); + expect(toggle, findsOneWidget); + await tester.tap(toggle); + await tester.pumpAndSettle(); + expect(container.read(playbackEnabledProvider).value, isTrue); + }); +} From 8e281709a80a6a667940a08517b2cb43e0d52fc9 Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 11:26:28 +0200 Subject: [PATCH 12/17] feat(mobile): add PlaybackDrawer UI --- mobile/lib/dev/playback_controller.dart | 17 ++ mobile/lib/dev/widgets/playback_drawer.dart | 158 ++++++++++++++++++ .../dev/widgets/playback_drawer_test.dart | 102 +++++++++++ 3 files changed, 277 insertions(+) create mode 100644 mobile/lib/dev/widgets/playback_drawer.dart create mode 100644 mobile/test/dev/widgets/playback_drawer_test.dart diff --git a/mobile/lib/dev/playback_controller.dart b/mobile/lib/dev/playback_controller.dart index 31cfe02..cde6511 100644 --- a/mobile/lib/dev/playback_controller.dart +++ b/mobile/lib/dev/playback_controller.dart @@ -102,6 +102,23 @@ class PlaybackController extends Notifier { _engine.clearOverride(); state = _engine.state; } + + /// Returns the puck's current map latlng — interpolated from the polyline + /// at `state.cumulativeM`, or the current override if set. Returns + /// `LatLng(0, 0)` if no route is loaded; callers should check + /// `state.routeLengthM > 0` first. + LatLng currentLatLng() { + final preview = + ref.read(routeControllerProvider.select((s) => s.preview)); + if (preview == null) return const LatLng(0, 0); + final override = state.overrideLatLng; + if (override != null) return override; + final coords = (preview.geometry['coordinates'] as List) + .map((c) => LatLng((c as List)[1] as double, c[0] as double)) + .toList(); + if (coords.length < 2) return const LatLng(0, 0); + return PolylineWalker(coords).positionAt(state.cumulativeM).latLng; + } } final playbackControllerProvider = diff --git a/mobile/lib/dev/widgets/playback_drawer.dart b/mobile/lib/dev/widgets/playback_drawer.dart new file mode 100644 index 0000000..5e0ae24 --- /dev/null +++ b/mobile/lib/dev/widgets/playback_drawer.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../playback_controller.dart'; +import '../playback_engine.dart'; + +class PlaybackDrawer extends ConsumerStatefulWidget { + const PlaybackDrawer({super.key}); + + @override + ConsumerState createState() => _PlaybackDrawerState(); +} + +class _PlaybackDrawerState extends ConsumerState { + // Direct engine reference — stored during build so we can safely call + // engine.pause() from dispose() without touching `ref` (forbidden post-unmount). + PlaybackEngine? _engine; + + @override + void dispose() { + // Stop the engine's periodic timer before the widget is fully unmounted. + // This prevents a fakeAsync "pending timer" invariant failure in tests and + // avoids unnecessary ticks after the overlay is removed in production. + _engine?.pause(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + _engine = ref.read(playbackEngineProvider); + final state = ref.watch(playbackControllerProvider); + final notifier = ref.read(playbackControllerProvider.notifier); + final hasRoute = state.routeLengthM > 0; + + return Container( + width: 64, + color: Colors.black.withValues(alpha: 0.7), + child: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _IconBtn( + btnKey: const ValueKey('playback-play'), + icon: state.isPlaying ? Icons.pause : Icons.play_arrow, + label: state.isPlaying ? 'pause' : 'play', + onPressed: !hasRoute + ? null + : () => state.isPlaying ? notifier.pause() : notifier.play(), + ), + _IconBtn( + btnKey: const ValueKey('playback-restart'), + icon: Icons.skip_previous, + label: 'restart', + onPressed: hasRoute ? notifier.restart : null, + ), + _IconBtn( + btnKey: const ValueKey('playback-back'), + icon: Icons.replay, + label: '-100m', + onPressed: hasRoute ? notifier.stepBack : null, + ), + _IconBtn( + btnKey: const ValueKey('playback-forward'), + icon: Icons.forward_30, + label: '+100m', + onPressed: hasRoute ? notifier.stepForward : null, + ), + _IconBtn( + btnKey: const ValueKey('playback-speed'), + icon: Icons.speed, + label: '${state.speedMultiplier}x', + onPressed: hasRoute ? notifier.cycleSpeed : null, + ), + _IconBtn( + btnKey: const ValueKey('playback-drag'), + icon: Icons.open_with, + label: state.overrideLatLng != null ? 'on' : 'off', + onPressed: !hasRoute + ? null + : () { + if (state.overrideLatLng != null) { + notifier.clearOverride(); + } else { + // Seed override at current sim latlng so the puck is + // grabbable immediately; the operator then drags it. + notifier.setOverride(notifier.currentLatLng()); + } + }, + ), + const SizedBox(height: 8), + Expanded( + child: RotatedBox( + quarterTurns: 3, + child: Slider( + key: const ValueKey('playback-progress'), + value: state.cumulativeM + .clamp(0.0, state.routeLengthM.clamp(1.0, double.infinity)), + min: 0.0, + max: state.routeLengthM <= 0 ? 1.0 : state.routeLengthM, + onChanged: hasRoute ? (v) => notifier.seek(v) : null, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + hasRoute + ? '${state.cumulativeM.round()} / ${state.routeLengthM.round()} m' + : '— / —', + style: const TextStyle(color: Colors.white, fontSize: 10), + ), + ), + ], + ), + ), + ); + } +} + +class _IconBtn extends StatelessWidget { + const _IconBtn({ + required this.btnKey, + required this.icon, + required this.label, + required this.onPressed, + }); + + final Key btnKey; + final IconData icon; + final String label; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final color = onPressed == null ? Colors.white38 : Colors.white; + return SizedBox( + height: 64, + child: IconButton( + key: btnKey, + iconSize: 36, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 64, minHeight: 64), + icon: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 24), + Text( + label, + style: TextStyle(color: color, fontSize: 9), + overflow: TextOverflow.clip, + ), + ], + ), + onPressed: onPressed, + ), + ); + } +} diff --git a/mobile/test/dev/widgets/playback_drawer_test.dart b/mobile/test/dev/widgets/playback_drawer_test.dart new file mode 100644 index 0000000..ae079e6 --- /dev/null +++ b/mobile/test/dev/widgets/playback_drawer_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:beebeebike/dev/playback_controller.dart'; +import 'package:beebeebike/dev/widgets/playback_drawer.dart'; +import 'package:beebeebike/models/route_preview.dart'; +import 'package:beebeebike/models/route_state.dart'; +import 'package:beebeebike/providers/route_provider.dart'; + +RoutePreview _preview() => const RoutePreview( + geometry: { + 'type': 'LineString', + 'coordinates': [ + [0.0, 0.0], + [0.001, 0.0], + [0.003, 0.0], + ], + }, + distance: 333.96, + time: 33.4, + ); + +class _FakeRouteController extends Notifier + implements RouteController { + _FakeRouteController(this._initial); + final RouteState _initial; + @override + RouteState build() => _initial; + @override + noSuchMethod(Invocation i) => throw UnimplementedError(i.toString()); +} + +Widget _wrap(ProviderContainer container) => UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: Scaffold(body: PlaybackDrawer()), + ), + ); + +void main() { + testWidgets('renders icon buttons for play/restart/back/forward/speed/drag', + (tester) async { + final c = ProviderContainer(overrides: [ + routeControllerProvider.overrideWith( + () => _FakeRouteController(RouteState(preview: _preview())), + ), + ]); + addTearDown(c.dispose); + await tester.pumpWidget(_wrap(c)); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('playback-play')), findsOneWidget); + expect(find.byKey(const ValueKey('playback-restart')), findsOneWidget); + expect(find.byKey(const ValueKey('playback-back')), findsOneWidget); + expect(find.byKey(const ValueKey('playback-forward')), findsOneWidget); + expect(find.byKey(const ValueKey('playback-speed')), findsOneWidget); + expect(find.byKey(const ValueKey('playback-drag')), findsOneWidget); + expect(find.byKey(const ValueKey('playback-progress')), findsOneWidget); + }); + + testWidgets('tap play flips isPlaying true', (tester) async { + final c = ProviderContainer(overrides: [ + routeControllerProvider.overrideWith( + () => _FakeRouteController(RouteState(preview: _preview())), + ), + ]); + addTearDown(c.dispose); + await tester.pumpWidget(_wrap(c)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('playback-play'))); + await tester.pumpAndSettle(); + expect(c.read(playbackControllerProvider).isPlaying, isTrue); + }); + + testWidgets('tap speed cycles multiplier', (tester) async { + final c = ProviderContainer(overrides: [ + routeControllerProvider.overrideWith( + () => _FakeRouteController(RouteState(preview: _preview())), + ), + ]); + addTearDown(c.dispose); + await tester.pumpWidget(_wrap(c)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('playback-speed'))); + await tester.pumpAndSettle(); + expect(c.read(playbackControllerProvider).speedMultiplier, 2.0); + }); + + testWidgets('disables controls when no route loaded', (tester) async { + final c = ProviderContainer(overrides: [ + routeControllerProvider.overrideWith( + () => _FakeRouteController(const RouteState()), + ), + ]); + addTearDown(c.dispose); + await tester.pumpWidget(_wrap(c)); + await tester.pumpAndSettle(); + final btn = tester.widget( + find.byKey(const ValueKey('playback-play')), + ); + expect(btn.onPressed, isNull); + }); +} From 37773c70fb90e9d696a35abb61238d9af408d0a8 Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 11:31:50 +0200 Subject: [PATCH 13/17] fix(mobile): init PlaybackDrawer engine ref in initState --- mobile/lib/dev/widgets/playback_drawer.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/mobile/lib/dev/widgets/playback_drawer.dart b/mobile/lib/dev/widgets/playback_drawer.dart index 5e0ae24..0e9de2a 100644 --- a/mobile/lib/dev/widgets/playback_drawer.dart +++ b/mobile/lib/dev/widgets/playback_drawer.dart @@ -12,10 +12,19 @@ class PlaybackDrawer extends ConsumerStatefulWidget { } class _PlaybackDrawerState extends ConsumerState { - // Direct engine reference — stored during build so we can safely call + // Direct engine reference — stored in initState so we can safely call // engine.pause() from dispose() without touching `ref` (forbidden post-unmount). + // Initialising here (rather than in build) also closes the race where dispose() + // could fire before the first build, leaving the Timer.periodic leaked. PlaybackEngine? _engine; + @override + void initState() { + super.initState(); + _engine = ProviderScope.containerOf(context, listen: false) + .read(playbackEngineProvider); + } + @override void dispose() { // Stop the engine's periodic timer before the widget is fully unmounted. @@ -27,7 +36,6 @@ class _PlaybackDrawerState extends ConsumerState { @override Widget build(BuildContext context) { - _engine = ref.read(playbackEngineProvider); final state = ref.watch(playbackControllerProvider); final notifier = ref.read(playbackControllerProvider.notifier); final hasRoute = state.routeLengthM > 0; From 68bf9ae4cebfb1d52bb6cf220195beb0ca466d29 Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 11:51:33 +0200 Subject: [PATCH 14/17] feat(mobile): mount playback drawer + drag puck overlay --- mobile/lib/dev/widgets/drag_puck_overlay.dart | 81 +++++++++++++++++++ mobile/lib/screens/map_screen.dart | 36 +++++++++ 2 files changed, 117 insertions(+) create mode 100644 mobile/lib/dev/widgets/drag_puck_overlay.dart diff --git a/mobile/lib/dev/widgets/drag_puck_overlay.dart b/mobile/lib/dev/widgets/drag_puck_overlay.dart new file mode 100644 index 0000000..5f96d47 --- /dev/null +++ b/mobile/lib/dev/widgets/drag_puck_overlay.dart @@ -0,0 +1,81 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +import '../playback_controller.dart'; + +class DragPuckOverlay extends ConsumerStatefulWidget { + const DragPuckOverlay({ + super.key, + required this.mapController, + }); + + final MapLibreMapController? mapController; + + @override + ConsumerState createState() => _DragPuckOverlayState(); +} + +class _DragPuckOverlayState extends ConsumerState { + Offset? _screenPos; + LatLng? _projectedFor; + + Future _project(LatLng latLng) async { + final c = widget.mapController; + if (c == null) return; + final pt = await c.toScreenLocation(latLng); + if (!mounted) return; + setState(() { + _screenPos = Offset(pt.x.toDouble(), pt.y.toDouble()); + _projectedFor = latLng; + }); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(playbackControllerProvider); + final notifier = ref.read(playbackControllerProvider.notifier); + final override = state.overrideLatLng; + if (override == null) { + _screenPos = null; + _projectedFor = null; + return const SizedBox.shrink(); + } + + if (_projectedFor != override) { + WidgetsBinding.instance.addPostFrameCallback((_) => _project(override)); + } + if (_screenPos == null) return const SizedBox.shrink(); + final pos = _screenPos!; + return Positioned( + left: pos.dx - 16, + top: pos.dy - 16, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanUpdate: (d) async { + final c = widget.mapController; + if (c == null) return; + final newScreen = pos + d.delta; + setState(() => _screenPos = newScreen); + final ll = await c.toLatLng( + math.Point(newScreen.dx, newScreen.dy), + ); + notifier.setOverride(ll); + }, + child: Container( + width: 32, + height: 32, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFFFFC107), + border: Border.fromBorderSide( + BorderSide(color: Colors.white, width: 2), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/screens/map_screen.dart b/mobile/lib/screens/map_screen.dart index a706ca9..517d6c6 100644 --- a/mobile/lib/screens/map_screen.dart +++ b/mobile/lib/screens/map_screen.dart @@ -37,6 +37,9 @@ import '../widgets/map/navigation_sheet.dart'; import '../widgets/map/route_sheet.dart'; import '../widgets/paint_sheet.dart'; import '../widgets/route_card.dart'; +import '../dev/dev_mode.dart'; +import '../dev/widgets/drag_puck_overlay.dart'; +import '../dev/widgets/playback_drawer.dart'; final _berlinBounds = LatLngBounds( southwest: const LatLng(52.3, 13.0), @@ -59,6 +62,7 @@ class _MapScreenState extends ConsumerState { BrushController? _brushNotifier; bool _rerouting = false; bool _browseAutocentered = false; + bool _playbackDrawerExpanded = false; int _paintPointerCount = 0; bool _paintMultiTouch = false; @@ -654,6 +658,7 @@ class _MapScreenState extends ConsumerState { if ((bearing - current).abs() > 0.1) { ref.read(mapBearingProvider.notifier).state = bearing; } + if (mounted) setState(() {}); // re-project drag puck }, ), ), @@ -721,6 +726,37 @@ class _MapScreenState extends ConsumerState { onNavigateHome: _navigateHome, ), ), + if ((ref.watch(playbackEnabledProvider).valueOrNull ?? false)) + Positioned( + left: 0, + top: 0, + bottom: 0, + child: Row( + children: [ + if (_playbackDrawerExpanded) const PlaybackDrawer(), + Material( + color: Colors.black.withValues(alpha: 0.5), + child: InkWell( + onTap: () => setState(() => + _playbackDrawerExpanded = !_playbackDrawerExpanded), + child: SizedBox( + width: 16, + height: 64, + child: Icon( + _playbackDrawerExpanded + ? Icons.chevron_left + : Icons.chevron_right, + color: Colors.white, + size: 18, + ), + ), + ), + ), + ], + ), + ), + if ((ref.watch(playbackEnabledProvider).valueOrNull ?? false)) + DragPuckOverlay(mapController: _mapController), ], ), ); From 9554b26198ea36b3b810bb7f9acf680ede4eb588 Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 12:09:09 +0200 Subject: [PATCH 15/17] fix(mobile): playback baseSpeed converts preview.time from ms to s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit preview.time is milliseconds (matches home_eta_provider, route_sheet usage). Treating it as seconds made baseSpeed 1000x too small — at 10x multiplier, slider advanced ~1m in 30s on a 2.5km route. --- mobile/lib/dev/playback_controller.dart | 5 ++++- mobile/test/dev/playback_controller_test.dart | 2 +- mobile/test/dev/widgets/playback_drawer_test.dart | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/mobile/lib/dev/playback_controller.dart b/mobile/lib/dev/playback_controller.dart index cde6511..6f1051d 100644 --- a/mobile/lib/dev/playback_controller.dart +++ b/mobile/lib/dev/playback_controller.dart @@ -45,7 +45,10 @@ class PlaybackController extends Notifier { engine.clearRoute(); return; } - final baseSpeed = preview.time > 0 ? preview.distance / preview.time : 1.0; + // preview.time is in milliseconds (see home_eta_provider.dart, route_sheet.dart). + final timeSeconds = preview.time / 1000.0; + final baseSpeed = + timeSeconds > 0 ? preview.distance / timeSeconds : 1.0; engine.setRoute( walker: PolylineWalker(coords), routeBaseSpeedMps: baseSpeed, diff --git a/mobile/test/dev/playback_controller_test.dart b/mobile/test/dev/playback_controller_test.dart index 155f8ea..6081315 100644 --- a/mobile/test/dev/playback_controller_test.dart +++ b/mobile/test/dev/playback_controller_test.dart @@ -16,7 +16,7 @@ RoutePreview _fixturePreview() => const RoutePreview( ], }, distance: 333.96, - time: 33.4, // ~10 m/s + time: 33400, // ms; ~10 m/s ); class _FakeRouteController extends Notifier diff --git a/mobile/test/dev/widgets/playback_drawer_test.dart b/mobile/test/dev/widgets/playback_drawer_test.dart index ae079e6..ff45915 100644 --- a/mobile/test/dev/widgets/playback_drawer_test.dart +++ b/mobile/test/dev/widgets/playback_drawer_test.dart @@ -17,7 +17,7 @@ RoutePreview _preview() => const RoutePreview( ], }, distance: 333.96, - time: 33.4, + time: 33400, // ms ); class _FakeRouteController extends Notifier From b9d98edf420aab54d3e67ab756cb6af99a85075a Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 12:14:51 +0200 Subject: [PATCH 16/17] feat(mobile): synthetic puck + playback-mode camera follow Add SyntheticPuckOverlay (blue 22px circle) that projects the playback controller's current latlng to screen coordinates, and a ref.listen on PlaybackState that animates the camera to the synthetic position on each tick when in following mode. Gate myLocationEnabled and trackingCompass calls by playback state so the native GPS puck is hidden during playback. --- .../dev/widgets/synthetic_puck_overlay.dart | 64 +++++++++++++++++++ mobile/lib/screens/map_screen.dart | 42 ++++++++++-- 2 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 mobile/lib/dev/widgets/synthetic_puck_overlay.dart diff --git a/mobile/lib/dev/widgets/synthetic_puck_overlay.dart b/mobile/lib/dev/widgets/synthetic_puck_overlay.dart new file mode 100644 index 0000000..bbf316d --- /dev/null +++ b/mobile/lib/dev/widgets/synthetic_puck_overlay.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +import '../playback_controller.dart'; + +class SyntheticPuckOverlay extends ConsumerStatefulWidget { + const SyntheticPuckOverlay({super.key, required this.mapController}); + + final MapLibreMapController? mapController; + + @override + ConsumerState createState() => + _SyntheticPuckOverlayState(); +} + +class _SyntheticPuckOverlayState extends ConsumerState { + Offset? _screenPos; + LatLng? _projectedFor; + + Future _project(LatLng ll) async { + final c = widget.mapController; + if (c == null) return; + final pt = await c.toScreenLocation(ll); + if (!mounted) return; + setState(() { + _screenPos = Offset(pt.x.toDouble(), pt.y.toDouble()); + _projectedFor = ll; + }); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(playbackControllerProvider); + if (state.routeLengthM <= 0) return const SizedBox.shrink(); + + final latLng = + ref.read(playbackControllerProvider.notifier).currentLatLng(); + + if (_projectedFor != latLng) { + WidgetsBinding.instance.addPostFrameCallback((_) => _project(latLng)); + } + + if (_screenPos == null) return const SizedBox.shrink(); + + return Positioned( + left: _screenPos!.dx - 11, + top: _screenPos!.dy - 11, + child: IgnorePointer( + child: Container( + width: 22, + height: 22, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFF1E88E5), + border: Border.fromBorderSide( + BorderSide(color: Colors.white, width: 2), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/screens/map_screen.dart b/mobile/lib/screens/map_screen.dart index 517d6c6..328fb56 100644 --- a/mobile/lib/screens/map_screen.dart +++ b/mobile/lib/screens/map_screen.dart @@ -38,8 +38,12 @@ import '../widgets/map/route_sheet.dart'; import '../widgets/paint_sheet.dart'; import '../widgets/route_card.dart'; import '../dev/dev_mode.dart'; +import '../dev/playback_controller.dart'; +import '../dev/playback_state.dart'; import '../dev/widgets/drag_puck_overlay.dart'; import '../dev/widgets/playback_drawer.dart'; +import '../dev/widgets/synthetic_puck_overlay.dart'; +import '../navigation/camera_controller.dart'; final _berlinBounds = LatLngBounds( southwest: const LatLng(52.3, 13.0), @@ -307,11 +311,16 @@ class _MapScreenState extends ConsumerState { cam.onFirstFix(); final controller = _mapController; if (controller == null) return; + final playback = ref.read(playbackEnabledProvider).valueOrNull ?? false; // Enable tracking first so maplibre drives the camera target, then // apply zoom. newLatLngZoom before trackingCompass gets clobbered — // the tracking mode kicks in and resets zoom to whatever it was. - await controller - .updateMyLocationTrackingMode(MyLocationTrackingMode.trackingCompass); + // Skip trackingCompass in playback mode — native GPS puck is hidden and + // camera follow is driven by the synthetic location listener instead. + if (!playback) { + await controller + .updateMyLocationTrackingMode(MyLocationTrackingMode.trackingCompass); + } if (!mounted) return; await controller.animateCamera(CameraUpdate.zoomTo(cam.followZoom)); } @@ -351,8 +360,13 @@ class _MapScreenState extends ConsumerState { await controller.animateCamera(CameraUpdate.newLatLngZoom( LatLng(snapped.lat, snapped.lng), cam.followZoom)); if (!mounted) return; - await controller - .updateMyLocationTrackingMode(MyLocationTrackingMode.trackingCompass); + final playback = ref.read(playbackEnabledProvider).valueOrNull ?? false; + // Skip trackingCompass in playback mode — native GPS puck is hidden and + // camera follow is driven by the synthetic location listener instead. + if (!playback) { + await controller + .updateMyLocationTrackingMode(MyLocationTrackingMode.trackingCompass); + } } void _onNavStateChange( @@ -466,6 +480,7 @@ class _MapScreenState extends ConsumerState { final paintMode = ref.watch( brushControllerProvider.select((s) => s.paintMode), ); + final playback = ref.watch(playbackEnabledProvider).valueOrNull ?? false; final routeActive = routeState.isLoading || routeState.error != null || preview != null; @@ -506,6 +521,21 @@ class _MapScreenState extends ConsumerState { _endNavigationSession(); } }); + ref.listen(playbackControllerProvider, (prev, next) { + final on = ref.read(playbackEnabledProvider).valueOrNull ?? false; + if (!on) return; + if (next.routeLengthM <= 0) return; + final mode = ref.read(navigationCameraControllerProvider).mode; + if (mode != CameraMode.following) return; + final c = _mapController; + if (c == null) return; + final ll = + ref.read(playbackControllerProvider.notifier).currentLatLng(); + c.animateCamera( + CameraUpdate.newLatLng(ll), + duration: const Duration(milliseconds: 240), + ); + }); return Scaffold( body: Stack( @@ -587,7 +617,7 @@ class _MapScreenState extends ConsumerState { ), cameraTargetBounds: CameraTargetBounds(_berlinBounds), minMaxZoomPreference: const MinMaxZoomPreference(10, 18), - myLocationEnabled: true, + myLocationEnabled: !playback, myLocationTrackingMode: MyLocationTrackingMode.none, trackCameraPosition: true, scrollGesturesEnabled: !paintMode, @@ -755,6 +785,8 @@ class _MapScreenState extends ConsumerState { ], ), ), + if ((ref.watch(playbackEnabledProvider).valueOrNull ?? false)) + SyntheticPuckOverlay(mapController: _mapController), if ((ref.watch(playbackEnabledProvider).valueOrNull ?? false)) DragPuckOverlay(mapController: _mapController), ], From 1fe862bd805cff8d210968f0be1abc9f9381c61c Mon Sep 17 00:00:00 2001 From: cafca Date: Sat, 25 Apr 2026 12:33:43 +0200 Subject: [PATCH 17/17] feat(mobile): red synthetic puck, 10m step, drop drag-puck UI --- mobile/lib/dev/playback_constants.dart | 2 +- mobile/lib/dev/widgets/drag_puck_overlay.dart | 81 ------------------- mobile/lib/dev/widgets/playback_drawer.dart | 20 +---- .../dev/widgets/synthetic_puck_overlay.dart | 2 +- mobile/lib/screens/map_screen.dart | 5 +- .../dev/widgets/playback_drawer_test.dart | 3 +- 6 files changed, 6 insertions(+), 107 deletions(-) delete mode 100644 mobile/lib/dev/widgets/drag_puck_overlay.dart diff --git a/mobile/lib/dev/playback_constants.dart b/mobile/lib/dev/playback_constants.dart index baf3d26..1f3bb8b 100644 --- a/mobile/lib/dev/playback_constants.dart +++ b/mobile/lib/dev/playback_constants.dart @@ -1,6 +1,6 @@ const int kPlaybackTickHz = 4; const Duration kPlaybackTickInterval = Duration(milliseconds: 250); -const double kPlaybackStepMeters = 100.0; +const double kPlaybackStepMeters = 10.0; const List kPlaybackSpeedMultipliers = [0.5, 1.0, 2.0, 5.0, 10.0]; const double kPlaybackDefaultMultiplier = 1.0; const double kPlaybackFakeAccuracyM = 5.0; diff --git a/mobile/lib/dev/widgets/drag_puck_overlay.dart b/mobile/lib/dev/widgets/drag_puck_overlay.dart deleted file mode 100644 index 5f96d47..0000000 --- a/mobile/lib/dev/widgets/drag_puck_overlay.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'dart:math' as math; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; - -import '../playback_controller.dart'; - -class DragPuckOverlay extends ConsumerStatefulWidget { - const DragPuckOverlay({ - super.key, - required this.mapController, - }); - - final MapLibreMapController? mapController; - - @override - ConsumerState createState() => _DragPuckOverlayState(); -} - -class _DragPuckOverlayState extends ConsumerState { - Offset? _screenPos; - LatLng? _projectedFor; - - Future _project(LatLng latLng) async { - final c = widget.mapController; - if (c == null) return; - final pt = await c.toScreenLocation(latLng); - if (!mounted) return; - setState(() { - _screenPos = Offset(pt.x.toDouble(), pt.y.toDouble()); - _projectedFor = latLng; - }); - } - - @override - Widget build(BuildContext context) { - final state = ref.watch(playbackControllerProvider); - final notifier = ref.read(playbackControllerProvider.notifier); - final override = state.overrideLatLng; - if (override == null) { - _screenPos = null; - _projectedFor = null; - return const SizedBox.shrink(); - } - - if (_projectedFor != override) { - WidgetsBinding.instance.addPostFrameCallback((_) => _project(override)); - } - if (_screenPos == null) return const SizedBox.shrink(); - final pos = _screenPos!; - return Positioned( - left: pos.dx - 16, - top: pos.dy - 16, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onPanUpdate: (d) async { - final c = widget.mapController; - if (c == null) return; - final newScreen = pos + d.delta; - setState(() => _screenPos = newScreen); - final ll = await c.toLatLng( - math.Point(newScreen.dx, newScreen.dy), - ); - notifier.setOverride(ll); - }, - child: Container( - width: 32, - height: 32, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Color(0xFFFFC107), - border: Border.fromBorderSide( - BorderSide(color: Colors.white, width: 2), - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/dev/widgets/playback_drawer.dart b/mobile/lib/dev/widgets/playback_drawer.dart index 0e9de2a..3aec32a 100644 --- a/mobile/lib/dev/widgets/playback_drawer.dart +++ b/mobile/lib/dev/widgets/playback_drawer.dart @@ -64,13 +64,13 @@ class _PlaybackDrawerState extends ConsumerState { _IconBtn( btnKey: const ValueKey('playback-back'), icon: Icons.replay, - label: '-100m', + label: '-10m', onPressed: hasRoute ? notifier.stepBack : null, ), _IconBtn( btnKey: const ValueKey('playback-forward'), icon: Icons.forward_30, - label: '+100m', + label: '+10m', onPressed: hasRoute ? notifier.stepForward : null, ), _IconBtn( @@ -79,22 +79,6 @@ class _PlaybackDrawerState extends ConsumerState { label: '${state.speedMultiplier}x', onPressed: hasRoute ? notifier.cycleSpeed : null, ), - _IconBtn( - btnKey: const ValueKey('playback-drag'), - icon: Icons.open_with, - label: state.overrideLatLng != null ? 'on' : 'off', - onPressed: !hasRoute - ? null - : () { - if (state.overrideLatLng != null) { - notifier.clearOverride(); - } else { - // Seed override at current sim latlng so the puck is - // grabbable immediately; the operator then drags it. - notifier.setOverride(notifier.currentLatLng()); - } - }, - ), const SizedBox(height: 8), Expanded( child: RotatedBox( diff --git a/mobile/lib/dev/widgets/synthetic_puck_overlay.dart b/mobile/lib/dev/widgets/synthetic_puck_overlay.dart index bbf316d..a9fd31d 100644 --- a/mobile/lib/dev/widgets/synthetic_puck_overlay.dart +++ b/mobile/lib/dev/widgets/synthetic_puck_overlay.dart @@ -52,7 +52,7 @@ class _SyntheticPuckOverlayState extends ConsumerState { height: 22, decoration: const BoxDecoration( shape: BoxShape.circle, - color: Color(0xFF1E88E5), + color: Color(0xFFE53935), border: Border.fromBorderSide( BorderSide(color: Colors.white, width: 2), ), diff --git a/mobile/lib/screens/map_screen.dart b/mobile/lib/screens/map_screen.dart index 328fb56..328580f 100644 --- a/mobile/lib/screens/map_screen.dart +++ b/mobile/lib/screens/map_screen.dart @@ -40,7 +40,6 @@ import '../widgets/route_card.dart'; import '../dev/dev_mode.dart'; import '../dev/playback_controller.dart'; import '../dev/playback_state.dart'; -import '../dev/widgets/drag_puck_overlay.dart'; import '../dev/widgets/playback_drawer.dart'; import '../dev/widgets/synthetic_puck_overlay.dart'; import '../navigation/camera_controller.dart'; @@ -688,7 +687,7 @@ class _MapScreenState extends ConsumerState { if ((bearing - current).abs() > 0.1) { ref.read(mapBearingProvider.notifier).state = bearing; } - if (mounted) setState(() {}); // re-project drag puck + if (mounted) setState(() {}); // re-project synthetic puck }, ), ), @@ -787,8 +786,6 @@ class _MapScreenState extends ConsumerState { ), if ((ref.watch(playbackEnabledProvider).valueOrNull ?? false)) SyntheticPuckOverlay(mapController: _mapController), - if ((ref.watch(playbackEnabledProvider).valueOrNull ?? false)) - DragPuckOverlay(mapController: _mapController), ], ), ); diff --git a/mobile/test/dev/widgets/playback_drawer_test.dart b/mobile/test/dev/widgets/playback_drawer_test.dart index ff45915..5ddd353 100644 --- a/mobile/test/dev/widgets/playback_drawer_test.dart +++ b/mobile/test/dev/widgets/playback_drawer_test.dart @@ -38,7 +38,7 @@ Widget _wrap(ProviderContainer container) => UncontrolledProviderScope( ); void main() { - testWidgets('renders icon buttons for play/restart/back/forward/speed/drag', + testWidgets('renders icon buttons for play/restart/back/forward/speed', (tester) async { final c = ProviderContainer(overrides: [ routeControllerProvider.overrideWith( @@ -53,7 +53,6 @@ void main() { expect(find.byKey(const ValueKey('playback-back')), findsOneWidget); expect(find.byKey(const ValueKey('playback-forward')), findsOneWidget); expect(find.byKey(const ValueKey('playback-speed')), findsOneWidget); - expect(find.byKey(const ValueKey('playback-drag')), findsOneWidget); expect(find.byKey(const ValueKey('playback-progress')), findsOneWidget); });