diff --git a/mobile/lib/dev/dev_mode.dart b/mobile/lib/dev/dev_mode.dart new file mode 100644 index 0000000..f87f434 --- /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({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 toggle() async { + final current = state.valueOrNull ?? false; + await setUnlocked(value: !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({required 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/lib/dev/playback_constants.dart b/mobile/lib/dev/playback_constants.dart new file mode 100644 index 0000000..c65ab07 --- /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 = 10; +const List kPlaybackSpeedMultipliers = [0.5, 1, 2, 5, 10]; +const double kPlaybackDefaultMultiplier = 1; +const double kPlaybackFakeAccuracyM = 5; diff --git a/mobile/lib/dev/playback_controller.dart b/mobile/lib/dev/playback_controller.dart new file mode 100644 index 0000000..0d0b1ea --- /dev/null +++ b/mobile/lib/dev/playback_controller.dart @@ -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((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(); + } + 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.new); diff --git a/mobile/lib/dev/playback_engine.dart b/mobile/lib/dev/playback_engine.dart new file mode 100644 index 0000000..3cf9c9b --- /dev/null +++ b/mobile/lib/dev/playback_engine.dart @@ -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.broadcast(); + Timer? _timer; + PolylineWalker? _walker; + PlaybackState _state = const PlaybackState(); + double _lastBearing = 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, + 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, + )); + } +} 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/lib/dev/polyline_walker.dart b/mobile/lib/dev/polyline_walker.dart new file mode 100644 index 0000000..6f9c450 --- /dev/null +++ b/mobile/lib/dev/polyline_walker.dart @@ -0,0 +1,87 @@ +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. + var lo = 1; + var 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]; + // 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]; + 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]; + 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/lib/dev/switchable_location_source.dart b/mobile/lib/dev/switchable_location_source.dart new file mode 100644 index 0000000..4f88de4 --- /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() { + unawaited(_sub?.cancel()); + unawaited(_out.close()); + } + + void _resubscribe() { + unawaited(_sub?.cancel()); + final src = usePlayback() ? playbackStream() : realStream(); + _sub = src.listen(_out.add, onError: _out.addError); + } +} diff --git a/mobile/lib/dev/widgets/playback_drawer.dart b/mobile/lib/dev/widgets/playback_drawer.dart new file mode 100644 index 0000000..48bdf76 --- /dev/null +++ b/mobile/lib/dev/widgets/playback_drawer.dart @@ -0,0 +1,148 @@ +import 'package:beebeebike/dev/playback_controller.dart'; +import 'package:beebeebike/dev/playback_engine.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class PlaybackDrawer extends ConsumerStatefulWidget { + const PlaybackDrawer({super.key}); + + @override + ConsumerState createState() => _PlaybackDrawerState(); +} + +class _PlaybackDrawerState extends ConsumerState { + // 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. + // 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) { + 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: '-10m', + onPressed: hasRoute ? notifier.stepBack : null, + ), + _IconBtn( + btnKey: const ValueKey('playback-forward'), + icon: Icons.forward_30, + label: '+10m', + onPressed: hasRoute ? notifier.stepForward : null, + ), + _IconBtn( + btnKey: const ValueKey('playback-speed'), + icon: Icons.speed, + label: '${state.speedMultiplier}x', + onPressed: hasRoute ? notifier.cycleSpeed : null, + ), + 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)), + max: state.routeLengthM <= 0 ? 1.0 : state.routeLengthM, + onChanged: hasRoute ? notifier.seek : 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/lib/dev/widgets/synthetic_puck_overlay.dart b/mobile/lib/dev/widgets/synthetic_puck_overlay.dart new file mode 100644 index 0000000..3422640 --- /dev/null +++ b/mobile/lib/dev/widgets/synthetic_puck_overlay.dart @@ -0,0 +1,63 @@ +import 'package:beebeebike/dev/playback_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +class SyntheticPuckOverlay extends ConsumerStatefulWidget { + const SyntheticPuckOverlay({required this.mapController, super.key}); + + 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(0xFFE53935), + border: Border.fromBorderSide( + BorderSide(color: Colors.white, width: 2), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/providers/navigation_provider.dart b/mobile/lib/providers/navigation_provider.dart index 8e181b7..3d24506 100644 --- a/mobile/lib/providers/navigation_provider.dart +++ b/mobile/lib/providers/navigation_provider.dart @@ -4,6 +4,9 @@ import 'dart:ui' as ui; import 'package:beebeebike/api/client.dart'; import 'package:beebeebike/api/routing_api.dart'; +import 'package:beebeebike/dev/dev_mode.dart'; +import 'package:beebeebike/dev/playback_controller.dart'; +import 'package:beebeebike/dev/switchable_location_source.dart'; import 'package:beebeebike/navigation/location_converter.dart'; import 'package:beebeebike/navigation/navigation_service.dart'; import 'package:beebeebike/providers/cobblestone_avoidance_provider.dart'; @@ -16,7 +19,7 @@ import 'package:flutter_tts/flutter_tts.dart'; import 'package:geolocator/geolocator.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; -Stream _buildLocationStream() async* { +Stream _buildRealLocationStream() async* { var permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); @@ -53,6 +56,21 @@ 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(); + }) + ..onDispose(source.dispose); + return source; +}); + final ttsFactoryProvider = Provider((_) => FlutterTts.new); /// Whether turn-by-turn voice is currently enabled. Toggled by the voice FAB @@ -144,7 +162,7 @@ final navigationServiceProvider = Provider((ref) { cobblestoneAvoidance: ref.read(cobblestoneAvoidanceProvider).wireValue, ), - locationStreamFactory: _buildLocationStream, + locationStreamFactory: () => ref.read(locationSourceProvider).stream, setWakelock: ({required enabled}) => WakelockPlus.toggle(enable: enabled), speakInstruction: (text) async { if (!ref.read(ttsEnabledProvider)) return; diff --git a/mobile/lib/screens/map_screen.dart b/mobile/lib/screens/map_screen.dart index 81fad36..df920fc 100644 --- a/mobile/lib/screens/map_screen.dart +++ b/mobile/lib/screens/map_screen.dart @@ -1,6 +1,11 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:beebeebike/dev/dev_mode.dart'; +import 'package:beebeebike/dev/playback_controller.dart'; +import 'package:beebeebike/dev/playback_state.dart'; +import 'package:beebeebike/dev/widgets/playback_drawer.dart'; +import 'package:beebeebike/dev/widgets/synthetic_puck_overlay.dart'; import 'package:beebeebike/l10n/generated/app_localizations.dart'; import 'package:beebeebike/models/location.dart'; import 'package:beebeebike/models/route_state.dart'; @@ -62,6 +67,7 @@ class _MapScreenState extends ConsumerState { BrushController? _brushNotifier; bool _rerouting = false; bool _browseAutocentered = false; + bool _playbackDrawerExpanded = false; int _paintPointerCount = 0; bool _paintMultiTouch = false; @@ -329,11 +335,16 @@ class _MapScreenState extends ConsumerState { cam.onNavStart(); 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)); } @@ -375,8 +386,13 @@ class _MapScreenState extends ConsumerState { await controller.animateCamera(CameraUpdate.newLatLngZoom( LatLng(loc.lat, loc.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( @@ -519,6 +535,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; @@ -560,6 +577,21 @@ class _MapScreenState extends ConsumerState { } else if (prev == true) { unawaited(_endNavigationSession()); } + }) + ..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(); + unawaited(c.animateCamera( + CameraUpdate.newLatLng(ll), + duration: const Duration(milliseconds: 240), + )); }); return Scaffold( @@ -644,7 +676,7 @@ class _MapScreenState extends ConsumerState { ), cameraTargetBounds: CameraTargetBounds(_berlinBounds), minMaxZoomPreference: const MinMaxZoomPreference(10, 18), - myLocationEnabled: true, + myLocationEnabled: !playback, trackCameraPosition: true, scrollGesturesEnabled: !paintMode, rotateGesturesEnabled: !paintMode, @@ -714,6 +746,7 @@ class _MapScreenState extends ConsumerState { if ((bearing - current).abs() > 0.1) { ref.read(mapBearingProvider.notifier).state = bearing; } + if (mounted) setState(() {}); // re-project synthetic puck }, ), ), @@ -783,6 +816,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) + SyntheticPuckOverlay(mapController: _mapController), ], ), ); diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index d947d6c..5a563d7 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:beebeebike/app.dart'; +import 'package:beebeebike/dev/dev_mode.dart'; import 'package:beebeebike/l10n/generated/app_localizations.dart'; import 'package:beebeebike/providers/auth_provider.dart'; import 'package:beebeebike/providers/cobblestone_avoidance_provider.dart'; @@ -15,6 +16,7 @@ import 'package:beebeebike/widgets/language_picker.dart'; 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'; class SettingsScreen extends ConsumerWidget { @@ -46,6 +48,8 @@ class SettingsScreen extends ConsumerWidget { _SectionDivider(), _DangerSection(), _CreditsSection(), + _DevModeSection(), + _VersionRow(), ], ), ); @@ -540,3 +544,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(value: 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, + ); + unawaited(_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/pubspec.lock b/mobile/pubspec.lock index b08fffd..fae78d7 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" @@ -732,7 +732,7 @@ packages: source: hosted version: "2.2.0" package_info_plus: - dependency: transitive + dependency: "direct main" description: name: package_info_plus sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 7fe06d0..2f4a78b 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: intl: any json_annotation: ^4.9.0 maplibre_gl: ^0.20.0 + package_info_plus: ^10.1.0 path_provider: ^2.1.5 sentry_flutter: ^9.19.0 shared_preferences: ^2.3.2 @@ -39,6 +40,7 @@ dependencies: dev_dependencies: build_runner: ^2.4.13 + fake_async: ^1.3.0 flutter_launcher_icons: ^0.14.4 flutter_lints: ^5.0.0 flutter_test: diff --git a/mobile/test/dev/dev_mode_test.dart b/mobile/test/dev/dev_mode_test.dart new file mode 100644 index 0000000..776cd15 --- /dev/null +++ b/mobile/test/dev/dev_mode_test.dart @@ -0,0 +1,60 @@ +import 'package:beebeebike/dev/dev_mode.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.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); + }); + }); + }); +} diff --git a/mobile/test/dev/playback_controller_test.dart b/mobile/test/dev/playback_controller_test.dart new file mode 100644 index 0000000..24b69b7 --- /dev/null +++ b/mobile/test/dev/playback_controller_test.dart @@ -0,0 +1,113 @@ +import 'package:beebeebike/dev/playback_controller.dart'; +import 'package:beebeebike/models/route_preview.dart'; +import 'package:beebeebike/models/route_state.dart'; +import 'package:beebeebike/providers/route_provider.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maplibre_gl/maplibre_gl.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: 33400, // ms; ~10 m/s + ); + +class _FakeRouteController extends Notifier + implements RouteController { + _FakeRouteController(this._initial); + final RouteState _initial; + @override + RouteState build() => _initial; + + // ignore: use_setters_to_change_properties -- test helper; setter would clash with Notifier.state + void emit(RouteState s) => state = s; + + @override + Never 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) + ..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) + ..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) + ..setOverride(const LatLng(1, 2)); + expect(container.read(playbackControllerProvider).overrideLatLng, + isNotNull); + notifier.clearOverride(); + expect(container.read(playbackControllerProvider).overrideLatLng, isNull); + }); + }); +} diff --git a/mobile/test/dev/playback_engine_test.dart b/mobile/test/dev/playback_engine_test.dart new file mode 100644 index 0000000..0e19858 --- /dev/null +++ b/mobile/test/dev/playback_engine_test.dart @@ -0,0 +1,165 @@ +import 'dart:async'; + +import 'package:beebeebike/dev/playback_engine.dart'; +import 'package:beebeebike/dev/polyline_walker.dart'; +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; + +void main() { + // East-running 333 m polyline along the equator. + final points = [ + const LatLng(0, 0), + const LatLng(0, 0.001), + const LatLng(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); + unawaited(sub.cancel()); + e.dispose(); + }); + }); + + test('play advances cumulative by baseSpeed * multiplier * dt', () { + fakeAsync((async) { + final e = PlaybackEngine() + ..setRoute(walker: walker(), routeBaseSpeedMps: 10); + 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)); + unawaited(sub.cancel()); + e.dispose(); + }); + }); + + test('pause halts advance', () { + fakeAsync((async) { + final e = PlaybackEngine() + ..setRoute(walker: walker(), routeBaseSpeedMps: 10) + ..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('pause emits last position once with speed=0', () { + fakeAsync((async) { + final e = PlaybackEngine() + ..setRoute(walker: walker(), routeBaseSpeedMps: 10); + 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); + unawaited(sub.cancel()); + e.dispose(); + }); + }); + + test('override emits override latlng with speed=0; advance halts', () { + fakeAsync((async) { + final e = PlaybackEngine() + ..setRoute(walker: walker(), routeBaseSpeedMps: 10); + 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); + unawaited(sub.cancel()); + e.dispose(); + }); + }); + + test('clearOverride snaps back to polyline at cumulative', () { + fakeAsync((async) { + final e = PlaybackEngine() + ..setRoute(walker: walker(), routeBaseSpeedMps: 10) + ..play(); + async.elapse(const Duration(seconds: 1)); + final beforeOverrideCum = e.state.cumulativeM; + e.setOverridePosition(const LatLng(1, 1)); + 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); + // 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(); + }); + }); + + test('reaching end pauses and clamps cumulative', () { + fakeAsync((async) { + final e = PlaybackEngine() + ..setRoute(walker: walker(), routeBaseSpeedMps: 10) + ..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) + ..seek(50); + 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) + ..setSpeedMultiplier(5); + expect(e.state.speedMultiplier, 5.0); + e.dispose(); + }); + + test('setRoute resets cumulative and stops playback', () { + final e = PlaybackEngine() + ..setRoute(walker: walker(), routeBaseSpeedMps: 10) + ..play() + ..seek(100) + ..setRoute(walker: walker(), routeBaseSpeedMps: 5); + expect(e.state.cumulativeM, 0.0); + expect(e.state.isPlaying, isFalse); + expect(e.state.routeBaseSpeedMps, 5.0); + e.dispose(); + }); + }); +} diff --git a/mobile/test/dev/polyline_walker_test.dart b/mobile/test/dev/polyline_walker_test.dart new file mode 100644 index 0000000..e6a516e --- /dev/null +++ b/mobile/test/dev/polyline_walker_test.dart @@ -0,0 +1,69 @@ +import 'package:beebeebike/dev/polyline_walker.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maplibre_gl/maplibre_gl.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), + const LatLng(0, 0.001), // ~111 m east + const LatLng(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); + }); + + test('handles zero-length segment without NaN', () { + final points = [ + const LatLng(0, 0), + const LatLng(0, 0), // duplicate of previous + const LatLng(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); + 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); + }); + }); +} 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..df493b4 --- /dev/null +++ b/mobile/test/dev/switchable_location_source_test.dart @@ -0,0 +1,81 @@ +import 'dart:async'; + +import 'package:beebeebike/dev/switchable_location_source.dart'; +import 'package:ferrostar_flutter/ferrostar_flutter.dart'; +import 'package:flutter_test/flutter_test.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(); + }); + }); +} 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..d6d104c --- /dev/null +++ b/mobile/test/dev/widgets/playback_drawer_test.dart @@ -0,0 +1,101 @@ +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'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.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: 33400, // ms + ); + +class _FakeRouteController extends Notifier + implements RouteController { + _FakeRouteController(this._initial); + final RouteState _initial; + @override + RouteState build() => _initial; + @override + Never 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', + (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-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); + }); +} 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..0848e9d --- /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: const MaterialApp( + locale: Locale('en'), + localizationsDelegates: + AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: 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); + }); +}