Skip to content

Commit 9b96a5d

Browse files
authored
fix(mobile): nav-start crash + stale-origin + missing recenter FAB (#65)
Three coupled bugs in the navigation flow: 1. EXC_BREAKPOINT crash on session start (GlitchTip BEEBEEBIKE-APP-4): `Int(p.durationRemaining * 1000)` in Serialization.swift traps when ferrostar emits a non-finite duration on the first progress tick. Guard the FFI bridge against NaN/Inf/overflow on duration + distance fields, and against CLLocation's -1 sentinel for course (UInt16 init trap). Defense-in-depth: also filter bearing/speed/accuracy at the Dart layer in maplibreToUserLocation before they cross the boundary. 2. New routes started 20-30m back from current GPS. Geolocator's last-known cache lagged behind MapLibre's stream. Cache the live MapLibre fix in userLocationProvider (same source as the blue dot) and prefer it when seeding nav, picking route origins, or recentering after reroute. 3. Recenter FAB / compass never appeared during real-device nav. The first-fix transition was wired to ferrostar's snapped_location stream, which doesn't emit until well into the session. Drive the awaitingFirstFix → following transition off the cached MapLibre location at session start instead, with onUserLocationUpdated as the fallback. Renamed onFirstFix → onNavStart for clarity. Also bump iOS deployment target 13.0 → 16.0 (Podfile already declared 16.0; pbxproj had been stale and SPM device builds caught the mismatch where pod-only sim builds let it slide).
1 parent 26f535e commit 9b96a5d

10 files changed

Lines changed: 231 additions & 83 deletions

File tree

mobile/ios/Runner.xcodeproj/project.pbxproj

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@
468468
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
469469
GCC_WARN_UNUSED_FUNCTION = YES;
470470
GCC_WARN_UNUSED_VARIABLE = YES;
471-
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
471+
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
472472
MTL_ENABLE_DEBUG_INFO = NO;
473473
SDKROOT = iphoneos;
474474
SUPPORTED_PLATFORMS = iphoneos;
@@ -600,7 +600,7 @@
600600
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
601601
GCC_WARN_UNUSED_FUNCTION = YES;
602602
GCC_WARN_UNUSED_VARIABLE = YES;
603-
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
603+
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
604604
MTL_ENABLE_DEBUG_INFO = YES;
605605
ONLY_ACTIVE_ARCH = YES;
606606
SDKROOT = iphoneos;
@@ -651,7 +651,7 @@
651651
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
652652
GCC_WARN_UNUSED_FUNCTION = YES;
653653
GCC_WARN_UNUSED_VARIABLE = YES;
654-
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
654+
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
655655
MTL_ENABLE_DEBUG_INFO = NO;
656656
SDKROOT = iphoneos;
657657
SUPPORTED_PLATFORMS = iphoneos;

mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mobile/lib/navigation/camera_controller.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ class NavigationCameraController extends ChangeNotifier {
99
CameraMode get mode => _mode;
1010
double get followZoom => _followZoom;
1111

12-
void onFirstFix() {
12+
/// Transitions awaitingFirstFix → following. Called when nav starts and we
13+
/// already have a cached user location, or (edge case) when the first
14+
/// location update arrives during nav after the session began with no fix.
15+
void onNavStart() {
1316
if (_mode != CameraMode.awaitingFirstFix) return;
1417
_mode = CameraMode.following;
1518
notifyListeners();

mobile/lib/navigation/location_converter.dart

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:ferrostar_flutter/ferrostar_flutter.dart';
22
import 'package:geolocator/geolocator.dart';
3+
import 'package:maplibre_gl/maplibre_gl.dart' as ml;
34

45
UserLocation positionToUserLocation(Position p) => UserLocation(
56
lat: p.latitude,
@@ -9,3 +10,21 @@ UserLocation positionToUserLocation(Position p) => UserLocation(
910
speedMps: p.speed >= 0 ? p.speed : null,
1011
timestampMs: p.timestamp.millisecondsSinceEpoch,
1112
);
13+
14+
UserLocation maplibreToUserLocation(ml.UserLocation l) {
15+
// CLLocation course is -1 when heading is unknown (e.g. user stationary).
16+
// The Swift bridge traps on UInt16(-1.0); drop sentinel + non-finite values.
17+
final b = l.bearing;
18+
final course = (b != null && b.isFinite && b >= 0 && b <= 360) ? b : null;
19+
final s = l.speed;
20+
final speed = (s != null && s.isFinite && s >= 0) ? s : null;
21+
final acc = l.horizontalAccuracy;
22+
return UserLocation(
23+
lat: l.position.latitude,
24+
lng: l.position.longitude,
25+
horizontalAccuracyM: (acc != null && acc.isFinite && acc >= 0) ? acc : 0,
26+
courseDeg: course,
27+
speedMps: speed,
28+
timestampMs: l.timestamp.millisecondsSinceEpoch,
29+
);
30+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import 'package:ferrostar_flutter/ferrostar_flutter.dart';
2+
import 'package:flutter_riverpod/flutter_riverpod.dart';
3+
4+
/// Latest user location observed by MapLibre's CLLocationManager (the same
5+
/// source that drives the pulsing blue dot). Written from the map's
6+
/// `onUserLocationUpdated` callback; read when seeding navigation, picking a
7+
/// route origin, or recentering the camera.
8+
///
9+
/// Decoupled from `Geolocator.getLastKnownPosition()` because that reads the
10+
/// system-wide cache which can lag behind MapLibre's stream and produced the
11+
/// "new route starts 20-30m back" symptom.
12+
final userLocationProvider = StateProvider<UserLocation?>((ref) => null);

mobile/lib/screens/map_screen.dart

Lines changed: 97 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import 'package:flutter/material.dart';
88
import 'package:flutter_riverpod/flutter_riverpod.dart';
99
import 'package:geolocator/geolocator.dart';
1010
import 'package:maplibre_gl/maplibre_gl.dart' hide UserLocation;
11+
import 'package:maplibre_gl/maplibre_gl.dart' as ml show UserLocation;
1112

1213
import '../l10n/generated/app_localizations.dart';
1314
import '../models/location.dart';
1415
import '../models/route_state.dart';
16+
import '../navigation/camera_controller.dart';
1517
import '../navigation/location_converter.dart';
1618
import '../navigation/nav_constants.dart';
1719
import '../providers/brush_provider.dart';
@@ -22,6 +24,7 @@ import '../providers/location_provider.dart';
2224
import '../providers/map_bearing_provider.dart';
2325
import '../providers/rating_overlay_provider.dart';
2426
import '../providers/route_provider.dart';
27+
import '../providers/user_location_provider.dart';
2528
import '../services/brush_overlay.dart';
2629
import '../services/error_reporter.dart';
2730
import '../services/haptics.dart';
@@ -69,21 +72,9 @@ class _MapScreenState extends ConsumerState<MapScreen> {
6972
final l10n = AppLocalizations.of(context)!;
7073
final notifier = ref.read(routeControllerProvider.notifier);
7174
if (ref.read(routeControllerProvider).origin == null) {
72-
Position? pos;
73-
try {
74-
pos = await Geolocator.getLastKnownPosition() ??
75-
await Geolocator.getCurrentPosition();
76-
} catch (_) {}
75+
final origin = await _resolveCurrentOriginLocation(l10n);
7776
if (!mounted) return;
78-
notifier.setOrigin(
79-
Location(
80-
id: 'gps',
81-
name: l10n.locationCurrent,
82-
label: l10n.locationCurrent,
83-
lng: pos?.longitude ?? 13.4533,
84-
lat: pos?.latitude ?? 52.5065,
85-
),
86-
);
77+
notifier.setOrigin(origin);
8778
}
8879
notifier.setDestination(
8980
Location(
@@ -194,19 +185,9 @@ class _MapScreenState extends ConsumerState<MapScreen> {
194185
final l10n = AppLocalizations.of(context)!;
195186
final notifier = ref.read(routeControllerProvider.notifier);
196187
if (ref.read(routeControllerProvider).origin == null) {
197-
Position? pos;
198-
try {
199-
pos = await Geolocator.getLastKnownPosition() ??
200-
await Geolocator.getCurrentPosition();
201-
} catch (_) {}
188+
final origin = await _resolveCurrentOriginLocation(l10n);
202189
if (!mounted) return;
203-
notifier.setOrigin(Location(
204-
id: 'gps',
205-
name: l10n.locationCurrent,
206-
label: l10n.locationCurrent,
207-
lng: pos?.longitude ?? 13.4533,
208-
lat: pos?.latitude ?? 52.5065,
209-
));
190+
notifier.setOrigin(origin);
210191
}
211192
notifier.setDestination(Location(
212193
id: home.id,
@@ -223,14 +204,27 @@ class _MapScreenState extends ConsumerState<MapScreen> {
223204
await _homeMarker.update(controller, home);
224205
}
225206

226-
Future<void> _handleBrowseLocationUpdate(double lat, double lng) async {
207+
/// Single entry point for MapLibre's user-location callback. Caches the
208+
/// fix in userLocationProvider (used to seed nav, pick route origins, and
209+
/// fall back recenter when ferrostar hasn't snapped yet), auto-centers in
210+
/// browse mode on first fix, and — edge case — promotes the camera into
211+
/// following mode if nav started before any fix was cached.
212+
Future<void> _onUserLocationUpdated(ml.UserLocation loc) async {
213+
final uloc = maplibreToUserLocation(loc);
214+
ref.read(userLocationProvider.notifier).state = uloc;
215+
if (ref.read(navigationSessionProvider)) {
216+
final cam = ref.read(navigationCameraControllerProvider);
217+
if (cam.mode == CameraMode.awaitingFirstFix) {
218+
await _activateFollowingCamera(uloc);
219+
}
220+
return;
221+
}
227222
if (_browseAutocentered) return;
228-
if (ref.read(navigationSessionProvider)) return;
229223
final controller = _mapController;
230224
if (controller == null) return;
231225
_browseAutocentered = true;
232226
await controller.animateCamera(
233-
CameraUpdate.newLatLngZoom(LatLng(lat, lng), 16),
227+
CameraUpdate.newLatLngZoom(LatLng(uloc.lat, uloc.lng), 16),
234228
);
235229
}
236230

@@ -244,13 +238,16 @@ class _MapScreenState extends ConsumerState<MapScreen> {
244238
}
245239
debugPrint('nav: start ${origin.name} -> ${destination.name}');
246240
final service = ref.read(navigationServiceProvider);
247-
// Fetch last-known position synchronously (no GPS wait) so the controller
248-
// has an initial fix and NavigationState emits immediately.
249-
UserLocation? initial;
250-
try {
251-
final pos = await Geolocator.getLastKnownPosition();
252-
if (pos != null) initial = positionToUserLocation(pos);
253-
} catch (_) {}
241+
// Prefer the live MapLibre fix (same source as the blue dot, written by
242+
// onUserLocationUpdated). Fall back to Geolocator's cache only if MapLibre
243+
// hasn't emitted yet — its cache can lag behind by 20-30m at cycling speed.
244+
UserLocation? initial = ref.read(userLocationProvider);
245+
if (initial == null) {
246+
try {
247+
final pos = await Geolocator.getLastKnownPosition();
248+
if (pos != null) initial = positionToUserLocation(pos);
249+
} catch (_) {}
250+
}
254251
try {
255252
await service.start(
256253
origin: WaypointInput(lat: origin.lat, lng: origin.lng),
@@ -259,6 +256,12 @@ class _MapScreenState extends ConsumerState<MapScreen> {
259256
initialLocation: initial,
260257
);
261258
if (mounted) _speakNav(AppLocalizations.of(context)!.navTtsDeparting);
259+
if (initial != null) {
260+
// We already have a fix — skip awaitingFirstFix entirely. Without this
261+
// the camera waits for ferrostar to emit a `.navigating` state with
262+
// snapped_location, which won't happen until a stream tick arrives.
263+
await _activateFollowingCamera(initial);
264+
}
262265
} catch (e, st) {
263266
reportError(e, st, context: 'nav.start');
264267
}
@@ -296,11 +299,15 @@ class _MapScreenState extends ConsumerState<MapScreen> {
296299
}
297300
}
298301

299-
Future<void> _handleFirstFix(UserLocation loc) async {
300-
debugPrint('nav: first fix');
301-
AppHaptics.firstFix();
302+
/// Transitions the camera into following mode for a nav session that has a
303+
/// known starting location. Idempotent: safe to call again on the first
304+
/// onUserLocationUpdated during nav as a fallback for the no-cache edge case.
305+
Future<void> _activateFollowingCamera(UserLocation loc) async {
302306
final cam = ref.read(navigationCameraControllerProvider);
303-
cam.onFirstFix();
307+
if (cam.mode != CameraMode.awaitingFirstFix) return;
308+
debugPrint('nav: activating following camera');
309+
AppHaptics.firstFix();
310+
cam.onNavStart();
304311
final controller = _mapController;
305312
if (controller == null) return;
306313
// Enable tracking first so maplibre drives the camera target, then
@@ -340,12 +347,16 @@ class _MapScreenState extends ConsumerState<MapScreen> {
340347
Future<void> _handleRecenterTap() async {
341348
final controller = _mapController;
342349
if (controller == null) return;
350+
// Prefer ferrostar's snapped position so the camera lands on the route
351+
// line, not the raw fix. Fall back to the latest MapLibre fix when nav
352+
// hasn't produced a snapped state (e.g. before the first ferrostar tick).
343353
final snapped = ref.read(navigationStateProvider).value?.snappedLocation;
344-
if (snapped == null) return;
354+
final loc = snapped ?? ref.read(userLocationProvider);
355+
if (loc == null) return;
345356
final cam = ref.read(navigationCameraControllerProvider);
346357
cam.onRecenterTapped();
347358
await controller.animateCamera(CameraUpdate.newLatLngZoom(
348-
LatLng(snapped.lat, snapped.lng), cam.followZoom));
359+
LatLng(loc.lat, loc.lng), cam.followZoom));
349360
if (!mounted) return;
350361
await controller
351362
.updateMyLocationTrackingMode(MyLocationTrackingMode.trackingCompass);
@@ -359,11 +370,6 @@ class _MapScreenState extends ConsumerState<MapScreen> {
359370
final nextState = next.value;
360371
if (nextState == null) return;
361372

362-
if (prevState?.snappedLocation == null &&
363-
nextState.snappedLocation != null) {
364-
_handleFirstFix(nextState.snappedLocation!);
365-
}
366-
367373
if (prevState?.status != TripStatus.complete &&
368374
nextState.status == TripStatus.complete) {
369375
_handleArrival();
@@ -390,26 +396,60 @@ class _MapScreenState extends ConsumerState<MapScreen> {
390396
}
391397

392398
Future<void> _refreshPreviewFromGps() async {
393-
Position? pos;
394-
try {
395-
pos = await Geolocator.getLastKnownPosition() ??
396-
await Geolocator.getCurrentPosition();
397-
} catch (e) {
398-
debugPrint('nav: refresh-preview GPS error: $e');
399+
final cached = ref.read(userLocationProvider);
400+
double? lat = cached?.lat;
401+
double? lng = cached?.lng;
402+
if (lat == null || lng == null) {
403+
try {
404+
final pos = await Geolocator.getLastKnownPosition() ??
405+
await Geolocator.getCurrentPosition();
406+
lat = pos.latitude;
407+
lng = pos.longitude;
408+
} catch (e) {
409+
debugPrint('nav: refresh-preview GPS error: $e');
410+
}
399411
}
400-
if (!mounted || pos == null) return;
412+
if (!mounted || lat == null || lng == null) return;
401413
final l10n = AppLocalizations.of(context)!;
402414
ref.read(routeControllerProvider.notifier).setOrigin(
403415
Location(
404416
id: 'gps',
405417
name: l10n.locationCurrent,
406418
label: l10n.locationCurrent,
407-
lat: pos.latitude,
408-
lng: pos.longitude,
419+
lat: lat,
420+
lng: lng,
409421
),
410422
);
411423
}
412424

425+
/// Resolves a "current location" Location for use as a route origin. Prefers
426+
/// the cached MapLibre fix (live, written from onUserLocationUpdated), then
427+
/// Geolocator's last-known/current, and finally a Berlin-center fallback.
428+
Future<Location> _resolveCurrentOriginLocation(AppLocalizations l10n) async {
429+
final cached = ref.read(userLocationProvider);
430+
if (cached != null) {
431+
return Location(
432+
id: 'gps',
433+
name: l10n.locationCurrent,
434+
label: l10n.locationCurrent,
435+
lat: cached.lat,
436+
lng: cached.lng,
437+
);
438+
}
439+
Position? pos;
440+
try {
441+
pos = await Geolocator.getLastKnownPosition() ??
442+
await Geolocator.getCurrentPosition();
443+
} catch (_) {}
444+
return Location(
445+
id: 'gps',
446+
name: l10n.locationCurrent,
447+
label: l10n.locationCurrent,
448+
lat: pos?.latitude ?? 52.5065,
449+
lng: pos?.longitude ?? 13.4533,
450+
);
451+
}
452+
413453
Future<TapFeature?> _probeRatingFeature(LatLng coords) async {
414454
final controller = _mapController;
415455
if (controller == null) return null;
@@ -613,8 +653,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
613653
onMapCreated: (controller) {
614654
_mapController = controller;
615655
},
616-
onUserLocationUpdated: (loc) => _handleBrowseLocationUpdate(
617-
loc.position.latitude, loc.position.longitude),
656+
onUserLocationUpdated: _onUserLocationUpdated,
618657
onStyleLoadedCallback: () async {
619658
// Attach rating overlay AFTER style is loaded — MapLibre
620659
// silently ignores addGeoJsonSource / addLayer calls

0 commit comments

Comments
 (0)