import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:siro_rider/views/widgets/error_snakbar.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:geolocator/geolocator.dart'; import 'package:get/get.dart'; import 'package:intaleq_maps/intaleq_maps.dart'; import 'package:http/http.dart' as http; import '../../../constant/box_name.dart'; import '../../../constant/links.dart'; import '../../../controller/functions/crud.dart'; import '../../../controller/functions/tts.dart'; import '../../../controller/home/decode_polyline_isolate.dart'; import '../../../env/env.dart'; import '../../../main.dart'; import '../../../print.dart'; import '../../../services/offline_map_service.dart'; class RouteData { final List coordinates; final List> steps; final double distanceM; final double durationS; final String points; RouteData({ required this.coordinates, required this.steps, required this.distanceM, required this.durationS, required this.points, }); } enum ManeuverSign { straight(0), slightRight(3), right(2), sharpRight(1), slightLeft(-3), left(-2), sharpLeft(-1), keepRight(7), keepLeft(-7), arrive(4), roundabout(6), unknown(0); final int value; const ManeuverSign(this.value); static ManeuverSign fromValue(dynamic v) { return ManeuverSign.values.firstWhere( (e) => e.value == v, orElse: () => ManeuverSign.unknown, ); } IconData get icon { switch (this) { case ManeuverSign.arrive: return Icons.place_rounded; case ManeuverSign.roundabout: return Icons.roundabout_right_rounded; case ManeuverSign.right: case ManeuverSign.keepRight: return Icons.turn_right_rounded; case ManeuverSign.slightRight: return Icons.turn_slight_right_rounded; case ManeuverSign.left: case ManeuverSign.keepLeft: return Icons.turn_left_rounded; case ManeuverSign.slightLeft: return Icons.turn_slight_left_rounded; case ManeuverSign.straight: case ManeuverSign.unknown: return Icons.straight_rounded; case ManeuverSign.sharpRight: return Icons.turn_sharp_right_rounded; case ManeuverSign.sharpLeft: return Icons.turn_sharp_left_rounded; } } } class NavigationController extends GetxController with GetSingleTickerProviderStateMixin { static const Duration _recordInterval = Duration(seconds: 4); static const Duration _uploadInterval = Duration(minutes: 2); static const double _minMoveToRecord = 10.0; static const double _minMoveToProcess = 2.0; static const double _offRouteThresholdM = 25.0; static const int _offRouteTriggerSeconds = 6; bool isLoading = false; IntaleqMapController? mapController; bool isStyleLoaded = false; final TextEditingController placeDestinationController = TextEditingController(); LatLng? myLocation; double _oldHeading = 0.0; double _targetHeading = 0.0; double _smoothedHeading = 0.0; AnimationController? _animController; LatLng? _oldLoc; LatLng? _targetLoc; double currentSpeed = 0.0; double totalDistance = 0.0; Set markers = {}; Set polylines = {}; Set circles = {}; Set polygons = {}; StreamSubscription? _locationStreamSubscription; LatLng? _lastProcessedLocation; List placesDestination = []; Timer? _debounce; // Alternative route handling bool _hasAlternativeRoutes = false; DateTime? _lastAutoRerouteTime; LatLng? _finalDestination; LatLng? _intermediateStop; List> routeSteps = []; List _fullRouteCoordinates = []; int _lastTraveledIndexInFullRoute = 0; bool _nextInstructionSpoken = false; String currentInstruction = ""; String nextInstruction = ""; int currentStepIndex = 0; String distanceToNextStep = ""; String totalDistanceRemaining = ""; String estimatedTimeRemaining = ""; ManeuverSign currentManeuverModifier = ManeuverSign.straight; String arrivalTime = "--:--"; double _routeTotalDistanceM = 0; double _routeTotalDurationS = 0; bool isNavigating = false; bool isMuted = false; // Sound toggle state String distanceWithUnit = ""; bool _cameraLockedToUser = true; bool _mapReady = false; bool isSelectingPlaceLocation = false; void togglePlaceSelectionMode() { isSelectingPlaceLocation = !isSelectingPlaceLocation; update(); } Future submitNewPlace(String name, String category) async { if (mapController == null || name.isEmpty || category.isEmpty) return; // Get current center of the map as the picked location final LatLng pickedPos = mapController!.cameraPosition!.target; isLoading = true; update(); final String country = box.read(BoxName.countryCode) == 'SY' ? 'syria' : 'jordan'; final Map payload = { 'name': name, 'category': category, 'lat': pickedPos.latitude, 'lng': pickedPos.longitude, 'country': country, }; try { final response = await CRUD().postMapSaas( link: AppLink.mapSaasPlaces, payload: payload, ); isLoading = false; if (response != null) { HapticFeedback.lightImpact(); mySnackbarSuccess(box.read(BoxName.lang) == 'ar' ? 'تمت إضافة المكان بنجاح! شكراً لمساهمتك.' : 'Place added successfully! Thanks for your contribution.'); isSelectingPlaceLocation = false; } else { mySnackbarWarning(box.read(BoxName.lang) == 'ar' ? 'تعذر إضافة المكان. يرجى المحاولة لاحقاً.' : 'Failed to add place. Please try again later.'); } update(); } catch (e) { isLoading = false; mySnackbarWarning(box.read(BoxName.lang) == 'ar' ? 'حدث خطأ أثناء الاتصال بالخادم.' : 'An error occurred while connecting to the server.'); update(); } } DateTime? _offRouteStartTime; bool _autoRecalcInProgress = false; final List> _trackBuffer = []; Timer? _recordTimer; Timer? _uploadBatchTimer; LatLng? _lastBufferedLocation; DateTime? _lastBufferedTime; LatLng? _lastDistanceLocation; List routes = []; int selectedRouteIndex = 0; List> recentLocations = []; double get _targetZoom { if (currentSpeed < 15) return 19.0; if (currentSpeed < 40) return 18.0; if (currentSpeed < 70) return 17.0; if (currentSpeed < 100) return 16.0; return 15.0; } double get _targetTilt { if (currentSpeed < 10) return 0.0; if (currentSpeed < 40) return 40.0; return 55.0; } // Categories list for the picker static final List> placeCategories = [ { 'id': 'restaurant', 'en': 'Restaurant', 'ar': 'مطعم', 'icon': 'restaurant' }, {'id': 'cafe', 'en': 'Cafe', 'ar': 'مقهى', 'icon': 'coffee'}, { 'id': 'supermarket', 'en': 'Supermarket', 'ar': 'سوبر ماركت', 'icon': 'shopping_basket' }, { 'id': 'pharmacy', 'en': 'Pharmacy', 'ar': 'صيدلية', 'icon': 'local_pharmacy' }, { 'id': 'gas_station', 'en': 'Gas Station', 'ar': 'محطة وقود', 'icon': 'local_gas_station' }, {'id': 'atm', 'en': 'ATM', 'ar': 'صراف آلي', 'icon': 'atm'}, {'id': 'bank', 'en': 'Bank', 'ar': 'بنك', 'icon': 'account_balance'}, {'id': 'mosque', 'en': 'Mosque', 'ar': 'مسجد', 'icon': 'mosque'}, { 'id': 'hospital', 'en': 'Hospital', 'ar': 'مستشفى', 'icon': 'local_hospital' }, {'id': 'school', 'en': 'School', 'ar': 'مدرسة', 'icon': 'school'}, { 'id': 'university', 'en': 'University', 'ar': 'جامعة', 'icon': 'account_balance' }, {'id': 'park', 'en': 'Park', 'ar': 'منتزه', 'icon': 'park'}, {'id': 'hotel', 'en': 'Hotel', 'ar': 'فندق', 'icon': 'hotel'}, { 'id': 'mall', 'en': 'Shopping Mall', 'ar': 'مركز تسوق', 'icon': 'shopping_mall' }, {'id': 'gym', 'en': 'Gym', 'ar': 'نادي رياضي', 'icon': 'fitness_center'}, { 'id': 'salon', 'en': 'Beauty Salon', 'ar': 'صالون تجميل', 'icon': 'content_cut' }, {'id': 'bakery', 'en': 'Bakery', 'ar': 'مخبز', 'icon': 'bakery_dining'}, { 'id': 'laundry', 'ar': 'مصبغة', 'en': 'Laundry', 'icon': 'local_laundry_service' }, { 'id': 'car_repair', 'en': 'Car Repair', 'ar': 'تصليح سيارات', 'icon': 'build' }, { 'id': 'government', 'en': 'Government Office', 'ar': 'دائرة حكومية', 'icon': 'gavel' }, ]; IconData get currentManeuverIcon => currentManeuverModifier.icon; void toggleMute() { isMuted = !isMuted; update(); } @override void onInit() { super.onInit(); _animController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1000)); _animController!.addListener(() { if (_oldLoc != null && _targetLoc != null && _mapReady) { final t = _animController!.value; final lat = lerpDouble(_oldLoc!.latitude, _targetLoc!.latitude, t)!; final lng = lerpDouble(_oldLoc!.longitude, _targetLoc!.longitude, t)!; myLocation = LatLng(lat, lng); _smoothedHeading = _lerpAngle(_oldHeading, _targetHeading, t); if (isStyleLoaded) { _updateCarMarker(); if (_cameraLockedToUser) { animateCameraToPosition(myLocation!, bearing: _smoothedHeading, zoom: isNavigating ? _targetZoom : 17.0, tilt: isNavigating ? _targetTilt : 0.0); } } } }); _initialize(); } Future _initialize() async { _loadRecentLocations(); await _getCurrentLocationAndStartUpdates(); } void _loadRecentLocations() { final dynamic stored = box.read(BoxName.recentLocations); if (stored != null) { try { List parsed; if (stored is String) { parsed = jsonDecode(stored); } else if (stored is List) { parsed = stored; } else { parsed = []; } recentLocations = parsed .map((e) => Map.from(e)) .toList() .reversed // Most recent first .take(3) .toList(); } catch (e) { Log.print("Error decoding recent locations: $e"); recentLocations = []; } } else { recentLocations = []; } update(); } @override void onClose() { _locationStreamSubscription?.cancel(); _recordTimer?.cancel(); _uploadBatchTimer?.cancel(); _debounce?.cancel(); _animController?.dispose(); mapController = null; placeDestinationController.dispose(); _flushBufferToServer(); super.onClose(); } void onMapCreated(IntaleqMapController controller) async { Log.print("DEBUG: NavigationController.onMapCreated called"); mapController = controller; } Future onStyleLoaded() async { Log.print("DEBUG: NavigationController.onStyleLoaded called"); isStyleLoaded = true; await _loadCustomIcons(); WidgetsBinding.instance.addPostFrameCallback((_) async { await Future.delayed(const Duration(milliseconds: 300)); if (!_mapReady) { Log.print("DEBUG: NavigationController setting _mapReady = true"); _mapReady = true; if (myLocation != null) { Log.print("DEBUG: Animating camera to initial location: $myLocation"); animateCameraToPosition(myLocation!); _updateCarMarker(); } if (_fullRouteCoordinates.isNotEmpty) { Log.print("DEBUG: Updating initial polylines"); _updatePolylinesSets([], _fullRouteCoordinates); } } }); } void onMapTapped(Point point, LatLng tappedPoint) { if (isNavigating || routes.isEmpty) return; int? bestIndex; double minDistance = 100.0; // 100 meters threshold for tap for (int i = 0; i < routes.length; i++) { for (var coord in routes[i].coordinates) { final dist = Geolocator.distanceBetween( tappedPoint.latitude, tappedPoint.longitude, coord.latitude, coord.longitude, ); if (dist < minDistance) { minDistance = dist; bestIndex = i; } } } if (bestIndex != null && bestIndex != selectedRouteIndex) { HapticFeedback.selectionClick(); selectRoute(bestIndex); } } Future onMapLongPressed(Point point, LatLng tappedPoint) async { HapticFeedback.mediumImpact(); final langCode = box.read(BoxName.lang) ?? 'ar'; Get.dialog( AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), title: Text(langCode == 'ar' ? 'بدء الملاحة؟' : 'Start Navigation?', style: const TextStyle(fontWeight: FontWeight.bold)), content: Text(langCode == 'ar' ? 'هل تريد الذهاب إلى هذا الموقع؟' : 'Go to this location?'), actions: [ TextButton( child: Text(langCode == 'ar' ? 'إلغاء' : 'Cancel', style: const TextStyle(color: Colors.grey)), onPressed: () => Get.back()), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF0D47A1), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12))), child: Text(langCode == 'ar' ? 'اذهب الآن' : 'Go now', style: const TextStyle(color: Colors.white)), onPressed: () { Get.back(); startNavigationTo(tappedPoint, infoWindowTitle: langCode == 'ar' ? 'الموقع المحدد' : 'Selected location'); }, ), ], ), ); } Future _getCurrentLocationAndStartUpdates() async { try { Log.print("DEBUG: Getting initial location..."); final position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high); myLocation = LatLng(position.latitude, position.longitude); Log.print("DEBUG: Initial location acquired: $myLocation"); _targetHeading = position.heading; _oldHeading = position.heading; _smoothedHeading = position.heading; update(); if (isStyleLoaded) animateCameraToPosition(myLocation!); // Start the Location Stream for real-time updates _startLocationStream(); _startBatchTimers(); } catch (e) { Log.print("DEBUG: Error getting initial location: $e"); } } void _startLocationStream() { _locationStreamSubscription?.cancel(); // Listen to location updates with minimum distance filter of 2 meters // This provides real-time updates without the 3-4 second delay _locationStreamSubscription = Geolocator.getPositionStream( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, distanceFilter: 2, // Update every 2 meters ), ).listen( (Position position) { _handleLocationUpdate(position); }, onError: (error) { Log.print("DEBUG: Location stream error: $error"); }, ); } bool _isProcessing = false; Future _handleLocationUpdate(Position position) async { if (_isProcessing) return; _isProcessing = true; try { final newLoc = LatLng(position.latitude, position.longitude); currentSpeed = position.speed * 3.6; // Convert m/s to km/h // Skip if movement is too small if (_lastProcessedLocation != null) { final d = Geolocator.distanceBetween( newLoc.latitude, newLoc.longitude, _lastProcessedLocation!.latitude, _lastProcessedLocation!.longitude, ); if (d < _minMoveToProcess) { _isProcessing = false; return; } } Log.print( "DEBUG: Location update - Speed: ${currentSpeed.toStringAsFixed(1)} km/h, Loc: $newLoc"); // Update total distance if (_lastDistanceLocation != null) { final d = Geolocator.distanceBetween( _lastDistanceLocation!.latitude, _lastDistanceLocation!.longitude, newLoc.latitude, newLoc.longitude, ); if (d > 5.0) totalDistance += d; } _lastDistanceLocation = newLoc; _oldLoc = myLocation ?? newLoc; _targetLoc = newLoc; _oldHeading = _smoothedHeading; if (currentSpeed > 1.5 && _oldLoc != null) { _targetHeading = Geolocator.bearingBetween( _oldLoc!.latitude, _oldLoc!.longitude, _targetLoc!.latitude, _targetLoc!.longitude, ); } else { _targetHeading = position.heading; } _animController?.forward(from: 0.0); _lastProcessedLocation = newLoc; if (isStyleLoaded) _updateCarMarker(); if (_fullRouteCoordinates.isNotEmpty) { _updateTraveledPolylineSmart(newLoc); _checkNavigationStep(newLoc); _recomputeETA(); _checkOffRoute(newLoc); } update(); } catch (e) { Log.print("DEBUG: Error in _handleLocationUpdate: $e"); } finally { _isProcessing = false; } } 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; } void _checkOffRoute(LatLng pos) { if (!isNavigating || _autoRecalcInProgress || isLoading) return; if (_fullRouteCoordinates.isEmpty) return; 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) { if (_offRouteStartTime == null) { _offRouteStartTime = DateTime.now(); } else { final elapsed = DateTime.now().difference(_offRouteStartTime!).inSeconds; if (elapsed >= _offRouteTriggerSeconds) { _offRouteStartTime = null; _autoRecalcInProgress = true; _smartRecalculateRoute(pos); } } } else { _offRouteStartTime = null; } } /// Recalculate immediately from the latest GPS point to the destination. Future _smartRecalculateRoute(LatLng currentPos) async { try { if (_finalDestination != null) { await recalculateRoute(origin: currentPos, keepNavigationActive: true); } _autoRecalcInProgress = false; } catch (e) { Log.print("DEBUG: Error in smart recalculate: $e"); _autoRecalcInProgress = false; } } void _startBatchTimers() { _recordTimer?.cancel(); _uploadBatchTimer?.cancel(); _recordTimer = Timer.periodic(_recordInterval, (_) => _recordToBuffer()); _uploadBatchTimer = Timer.periodic(_uploadInterval, (_) => _flushBufferToServer()); } void _recordToBuffer() { if (myLocation == null || (myLocation!.latitude == 0 && myLocation!.longitude == 0)) { return; } final now = DateTime.now(); final distFromLast = _lastBufferedLocation == null ? 999.0 : Geolocator.distanceBetween( _lastBufferedLocation!.latitude, _lastBufferedLocation!.longitude, myLocation!.latitude, myLocation!.longitude); final bool moved = distFromLast > _minMoveToRecord && currentSpeed > 0.5; final bool timeForced = _lastBufferedTime == null || now.difference(_lastBufferedTime!).inSeconds >= 60; if (!moved && !timeForced) return; _lastBufferedLocation = myLocation; _lastBufferedTime = now; _trackBuffer.add({ 'lat': double.parse(myLocation!.latitude.toStringAsFixed(6)), 'lng': double.parse(myLocation!.longitude.toStringAsFixed(6)), 'spd': double.parse(currentSpeed.toStringAsFixed(1)), 'head': _smoothedHeading.toStringAsFixed(0), 'dist': double.parse(totalDistance.toStringAsFixed(1)), 'ts': now.toIso8601String(), }); } Future _flushBufferToServer() async { if (_trackBuffer.isEmpty) return; final batch = List>.from(_trackBuffer); _trackBuffer.clear(); final String passengerId = (box.read(BoxName.passengerID) ?? '').toString(); try { await CRUD().post( link: '${AppLink.locationServerSide}/add_batch.php', payload: { 'driver_id': passengerId, 'batch_data': jsonEncode(batch), 'session_dist': totalDistance.toStringAsFixed(1), }, ); } catch (e) { _trackBuffer.insertAll(0, batch); } } Future _updateCarMarker() async { if (myLocation == null || !isStyleLoaded) return; markers.removeWhere((m) => m.markerId.value == 'car'); markers.add(Marker( markerId: const MarkerId('car'), position: myLocation!, icon: InlqBitmap.fromStyleImage('car_icon'), anchor: const Offset(0.5, 0.5), flat: true, rotation: _smoothedHeading, zIndex: 100, )); } void animateCameraToPosition(LatLng position, {double? zoom, double bearing = 0.0, double tilt = 0.0}) { if (!_mapReady || mapController == null) return; mapController!.animateCamera(CameraUpdate.newCameraPosition(CameraPosition( target: position, zoom: zoom ?? (isNavigating ? _targetZoom : 16.0), bearing: bearing, tilt: tilt))); } Future _safeAnimateCameraBounds(LatLngBounds? bounds, {double left = 60, double top = 60, double right = 60, double bottom = 60}) async { if (bounds == null || mapController == null) return; try { final latSpan = (bounds.northeast.latitude - bounds.southwest.latitude).abs(); final lngSpan = (bounds.northeast.longitude - bounds.southwest.longitude).abs(); if (latSpan < 0.0001 && lngSpan < 0.0001) { mapController ?.animateCamera(CameraUpdate.newLatLngZoom(bounds.northeast, 16)); return; } await Future.delayed(const Duration(milliseconds: 200)); await mapController?.animateCamera(CameraUpdate.newLatLngBounds(bounds, left: left, top: top, right: right, bottom: bottom)); } catch (e) { try { await mapController ?.animateCamera(CameraUpdate.newLatLngZoom(bounds!.northeast, 14)); } catch (_) {} } } void onUserPanned() { _cameraLockedToUser = false; update(); } void relockCameraToUser() { _cameraLockedToUser = true; if (myLocation != null) { animateCameraToPosition(myLocation!, bearing: _smoothedHeading, zoom: _targetZoom, tilt: _targetTilt); } update(); } bool get isCameraLocked => _cameraLockedToUser; void _updateTraveledPolylineSmart(LatLng currentPos) { if (_fullRouteCoordinates.isEmpty) return; const int searchWindow = 60; final int startIndex = _lastTraveledIndexInFullRoute; final int endIndex = min(startIndex + searchWindow, _fullRouteCoordinates.length); double minDist = double.infinity; int closestIdx = startIndex; bool foundCloser = false; for (int i = startIndex; i < endIndex; i++) { final d = Geolocator.distanceBetween( currentPos.latitude, currentPos.longitude, _fullRouteCoordinates[i].latitude, _fullRouteCoordinates[i].longitude, ); if (d < minDist) { minDist = d; closestIdx = i; foundCloser = true; } } if (foundCloser && minDist < 50 && closestIdx > _lastTraveledIndexInFullRoute) { _lastTraveledIndexInFullRoute = closestIdx; _updatePolylinesSets(_fullRouteCoordinates.sublist(0, closestIdx + 1), _fullRouteCoordinates.sublist(closestIdx)); } } Future _updatePolylinesSets( List traveled, List remaining) async { Log.print( "DEBUG: Updating polylines. Traveled: ${traveled.length}, Remaining: ${remaining.length}"); Set newPolylines = {}; // Render Alternative Routes first for (int i = 0; i < routes.length; i++) { if (i == selectedRouteIndex) continue; newPolylines.add(Polyline( polylineId: PolylineId('alt_$i'), points: routes[i].coordinates, color: const Color(0xFFB0BEC5).withOpacity(0.8), width: 6, )); } if (remaining.isNotEmpty) { newPolylines.add(Polyline( polylineId: const PolylineId('remaining'), points: remaining, color: const Color(0xFF00E5FF), width: 8, )); } if (traveled.isNotEmpty) { newPolylines.add(Polyline( polylineId: const PolylineId('traveled'), points: traveled, color: const Color(0xFFBDBDBD).withOpacity(0.6), width: 5, )); } polylines = newPolylines; update(); } void selectRoute(int index) { if (index < 0 || index >= routes.length) return; selectedRouteIndex = index; final r = routes[index]; _fullRouteCoordinates = r.coordinates; routeSteps = r.steps; _routeTotalDistanceM = r.distanceM; _routeTotalDurationS = r.durationS; _lastTraveledIndexInFullRoute = 0; _recomputeETA(); _updatePolylinesSets([], _fullRouteCoordinates); update(); } void goToFavorite(String type) { LatLng? dest; switch (type) { case 'home': dest = getHomeLatLng(); break; case 'work': dest = getWorkLatLng(); break; case 'airport': dest = getAirportLatLng(); break; } if (dest != null && myLocation != null) { getRoute(myLocation!, dest); } else { mySnackbarWarning('الموقع غير متاح حالياً.'); } } LatLng? getHomeLatLng() { final dynamic stored = box.read(BoxName.addHome); if (stored != null && stored is String && stored.contains(',')) { final parts = stored.split(','); return LatLng(double.parse(parts[0]), double.parse(parts[1])); } return null; } LatLng? getWorkLatLng() { final dynamic stored = box.read(BoxName.addWork); if (stored != null && stored is String && stored.contains(',')) { final parts = stored.split(','); return LatLng(double.parse(parts[0]), double.parse(parts[1])); } return null; } LatLng getAirportLatLng() { final String country = box.read(BoxName.countryCode) ?? 'JO'; if (country == 'SY') { return const LatLng(33.4111, 36.5147); // Damascus Airport } return const LatLng(31.7225, 35.9933); // Queen Alia Airport (JO) } Future getRoute(LatLng origin, LatLng destination, {bool keepNavigationActive = false, int retryCount = 0}) async { isLoading = true; update(); final String langCode = box.read(BoxName.lang) ?? 'ar'; final Map queryParams = { 'fromLat': origin.latitude.toString(), 'fromLng': origin.longitude.toString(), 'toLat': destination.latitude.toString(), 'toLng': destination.longitude.toString(), 'steps': 'true', 'alternatives': 'true', 'locale': langCode, }; if (_intermediateStop != null) { queryParams['stop1Lat'] = _intermediateStop!.latitude.toString(); queryParams['stop1Lng'] = _intermediateStop!.longitude.toString(); } final saasUri = Uri.parse(AppLink.mapSaasRoute).replace(queryParameters: queryParams); try { final response = await http .get(saasUri, headers: {'x-api-key': Env.mapSaasKey}) .timeout(const Duration(seconds: 15)); if (response.statusCode != 200) { if (retryCount < 2) { await Future.delayed(const Duration(seconds: 2)); return getRoute(origin, destination, keepNavigationActive: keepNavigationActive, retryCount: retryCount + 1); } isLoading = false; update(); mySnackbarWarning(langCode == 'ar' ? 'تعذر الاتصال بخدمة التوجيه.' : 'Route service unavailable.'); return; } final data = jsonDecode(response.body); // ── Parse primary route (top-level in response) ── routes.clear(); final primaryPts = data['points']?.toString() ?? ""; if (primaryPts.isNotEmpty) { final coords = await compute>( decodePolylineIsolate, primaryPts); routes.add(RouteData( coordinates: coords, steps: List>.from(data['instructions'] ?? []), distanceM: (data['distance'] as num).toDouble(), durationS: (data['duration'] as num).toDouble(), points: primaryPts, )); } // ── Parse alternative routes (in data['alternatives']) ── // إذا كان هناك routes بديلة متاحة من API if (data['alternatives'] != null && data['alternatives'] is List) { _hasAlternativeRoutes = data['alternatives'].isNotEmpty; for (var alt in data['alternatives']) { final altPts = alt['points']?.toString() ?? ""; if (altPts.isEmpty) continue; final altCoords = await compute>( decodePolylineIsolate, altPts); routes.add(RouteData( coordinates: altCoords, steps: List>.from(alt['instructions'] ?? []), distanceM: (alt['distance'] as num).toDouble(), durationS: (alt['duration'] as num).toDouble(), points: altPts, )); } if (_hasAlternativeRoutes) { Log.print("DEBUG: ${routes.length - 1} alternative routes available"); } } else { _hasAlternativeRoutes = false; } if (routes.isEmpty) { isLoading = false; update(); mySnackbarWarning('لم يتم العثور على مسار.'); return; } selectedRouteIndex = 0; final selected = routes[0]; _fullRouteCoordinates = selected.coordinates; routeSteps = selected.steps; _routeTotalDistanceM = selected.distanceM; _routeTotalDurationS = selected.durationS; _lastTraveledIndexInFullRoute = 0; if (isStyleLoaded) _updatePolylinesSets([], _fullRouteCoordinates); if (_fullRouteCoordinates.isNotEmpty) { OfflineMapService.instance .downloadRegion(_fullRouteCoordinates.last, radiusKm: 2.0); } _recomputeETA(); currentStepIndex = 0; _nextInstructionSpoken = false; isNavigating = keepNavigationActive; _cameraLockedToUser = keepNavigationActive; _offRouteStartTime = null; isLoading = false; update(); if (routeSteps.isNotEmpty) { currentInstruction = routeSteps[0]['text'] ?? ""; currentManeuverModifier = ManeuverSign.fromValue(routeSteps[0]['sign']); nextInstruction = routeSteps.length > 1 ? (langCode == 'ar' ? "ثم ${routeSteps[1]['text']}" : "Then ${routeSteps[1]['text']}") : (langCode == 'ar' ? "الوجهة النهائية" : "Destination"); if (!isMuted) { Get.find().speakText(currentInstruction); } } // Re-add car marker after polyline updates (ensures it stays on top) if (isStyleLoaded) _updateCarMarker(); if (keepNavigationActive && myLocation != null) { animateCameraToPosition(myLocation!, bearing: _smoothedHeading, zoom: _targetZoom, tilt: _targetTilt); } else if (_fullRouteCoordinates.length >= 2) { final bounds = data['bbox'] != null && (data['bbox'] as List).length == 4 ? LatLngBounds( southwest: LatLng(data['bbox'][1], data['bbox'][0]), northeast: LatLng(data['bbox'][3], data['bbox'][2])) : _boundsFromLatLngList(_fullRouteCoordinates); await _safeAnimateCameraBounds(bounds, bottom: 320, top: 150, left: 50, right: 50); } update(); } catch (e) { isLoading = false; update(); Log.print("GetRoute Error: $e"); } } void _recomputeETA() { if (_routeTotalDistanceM == 0 || _fullRouteCoordinates.isEmpty) return; final fraction = (_fullRouteCoordinates.length - _lastTraveledIndexInFullRoute) / _fullRouteCoordinates.length; final remainingM = _routeTotalDistanceM * fraction; // Time remaining: use current speed if moving, fall back to route estimate double remainingS = _routeTotalDurationS * fraction; if (currentSpeed > 5.0) { final speedEstimate = currentSpeed / 3.6; // km/h → m/s remainingS = remainingM / speedEstimate; } final String langCode = box.read(BoxName.lang) ?? 'ar'; totalDistanceRemaining = remainingM > 1000 ? (remainingM / 1000).toStringAsFixed(1) : remainingM.toStringAsFixed(0); distanceWithUnit = _formatDistance(remainingM, langCode); final minutes = (remainingS / 60).round(); estimatedTimeRemaining = minutes.toString(); final arrival = DateTime.now().add(Duration(seconds: remainingS.toInt())); final h = arrival.hour > 12 ? arrival.hour - 12 : (arrival.hour == 0 ? 12 : arrival.hour); final m = arrival.minute.toString().padLeft(2, '0'); final ampm = arrival.hour >= 12 ? 'PM' : 'AM'; arrivalTime = "$h:$m $ampm"; } Future startNavigationTo(LatLng destination, {String infoWindowTitle = ''}) async { isLoading = true; update(); try { _finalDestination = destination; await clearRoute(isNewRoute: true); // Preserve car marker if it exists markers = markers.where((m) => m.markerId.value == 'car').toSet(); markers.add(Marker( markerId: const MarkerId('destination'), position: destination, icon: InlqBitmap.fromStyleImage('dest_icon'), infoWindow: infoWindowTitle.isNotEmpty ? InfoWindow(title: infoWindowTitle) : InfoWindow.noText, )); if (myLocation != null) { markers.add(Marker( markerId: const MarkerId('origin'), position: myLocation!, icon: InlqBitmap.fromStyleImage('start_icon'), )); await getRoute(myLocation!, destination); } } finally { isLoading = false; update(); } } Future recalculateRoute( {LatLng? origin, bool keepNavigationActive = false}) async { final LatLng? routeOrigin = origin ?? myLocation; if (routeOrigin == null || _finalDestination == null || isLoading) return; isLoading = true; update(); markers = markers.where((m) => m.markerId.value != 'origin').toSet(); markers.add(Marker( markerId: const MarkerId('origin'), position: routeOrigin, icon: InlqBitmap.fromStyleImage('start_icon'), )); await getRoute(routeOrigin, _finalDestination!, keepNavigationActive: keepNavigationActive); isLoading = false; update(); } Future startActiveNavigation() async { if (routes.isEmpty) { mySnackbarWarning(box.read(BoxName.lang) == 'ar' ? 'لا يوجد مسار لبدء الملاحة.' : 'No route to start navigation.'); return; } if (isNavigating) return; isNavigating = true; _cameraLockedToUser = true; // Ensure ETA and distances are up-to-date _lastTraveledIndexInFullRoute = _lastTraveledIndexInFullRoute; _recomputeETA(); // Initialize current instruction if available if (routeSteps.isNotEmpty && currentStepIndex < routeSteps.length) { currentInstruction = routeSteps[currentStepIndex]['text'] ?? ""; currentManeuverModifier = ManeuverSign.fromValue(routeSteps[currentStepIndex]['sign']); nextInstruction = (currentStepIndex + 1) < routeSteps.length ? (box.read(BoxName.lang) == 'ar' ? "ثم ${routeSteps[currentStepIndex + 1]['text']}" : "Then ${routeSteps[currentStepIndex + 1]['text']}") : (box.read(BoxName.lang) == 'ar' ? 'الوجهة' : 'Destination'); if (!isMuted) { try { Get.find().speakText(currentInstruction); } catch (_) {} } } // Center camera on user for navigation mode if (myLocation != null) { animateCameraToPosition(myLocation!, bearing: _smoothedHeading, zoom: _targetZoom, tilt: _targetTilt); } update(); } Future clearEverything() async { placeDestinationController.clear(); placesDestination = []; await clearRoute(); } Future clearRoute({bool isNewRoute = false}) async { _offRouteStartTime = null; _autoRecalcInProgress = false; if (!isNewRoute) { markers = {}; polylines = {}; circles = {}; polygons = {}; _finalDestination = null; isNavigating = false; routes = []; await _flushBufferToServer(); } routeSteps = []; _fullRouteCoordinates = []; _lastTraveledIndexInFullRoute = 0; currentInstruction = ""; nextInstruction = ""; currentManeuverModifier = ManeuverSign.straight; distanceToNextStep = ""; totalDistanceRemaining = ""; estimatedTimeRemaining = ""; arrivalTime = "--:--"; _routeTotalDistanceM = 0; _routeTotalDurationS = 0; if (!isNewRoute) { await _updateCarMarker(); } update(); } Future _loadCustomIcons() async { if (mapController == null) return; final carBytes = await rootBundle.load('assets/images/car.png'); final startBytes = await rootBundle.load('assets/images/A.png'); final destBytes = await rootBundle.load('assets/images/b.png'); await mapController!.addImage('car_icon', carBytes.buffer.asUint8List()); await mapController! .addImage('start_icon', startBytes.buffer.asUint8List()); await mapController!.addImage('dest_icon', destBytes.buffer.asUint8List()); } void _checkNavigationStep(LatLng pos) { if (routeSteps.isEmpty || currentStepIndex >= routeSteps.length) return; final interval = routeSteps[currentStepIndex]['interval'] as List; final endIdx = interval[1] as int; if (endIdx >= _fullRouteCoordinates.length) return; final endLatLng = _fullRouteCoordinates[endIdx]; final distance = Geolocator.distanceBetween( pos.latitude, pos.longitude, endLatLng.latitude, endLatLng.longitude); distanceToNextStep = distance > 1000 ? "${(distance / 1000).toStringAsFixed(1)} km" : "${distance.toStringAsFixed(0)} m"; if (distance < 50 && !_nextInstructionSpoken && nextInstruction.isNotEmpty) { if (!isMuted) { Get.find().speakText(nextInstruction); } _nextInstructionSpoken = true; } if (distance < 20) _advanceStep(); } void _advanceStep() { currentStepIndex++; final String langCode = box.read(BoxName.lang) ?? 'ar'; if (currentStepIndex < routeSteps.length) { currentInstruction = routeSteps[currentStepIndex]['text'] ?? ""; currentManeuverModifier = ManeuverSign.fromValue(routeSteps[currentStepIndex]['sign']); nextInstruction = (currentStepIndex + 1) < routeSteps.length ? (langCode == 'ar' ? "ثم ${routeSteps[currentStepIndex + 1]['text']}" : "Then ${routeSteps[currentStepIndex + 1]['text']}") : (langCode == 'ar' ? "ستصل إلى وجهتك" : "Arriving soon"); _nextInstructionSpoken = false; update(); } else { _finishNavigation(); } } void _finishNavigation() { final String langCode = box.read(BoxName.lang) ?? 'ar'; currentInstruction = langCode == 'ar' ? "لقد وصلت إلى وجهتك" : "You have arrived"; currentManeuverModifier = ManeuverSign.arrive; nextInstruction = ""; distanceToNextStep = ""; isNavigating = false; if (!isMuted) { Get.find().speakText(currentInstruction); } _flushBufferToServer(); update(); } Future getPlaces() async { final q = placeDestinationController.text.trim(); if (q.length < 3) { placesDestination = []; update(); return; } if (mapController == null) return; try { // ✅ Use searchPlaces from intaleq_maps SDK final results = await mapController!.searchPlaces(q); if (myLocation != null) { for (final p in results) { final plat = double.tryParse(p['latitude']?.toString() ?? '0') ?? 0.0; final plng = double.tryParse(p['longitude']?.toString() ?? '0') ?? 0.0; p['distanceKm'] = _haversineKm( myLocation!.latitude, myLocation!.longitude, plat, plng); } results.sort((a, b) => (a['distanceKm'] as double).compareTo(b['distanceKm'] as double)); } placesDestination = results; update(); } catch (e) { Log.print('getPlaces error: $e'); } } Future selectDestination(dynamic place) async { placeDestinationController.clear(); placesDestination = []; final lat = double.parse(place['latitude'].toString()); final lng = double.parse(place['longitude'].toString()); await startNavigationTo(LatLng(lat, lng), infoWindowTitle: place['name'] ?? 'وجهة'); } void onSearchChanged(String query) { if (_debounce?.isActive ?? false) _debounce!.cancel(); _debounce = Timer(const Duration(milliseconds: 500), () => getPlaces()); } double _haversineKm(double lat1, double lon1, double lat2, double lon2) { const R = 6371.0; final dLat = (lat2 - lat1) * (pi / 180.0); final dLon = (lon2 - lon1) * (pi / 180.0); final a = sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * pi / 180) * cos(lat2 * pi / 180) * sin(dLon / 2) * sin(dLon / 2); return R * 2 * atan2(sqrt(a), sqrt(1 - a)); } LatLngBounds _boundsFromLatLngList(List list) { double? x0, x1, y0, y1; for (final ll in list) { if (x0 == null) { x0 = x1 = ll.latitude; y0 = y1 = ll.longitude; } else { if (ll.latitude > x1!) x1 = ll.latitude; if (ll.latitude < x0) x0 = ll.latitude; if (ll.longitude > y1!) y1 = ll.longitude; if (ll.longitude < y0!) y0 = ll.longitude; } } return LatLngBounds( northeast: LatLng(x1!, y1!), southwest: LatLng(x0!, y0!)); } void setIntermediateStop(LatLng stop) { _intermediateStop = stop; if (myLocation != null && _finalDestination != null) { getRoute(myLocation!, _finalDestination!); } update(); } void clearIntermediateStop() { _intermediateStop = null; if (myLocation != null && _finalDestination != null) { getRoute(myLocation!, _finalDestination!); } update(); } String _formatDistance(double meters, String lang) { if (meters >= 1000) { return "${(meters / 1000).toStringAsFixed(1)} ${lang == 'ar' ? 'كم' : 'km'}"; } else { return "${meters.toStringAsFixed(0)} ${lang == 'ar' ? 'م' : 'm'}"; } } Future submitPlaceSuggestion(String name) async { if (name.trim().isEmpty || myLocation == null) return; isLoading = true; update(); try { final payload = { 'name': name, 'lat': myLocation!.latitude.toString(), 'lng': myLocation!.longitude.toString(), 'passenger_id': box.read(BoxName.passengerID), }; await CRUD().post(link: AppLink.getPlacesSyria, payload: payload); mySnackbarInfo(box.read(BoxName.lang) == 'ar' ? "تم استلام اقتراحك! مكافأتك: +٥٠ نقطة" : "Suggestion received! Reward: +50 points"); } finally { isLoading = false; update(); } } }