import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:geolocator/geolocator.dart'; import 'package:intaleq_maps/intaleq_maps.dart'; import 'package:intl/intl.dart'; import '../../../core/constants/api_constants.dart'; import '../../../core/constants/app_colors.dart'; import '../../../core/services/car_platform_bridge.dart'; import '../../../core/services/connectivity_service.dart'; import '../../../core/services/location_service.dart'; import '../../../core/services/pip_service.dart'; import '../../../core/services/telemetry_tracking_service.dart'; import '../../../core/services/tts_service.dart'; import '../../../core/services/vehicle_icon_generator.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../../data/models/hazard_model.dart'; import '../../../data/models/route_model.dart'; import '../../../data/models/tourist_landmark_model.dart'; import '../../../data/repositories/map_saas_repository.dart'; import 'navigation_state.dart'; class NavigationCubit extends Cubit { final MapSaasRepository repository; final LocationService locationService; final TtsService ttsService; final ConnectivityService connectivityService; IntaleqMapController? mapController; StreamSubscription? _positionStreamSub; StreamSubscription? _connectivitySub; Timer? _searchDebounce; static const double _offRouteThresholdM = 50.0; // 50m rerouting threshold DateTime? _offRouteStartTime; bool _isRerouting = false; int _lastTraveledIndexInFullRoute = 0; NavigationCubit({ required this.repository, LocationService? locationService, TtsService? ttsService, ConnectivityService? connectivityService, }) : locationService = locationService ?? LocationService.instance, ttsService = ttsService ?? TtsService.instance, connectivityService = connectivityService ?? ConnectivityService.instance, super(const NavigationState()) { _init(); } // ── MOVEMENT INTERPOLATION ENGINE (30 FPS Smooth Vehicle Motion) ── Timer? _movementInterpolationTimer; LatLng? _currentDisplayPosition; double _currentDisplayHeading = 0.0; double _currentCameraBearing = 0.0; LatLng? _animStartPosition; LatLng? _animTargetPosition; double _animStartHeading = 0.0; double _animTargetHeading = 0.0; int _animCurrentStep = 0; static const int _animTotalSteps = 25; // 25 steps * 40ms = 1000ms duration static const Duration _animTickDuration = Duration(milliseconds: 40); void updateCameraBearing(double bearing) { _currentCameraBearing = bearing; if (_currentDisplayPosition != null) { _updateCarMarker(_currentDisplayPosition!, _currentDisplayHeading); } } Future _init() async { print("🚀 [NavigationCubit] Initializing NavigationCubit..."); CarPlatformBridge.ensureInitialized(); await ttsService.init(); // The tracking session begins only once the user starts navigation. TelemetryTrackingService.instance.checkPeriodicUpdates(); // Load saved vehicle & search preferences try { final prefs = await SharedPreferences.getInstance(); final savedColor = prefs.getInt('siro_vehicle_color') ?? 0xFF007AFF; final savedStyle = prefs.getString('siro_vehicle_style') ?? 'car'; final savedScale = prefs.getDouble('siro_vehicle_scale') ?? 1.8; final savedRecent = prefs.getStringList('siro_recent_searches') ?? []; emit(state.copyWith( selectedVehicleColor: savedColor, selectedVehicleStyle: savedStyle, vehicleScale: savedScale, recentSearches: savedRecent, )); } catch (e) { print("⚠️ [NavigationCubit] SharedPreferences load error: $e"); } // Check & listen to network connectivity connectivityService.initialize(); final isOnline = await connectivityService.checkConnection(); print( "🌐 [NavigationCubit] Network connectivity status: isOnline=$isOnline"); emit(state.copyWith(isOnline: isOnline)); _connectivitySub = connectivityService.onConnectivityChanged.listen((online) { print("🌐 [NavigationCubit] Connectivity changed event: online=$online"); emit(state.copyWith(isOnline: online)); }); // Default to Amman if initial position is fetching final defaultPos = const LatLng(ApiConstants.defaultLat, ApiConstants.defaultLng); emit(state.copyWith(myLocation: defaultPos, altitude: 0.0)); final position = await locationService.getCurrentPosition(); if (position != null) { final loc = LatLng(position.latitude, position.longitude); final alt = (position.altitude.isNaN || position.altitude.isInfinite) ? 0.0 : position.altitude; print( "📍 [NavigationCubit] Initial GPS location acquired: lat=${loc.latitude.toStringAsFixed(6)}, lng=${loc.longitude.toStringAsFixed(6)}, alt=${alt.toStringAsFixed(1)}m, heading=${position.heading.toStringAsFixed(1)}°"); emit(state.copyWith( myLocation: loc, altitude: alt, heading: position.heading, speed: position.speed * 3.6, )); _currentDisplayPosition = loc; _currentDisplayHeading = position.heading; _updateCarMarker(loc, position.heading); } else { print( "⚠️ [NavigationCubit] Initial GPS returned null, using default Amman center"); } _startLocationUpdates(); } bool _hasInitiallyCenteredCamera = false; bool _isMapStyleLoaded = false; bool get isMapStyleLoaded => _isMapStyleLoaded; int? _hasAnnouncedEarlyStepIndex; void onMapCreated(IntaleqMapController controller) { print( "🗺️ [NavigationCubit] onMapCreated: Native map view created, controller attached."); mapController = controller; emit(state.copyWith(status: NavigationStatus.mapReady)); } Future _animateCameraToCurrentPosition() async { LatLng? target = state.myLocation; double heading = state.heading; print( "🎥 [NavigationCubit] _animateCameraToCurrentPosition (target=$target, styleLoaded=$_isMapStyleLoaded)"); if (target == null || (target.latitude == ApiConstants.defaultLat && target.longitude == ApiConstants.defaultLng)) { final pos = await locationService.getCurrentPosition(); if (pos != null) { final loc = LatLng(pos.latitude, pos.longitude); final alt = (pos.altitude.isNaN || pos.altitude.isInfinite) ? 0.0 : pos.altitude; target = loc; heading = pos.heading; emit(state.copyWith( myLocation: loc, altitude: alt, heading: pos.heading, speed: pos.speed * 3.6, )); _currentDisplayPosition = loc; _currentDisplayHeading = pos.heading; _updateCarMarker(loc, pos.heading); } } if (target != null && mapController != null && _isMapStyleLoaded) { mapController!.animateCamera( CameraUpdate.newCameraPosition( CameraPosition( target: target, zoom: 16.5, bearing: heading, ), ), ); } } Future _registerCurrentVehicleIcon() async { if (mapController == null) return; try { final bytes = await VehicleIconGenerator.generateVehicleIconBytes( styleId: state.selectedVehicleStyle, primaryColor: Color(state.selectedVehicleColor), ); await mapController!.addImage('current_vehicle_icon', bytes); print( "🚗 [NavigationCubit] Registered dynamic current_vehicle_icon (style=${state.selectedVehicleStyle}, color=0x${state.selectedVehicleColor.toRadixString(16)}, scale=${state.vehicleScale})"); } catch (e) { print("⚠️ [NavigationCubit] Error generating vehicle icon: $e"); } } Future _loadCustomIcons() async { if (mapController == null) return; // 1. Dynamic vehicle icon (with user-selected color & model) await _registerCurrentVehicleIcon(); // 2. Fallback static car icon try { final carBytes = await rootBundle.load('assets/images/car.png'); await mapController!.addImage('car_icon', carBytes.buffer.asUint8List()); } catch (_) {} // 3. Start & Destination pins try { final startBytes = await rootBundle.load('assets/images/A.png'); await mapController! .addImage('start_icon', startBytes.buffer.asUint8List()); await mapController!.addImage( 'asset_assets_images_A_png', startBytes.buffer.asUint8List()); } catch (_) {} try { final destBytes = await rootBundle.load('assets/images/b.png'); await mapController! .addImage('dest_icon', destBytes.buffer.asUint8List()); await mapController!.addImage( 'asset_assets_images_b_png', destBytes.buffer.asUint8List()); } catch (_) {} } Future onStyleLoaded() async { print( "🎨 [NavigationCubit] onStyleLoaded: Map style rendered successfully! Registering custom icons & centering camera."); _isMapStyleLoaded = true; await _loadCustomIcons(); if (state.myLocation != null) { _updateCarMarker( _currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading); } _animateCameraToCurrentPosition(); // Apply 3D terrain exaggeration (default 2.0x) and sun lighting applySunLighting(state.sunTimeMinutes, state.terrainExaggeration); mapController?.setTilt(state.cameraTilt); } // ── 3D CINEMATIC STUDIO & ORBIT ENGINE ──────────────────────── Timer? _orbitTimer; Timer? _sunTimer; void toggleOrbit() { final nextOrbiting = !state.isOrbiting; emit(state.copyWith(isOrbiting: nextOrbiting)); _orbitTimer?.cancel(); if (nextOrbiting) { _orbitTimer = Timer.periodic(const Duration(milliseconds: 60), (_) { if (isClosed || !state.isOrbiting) return; mapController?.orbitStep( state.orbitSpeed, duration: const Duration(milliseconds: 60), ); }); } } void stopOrbit() { _orbitTimer?.cancel(); emit(state.copyWith(isOrbiting: false)); } void setOrbitSpeed(double speed) { emit(state.copyWith(orbitSpeed: speed)); } void toggleSunAutoPlay() { final nextSunPlaying = !state.isSunPlaying; emit(state.copyWith(isSunPlaying: nextSunPlaying)); _sunTimer?.cancel(); if (nextSunPlaying) { _sunTimer = Timer.periodic(const Duration(milliseconds: 130), (_) { if (isClosed || !state.isSunPlaying) return; int nextMinutes = state.sunTimeMinutes + 12; if (nextMinutes > 1260) { nextMinutes = 330; } emit(state.copyWith(sunTimeMinutes: nextMinutes)); applySunLighting(nextMinutes); }); } } void stopSunAutoPlay() { _sunTimer?.cancel(); emit(state.copyWith(isSunPlaying: false)); } void setSunTimeMinutes(int minutes) { emit(state.copyWith(sunTimeMinutes: minutes)); applySunLighting(minutes); } void setTerrainExaggeration(double value) { emit(state.copyWith(terrainExaggeration: value)); applySunLighting(state.sunTimeMinutes, value); } void setCameraTilt(double tilt) { emit(state.copyWith(cameraTilt: tilt)); mapController?.setTilt(tilt); } void toggleBuildings() { final next = !state.showBuildings; emit(state.copyWith(showBuildings: next)); mapController?.setLayerVisibility('building-3d', next); mapController?.setLayerVisibility('building-3d-osm', next); } void toggleContours() { final next = !state.showContours; emit(state.copyWith(showContours: next)); mapController?.setLayerVisibility('tactical-contour-minor', next); mapController?.setLayerVisibility('tactical-contour-major', next); mapController?.setLayerVisibility('opentopo-contour-lines', next); } void toggleHillshading() { final next = !state.showHillshading; emit(state.copyWith(showHillshading: next)); mapController?.setLayerVisibility('hillshading', next); } void applySunLighting([int? minutes, double? exaggeration]) { final m = minutes ?? state.sunTimeMinutes; final ex = exaggeration ?? state.terrainExaggeration; final double fraction = ((m - 330) / (1260 - 330)).clamp(0.0, 1.0); final double sunAzimuth = 75.0 + (fraction * 215.0); String shadowColor; String highlightColor; String buildingColor; double opacity = 0.88; if (m < 420) { shadowColor = '#4c1d95'; highlightColor = '#fed7aa'; buildingColor = '#fbcfe8'; opacity = 0.85; } else if (m < 660) { shadowColor = '#334155'; highlightColor = '#ffffff'; buildingColor = '#fef08a'; opacity = 0.88; } else if (m < 840) { shadowColor = '#1e293b'; highlightColor = '#ffffff'; buildingColor = '#f8fafc'; opacity = 0.90; } else if (m < 1050) { shadowColor = '#374151'; highlightColor = '#ffedd5'; buildingColor = '#fed7aa'; opacity = 0.88; } else if (m < 1170) { shadowColor = '#3b0764'; highlightColor = '#ffedd5'; buildingColor = '#ea580c'; opacity = 0.92; } else { shadowColor = '#020617'; highlightColor = '#60a5fa'; buildingColor = '#1e293b'; opacity = 0.80; } mapController?.updateSunSimulation( sunAzimuth: sunAzimuth, shadowColorHex: shadowColor, highlightColorHex: highlightColor, buildingColorHex: buildingColor, buildingOpacity: opacity, hillshadeExaggeration: ex, ); } // ── TOURISM & LANDMARKS 3D ─────────────────────────────────── void toggleTouristMode() { final next = !state.isTouristModeEnabled; emit(state.copyWith(isTouristModeEnabled: next)); if (next) { // Switch map to topographical/scenic view with 2.0x 3D terrain setTerrainExaggeration(2.0); setCameraTilt(55.0); if (!state.showHillshading) toggleHillshading(); } } void flyToLandmark(TouristLandmark landmark) { emit(state.copyWith( selectedLandmarkId: landmark.id, isTouristModeEnabled: true, )); // Ensure 3D terrain & hillshading are active if (!state.showHillshading) toggleHillshading(); setTerrainExaggeration(2.0); // Smoothly animate camera to the landmark in 3D mapController?.animateCamera( CameraUpdate.newCameraPosition( CameraPosition( target: landmark.coordinates, zoom: 14.5, tilt: 58.0, bearing: 45.0, ), ), // duration: const Duration(milliseconds: 1600), ); } void startOrbitAroundLandmark(TouristLandmark landmark) { flyToLandmark(landmark); if (!state.isOrbiting) { toggleOrbit(); } } void clearSelectedLandmark() { emit(state.copyWith(clearSelectedLandmark: true)); } // ── VEHICLE CUSTOMIZATION HANDLERS ────────────────────────── Future setVehicleColor(int colorValue) async { emit(state.copyWith(selectedVehicleColor: colorValue)); try { final prefs = await SharedPreferences.getInstance(); await prefs.setInt('siro_vehicle_color', colorValue); } catch (_) {} await _registerCurrentVehicleIcon(); if (state.myLocation != null) { _updateCarMarker( _currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading); } } Future setVehicleStyle(String styleId) async { emit(state.copyWith(selectedVehicleStyle: styleId)); try { final prefs = await SharedPreferences.getInstance(); await prefs.setString('siro_vehicle_style', styleId); } catch (_) {} await _registerCurrentVehicleIcon(); if (state.myLocation != null) { _updateCarMarker( _currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading); } } Future setVehicleScale(double scale) async { emit(state.copyWith(vehicleScale: scale)); try { final prefs = await SharedPreferences.getInstance(); await prefs.setDouble('siro_vehicle_scale', scale); } catch (_) {} if (state.myLocation != null) { _updateCarMarker( _currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading); } } // ── LOCATION STREAM & SMOOTH INTERPOLATION ──────────────────── void _startLocationUpdates() { _positionStreamSub?.cancel(); _positionStreamSub = locationService.getPositionStream().listen((pos) { final newLoc = LatLng(pos.latitude, pos.longitude); final speedKmH = pos.speed * 3.6; final heading = pos.heading; final alt = (pos.altitude.isNaN || pos.altitude.isInfinite) ? 0.0 : pos.altitude; _onNewLocationFix(newLoc, heading, speedKmH, alt); }); } void _onNewLocationFix( LatLng newLoc, double heading, double speedKmH, double alt) { double resolvedHeading = heading; // Calculate heading from real movement if GPS course is missing / 0.0 if (resolvedHeading <= 0.0 || resolvedHeading.isNaN) { if (_currentDisplayPosition != null) { final dist = locationService.calculateDistance(_currentDisplayPosition!, newLoc); if (dist >= 1.5) { final computed = locationService.calculateBearing(_currentDisplayPosition!, newLoc); resolvedHeading = (computed % 360.0 + 360.0) % 360.0; } else { resolvedHeading = _currentDisplayHeading; } } else { resolvedHeading = _currentDisplayHeading; } } // In active navigation, if stopped or crawling, lock heading to route segment ahead if (state.isNavigating && speedKmH < 4.0 && state.currentRoute != null) { final coords = state.currentRoute!.coordinates; if (_lastTraveledIndexInFullRoute + 1 < coords.length) { resolvedHeading = _calculateBearing( coords[_lastTraveledIndexInFullRoute], coords[_lastTraveledIndexInFullRoute + 1], ); } } emit(state.copyWith( myLocation: newLoc, altitude: alt, heading: resolvedHeading, speed: speedKmH, )); // Smooth movement interpolation for the vehicle marker and 3D camera if (_currentDisplayPosition == null) { _currentDisplayPosition = newLoc; _currentDisplayHeading = resolvedHeading; _updateCarMarker(newLoc, resolvedHeading); } else { _startMovementInterpolation(newLoc, resolvedHeading); } // Proactively move camera on first acquired GPS lock (only when style is loaded) if (!_hasInitiallyCenteredCamera && mapController != null && _isMapStyleLoaded) { _hasInitiallyCenteredCamera = true; _currentCameraBearing = resolvedHeading; mapController!.animateCamera( CameraUpdate.newCameraPosition( CameraPosition(target: newLoc, zoom: 16.5, bearing: resolvedHeading), ), ); } if (state.isNavigating) { // Navigation-only telemetry: sampled every 3 seconds and sent as a batch every 2 minutes. TelemetryTrackingService.instance.recordPosition( latitude: newLoc.latitude, longitude: newLoc.longitude, speedKmH: speedKmH, heading: resolvedHeading, elevation: alt, remainingDistance: state.remainingDistance, ); _processActiveNavigationTick(newLoc, speedKmH, resolvedHeading); } } void _startMovementInterpolation(LatLng targetPos, double targetHeading) { _movementInterpolationTimer?.cancel(); _animStartPosition = _currentDisplayPosition ?? targetPos; _animStartHeading = _currentDisplayHeading; _animTargetPosition = targetPos; _animTargetHeading = targetHeading; _animCurrentStep = 0; _movementInterpolationTimer = Timer.periodic(_animTickDuration, (timer) { _animCurrentStep++; final double t = (_animCurrentStep / _animTotalSteps).clamp(0.0, 1.0); // Smooth ease-out curve for natural vehicle movement final double curvedT = 1.0 - (1.0 - t) * (1.0 - t); final interpPos = _lerpLatLng(_animStartPosition!, _animTargetPosition!, curvedT); final interpHeading = _lerpAngle(_animStartHeading, _animTargetHeading, curvedT); _currentDisplayPosition = interpPos; _currentDisplayHeading = interpHeading; _updateCarMarker(interpPos, interpHeading); // Smoothly update Google Maps style 3D camera periodically (~every 200ms) or on completion if (state.isCameraLocked && mapController != null && _isMapStyleLoaded && state.isNavigating) { if (_animCurrentStep % 5 == 0 || _animCurrentStep == _animTotalSteps) { _updateNavigationCamera( interpPos, interpHeading, state.speed, state.distanceToNextStep); } } if (_animCurrentStep >= _animTotalSteps) { timer.cancel(); _movementInterpolationTimer = null; } }); } // ── GOOGLE MAPS NAVIGATION PERSPECTIVE & DYNAMIC ZOOM ───────── void _updateNavigationCamera( LatLng pos, double heading, double speedKmH, double distanceToStep) { if (mapController == null || !_isMapStyleLoaded || !state.isCameraLocked) return; // 1. Dynamic speed-adaptive zoom & lookahead calculation double targetZoom; double lookAheadMeters; const double targetTilt = 45.0; // Fixed 45-degree angle as requested by user if (distanceToStep > 0 && distanceToStep < 100.0) { // Approaching turn / maneuver: camera descends down close to show intersection targetZoom = 18.2; lookAheadMeters = 35.0; } else if (speedKmH > 70.0) { // High speed (highway): opens up distance ahead ("تكبر تفتح مسافة أكثر") final speedFactor = ((speedKmH - 70.0) / 50.0).clamp(0.0, 1.0); targetZoom = 16.5 - (0.7 * speedFactor); // 16.5 -> 15.8 lookAheadMeters = 70.0 + (30.0 * speedFactor); // 70m -> 100m } else if (speedKmH > 20.0) { // Moderate city speed final speedFactor = ((speedKmH - 20.0) / 50.0).clamp(0.0, 1.0); targetZoom = 17.8 - (0.8 * speedFactor); // 17.8 -> 17.0 lookAheadMeters = 40.0 + (25.0 * speedFactor); // 40m -> 65m } else { // Stopped / very slow (< 20 km/h) targetZoom = 18.0; lookAheadMeters = 35.0; } // 2. Heading stabilization (lock to route if stopped/creeping) double effectiveBearing = heading; if (speedKmH < 4.0 && state.currentRoute != null) { final coords = state.currentRoute!.coordinates; if (_lastTraveledIndexInFullRoute + 1 < coords.length) { effectiveBearing = _calculateBearing( coords[_lastTraveledIndexInFullRoute], coords[_lastTraveledIndexInFullRoute + 1], ); } } _currentCameraBearing = effectiveBearing; // 3. Shift camera target ahead along heading vector // This positions the car in the bottom ~28% of the viewport with 45° tilt! final cameraTarget = _computeOffset(pos, lookAheadMeters, effectiveBearing); mapController!.animateCamera( CameraUpdate.newCameraPosition( CameraPosition( target: cameraTarget, zoom: targetZoom, tilt: targetTilt, bearing: effectiveBearing, ), ), ); } LatLng _computeOffset( LatLng from, double distanceMeters, double bearingDegrees) { const double earthRadius = 6378137.0; // WGS-84 earth radius in meters final double dByR = distanceMeters / earthRadius; final double latRad = from.latitude * (pi / 180.0); final double lonRad = from.longitude * (pi / 180.0); final double bearingRad = bearingDegrees * (pi / 180.0); final double targetLatRad = asin( sin(latRad) * cos(dByR) + cos(latRad) * sin(dByR) * cos(bearingRad), ); final double targetLonRad = lonRad + atan2( sin(bearingRad) * sin(dByR) * cos(latRad), cos(dByR) - sin(latRad) * sin(targetLatRad), ); return LatLng(targetLatRad * (180.0 / pi), targetLonRad * (180.0 / pi)); } 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; } LatLng _lerpLatLng(LatLng a, LatLng b, double t) { return LatLng( a.latitude + (b.latitude - a.latitude) * t, a.longitude + (b.longitude - a.longitude) * t, ); } Future _updateCarMarker(LatLng position, double worldHeading) async { if (mapController == null || !_isMapStyleLoaded) return; try { // MapLibre symbols have viewport-aligned rotation. // (worldHeading - _currentCameraBearing) keeps the vehicle oriented correctly: // In active navigation with locked camera, (worldHeading - _currentCameraBearing) = 0° (facing UP/forward along the road). // During turns, it tilts smoothly into the corner as the car turns. // In free map exploration, it points towards true geographic direction relative to rotated map. final double screenRotation = (worldHeading - _currentCameraBearing + 360.0) % 360.0; await mapController!.setUserMarker(Marker( markerId: const MarkerId('current_user_car'), position: position, rotation: screenRotation, anchor: const Offset(0.5, 0.5), flat: true, icon: InlqBitmap.fromStyleImage('current_vehicle_icon', size: state.vehicleScale), zIndex: 100, )); } catch (e) { print("⚠️ [NavigationCubit] _updateCarMarker error: $e"); } } void setCameraLocked(bool locked) { emit(state.copyWith(isCameraLocked: locked)); } void relockCameraToUser() { print( "🎯 [NavigationCubit] relockCameraToUser requested (styleLoaded=$_isMapStyleLoaded, loc=${state.myLocation})"); emit(state.copyWith(isCameraLocked: true)); if (state.myLocation != null && mapController != null && _isMapStyleLoaded) { if (state.isNavigating) { _updateNavigationCamera( _currentDisplayPosition ?? state.myLocation!, _currentDisplayHeading, state.speed, state.distanceToNextStep, ); } else { _currentCameraBearing = state.heading; mapController!.animateCamera( CameraUpdate.newCameraPosition( CameraPosition( target: state.myLocation!, zoom: 16.5, tilt: 0.0, bearing: state.heading, ), ), ); } } } void setMapTheme(MapThemeType theme) { print( "🎨 [NavigationCubit] Changing map theme to: $theme (resetting _isMapStyleLoaded=false)"); _isMapStyleLoaded = false; emit(state.copyWith(mapTheme: theme)); } void toggleMute() { ttsService.toggleMute(); emit(state.copyWith(isMuted: ttsService.isMuted)); } void toggleLocationPicker() { emit(state.copyWith( isSelectingLocationOnMap: !state.isSelectingLocationOnMap, )); } // ── SEARCH & DESTINATION SELECTION ────────────────────────── void onSearchChanged(String query) { _searchDebounce?.cancel(); final trimmed = query.trim(); if (trimmed.length < 2) { emit(state.copyWith(searchResults: [], isSearching: false)); return; } emit(state.copyWith(isSearching: true)); _searchDebounce = Timer(const Duration(milliseconds: 350), () async { final results = await repository.searchPlaces( query: trimmed, userLocation: state.myLocation, ); emit(state.copyWith(searchResults: results, isSearching: false)); }); } Future searchImmediately(String query) async { _searchDebounce?.cancel(); final trimmed = query.trim(); if (trimmed.isEmpty) { emit(state.copyWith(searchResults: [], isSearching: false)); return; } emit(state.copyWith(isSearching: true)); await saveRecentSearch(trimmed); final results = await repository.searchPlaces( query: trimmed, userLocation: state.myLocation, ); emit(state.copyWith(searchResults: results, isSearching: false)); } Future saveRecentSearch(String query) async { final trimmed = query.trim(); if (trimmed.length < 2) return; final updated = List.from(state.recentSearches) ..remove(trimmed) ..insert(0, trimmed); if (updated.length > 8) updated.removeLast(); emit(state.copyWith(recentSearches: updated)); try { final prefs = await SharedPreferences.getInstance(); await prefs.setStringList('siro_recent_searches', updated); } catch (_) {} } Future clearRecentSearches() async { emit(state.copyWith(recentSearches: [])); try { final prefs = await SharedPreferences.getInstance(); await prefs.remove('siro_recent_searches'); } catch (_) {} } void clearSearch() { _searchDebounce?.cancel(); emit(state.copyWith(searchResults: [], isSearching: false)); } // ── STEP BOUNDING OVERVIEW ────────────────────────────────── void overviewStepBounding(int stepIndex) { if (state.currentRoute == null || stepIndex < 0 || stepIndex >= state.routeSteps.length) return; final step = state.routeSteps[stepIndex]; final interval = step['interval']; final coords = state.currentRoute!.coordinates; if (interval is List && interval.length >= 2) { final startIdx = (interval[0] as num).toInt().clamp(0, coords.length - 1); final endIdx = (interval[1] as num).toInt().clamp(0, coords.length - 1); if (startIdx <= endIdx) { final stepCoords = coords.sublist(startIdx, endIdx + 1); if (stepCoords.isNotEmpty) { _fitRouteInView(stepCoords); return; } } } final stepLat = (step['lat'] as num?)?.toDouble() ?? 0.0; final stepLng = (step['lng'] as num?)?.toDouble() ?? 0.0; if (stepLat != 0.0 && stepLng != 0.0 && mapController != null && _isMapStyleLoaded) { mapController!.animateCamera( CameraUpdate.newCameraPosition( CameraPosition( target: LatLng(stepLat, stepLng), zoom: 17.5, tilt: 45.0), ), ); } } Future calculateRouteTo( LatLng destination, { String title = 'وجهة مختارة', String originTitle = 'موقعي الحالي', }) async { print( "🛣️ [NavigationCubit] calculateRouteTo: origin=$originTitle, target=$destination, title=$title, myLocation=${state.myLocation}"); if (state.myLocation == null) { print( "⚠️ [NavigationCubit] Cannot calculate route: current GPS location is null!"); return; } emit(state.copyWith( status: NavigationStatus.loading, destination: destination, destinationTitle: title, originTitle: originTitle, searchResults: [], )); try { print("🌐 [NavigationCubit] Requesting route from repository..."); final routes = await repository.getRoute( origin: state.myLocation!, destination: destination, ); if (routes.isEmpty) { print("❌ [NavigationCubit] Repository returned 0 routes!"); emit(state.copyWith( status: NavigationStatus.error, errorMessage: 'تعذر حساب المسار إلى الوجهة المحددة.', )); return; } final primaryRoute = routes.first; print( "✅ [NavigationCubit] Route calculated successfully: ${routes.length} routes found, primary: dist=${primaryRoute.formattedDistance}, dur=${primaryRoute.formattedDuration}, coordsCount=${primaryRoute.coordinates.length}"); final polylines = _createPolylines(routes, 0); // Origin Pin A final startMarker = Marker( markerId: const MarkerId('origin_pin'), position: state.myLocation!, icon: InlqBitmap.fromStyleImage('start_icon'), anchor: const Offset(0.5, 1.0), infoWindow: InfoWindow(title: originTitle, snippet: 'start'), zIndex: 90, ); // Destination Pin B final destMarker = Marker( markerId: const MarkerId('dest_pin'), position: destination, icon: InlqBitmap.fromStyleImage('dest_icon'), anchor: const Offset(0.5, 1.0), infoWindow: InfoWindow(title: title, snippet: 'end'), zIndex: 90, ); final updatedMarkers = Set.from(state.markers) ..removeWhere((m) => m.markerId.value == 'origin_pin' || m.markerId.value == 'dest_pin') ..add(startMarker) ..add(destMarker); emit(state.copyWith( status: NavigationStatus.routePreview, routes: routes, selectedRouteIndex: 0, destination: destination, destinationTitle: title, originTitle: originTitle, routeSteps: primaryRoute.steps, remainingDistance: primaryRoute.distanceM, remainingDuration: primaryRoute.durationS, polylines: polylines, markers: updatedMarkers, arrivalTime: _calculateArrivalTime(primaryRoute.durationS), )); if (mapController != null && _isMapStyleLoaded) { await mapController!.addMarker(startMarker); await mapController!.addMarker(destMarker); print( "📍 [NavigationCubit] Directly added startMarker & destMarker to native map engine."); } _fitRouteInView(primaryRoute.coordinates); } on RouteException catch (e) { print("❌ [NavigationCubit] RouteException: ${e.message}"); emit(state.copyWith( status: NavigationStatus.error, errorMessage: e.message, )); } catch (e) { print("❌ [NavigationCubit] Error calculating route: $e"); emit(state.copyWith( status: NavigationStatus.error, errorMessage: 'حدث خطأ غير متوقع أثناء استدعاء خدمة التوجيه.', )); } } void selectRoute(int index) { if (index < 0 || index >= state.routes.length) return; final route = state.routes[index]; final polylines = _createPolylines(state.routes, index); emit(state.copyWith( selectedRouteIndex: index, routeSteps: route.steps, remainingDistance: route.distanceM, remainingDuration: route.durationS, polylines: polylines, arrivalTime: _calculateArrivalTime(route.durationS), )); _fitRouteInView(route.coordinates); } // ── TURN-BY-TURN NAVIGATION LIFECYCLE ──────────────────────── void startNavigation() { print("🧭 [NavigationCubit] startNavigation triggered!"); if (state.currentRoute == null) { print( "⚠️ [NavigationCubit] startNavigation aborted: currentRoute is null"); return; } final route = state.currentRoute!; TelemetryTrackingService.instance.initialize(); final steps = route.steps; print( "🧭 [NavigationCubit] Starting navigation along route: ${route.formattedDistance}, ETA ${route.formattedDuration}, ${steps.length} steps"); _lastTraveledIndexInFullRoute = 0; _offRouteStartTime = null; final firstInstruction = steps.isNotEmpty ? (steps[0]['text']?.toString() ?? 'انطلق نحو الوجهة') : 'انطلق نحو الوجهة'; final nextInst = steps.length > 1 ? (steps[1]['text']?.toString() ?? '') : ''; final initialModifier = steps.isNotEmpty ? (steps[0]['sign'] ?? steps[0]['modifier'] ?? 0) : 0; emit(state.copyWith( status: NavigationStatus.navigating, currentStepIndex: 0, currentInstruction: firstInstruction, nextInstruction: nextInst, currentManeuverModifier: initialModifier, isCameraLocked: true, polylines: _createPolylines(state.routes, state.selectedRouteIndex, traveledIndex: 0), )); ttsService.speak(firstInstruction); // Immediate 3D Heading-Up camera orientation with 45° tilt and lookahead offset if (state.myLocation != null && mapController != null && _isMapStyleLoaded) { double bearing = state.heading; if (bearing == 0.0 && route.coordinates.length > 1) { bearing = _calculateBearing(route.coordinates[0], route.coordinates[1]); } _currentDisplayPosition = state.myLocation; _currentDisplayHeading = bearing; _updateNavigationCamera(state.myLocation!, bearing, state.speed, 100.0); } CarPlatformBridge.updateNavState( lat: state.myLocation!.latitude, lng: state.myLocation!.longitude, bearing: state.heading, speed: state.speed, instruction: firstInstruction, distanceToStep: 100, totalDistance: route.distanceM, eta: route.durationS, maneuver: initialModifier, isNavigating: true, ); PipService.instance.setNavigating(true); } void stopNavigation() { print("🛑 [NavigationCubit] stopNavigation triggered."); // Stop the two-minute timer and send the final in-memory batch. TelemetryTrackingService.instance.dispose(repository: repository); _movementInterpolationTimer?.cancel(); _movementInterpolationTimer = null; ttsService.stop(); CarPlatformBridge.stopNavigation(); PipService.instance.setNavigating(false); _lastTraveledIndexInFullRoute = 0; _offRouteStartTime = null; _hasAnnouncedEarlyStepIndex = null; final remainingMarkers = Set.from(state.markers) ..removeWhere((m) => m.markerId.value == 'origin_pin' || m.markerId.value == 'dest_pin'); if (mapController != null && _isMapStyleLoaded) { mapController!.removeMarker(const MarkerId('origin_pin')); mapController!.removeMarker(const MarkerId('dest_pin')); } emit(state.copyWith( status: NavigationStatus.mapReady, routes: [], routeSteps: [], polylines: {}, markers: remainingMarkers, destination: null, destinationTitle: '', originTitle: 'موقعي الحالي', currentInstruction: '', nextInstruction: '', isCameraLocked: true, )); if (state.myLocation != null && mapController != null && _isMapStyleLoaded) { mapController!.animateCamera( CameraUpdate.newCameraPosition( CameraPosition(target: state.myLocation!, zoom: 16.5, tilt: 0), ), ); } } void simulateLocationTick(LatLng pos, {double speed = 60.0, double heading = 0.0, double altitude = 0.0}) { _onNewLocationFix(pos, heading, speed, altitude); } void _processActiveNavigationTick( LatLng pos, double speedKmH, double heading) { if (state.currentRoute == null || state.routeSteps.isEmpty) return; final route = state.currentRoute!; final coords = route.coordinates; // 1. Check destination arrival final dest = state.destination; if (dest != null) { final distToFinal = locationService.calculateDistance(pos, dest); if (distToFinal < 25.0) { ttsService.speak('لقد وصلت إلى وجهتك.'); emit(state.copyWith(status: NavigationStatus.arrived)); CarPlatformBridge.stopNavigation(); return; } } // 2. Turn-by-Turn step progression final steps = state.routeSteps; int stepIdx = state.currentStepIndex; if (stepIdx < steps.length) { final step = steps[stepIdx]; LatLng? stepTarget; final stepLat = (step['lat'] as num?)?.toDouble() ?? 0.0; final stepLng = (step['lng'] as num?)?.toDouble() ?? 0.0; if (stepLat != 0.0 && stepLng != 0.0) { stepTarget = LatLng(stepLat, stepLng); } else { final interval = step['interval']; if (interval is List && interval.length >= 2) { final endIdx = (interval[1] as num).toInt(); if (endIdx >= 0 && endIdx < coords.length) { stepTarget = coords[endIdx]; } } } if (stepTarget != null) { final distToStep = locationService.calculateDistance(pos, stepTarget); emit(state.copyWith(distanceToNextStep: distToStep)); // Advance voice announcement when approaching turn (150m - 200m) if (distToStep <= 180.0 && distToStep > 50.0 && _hasAnnouncedEarlyStepIndex != stepIdx) { _hasAnnouncedEarlyStepIndex = stepIdx; final roundDist = ((distToStep / 50).round() * 50).clamp(50, 200); final text = step['text']?.toString() ?? ''; if (text.isNotEmpty) { ttsService.speak('بعد $roundDist متراً، $text'); } } final stepEndIdx = (step['interval'] is List && (step['interval'] as List).length >= 2) ? ((step['interval'] as List)[1] as num).toInt() : -1; final hasPassedStep = stepEndIdx > 0 && _lastTraveledIndexInFullRoute >= stepEndIdx; if ((distToStep < 35.0 || hasPassedStep) && stepIdx + 1 < steps.length) { stepIdx++; _hasAnnouncedEarlyStepIndex = null; final nextStep = steps[stepIdx]; final text = nextStep['text']?.toString() ?? ''; final upcomingText = stepIdx + 1 < steps.length ? (steps[stepIdx + 1]['text']?.toString() ?? '') : ''; final modifier = nextStep['sign'] ?? nextStep['modifier'] ?? 0; emit(state.copyWith( currentStepIndex: stepIdx, currentInstruction: text, nextInstruction: upcomingText, currentManeuverModifier: modifier, )); ttsService.speak(text); CarPlatformBridge.updateNavState( lat: pos.latitude, lng: pos.longitude, bearing: heading, speed: speedKmH, instruction: text, distanceToStep: distToStep, totalDistance: state.remainingDistance, eta: state.remainingDuration, maneuver: modifier, isNavigating: true, ); } } } // 3. Mathematical map matching & deviation check with opposite lane rejection _checkOffRoute(pos, heading, speedKmH); } void _checkOffRoute(LatLng pos, double heading, double speedKmH) { if (state.currentRoute == null || _isRerouting) return; final coords = state.currentRoute!.coordinates; if (coords.length < 2) return; // Search window constrained around vehicle's last known route progress final int startWindow = (_lastTraveledIndexInFullRoute - 3).clamp(0, coords.length - 2); final int endWindow = (_lastTraveledIndexInFullRoute + 45).clamp(0, coords.length - 1); double minDistance = double.infinity; int closestSegmentIndex = _lastTraveledIndexInFullRoute; for (int i = startWindow; i < endWindow; i++) { final p1 = coords[i]; final p2 = coords[i + 1]; final distToSeg = _distanceToSegment(pos, p1, p2); final segBearing = _calculateBearing(p1, p2); // OSM 4-6m Opposite Lane Filter: // Dual carriageways in Jordan/MENA are separated by 4-8m. // If the driver is moving (> 8 km/h), compute angle difference with the segment. // If angle delta > 85° (driving opposite to the segment), add heavy penalty (120m) // to guarantee we NEVER snap onto the oncoming opposite lane! double effectiveDist = distToSeg; if (speedKmH > 8.0) { final angleDiff = ((heading - segBearing + 540) % 360) - 180; if (angleDiff.abs() > 85.0) { effectiveDist += 120.0; // Penalty: opposite direction carriageway } } if (effectiveDist < minDistance) { minDistance = effectiveDist; closestSegmentIndex = i; } } // Check against 50-meter threshold as required by user if (minDistance > _offRouteThresholdM) { _offRouteStartTime ??= DateTime.now(); // Sustain deviation for 4 seconds before rerouting to prevent GPS jitter loops if (DateTime.now().difference(_offRouteStartTime!).inSeconds >= 4) { _recalculateRouteDueToDeviation(pos); } } else { _offRouteStartTime = null; // Vehicle is progressing on the route! if (closestSegmentIndex > _lastTraveledIndexInFullRoute) { _lastTraveledIndexInFullRoute = closestSegmentIndex; // Progressively recalculate remaining distance & duration and update traveled line _updateRemainingRouteMetrics(coords, closestSegmentIndex); } } } void _updateRemainingRouteMetrics(List coords, int fromIndex) { double remainingM = 0; for (int i = fromIndex; i < coords.length - 1; i++) { remainingM += locationService.calculateDistance(coords[i], coords[i + 1]); } final speedMps = (state.speed > 10 ? state.speed : 40.0) / 3.6; final remainingSec = remainingM / speedMps; final updatedPolylines = _createPolylines( state.routes, state.selectedRouteIndex, traveledIndex: fromIndex, ); emit(state.copyWith( remainingDistance: remainingM, remainingDuration: remainingSec, arrivalTime: _calculateArrivalTime(remainingSec), polylines: updatedPolylines, )); } Future _recalculateRouteDueToDeviation(LatLng pos) async { if (_isRerouting || state.destination == null) return; _isRerouting = true; _offRouteStartTime = null; ttsService.speak('إعادة حساب المسار...'); try { final routes = await repository.getRoute( origin: pos, destination: state.destination!, ); if (routes.isNotEmpty) { final newRoute = routes.first; _lastTraveledIndexInFullRoute = 0; final polylines = _createPolylines(routes, 0); emit(state.copyWith( routes: routes, selectedRouteIndex: 0, routeSteps: newRoute.steps, currentStepIndex: 0, remainingDistance: newRoute.distanceM, remainingDuration: newRoute.durationS, polylines: polylines, arrivalTime: _calculateArrivalTime(newRoute.durationS), currentInstruction: newRoute.steps.isNotEmpty ? (newRoute.steps[0]['text']?.toString() ?? 'تابع السير') : 'تابع السير', )); } } catch (_) {} _isRerouting = false; } // ── USER SUBMISSIONS: PLACES & HAZARDS (WITH PIN PICKER) ───── void startLocationPicking(String mode) { final targetLoc = mapController?.cameraPosition?.target ?? state.myLocation ?? const LatLng(31.9539, 35.9106); final pickMarker = Marker( markerId: const MarkerId('picker_pin'), position: targetLoc, icon: InlqBitmap.fromStyleImage('dest_icon'), anchor: const Offset(0.5, 1.0), infoWindow: InfoWindow( title: mode == 'place' ? '📍 موقع المنشأة المحددة' : '⚠️ موقع البلاغ المحدد', ), zIndex: 95, ); final updatedMarkers = Set.from(state.markers) ..removeWhere((m) => m.markerId.value == 'picker_pin') ..add(pickMarker); emit(state.copyWith( isSelectingLocationOnMap: true, activePickerMode: mode, pickedLocation: targetLoc, markers: updatedMarkers, )); if (mapController != null && _isMapStyleLoaded) { mapController!.addMarker(pickMarker); } } void updatePickedLocation(LatLng newLoc) { if (!state.isSelectingLocationOnMap) return; final pickMarker = Marker( markerId: const MarkerId('picker_pin'), position: newLoc, icon: InlqBitmap.fromStyleImage('dest_icon'), anchor: const Offset(0.5, 1.0), infoWindow: InfoWindow( title: state.activePickerMode == 'place' ? '📍 موقع المنشأة المحددة' : '⚠️ موقع البلاغ المحدد', ), zIndex: 95, ); final updatedMarkers = Set.from(state.markers) ..removeWhere((m) => m.markerId.value == 'picker_pin') ..add(pickMarker); emit(state.copyWith( pickedLocation: newLoc, markers: updatedMarkers, )); if (mapController != null && _isMapStyleLoaded) { mapController!.addMarker(pickMarker); } } void cancelLocationPicking() { final remainingMarkers = Set.from(state.markers) ..removeWhere((m) => m.markerId.value == 'picker_pin'); emit(state.copyWith( isSelectingLocationOnMap: false, clearActivePickerMode: true, clearPickedLocation: true, markers: remainingMarkers, )); if (mapController != null && _isMapStyleLoaded) { mapController!.removeMarker(const MarkerId('picker_pin')); } } Future submitPlace(String name, String category, {LatLng? position}) async { final targetPos = position ?? state.pickedLocation ?? mapController?.cameraPosition?.target ?? state.myLocation; if (targetPos == null) return false; final success = await repository.submitNewPlace( name: name, category: category, position: targetPos, altitude: state.altitude, ); if (success) { cancelLocationPicking(); } return success; } Future reportHazard({ required String type, required String title, required String description, LatLng? position, }) async { final targetPos = position ?? state.pickedLocation ?? state.myLocation; if (targetPos == null) return false; final hazard = HazardModel( type: type, title: title, description: description, latitude: targetPos.latitude, longitude: targetPos.longitude, altitude: state.altitude, createdAt: DateTime.now(), ); final success = await repository.reportHazard(hazard); if (success) { cancelLocationPicking(); } return success; } // ── HELPERS ────────────────────────────────────────────────── double _calculateBearing(LatLng from, LatLng to) { final lat1 = from.latitude * (pi / 180.0); final lon1 = from.longitude * (pi / 180.0); final lat2 = to.latitude * (pi / 180.0); final lon2 = to.longitude * (pi / 180.0); final dLon = lon2 - lon1; final y = sin(dLon) * cos(lat2); final x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon); final radians = atan2(y, x); return (radians * (180.0 / pi) + 360.0) % 360.0; } double _distanceToSegment(LatLng p, LatLng a, LatLng b) { final double midLatRad = (a.latitude + b.latitude) * 0.5 * (pi / 180.0); final double cosMid = cos(midLatRad); final double dx = (b.longitude - a.longitude) * cosMid; final double dy = b.latitude - a.latitude; final double segLenSquared = dx * dx + dy * dy; if (segLenSquared <= 1e-12) { return locationService.calculateDistance(p, a); } final double px = (p.longitude - a.longitude) * cosMid; final double py = p.latitude - a.latitude; final double t = ((px * dx + py * dy) / segLenSquared).clamp(0.0, 1.0); final double projLat = a.latitude + t * (b.latitude - a.latitude); final double projLng = a.longitude + t * (b.longitude - a.longitude); return locationService.calculateDistance(p, LatLng(projLat, projLng)); } Set _createPolylines(List routes, int activeIndex, {int traveledIndex = 0}) { final Set set = {}; // 1. Render alternative routes first for (int i = 0; i < routes.length; i++) { if (i == activeIndex) continue; set.add(Polyline( polylineId: PolylineId('route_$i'), points: routes[i].coordinates, color: const Color(0xFF90A4AE).withValues(alpha: 0.6), width: 5, )); } // 2. Render selected active route if (activeIndex >= 0 && activeIndex < routes.length) { final activeCoords = routes[activeIndex].coordinates; if (traveledIndex > 0 && traveledIndex < activeCoords.length) { // Traveled portion (muted gray) set.add(Polyline( polylineId: const PolylineId('route_traveled'), points: activeCoords.sublist(0, traveledIndex + 1), color: const Color(0xFF90A4AE).withValues(alpha: 0.5), width: 6, )); // Remaining portion (active Apple blue) set.add(Polyline( polylineId: const PolylineId('route_remaining'), points: activeCoords.sublist(traveledIndex), color: AppColors.appleBlue, width: 7, )); } else { set.add(Polyline( polylineId: PolylineId('route_$activeIndex'), points: activeCoords, color: AppColors.appleBlue, width: 7, )); } } return set; } void _fitRouteInView(List coords) { if (coords.isEmpty || mapController == null || !_isMapStyleLoaded) { print( "⚠️ [NavigationCubit] _fitRouteInView deferred/skipped (coordsCount=${coords.length}, mapController=${mapController != null}, styleLoaded=$_isMapStyleLoaded)"); return; } double minLat = coords.first.latitude; double maxLat = coords.first.latitude; double minLng = coords.first.longitude; double maxLng = coords.first.longitude; for (var c in coords) { if (c.latitude < minLat) minLat = c.latitude; if (c.latitude > maxLat) maxLat = c.latitude; if (c.longitude < minLng) minLng = c.longitude; if (c.longitude > maxLng) maxLng = c.longitude; } print( "🎬 [NavigationCubit] _fitRouteInView: bounds SW=($minLat, $minLng), NE=($maxLat, $maxLng)"); mapController!.animateCamera( CameraUpdate.newLatLngBounds( LatLngBounds( southwest: LatLng(minLat, minLng), northeast: LatLng(maxLat, maxLng), ), left: 50, right: 50, top: 100, bottom: 220, ), ); } String _calculateArrivalTime(double durationSeconds) { final arrival = DateTime.now().add(Duration(seconds: durationSeconds.round())); return DateFormat('hh:mm a').format(arrival); } @override Future close() { _orbitTimer?.cancel(); _sunTimer?.cancel(); _movementInterpolationTimer?.cancel(); _positionStreamSub?.cancel(); _connectivitySub?.cancel(); _searchDebounce?.cancel(); ttsService.stop(); TelemetryTrackingService.instance.dispose(repository: repository); return super.close(); } }