Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6685aa2
chore(mobile): add package_info_plus for settings version row
cafca Apr 25, 2026
0c5019a
chore(mobile): bump package_info_plus constraint to avoid peer downgr…
cafca Apr 25, 2026
6b64a97
feat(mobile): add PolylineWalker for GPS playback interpolation
cafca Apr 25, 2026
ff82588
fix(mobile): guard PolylineWalker against zero-length segments
cafca Apr 25, 2026
4802c06
feat(mobile): add PlaybackEngine for GPS simulation
cafca Apr 25, 2026
ab7c2e8
fix(mobile): re-emit on pause and guard PlaybackEngine override on no…
cafca Apr 25, 2026
0221136
feat(mobile): add dev-mode unlock + persisted flags
cafca Apr 25, 2026
8116a7f
feat(mobile): add PlaybackController wiring engine to route preview
cafca Apr 25, 2026
3fbb0d1
feat(mobile): switchable location source for GPS playback
cafca Apr 25, 2026
5265075
chore(mobile): drop unused imports in dev tests
cafca Apr 25, 2026
af6a09e
feat(mobile): settings version row + Developer mode unlock
cafca Apr 25, 2026
8e28170
feat(mobile): add PlaybackDrawer UI
cafca Apr 25, 2026
37773c7
fix(mobile): init PlaybackDrawer engine ref in initState
cafca Apr 25, 2026
68bf9ae
feat(mobile): mount playback drawer + drag puck overlay
cafca Apr 25, 2026
9554b26
fix(mobile): playback baseSpeed converts preview.time from ms to s
cafca Apr 25, 2026
b9d98ed
feat(mobile): synthetic puck + playback-mode camera follow
cafca Apr 25, 2026
1fe862b
feat(mobile): red synthetic puck, 10m step, drop drag-puck UI
cafca Apr 25, 2026
2128c80
chore(mobile): satisfy very_good_analysis lints in dev/ files
cafca Apr 25, 2026
3fe2c59
Merge remote-tracking branch 'origin/main' into claude/crazy-benz-3ed736
cafca May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions mobile/lib/dev/dev_mode.dart
Original file line number Diff line number Diff line change
@@ -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<bool> {
@override
Future<bool> build() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(kPrefDevModeUnlocked) ?? false;
}

Future<void> setUnlocked({required 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<void> toggle() async {
final current = state.valueOrNull ?? false;
await setUnlocked(value: !current);
}
}

final devModeProvider =
AsyncNotifierProvider<DevModeController, bool>(DevModeController.new);

class PlaybackEnabledController extends AsyncNotifier<bool> {
@override
Future<bool> build() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(kPrefPlaybackEnabled) ?? false;
}

Future<void> set({required bool value}) async {
state = AsyncData(value);
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(kPrefPlaybackEnabled, value);
}
}

final playbackEnabledProvider =
AsyncNotifierProvider<PlaybackEnabledController, bool>(
PlaybackEnabledController.new);
6 changes: 6 additions & 0 deletions mobile/lib/dev/playback_constants.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const int kPlaybackTickHz = 4;
const Duration kPlaybackTickInterval = Duration(milliseconds: 250);
const double kPlaybackStepMeters = 10;
const List<double> kPlaybackSpeedMultipliers = [0.5, 1, 2, 5, 10];
const double kPlaybackDefaultMultiplier = 1;
const double kPlaybackFakeAccuracyM = 5;
127 changes: 127 additions & 0 deletions mobile/lib/dev/playback_controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import 'dart:async';

import 'package:beebeebike/dev/playback_constants.dart';
import 'package:beebeebike/dev/playback_engine.dart';
import 'package:beebeebike/dev/playback_state.dart';
import 'package:beebeebike/dev/polyline_walker.dart';
import 'package:beebeebike/models/route_preview.dart';
import 'package:beebeebike/providers/route_provider.dart';
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;

final playbackEngineProvider = Provider<PlaybackEngine>((ref) {
final engine = PlaybackEngine();
ref.onDispose(engine.dispose);
return engine;
});

class PlaybackController extends Notifier<PlaybackState> {
StreamSubscription<UserLocation>? _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();
}
unawaited(_engineSub?.cancel());
_engineSub = engine.stream.listen((_) {
state = engine.state;
});
ref.onDispose(() => unawaited(_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;
}
// 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,
);
}

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() {
const 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;
}

/// 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 =
NotifierProvider<PlaybackController, PlaybackState>(PlaybackController.new);
147 changes: 147 additions & 0 deletions mobile/lib/dev/playback_engine.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import 'dart:async';

import 'package:beebeebike/dev/playback_constants.dart';
import 'package:beebeebike/dev/playback_state.dart';
import 'package:beebeebike/dev/polyline_walker.dart';
import 'package:ferrostar_flutter/ferrostar_flutter.dart';
import 'package:maplibre_gl/maplibre_gl.dart' hide UserLocation;

class PlaybackEngine {
PlaybackEngine();

final _controller = StreamController<UserLocation>.broadcast();
Timer? _timer;
PolylineWalker? _walker;
PlaybackState _state = const PlaybackState();
double _lastBearing = 0;

Stream<UserLocation> 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,
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);
_emitCurrent();
_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) {
if (_walker == null) return;
_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();
unawaited(_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) {
_emitCurrent();
_stopTimer();
return;
}
const 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,
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,
));
}
}
16 changes: 16 additions & 0 deletions mobile/lib/dev/playback_state.dart
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading