Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions mobile/ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
Expand Down Expand Up @@ -600,7 +600,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
Expand Down Expand Up @@ -651,7 +651,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion mobile/lib/navigation/camera_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ class NavigationCameraController extends ChangeNotifier {
CameraMode get mode => _mode;
double get followZoom => _followZoom;

void onFirstFix() {
/// Transitions awaitingFirstFix → following. Called when nav starts and we
/// already have a cached user location, or (edge case) when the first
/// location update arrives during nav after the session began with no fix.
void onNavStart() {
if (_mode != CameraMode.awaitingFirstFix) return;
_mode = CameraMode.following;
notifyListeners();
Expand Down
19 changes: 19 additions & 0 deletions mobile/lib/navigation/location_converter.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:ferrostar_flutter/ferrostar_flutter.dart';
import 'package:geolocator/geolocator.dart';
import 'package:maplibre_gl/maplibre_gl.dart' as ml;

UserLocation positionToUserLocation(Position p) => UserLocation(
lat: p.latitude,
Expand All @@ -9,3 +10,21 @@ UserLocation positionToUserLocation(Position p) => UserLocation(
speedMps: p.speed >= 0 ? p.speed : null,
timestampMs: p.timestamp.millisecondsSinceEpoch,
);

UserLocation maplibreToUserLocation(ml.UserLocation l) {
// CLLocation course is -1 when heading is unknown (e.g. user stationary).
// The Swift bridge traps on UInt16(-1.0); drop sentinel + non-finite values.
final b = l.bearing;
final course = (b != null && b.isFinite && b >= 0 && b <= 360) ? b : null;
final s = l.speed;
final speed = (s != null && s.isFinite && s >= 0) ? s : null;
final acc = l.horizontalAccuracy;
return UserLocation(
lat: l.position.latitude,
lng: l.position.longitude,
horizontalAccuracyM: (acc != null && acc.isFinite && acc >= 0) ? acc : 0,
courseDeg: course,
speedMps: speed,
timestampMs: l.timestamp.millisecondsSinceEpoch,
);
}
12 changes: 12 additions & 0 deletions mobile/lib/providers/user_location_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import 'package:ferrostar_flutter/ferrostar_flutter.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

/// Latest user location observed by MapLibre's CLLocationManager (the same
/// source that drives the pulsing blue dot). Written from the map's
/// `onUserLocationUpdated` callback; read when seeding navigation, picking a
/// route origin, or recentering the camera.
///
/// Decoupled from `Geolocator.getLastKnownPosition()` because that reads the
/// system-wide cache which can lag behind MapLibre's stream and produced the
/// "new route starts 20-30m back" symptom.
final userLocationProvider = StateProvider<UserLocation?>((ref) => null);
155 changes: 97 additions & 58 deletions mobile/lib/screens/map_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:maplibre_gl/maplibre_gl.dart' hide UserLocation;
import 'package:maplibre_gl/maplibre_gl.dart' as ml show UserLocation;

import '../l10n/generated/app_localizations.dart';
import '../models/location.dart';
import '../models/route_state.dart';
import '../navigation/camera_controller.dart';
import '../navigation/location_converter.dart';
import '../navigation/nav_constants.dart';
import '../providers/brush_provider.dart';
Expand All @@ -22,6 +24,7 @@ import '../providers/location_provider.dart';
import '../providers/map_bearing_provider.dart';
import '../providers/rating_overlay_provider.dart';
import '../providers/route_provider.dart';
import '../providers/user_location_provider.dart';
import '../services/brush_overlay.dart';
import '../services/error_reporter.dart';
import '../services/haptics.dart';
Expand Down Expand Up @@ -69,21 +72,9 @@ class _MapScreenState extends ConsumerState<MapScreen> {
final l10n = AppLocalizations.of(context)!;
final notifier = ref.read(routeControllerProvider.notifier);
if (ref.read(routeControllerProvider).origin == null) {
Position? pos;
try {
pos = await Geolocator.getLastKnownPosition() ??
await Geolocator.getCurrentPosition();
} catch (_) {}
final origin = await _resolveCurrentOriginLocation(l10n);
if (!mounted) return;
notifier.setOrigin(
Location(
id: 'gps',
name: l10n.locationCurrent,
label: l10n.locationCurrent,
lng: pos?.longitude ?? 13.4533,
lat: pos?.latitude ?? 52.5065,
),
);
notifier.setOrigin(origin);
}
notifier.setDestination(
Location(
Expand Down Expand Up @@ -194,19 +185,9 @@ class _MapScreenState extends ConsumerState<MapScreen> {
final l10n = AppLocalizations.of(context)!;
final notifier = ref.read(routeControllerProvider.notifier);
if (ref.read(routeControllerProvider).origin == null) {
Position? pos;
try {
pos = await Geolocator.getLastKnownPosition() ??
await Geolocator.getCurrentPosition();
} catch (_) {}
final origin = await _resolveCurrentOriginLocation(l10n);
if (!mounted) return;
notifier.setOrigin(Location(
id: 'gps',
name: l10n.locationCurrent,
label: l10n.locationCurrent,
lng: pos?.longitude ?? 13.4533,
lat: pos?.latitude ?? 52.5065,
));
notifier.setOrigin(origin);
}
notifier.setDestination(Location(
id: home.id,
Expand All @@ -223,14 +204,27 @@ class _MapScreenState extends ConsumerState<MapScreen> {
await _homeMarker.update(controller, home);
}

Future<void> _handleBrowseLocationUpdate(double lat, double lng) async {
/// Single entry point for MapLibre's user-location callback. Caches the
/// fix in userLocationProvider (used to seed nav, pick route origins, and
/// fall back recenter when ferrostar hasn't snapped yet), auto-centers in
/// browse mode on first fix, and — edge case — promotes the camera into
/// following mode if nav started before any fix was cached.
Future<void> _onUserLocationUpdated(ml.UserLocation loc) async {
final uloc = maplibreToUserLocation(loc);
ref.read(userLocationProvider.notifier).state = uloc;
if (ref.read(navigationSessionProvider)) {
final cam = ref.read(navigationCameraControllerProvider);
if (cam.mode == CameraMode.awaitingFirstFix) {
await _activateFollowingCamera(uloc);
}
return;
}
if (_browseAutocentered) return;
if (ref.read(navigationSessionProvider)) return;
final controller = _mapController;
if (controller == null) return;
_browseAutocentered = true;
await controller.animateCamera(
CameraUpdate.newLatLngZoom(LatLng(lat, lng), 16),
CameraUpdate.newLatLngZoom(LatLng(uloc.lat, uloc.lng), 16),
);
}

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

Future<void> _handleFirstFix(UserLocation loc) async {
debugPrint('nav: first fix');
AppHaptics.firstFix();
/// Transitions the camera into following mode for a nav session that has a
/// known starting location. Idempotent: safe to call again on the first
/// onUserLocationUpdated during nav as a fallback for the no-cache edge case.
Future<void> _activateFollowingCamera(UserLocation loc) async {
final cam = ref.read(navigationCameraControllerProvider);
cam.onFirstFix();
if (cam.mode != CameraMode.awaitingFirstFix) return;
debugPrint('nav: activating following camera');
AppHaptics.firstFix();
cam.onNavStart();
final controller = _mapController;
if (controller == null) return;
// Enable tracking first so maplibre drives the camera target, then
Expand Down Expand Up @@ -340,12 +347,16 @@ class _MapScreenState extends ConsumerState<MapScreen> {
Future<void> _handleRecenterTap() async {
final controller = _mapController;
if (controller == null) return;
// Prefer ferrostar's snapped position so the camera lands on the route
// line, not the raw fix. Fall back to the latest MapLibre fix when nav
// hasn't produced a snapped state (e.g. before the first ferrostar tick).
final snapped = ref.read(navigationStateProvider).value?.snappedLocation;
if (snapped == null) return;
final loc = snapped ?? ref.read(userLocationProvider);
if (loc == null) return;
final cam = ref.read(navigationCameraControllerProvider);
cam.onRecenterTapped();
await controller.animateCamera(CameraUpdate.newLatLngZoom(
LatLng(snapped.lat, snapped.lng), cam.followZoom));
LatLng(loc.lat, loc.lng), cam.followZoom));
if (!mounted) return;
await controller
.updateMyLocationTrackingMode(MyLocationTrackingMode.trackingCompass);
Expand All @@ -359,11 +370,6 @@ class _MapScreenState extends ConsumerState<MapScreen> {
final nextState = next.value;
if (nextState == null) return;

if (prevState?.snappedLocation == null &&
nextState.snappedLocation != null) {
_handleFirstFix(nextState.snappedLocation!);
}

if (prevState?.status != TripStatus.complete &&
nextState.status == TripStatus.complete) {
_handleArrival();
Expand All @@ -390,26 +396,60 @@ class _MapScreenState extends ConsumerState<MapScreen> {
}

Future<void> _refreshPreviewFromGps() async {
Position? pos;
try {
pos = await Geolocator.getLastKnownPosition() ??
await Geolocator.getCurrentPosition();
} catch (e) {
debugPrint('nav: refresh-preview GPS error: $e');
final cached = ref.read(userLocationProvider);
double? lat = cached?.lat;
double? lng = cached?.lng;
if (lat == null || lng == null) {
try {
final pos = await Geolocator.getLastKnownPosition() ??
await Geolocator.getCurrentPosition();
lat = pos.latitude;
lng = pos.longitude;
} catch (e) {
debugPrint('nav: refresh-preview GPS error: $e');
}
}
if (!mounted || pos == null) return;
if (!mounted || lat == null || lng == null) return;
final l10n = AppLocalizations.of(context)!;
ref.read(routeControllerProvider.notifier).setOrigin(
Location(
id: 'gps',
name: l10n.locationCurrent,
label: l10n.locationCurrent,
lat: pos.latitude,
lng: pos.longitude,
lat: lat,
lng: lng,
),
);
}

/// Resolves a "current location" Location for use as a route origin. Prefers
/// the cached MapLibre fix (live, written from onUserLocationUpdated), then
/// Geolocator's last-known/current, and finally a Berlin-center fallback.
Future<Location> _resolveCurrentOriginLocation(AppLocalizations l10n) async {
final cached = ref.read(userLocationProvider);
if (cached != null) {
return Location(
id: 'gps',
name: l10n.locationCurrent,
label: l10n.locationCurrent,
lat: cached.lat,
lng: cached.lng,
);
}
Position? pos;
try {
pos = await Geolocator.getLastKnownPosition() ??
await Geolocator.getCurrentPosition();
} catch (_) {}
return Location(
id: 'gps',
name: l10n.locationCurrent,
label: l10n.locationCurrent,
lat: pos?.latitude ?? 52.5065,
lng: pos?.longitude ?? 13.4533,
);
}

Future<TapFeature?> _probeRatingFeature(LatLng coords) async {
final controller = _mapController;
if (controller == null) return null;
Expand Down Expand Up @@ -613,8 +653,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
onMapCreated: (controller) {
_mapController = controller;
},
onUserLocationUpdated: (loc) => _handleBrowseLocationUpdate(
loc.position.latitude, loc.position.longitude),
onUserLocationUpdated: _onUserLocationUpdated,
onStyleLoadedCallback: () async {
// Attach rating overlay AFTER style is loaded — MapLibre
// silently ignores addGeoJsonSource / addLayer calls
Expand Down
Loading
Loading