2026-04-06 redesign splash screen and drawer menu
This commit is contained in:
@@ -34,6 +34,12 @@ class NavigationController extends GetxController {
|
||||
/// Minimum metres the device must move between general location ticks.
|
||||
static const double _minMoveToProcess = 2.0;
|
||||
|
||||
/// Metres off-route before the auto-recalculate countdown starts.
|
||||
static const double _offRouteThresholdM = 25.0;
|
||||
|
||||
/// Seconds the user must remain off-route before auto-recalculate fires.
|
||||
static const int _offRouteTriggerSeconds = 6;
|
||||
|
||||
// ==========================================================================
|
||||
// ── Map state ─────────────────────────────────────────────────────────────
|
||||
// ==========================================================================
|
||||
@@ -46,6 +52,11 @@ class NavigationController extends GetxController {
|
||||
|
||||
LatLng? myLocation;
|
||||
double heading = 0.0;
|
||||
|
||||
/// Smoothed heading used for the car icon and camera bearing.
|
||||
/// Updated every tick via angle-aware lerp to eliminate snap/jitter.
|
||||
double _smoothedHeading = 0.0;
|
||||
|
||||
double currentSpeed = 0.0; // km/h
|
||||
double totalDistance = 0.0; // metres accumulated this session
|
||||
|
||||
@@ -85,6 +96,19 @@ class NavigationController extends GetxController {
|
||||
// Camera
|
||||
bool isNavigating = false;
|
||||
bool _cameraLockedToUser = true;
|
||||
bool _mapReady = false; // true only after layout has settled
|
||||
|
||||
// ==========================================================================
|
||||
// ── Off-route auto-recalculate ────────────────────────────────────────────
|
||||
// ==========================================================================
|
||||
|
||||
/// Wall-clock time when the user first went more than [_offRouteThresholdM]
|
||||
/// metres away from the nearest route point. Null means on-route.
|
||||
DateTime? _offRouteStartTime;
|
||||
|
||||
/// True while an auto-recalculate triggered from off-route detection is in
|
||||
/// progress — prevents a second trigger from firing.
|
||||
bool _autoRecalcInProgress = false;
|
||||
|
||||
// ==========================================================================
|
||||
// ── Batch location tracking ───────────────────────────────────────────────
|
||||
@@ -167,14 +191,22 @@ class NavigationController extends GetxController {
|
||||
isStyleLoaded = true;
|
||||
await _loadCustomIcons();
|
||||
|
||||
if (myLocation != null) {
|
||||
animateCameraToPosition(myLocation!);
|
||||
_updateCarMarker();
|
||||
}
|
||||
|
||||
if (_fullRouteCoordinates.isNotEmpty) {
|
||||
_updatePolylinesSets([], _fullRouteCoordinates);
|
||||
}
|
||||
// Wait one full frame for the native MapLibre view to finish layout.
|
||||
// Without this, ANY animateCamera call throws std::domain_error on iOS
|
||||
// because the view still has zero pixel dimensions at this point.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
if (!_mapReady) {
|
||||
_mapReady = true;
|
||||
if (myLocation != null) {
|
||||
animateCameraToPosition(myLocation!);
|
||||
_updateCarMarker();
|
||||
}
|
||||
if (_fullRouteCoordinates.isNotEmpty) {
|
||||
_updatePolylinesSets([], _fullRouteCoordinates);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> onMapLongPressed(Point<double> point, LatLng tappedPoint) async {
|
||||
@@ -217,6 +249,7 @@ class NavigationController extends GetxController {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high);
|
||||
myLocation = LatLng(position.latitude, position.longitude);
|
||||
_smoothedHeading = position.heading; // seed so first lerp is instant
|
||||
update();
|
||||
if (isStyleLoaded) animateCameraToPosition(myLocation!);
|
||||
_startLocationTimer();
|
||||
@@ -264,6 +297,13 @@ class NavigationController extends GetxController {
|
||||
myLocation = newLoc;
|
||||
_lastProcessedLocation = newLoc;
|
||||
heading = position.heading;
|
||||
|
||||
// ── Smooth the heading with an angle-aware exponential lerp ──────────
|
||||
// Factor 0.25 means ~75 % of the old angle is kept each tick, giving a
|
||||
// ~4-tick (≈4 s) settling time — smooth enough to look fluid on screen
|
||||
// while still reacting quickly to real turns.
|
||||
_smoothedHeading = _lerpAngle(_smoothedHeading, heading, 0.25);
|
||||
|
||||
currentSpeed = position.speed * 3.6;
|
||||
|
||||
if (isStyleLoaded) _updateCarMarker();
|
||||
@@ -271,17 +311,86 @@ class NavigationController extends GetxController {
|
||||
if (_fullRouteCoordinates.isNotEmpty) {
|
||||
if (_cameraLockedToUser) {
|
||||
animateCameraToPosition(myLocation!,
|
||||
bearing: heading, zoom: _targetZoom, tilt: _targetTilt);
|
||||
bearing: _smoothedHeading, zoom: _targetZoom, tilt: _targetTilt);
|
||||
}
|
||||
_updateTraveledPolylineSmart(myLocation!);
|
||||
_checkNavigationStep(myLocation!);
|
||||
_recomputeETA();
|
||||
|
||||
// ── Off-route auto-recalculate ─────────────────────────────────────
|
||||
_checkOffRoute(myLocation!);
|
||||
}
|
||||
|
||||
update();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ── Heading utilities ─────────────────────────────────────────────────────
|
||||
// ==========================================================================
|
||||
|
||||
/// Lerps from [from] to [to] by factor [t], correctly handling the 0/360
|
||||
/// wrap-around so we never spin the wrong way (e.g. 350° → 10° goes +20°,
|
||||
/// not −340°).
|
||||
double _lerpAngle(double from, double to, double t) {
|
||||
final double diff = ((to - from + 540.0) % 360.0) - 180.0;
|
||||
return (from + diff * t + 360.0) % 360.0;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ── Off-route detection ───────────────────────────────────────────────────
|
||||
// ==========================================================================
|
||||
|
||||
/// Called every tick while navigating. Measures the distance from [pos] to
|
||||
/// the nearest upcoming route coordinate. If the driver stays more than
|
||||
/// [_offRouteThresholdM] metres away for at least [_offRouteTriggerSeconds]
|
||||
/// seconds, an automatic route recalculation is triggered.
|
||||
void _checkOffRoute(LatLng pos) {
|
||||
if (_autoRecalcInProgress || isLoading) return;
|
||||
if (_fullRouteCoordinates.isEmpty) return;
|
||||
|
||||
// Search a window ahead of the last tracked index for the nearest point.
|
||||
const int searchWindow = 80;
|
||||
final int start = _lastTraveledIndexInFullRoute;
|
||||
final int end = min(start + searchWindow, _fullRouteCoordinates.length);
|
||||
|
||||
double minDist = double.infinity;
|
||||
for (int i = start; i < end; i++) {
|
||||
final d = Geolocator.distanceBetween(
|
||||
pos.latitude,
|
||||
pos.longitude,
|
||||
_fullRouteCoordinates[i].latitude,
|
||||
_fullRouteCoordinates[i].longitude,
|
||||
);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
|
||||
if (minDist > _offRouteThresholdM) {
|
||||
// Driver is off the route.
|
||||
if (_offRouteStartTime == null) {
|
||||
_offRouteStartTime = DateTime.now();
|
||||
Log.print('⚠️ Off-route detected (${minDist.toStringAsFixed(0)} m). '
|
||||
'Countdown started.');
|
||||
} else {
|
||||
final elapsed =
|
||||
DateTime.now().difference(_offRouteStartTime!).inSeconds;
|
||||
if (elapsed >= _offRouteTriggerSeconds) {
|
||||
Log.print('🔄 Auto-recalculate triggered after ${elapsed}s '
|
||||
'off-route (${minDist.toStringAsFixed(0)} m).');
|
||||
_offRouteStartTime = null;
|
||||
_autoRecalcInProgress = true;
|
||||
recalculateRoute().then((_) => _autoRecalcInProgress = false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Back on (or close enough to) the route — reset the clock.
|
||||
if (_offRouteStartTime != null) {
|
||||
Log.print('✅ Back on route — off-route timer reset.');
|
||||
}
|
||||
_offRouteStartTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ── Batch tracking: record every 3 s, upload every 2 min ─────────────────
|
||||
// ==========================================================================
|
||||
@@ -389,12 +498,15 @@ class NavigationController extends GetxController {
|
||||
geometry: myLocation,
|
||||
iconImage: 'car_icon',
|
||||
iconSize: 1.0,
|
||||
iconRotate: heading,
|
||||
iconRotate: _smoothedHeading, // ← use smoothed heading
|
||||
));
|
||||
} else {
|
||||
mapController!.updateSymbol(
|
||||
carSymbol!,
|
||||
SymbolOptions(geometry: myLocation, iconRotate: heading),
|
||||
SymbolOptions(
|
||||
geometry: myLocation,
|
||||
iconRotate: _smoothedHeading, // ← use smoothed heading
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -405,7 +517,10 @@ class NavigationController extends GetxController {
|
||||
|
||||
void animateCameraToPosition(LatLng position,
|
||||
{double? zoom, double bearing = 0.0, double tilt = 0.0}) {
|
||||
mapController?.animateCamera(
|
||||
// Guard: skip if the native view is not ready yet
|
||||
if (!_mapReady || mapController == null) return;
|
||||
|
||||
mapController!.animateCamera(
|
||||
CameraUpdate.newCameraPosition(
|
||||
CameraPosition(
|
||||
target: position,
|
||||
@@ -426,7 +541,9 @@ class NavigationController extends GetxController {
|
||||
_cameraLockedToUser = true;
|
||||
if (myLocation != null) {
|
||||
animateCameraToPosition(myLocation!,
|
||||
bearing: heading, zoom: _targetZoom, tilt: _targetTilt);
|
||||
bearing: _smoothedHeading, // ← use smoothed heading
|
||||
zoom: _targetZoom,
|
||||
tilt: _targetTilt);
|
||||
}
|
||||
update();
|
||||
}
|
||||
@@ -561,6 +678,9 @@ class NavigationController extends GetxController {
|
||||
isNavigating = true;
|
||||
_cameraLockedToUser = true;
|
||||
|
||||
// Reset off-route state after a successful recalculation
|
||||
_offRouteStartTime = null;
|
||||
|
||||
if (routeSteps.isNotEmpty) {
|
||||
currentInstruction = routeSteps[0]['instruction_text'];
|
||||
nextInstruction = routeSteps.length > 1
|
||||
@@ -569,10 +689,20 @@ class NavigationController extends GetxController {
|
||||
Get.find<TextToSpeechController>().speakText(currentInstruction);
|
||||
}
|
||||
|
||||
if (_fullRouteCoordinates.isNotEmpty) {
|
||||
if (_fullRouteCoordinates.length >= 2) {
|
||||
final bounds = _boundsFromLatLngList(_fullRouteCoordinates);
|
||||
mapController?.animateCamera(CameraUpdate.newLatLngBounds(bounds,
|
||||
bottom: 220, top: 150, left: 50, right: 50));
|
||||
|
||||
final latDiff =
|
||||
(bounds.northeast.latitude - bounds.southwest.latitude).abs();
|
||||
final lngDiff =
|
||||
(bounds.northeast.longitude - bounds.southwest.longitude).abs();
|
||||
|
||||
if (latDiff > 0.0001 || lngDiff > 0.0001) {
|
||||
mapController?.animateCamera(CameraUpdate.newLatLngBounds(bounds,
|
||||
bottom: 220, top: 150, left: 50, right: 50));
|
||||
} else {
|
||||
animateCameraToPosition(_fullRouteCoordinates.first, zoom: 15.0);
|
||||
}
|
||||
}
|
||||
|
||||
update();
|
||||
@@ -638,6 +768,10 @@ class NavigationController extends GetxController {
|
||||
}
|
||||
|
||||
Future<void> clearRoute({bool isNewRoute = false}) async {
|
||||
// Reset off-route state whenever the route is cleared
|
||||
_offRouteStartTime = null;
|
||||
_autoRecalcInProgress = false;
|
||||
|
||||
if (!isNewRoute) {
|
||||
if (destinationSymbol != null && mapController != null) {
|
||||
await mapController!.removeSymbol(destinationSymbol!);
|
||||
|
||||
Reference in New Issue
Block a user