import 'dart:async'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_tts/flutter_tts.dart'; import 'package:intl/intl.dart'; import '../location_service.dart'; import '../map/map_provider_interface.dart'; import '../routes/route_models.dart'; import '../routes/route_provider.dart'; import '../routes/route_providers.dart'; /// Navigation session status lifecycle. enum NavigationStatus { idle, navigating, paused, rerouting, offRoute, arrived, } /// State of an active turn-by-turn navigation session. @immutable class NavigationSessionState { final NavigationStatus status; final MotorSignalRoute? route; final ResolvedPlace? destination; final RouteCalculationResult? calculationResult; final List maneuvers; final int currentManeuverIndex; final double distanceToNextManeuverMeters; final double remainingDistanceMeters; final int remainingDurationSeconds; final bool ttsEnabled; final String? lastAnnouncedInstruction; final RoutingOptions routingOptions; final DateTime? startedAt; const NavigationSessionState({ this.status = NavigationStatus.idle, this.route, this.destination, this.calculationResult, this.maneuvers = const [], this.currentManeuverIndex = 0, this.distanceToNextManeuverMeters = 0.0, this.remainingDistanceMeters = 0.0, this.remainingDurationSeconds = 0, this.ttsEnabled = true, this.lastAnnouncedInstruction, this.routingOptions = const RoutingOptions(), this.startedAt, }); bool get isNavigating => status == NavigationStatus.navigating || status == NavigationStatus.rerouting; bool get isOffRoute => status == NavigationStatus.offRoute; bool get hasActiveRoute => maneuvers.isNotEmpty; RouteManeuver? get currentManeuver => currentManeuverIndex < maneuvers.length ? maneuvers[currentManeuverIndex] : null; RouteManeuver? get nextManeuver => (currentManeuverIndex + 1) < maneuvers.length ? maneuvers[currentManeuverIndex + 1] : null; double get distanceToNextMiles => distanceToNextManeuverMeters * 0.000621371; double get remainingDistanceMiles => remainingDistanceMeters * 0.000621371; String get formattedDistanceToNext { if (distanceToNextMiles < 0.1) { final feet = (distanceToNextManeuverMeters * 3.28084).round(); return '$feet ft'; } return '${distanceToNextMiles.toStringAsFixed(1)} mi'; } String get formattedRemainingDistance { return '${remainingDistanceMiles.toStringAsFixed(1)} mi'; } String get formattedEta { if (remainingDurationSeconds <= 0) return '--:--'; final arrival = DateTime.now().add(Duration(seconds: remainingDurationSeconds)); return DateFormat('h:mm a').format(arrival); } String get formattedDurationRemaining { if (remainingDurationSeconds <= 0) return '0 min'; final totalSec = remainingDurationSeconds; final hours = totalSec ~/ 3600; final minutes = (totalSec % 3600) ~/ 60; if (hours > 0) { return '${hours}h ${minutes}m'; } return '${minutes.clamp(1, 60)} min'; } NavigationSessionState copyWith({ NavigationStatus? status, MotorSignalRoute? route, ResolvedPlace? destination, RouteCalculationResult? calculationResult, List? maneuvers, int? currentManeuverIndex, double? distanceToNextManeuverMeters, double? remainingDistanceMeters, int? remainingDurationSeconds, bool? ttsEnabled, String? lastAnnouncedInstruction, RoutingOptions? routingOptions, DateTime? startedAt, }) { return NavigationSessionState( status: status ?? this.status, route: route ?? this.route, destination: destination ?? this.destination, calculationResult: calculationResult ?? this.calculationResult, maneuvers: maneuvers ?? this.maneuvers, currentManeuverIndex: currentManeuverIndex ?? this.currentManeuverIndex, distanceToNextManeuverMeters: distanceToNextManeuverMeters ?? this.distanceToNextManeuverMeters, remainingDistanceMeters: remainingDistanceMeters ?? this.remainingDistanceMeters, remainingDurationSeconds: remainingDurationSeconds ?? this.remainingDurationSeconds, ttsEnabled: ttsEnabled ?? this.ttsEnabled, lastAnnouncedInstruction: lastAnnouncedInstruction ?? this.lastAnnouncedInstruction, routingOptions: routingOptions ?? this.routingOptions, startedAt: startedAt ?? this.startedAt, ); } } /// Controller managing live motorcycle turn-by-turn navigation session. class NavigationSessionNotifier extends StateNotifier { final FlutterTts? _tts; final RouteProvider? routeProvider; bool _ttsInitialized = false; bool _isRecalculating = false; DateTime? _lastRecalculationAttempt; int _lastMatchedSegment = 0; static const double defaultOffRouteThresholdMeters = 65.0; NavigationSessionNotifier({ FlutterTts? tts, this.routeProvider, }) : _tts = tts ?? _createTtsSafe(), super(const NavigationSessionState()) { _initTts(); } static FlutterTts? _createTtsSafe() { try { return FlutterTts(); } catch (_) { return null; } } bool get isNavigating => state.isNavigating; Future _initTts() async { if (_tts == null) return; try { await _tts.setLanguage('en-US'); await _tts.setSpeechRate(0.5); // Clear motorcycle cadence await _tts.setVolume(1.0); await _tts.setPitch(1.0); try { await _tts.setIosAudioCategory( IosTextToSpeechAudioCategory.playback, [ IosTextToSpeechAudioCategoryOptions.allowBluetooth, IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP, IosTextToSpeechAudioCategoryOptions.duckOthers, ], IosTextToSpeechAudioMode.voicePrompt, ); } catch (_) {} _ttsInitialized = true; } catch (_) { _ttsInitialized = false; } } /// Begins turn-by-turn guidance along a calculated route. Future startNavigation({ required RouteCalculationResult calculationResult, ResolvedPlace? destination, MotorSignalRoute? route, RoutingOptions? options, }) async { final maneuvers = calculationResult.maneuvers; if (maneuvers.isEmpty) { return; } _lastMatchedSegment = 0; final initialDist = calculationResult.estimatedDistanceMeters ?? calculationResult.straightLineDistanceMeters; final initialDuration = calculationResult.estimatedDurationSeconds ?? (initialDist / 15.0).round(); // Default ~33 mph assumption if null final firstManeuverDist = maneuvers.first.distanceMeters > 0 ? maneuvers.first.distanceMeters : (maneuvers.length > 1 ? maneuvers[1].distanceMeters : initialDist); state = NavigationSessionState( status: NavigationStatus.navigating, route: route, destination: destination, calculationResult: calculationResult, maneuvers: maneuvers, currentManeuverIndex: 0, distanceToNextManeuverMeters: firstManeuverDist, remainingDistanceMeters: initialDist, remainingDurationSeconds: initialDuration, ttsEnabled: state.ttsEnabled, routingOptions: options ?? const RoutingOptions(), startedAt: DateTime.now(), ); // Speak initial maneuver final firstInstruction = maneuvers.first.instruction; _speakInstruction(firstInstruction); } /// Halts and clears active navigation. Future stopNavigation() async { if (_tts != null && _ttsInitialized) { try { await _tts.stop(); } catch (_) {} } state = const NavigationSessionState(); } /// Pauses turn-by-turn tracking (e.g. during a rest stop). void pauseNavigation() { if (state.isNavigating) { state = state.copyWith(status: NavigationStatus.paused); } } /// Resumes paused navigation. void resumeNavigation() { if (state.status == NavigationStatus.paused) { state = state.copyWith(status: NavigationStatus.navigating); final current = state.currentManeuver; if (current != null) { _speakInstruction('Resuming route. ${current.instruction}'); } } } /// Toggles spoken voice guidance. Future toggleTts() async { final newEnabled = !state.ttsEnabled; state = state.copyWith(ttsEnabled: newEnabled); if (!newEnabled && _tts != null && _ttsInitialized) { try { await _tts.stop(); } catch (_) {} } } /// Updates navigation progress against rider's current location and speed. void updateLocation(AppLatLng riderPos, {double speedMph = 0.0}) { if (!state.isNavigating || state.maneuvers.isEmpty) return; final points = state.calculationResult?.points ?? []; if (points.isNotEmpty) { final proj = _projectPointOntoPolyline( riderPos, points, startSearchSegment: math.max(0, _lastMatchedSegment - 2), ); // Off-route detection check against polyline if (proj.distanceToRouteMeters > defaultOffRouteThresholdMeters) { if (!_isRecalculating) { final now = DateTime.now(); if (_lastRecalculationAttempt == null || now.difference(_lastRecalculationAttempt!) > const Duration(seconds: 8)) { _lastRecalculationAttempt = now; _triggerReroute(riderPos); } } return; } _lastMatchedSegment = proj.segmentIndex; // Check arrival at final destination stop final lastManeuver = state.maneuvers.last; final distToDest = _haversineMeters(riderPos, lastManeuver.location); if (distToDest <= 25.0 && (proj.segmentIndex >= points.length - 2 || state.currentManeuverIndex >= state.maneuvers.length - 1)) { _completeArrival(); return; } // Remaining distance strictly counts down along route polyline final remainingMeters = _remainingDistanceAlongPolyline( points, proj.segmentIndex, proj.projectedPoint, ); // Maneuver advancement based on polyline progression final offsets = _getManeuverOffsets(state.maneuvers, points); int currIdx = state.currentManeuverIndex; // If at step 0 (Depart), advance only once rider has genuinely departed (moving > 3.0 mph or past start point) if (currIdx == 0 && state.maneuvers.first.type == ManeuverType.depart && state.maneuvers.length > 1) { final distFromStart = _haversineMeters(riderPos, state.maneuvers.first.location); if (speedMph > 3.0 || distFromStart > 35.0 || proj.segmentIndex > 0) { currIdx = 1; } } // Advance through completed subsequent maneuvers along the route (step 1+) while (currIdx > 0 && currIdx < state.maneuvers.length - 1) { final targetOffset = offsets[currIdx]; final targetLoc = state.maneuvers[currIdx].location; final distToTarget = _haversineMeters(riderPos, targetLoc); // Advance if rider reached within 25m of maneuver point or passed its polyline index if (distToTarget <= 25.0 || (proj.segmentIndex >= targetOffset && targetOffset > 0)) { currIdx++; } else { break; } } // Announce updated maneuver if advanced if (currIdx != state.currentManeuverIndex && currIdx < state.maneuvers.length) { _speakInstruction(state.maneuvers[currIdx].instruction); } // Distance to next active maneuver along polyline final targetOffset = offsets[currIdx]; final distToNext = _distanceToOffsetAlongPolyline( points, proj.segmentIndex, proj.projectedPoint, targetOffset, targetLocation: state.maneuvers[currIdx].location, ); final effectiveSpeedMps = speedMph > 5.0 ? (speedMph * 0.44704) : 13.4; final remainingSec = (remainingMeters / effectiveSpeedMps).round(); state = state.copyWith( currentManeuverIndex: currIdx, distanceToNextManeuverMeters: distToNext, remainingDistanceMeters: remainingMeters, remainingDurationSeconds: remainingSec, ); return; } // Fallback when polyline points are not available final currIdx = state.currentManeuverIndex; if (currIdx >= state.maneuvers.length) { _completeArrival(); return; } final currentManeuver = state.maneuvers[currIdx]; final distToManeuver = _haversineMeters(riderPos, currentManeuver.location); if (distToManeuver <= 25.0 && currIdx < state.maneuvers.length - 1) { final nextIdx = currIdx + 1; final nextManeuver = state.maneuvers[nextIdx]; final distToNext = _haversineMeters(riderPos, nextManeuver.location); state = state.copyWith( currentManeuverIndex: nextIdx, distanceToNextManeuverMeters: distToNext, ); _speakInstruction(nextManeuver.instruction); return; } double remainingMeters = distToManeuver; for (int i = currIdx + 1; i < state.maneuvers.length; i++) { remainingMeters += state.maneuvers[i].distanceMeters; } final effectiveSpeedMps = speedMph > 5.0 ? (speedMph * 0.44704) : 13.4; final remainingSec = (remainingMeters / effectiveSpeedMps).round(); state = state.copyWith( distanceToNextManeuverMeters: distToManeuver, remainingDistanceMeters: remainingMeters, remainingDurationSeconds: remainingSec, ); } /// Triggers real route recalculation from rider's current position to destination. Future _triggerReroute(AppLatLng riderPos) async { if (_isRecalculating) return; _isRecalculating = true; state = state.copyWith(status: NavigationStatus.rerouting); _speakInstruction('Rerouting. Recalculating route.'); try { final destStop = state.route?.stops.isNotEmpty == true ? state.route!.stops.last : (state.destination != null ? RouteStop( id: 'dest', order: 1, displayName: state.destination!.displayName, latitude: state.destination!.latitude, longitude: state.destination!.longitude, ) : null); final provider = routeProvider; if (destStop == null || provider == null) { state = state.copyWith(status: NavigationStatus.offRoute); _isRecalculating = false; return; } final originStop = RouteStop( id: 'reroute_origin', order: 0, displayName: 'Current Location', latitude: riderPos.latitude, longitude: riderPos.longitude, ); final newResult = await provider.calculateRoute( [originStop, destStop], options: state.routingOptions, ); if (newResult.maneuvers.isEmpty) { state = state.copyWith(status: NavigationStatus.offRoute); _isRecalculating = false; return; } final initialDist = newResult.estimatedDistanceMeters ?? newResult.straightLineDistanceMeters; final initialDuration = newResult.estimatedDurationSeconds ?? (initialDist / 15.0).round(); _lastMatchedSegment = 0; state = state.copyWith( status: NavigationStatus.navigating, calculationResult: newResult, maneuvers: newResult.maneuvers, currentManeuverIndex: 0, distanceToNextManeuverMeters: newResult.maneuvers.first.distanceMeters, remainingDistanceMeters: initialDist, remainingDurationSeconds: initialDuration, ); _isRecalculating = false; _speakInstruction(newResult.maneuvers.first.instruction); } catch (_) { state = state.copyWith(status: NavigationStatus.offRoute); _isRecalculating = false; } } void _completeArrival() { state = state.copyWith( status: NavigationStatus.arrived, remainingDistanceMeters: 0.0, remainingDurationSeconds: 0, distanceToNextManeuverMeters: 0.0, ); _speakInstruction('You have arrived at your destination.'); } Future _speakInstruction(String instruction) async { if (!state.ttsEnabled || _tts == null || !_ttsInitialized) return; if (state.lastAnnouncedInstruction == instruction) return; state = state.copyWith(lastAnnouncedInstruction: instruction); try { await _tts.speak(instruction, focus: true); } catch (_) {} } /// Projects a GPS point onto the route polyline with segment index tracking. static _PolylineProjection _projectPointOntoPolyline( AppLatLng point, List polyline, { int startSearchSegment = 0, }) { if (polyline.isEmpty) { return _PolylineProjection( segmentIndex: 0, projectedPoint: point, distanceToRouteMeters: 0.0, ); } if (polyline.length == 1) { return _PolylineProjection( segmentIndex: 0, projectedPoint: polyline.first, distanceToRouteMeters: _haversineMeters(point, polyline.first), ); } int bestSegment = 0; AppLatLng bestProj = polyline.first; double minDistance = double.infinity; final searchStart = startSearchSegment.clamp(0, polyline.length - 2); for (int i = searchStart; i < polyline.length - 1; i++) { final a = polyline[i]; final b = polyline[i + 1]; final proj = _projectPointOntoSegment(point, a, b); final dist = _haversineMeters(point, proj); if (dist < minDistance) { minDistance = dist; bestSegment = i; bestProj = proj; } } // If searching forward yielded an unacceptable match (> threshold) // and search started midway, search earlier segments in case rider turned around if (minDistance > defaultOffRouteThresholdMeters && searchStart > 0) { for (int i = 0; i < searchStart; i++) { final a = polyline[i]; final b = polyline[i + 1]; final proj = _projectPointOntoSegment(point, a, b); final dist = _haversineMeters(point, proj); if (dist < minDistance) { minDistance = dist; bestSegment = i; bestProj = proj; } } } return _PolylineProjection( segmentIndex: bestSegment, projectedPoint: bestProj, distanceToRouteMeters: minDistance, ); } static AppLatLng _projectPointOntoSegment(AppLatLng p, AppLatLng a, AppLatLng b) { final dAB = _haversineMeters(a, b); if (dAB == 0.0) return a; final latRad = a.latitude * (math.pi / 180.0); final cosLat = math.cos(latRad); final dx = (b.longitude - a.longitude) * cosLat; final dy = b.latitude - a.latitude; final px = (p.longitude - a.longitude) * cosLat; final py = p.latitude - a.latitude; final lenSq = dx * dx + dy * dy; if (lenSq == 0.0) return a; final t = ((px * dx + py * dy) / lenSq).clamp(0.0, 1.0); return AppLatLng( a.latitude + t * (b.latitude - a.latitude), a.longitude + t * (b.longitude - a.longitude), ); } /// Calculates strictly decreasing remaining distance along the polyline. static double _remainingDistanceAlongPolyline( List polyline, int segmentIndex, AppLatLng projectedPoint, ) { if (polyline.isEmpty) return 0.0; if (polyline.length == 1) return 0.0; if (segmentIndex >= polyline.length - 1) { return _haversineMeters(projectedPoint, polyline.last); } double remaining = _haversineMeters(projectedPoint, polyline[segmentIndex + 1]); for (int i = segmentIndex + 1; i < polyline.length - 1; i++) { remaining += _haversineMeters(polyline[i], polyline[i + 1]); } return remaining; } /// Calculates distance along polyline from projected point to a maneuver offset. static double _distanceToOffsetAlongPolyline( List polyline, int segmentIndex, AppLatLng projectedPoint, int targetOffset, { required AppLatLng targetLocation, }) { if (polyline.isEmpty) return _haversineMeters(projectedPoint, targetLocation); if (segmentIndex >= targetOffset) { return _haversineMeters(projectedPoint, targetLocation); } double dist = _haversineMeters(projectedPoint, polyline[segmentIndex + 1]); final limit = math.min(targetOffset, polyline.length - 1); for (int i = segmentIndex + 1; i < limit; i++) { dist += _haversineMeters(polyline[i], polyline[i + 1]); } return dist; } /// Resolves monotonically increasing polyline indices for each maneuver. static List _getManeuverOffsets( List maneuvers, List points, ) { final offsets = []; for (int i = 0; i < maneuvers.length; i++) { final m = maneuvers[i]; if (m.geometryOffset != null && m.geometryOffset! >= 0 && m.geometryOffset! < points.length) { offsets.add(m.geometryOffset!); } else { int bestIdx = 0; double bestDist = double.infinity; for (int j = 0; j < points.length; j++) { final d = _haversineMeters(points[j], m.location); if (d < bestDist) { bestDist = d; bestIdx = j; } } offsets.add(bestIdx); } } for (int i = 1; i < offsets.length; i++) { if (offsets[i] < offsets[i - 1]) { offsets[i] = offsets[i - 1]; } } return offsets; } static double _haversineMeters(AppLatLng p1, AppLatLng p2) { const r = 6371000.0; // Earth radius in meters final dLat = (p2.latitude - p1.latitude) * (math.pi / 180.0); final dLon = (p2.longitude - p1.longitude) * (math.pi / 180.0); final lat1 = p1.latitude * (math.pi / 180.0); final lat2 = p2.latitude * (math.pi / 180.0); final a = math.sin(dLat / 2) * math.sin(dLat / 2) + math.sin(dLon / 2) * math.sin(dLon / 2) * math.cos(lat1) * math.cos(lat2); final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); return r * c; } } class _PolylineProjection { final int segmentIndex; final AppLatLng projectedPoint; final double distanceToRouteMeters; const _PolylineProjection({ required this.segmentIndex, required this.projectedPoint, required this.distanceToRouteMeters, }); } /// Global shared navigation session provider across Map and Dashboard. final navigationSessionProvider = StateNotifierProvider( (ref) { final routeProvider = ref.watch(activeRouteCalculationProvider); final notifier = NavigationSessionNotifier(routeProvider: routeProvider); // Connect live location updates when navigating ref.listen(locationStreamProvider, (_, next) { final pos = next.value; if (pos != null && notifier.isNavigating) { notifier.updateLocation( AppLatLng(pos.latitude, pos.longitude), speedMph: pos.speed * 2.23694, ); } }); return notifier; }, );