startNavigation method
Implementation
Future<void> startNavigation(
PowerMapRouteResult route, {
PowerNavigationMode mode = PowerNavigationMode.simulation,
}) async {
_currentRoute = route;
_originalRoute ??= route; // Save the very first route only
_mode = mode;
_distanceTraveled = 0.0;
_playedInstructions.clear();
_cameraBearing = null;
_vehicleBearing = null;
_smoothedGps = null;
_smoothedZoom = null; // Fix: Reset zoom state so it doesn't jump back to summary zoom
_isTracking = true; // Fix: Always start tracking when navigation begins
_isReturningToVehicle = false;
_isCameraTransitioning = false;
_nearDestSince = null;
_hasTriggeredArrival = false;
_lastSegmentIndex = 0; // D+1: Reset monotonic progress
_lastGpsUpdateTime = null; // D+2: Reset throttle
_playedDistanceAnnouncements.clear(); // D+3: Reset voice announcements
_registerLifecycleObserver();
if (route.geometry.isEmpty) {
throw ArgumentError(
'Route cannot be empty. Please ensure you have successfully fetched a route from the routing API before starting navigation.',
);
}
// Save current camera state before entering navigation
final currentCam = _controller.mapController.cameraPosition;
_savedCenter = currentCam?.target;
_savedZoom = currentCam?.zoom;
// A2 Fix: Use real GPS position for GPS mode to prevent warp to route start
LatLng startPos;
if (mode == PowerNavigationMode.gps) {
_locationTracker.startTracking(); // Immediately trigger GPS listener start
final currentGps = _locationTracker.currentLocation.value;
if (currentGps != null) {
startPos = LatLng(currentGps.latitude, currentGps.longitude);
} else {
startPos = route.geometry.first; // fallback
}
} else {
startPos = route.geometry.first;
}
// Calculate initial bearing so camera faces the route direction from the start
double initialBearing = 0.0;
if (route.geometry.length > 1) {
initialBearing = NavigationMathUtils.computeBearing(
route.geometry[0],
route.geometry[1],
);
}
// Initialize internal bearing state
_cameraBearing = initialBearing;
_vehicleBearing = initialBearing;
// Calculate initial ETA and Distance so they are not hidden on start
final initialEtaSec = route.duration > 0 ? route.duration : 0;
final initialEta = DateTime.now().add(Duration(seconds: initialEtaSec.round()));
stateNotifier.value = NavigationState.initial(
startPos,
).copyWith(
mode: _mode,
vehicleBearing: initialBearing,
totalRemainingDistance: route.distance,
distanceToNextManeuver: route.steps.isNotEmpty ? route.steps.first.distance : 0.0,
estimatedArrivalTime: initialEta,
instructionText: route.steps.isNotEmpty ? route.steps.first.text.replaceAll(RegExp(r'<[^>]*>'), ' ').trim() : '',
);
_checkAndUpdateSpeedLimit(startPos);
// In GPS mode, start listening to GPS stream immediately so hardware fixes are processed without delay
if (_mode == PowerNavigationMode.gps) {
_startGpsLoop();
}
// ── PHASE 1: Lock camera during initial fly-to transition ──
_isCameraTransitioning = true;
_isReturningToVehicle = true;
// Initial camera setup — smooth transition from route overview to navigation view
// Offset camera target forward so vehicle appears in the lower portion of screen
final offsetStartPos = NavigationMathUtils.computeDestination(
startPos, initialBearing, 70.0,
);
await _controller.mapController.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(target: offsetStartPos, zoom: 17.0, tilt: _cameraTilt, bearing: initialBearing),
),
duration: const Duration(milliseconds: 800),
);
// Hide native user location indicator to prevent overlap with navigation vehicle
await _controller.showUserLocation(false);
// Add 2D Vehicle Symbol via GeoJSON source to prevent flickering
await _ensureArrowImage();
final sourceData = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [startPos.longitude, startPos.latitude],
},
"properties": {"bearing": 0.0},
},
],
};
try {
await _controller.mapController.addGeoJsonSource(
"vehicle-source",
sourceData,
);
await _controller.mapController.addSymbolLayer(
"vehicle-source",
"vehicle-layer",
const SymbolLayerProperties(
iconImage: 'nav-arrow',
iconSize: 1.0,
iconAnchor: 'center',
iconPitchAlignment: 'map',
iconRotationAlignment: 'map',
iconRotate: ['get', 'bearing'],
iconAllowOverlap: true,
iconIgnorePlacement: true,
),
);
// Add invisible circle layer specifically to increase hit area size!
await _controller.mapController.addCircleLayer(
"vehicle-source",
"vehicle-hitbox-layer",
const CircleLayerProperties(
circleRadius: 35.0, // Large hit radius
circleColor: 'rgba(0,0,0,0)', // Fully transparent
circleOpacity: 0.01, // Prevent engine from stripping out the hit-test
circlePitchAlignment: 'map',
),
);
_vehicleSymbolAdded = true;
} catch (e) {
// In case source already exists, just update it
await _controller.mapController.setGeoJsonSource(
"vehicle-source",
sourceData,
);
_vehicleSymbolAdded = true;
}
// Register tap listener for the vehicle icon (using MapLibre's built-in tap event)
_controller.mapController.onFeatureTapped.add(_handleFeatureTapped);
// C4: Initialize traveled route layer (gray faded line)
await _initTraveledRoute(startPos);
// Render active navigation turn guidance arrows (Option A: compact white sub-polyline arrows on route line)
await _updateActiveNavigationTurnArrows(0);
// ── PHASE 2: Unlock camera — enable follow mode and start simulation loop ──
_isCameraTransitioning = false;
_isReturningToVehicle = false;
debugPrint('[NAV] Camera transition complete. Enabling follow mode.');
if (_mode == PowerNavigationMode.simulation) {
_startSimulationLoop();
}
}