import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:Intaleq/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:maplibre_gl/maplibre_gl.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 '../../../main.dart'; import '../../../print.dart'; import 'dart:ui'; import '../../../services/offline_map_service.dart'; class NavigationController extends GetxController with GetSingleTickerProviderStateMixin { // ========================================================================== // ── Tunables ────────────────────────────────────────────────────────────── // ========================================================================== /// How often we snapshot the current position into the local buffer. static const Duration _recordInterval = Duration(seconds: 3); /// How often we flush the buffer and POST it to the server. static const Duration _uploadInterval = Duration(minutes: 2); /// Minimum metres the device must move before we bother recording a point. static const double _minMoveToRecord = 10.0; /// Minimum metres the device must move between general location ticks. static const double _minMoveToProcess = 2.0; /// Metres off-route before the auto-recalculate countdown starts. static const double _offRouteThresholdM = 25.0; /// Seconds the user must remain off-route before auto-recalculate fires. static const int _offRouteTriggerSeconds = 6; // ========================================================================== // ── Map state ───────────────────────────────────────────────────────────── // ========================================================================== bool isLoading = false; MaplibreMapController? mapController; bool isStyleLoaded = false; final TextEditingController placeDestinationController = TextEditingController(); LatLng? myLocation; double heading = 0.0; /// Smoothed heading used for the car icon and camera bearing. /// Updated every tick via angle-aware lerp to eliminate snap/jitter. double _smoothedHeading = 0.0; // Animation for smooth tracking AnimationController? _animController; LatLng? _oldLoc; LatLng? _targetLoc; double currentSpeed = 0.0; // km/h double totalDistance = 0.0; // metres accumulated this session // MapLibre objects Symbol? carSymbol; Symbol? originSymbol; Symbol? destinationSymbol; Line? remainingRouteLine; Line? traveledRouteLine; // General location polling Timer? _locationUpdateTimer; LatLng? _lastProcessedLocation; // Search List placesDestination = []; Timer? _debounce; // Route LatLng? _finalDestination; List> routeSteps = []; List _fullRouteCoordinates = []; int _lastTraveledIndexInFullRoute = 0; // Navigation guidance bool _nextInstructionSpoken = false; String currentInstruction = ""; String nextInstruction = ""; int currentStepIndex = 0; String distanceToNextStep = ""; String totalDistanceRemaining = ""; String estimatedTimeRemaining = ""; // Stored route totals (for ETA re-calculation) double _routeTotalDistanceM = 0; double _routeTotalDurationS = 0; // Camera bool isNavigating = false; bool _cameraLockedToUser = true; bool _mapReady = false; // true only after layout has settled // ========================================================================== // ── Off-route auto-recalculate ──────────────────────────────────────────── // ========================================================================== /// Wall-clock time when the user first went more than [_offRouteThresholdM] /// metres away from the nearest route point. Null means on-route. DateTime? _offRouteStartTime; /// True while an auto-recalculate triggered from off-route detection is in /// progress — prevents a second trigger from firing. bool _autoRecalcInProgress = false; // ========================================================================== // ── Batch location tracking ─────────────────────────────────────────────── // ========================================================================== /// In-memory ring buffer — points accumulate here every 3 s. final List> _trackBuffer = []; Timer? _recordTimer; Timer? _uploadBatchTimer; /// Last position that was written to the buffer (for distance gate). LatLng? _lastBufferedLocation; DateTime? _lastBufferedTime; /// Last position used to accumulate `totalDistance`. LatLng? _lastDistanceLocation; // ========================================================================== // ── Speed-adaptive camera ───────────────────────────────────────────────── // ========================================================================== 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; } static final String _routeApiBaseUrl = "${AppLink.routesOsm}/route/v1/driving"; // ========================================================================== // ── Lifecycle ───────────────────────────────────────────────────────────── // ========================================================================== @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); if (isStyleLoaded) { _updateCarMarker(); if (_fullRouteCoordinates.isNotEmpty && _cameraLockedToUser) { animateCameraToPosition(myLocation!, bearing: _smoothedHeading, zoom: _targetZoom, tilt: _targetTilt); } } } }); _initialize(); } Future _initialize() async { await _getCurrentLocationAndStartUpdates(); } @override void onClose() { _locationUpdateTimer?.cancel(); _recordTimer?.cancel(); _uploadBatchTimer?.cancel(); _debounce?.cancel(); _animController?.dispose(); mapController?.dispose(); placeDestinationController.dispose(); // Final flush before closing so no points are lost _flushBufferToServer(); super.onClose(); } // ========================================================================== // ── Map callbacks ───────────────────────────────────────────────────────── // ========================================================================== void onMapCreated(MaplibreMapController controller) { mapController = controller; } Future onStyleLoaded() async { isStyleLoaded = true; await _loadCustomIcons(); // Wait one full frame for the native MapLibre view to finish layout. // Without this, ANY animateCamera call throws std::domain_error on iOS // because the view still has zero pixel dimensions at this point. WidgetsBinding.instance.addPostFrameCallback((_) async { await Future.delayed(const Duration(milliseconds: 300)); if (!_mapReady) { _mapReady = true; if (myLocation != null) { animateCameraToPosition(myLocation!); _updateCarMarker(); } if (_fullRouteCoordinates.isNotEmpty) { _updatePolylinesSets([], _fullRouteCoordinates); } } }); } Future onMapLongPressed(Point point, LatLng tappedPoint) async { HapticFeedback.mediumImpact(); Get.dialog( AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), title: const Text('بدء الملاحة؟', style: TextStyle(fontWeight: FontWeight.bold)), content: const Text('هل تريد الذهاب إلى هذا الموقع؟'), actions: [ TextButton( child: const Text('إلغاء', style: TextStyle(color: Colors.grey)), onPressed: () => Get.back(), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF0D47A1), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12)), ), child: const Text('اذهب الآن', style: TextStyle(color: Colors.white)), onPressed: () { Get.back(); startNavigationTo(tappedPoint, infoWindowTitle: 'الموقع المحدد'); }, ), ], ), ); } // ========================================================================== // ── Location polling (every second) ────────────────────────────────────── // ========================================================================== Future _getCurrentLocationAndStartUpdates() async { try { final position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high); myLocation = LatLng(position.latitude, position.longitude); _smoothedHeading = position.heading; // seed so first lerp is instant update(); if (isStyleLoaded) animateCameraToPosition(myLocation!); _startLocationTimer(); _startBatchTimers(); // ← start tracking as soon as we have a fix } catch (e) { Log.print("Error getting initial location: $e"); } } void _startLocationTimer() { _locationUpdateTimer?.cancel(); _locationUpdateTimer = Timer.periodic(const Duration(seconds: 1), (_) => _tick()); } Future _tick() async { try { final position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high); final newLoc = LatLng(position.latitude, position.longitude); // Gate: ignore micro-jitter if (_lastProcessedLocation != null) { final d = Geolocator.distanceBetween( newLoc.latitude, newLoc.longitude, _lastProcessedLocation!.latitude, _lastProcessedLocation!.longitude, ); if (d < _minMoveToProcess) return; } // Accumulate session 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; _animController?.forward(from: 0.0); _lastProcessedLocation = newLoc; heading = position.heading; // ── Smooth the heading with an angle-aware exponential lerp ────────── // Factor 0.25 means ~75 % of the old angle is kept each tick, giving a // ~4-tick (≈4 s) settling time — smooth enough to look fluid on screen // while still reacting quickly to real turns. _smoothedHeading = _lerpAngle(_smoothedHeading, heading, 0.25); currentSpeed = position.speed * 3.6; // Initial visual update if map is fresh if (isStyleLoaded && myLocation == null) _updateCarMarker(); if (_fullRouteCoordinates.isNotEmpty) { _updateTraveledPolylineSmart(newLoc); _checkNavigationStep(newLoc); _recomputeETA(); // ── Off-route auto-recalculate ───────────────────────────────────── _checkOffRoute(newLoc); } update(); } catch (e) { Log.print("Error occurred: $e"); } } // ========================================================================== // ── Heading utilities ───────────────────────────────────────────────────── // ========================================================================== /// Lerps from [from] to [to] by factor [t], correctly handling the 0/360 /// wrap-around so we never spin the wrong way (e.g. 350° → 10° goes +20°, /// not −340°). double _lerpAngle(double from, double to, double t) { final double diff = ((to - from + 540.0) % 360.0) - 180.0; return (from + diff * t + 360.0) % 360.0; } // ========================================================================== // ── Off-route detection ─────────────────────────────────────────────────── // ========================================================================== /// Called every tick while navigating. Measures the distance from [pos] to /// the nearest upcoming route coordinate. If the driver stays more than /// [_offRouteThresholdM] metres away for at least [_offRouteTriggerSeconds] /// seconds, an automatic route recalculation is triggered. void _checkOffRoute(LatLng pos) { if (_autoRecalcInProgress || isLoading) return; if (_fullRouteCoordinates.isEmpty) return; // Search a window ahead of the last tracked index for the nearest point. const int searchWindow = 80; final int start = _lastTraveledIndexInFullRoute; final int end = min(start + searchWindow, _fullRouteCoordinates.length); double minDist = double.infinity; for (int i = start; i < end; i++) { final d = Geolocator.distanceBetween( pos.latitude, pos.longitude, _fullRouteCoordinates[i].latitude, _fullRouteCoordinates[i].longitude, ); if (d < minDist) minDist = d; } if (minDist > _offRouteThresholdM) { // Driver is off the route. if (_offRouteStartTime == null) { _offRouteStartTime = DateTime.now(); Log.print('⚠️ Off-route detected (${minDist.toStringAsFixed(0)} m). ' 'Countdown started.'); } else { final elapsed = DateTime.now().difference(_offRouteStartTime!).inSeconds; if (elapsed >= _offRouteTriggerSeconds) { Log.print('🔄 Auto-recalculate triggered after ${elapsed}s ' 'off-route (${minDist.toStringAsFixed(0)} m).'); _offRouteStartTime = null; _autoRecalcInProgress = true; recalculateRoute().then((_) => _autoRecalcInProgress = false); } } } else { // Back on (or close enough to) the route — reset the clock. if (_offRouteStartTime != null) { Log.print('✅ Back on route — off-route timer reset.'); } _offRouteStartTime = null; } } // ========================================================================== // ── Batch tracking: record every 3 s, upload every 2 min ───────────────── // ========================================================================== void _startBatchTimers() { _recordTimer?.cancel(); _uploadBatchTimer?.cancel(); _recordTimer = Timer.periodic(_recordInterval, (_) => _recordToBuffer()); _uploadBatchTimer = Timer.periodic(_uploadInterval, (_) => _flushBufferToServer()); Log.print('📍 Batch tracking started ' '(record: ${_recordInterval.inSeconds}s, ' 'upload: ${_uploadInterval.inMinutes}min)'); } void _stopBatchTimers() { _recordTimer?.cancel(); _uploadBatchTimer?.cancel(); _recordTimer = null; _uploadBatchTimer = null; } /// Called every 3 seconds. Adds one point to the buffer if the device /// has moved enough OR if 60 s have elapsed since the last record. void _recordToBuffer() { if (myLocation == null) return; if (myLocation!.latitude == 0 && myLocation!.longitude == 0) return; final now = DateTime.now(); // Distance gate 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; final point = { 'lat': double.parse(myLocation!.latitude.toStringAsFixed(6)), 'lng': double.parse(myLocation!.longitude.toStringAsFixed(6)), 'spd': double.parse(currentSpeed.toStringAsFixed(1)), 'head': heading.toStringAsFixed(0), 'dist': double.parse(totalDistance.toStringAsFixed(1)), 'ts': now.toIso8601String(), }; _trackBuffer.add(point); Log.print('📌 Buffered point #${_trackBuffer.length} ' '(${point['lat']}, ${point['lng']}) ${point['spd']} km/h'); } /// Drains the buffer and POSTs the JSON batch to the server. /// Called every 2 minutes (and once more on close). Future _flushBufferToServer() async { if (_trackBuffer.isEmpty) return; final batch = List>.from(_trackBuffer); _trackBuffer.clear(); final String passengerId = (box.read(BoxName.passengerID) ?? '').toString(); Log.print('📤 Uploading ${batch.length} tracking points ' 'for passenger $passengerId...'); try { await CRUD().post( link: '${AppLink.locationServerSide}/add_batch.php', payload: { 'driver_id': passengerId, 'batch_data': jsonEncode(batch), 'session_dist': totalDistance.toStringAsFixed(1), }, ); Log.print('✅ Batch uploaded successfully.'); } catch (e) { // Put the points back so they are retried on the next cycle _trackBuffer.insertAll(0, batch); Log.print('❌ Batch upload failed – points kept for retry: $e'); } } // ========================================================================== // ── Car marker ──────────────────────────────────────────────────────────── // ========================================================================== Future _updateCarMarker() async { if (myLocation == null || mapController == null || !isStyleLoaded) return; if (carSymbol == null) { carSymbol = await mapController!.addSymbol(SymbolOptions( geometry: myLocation, iconImage: 'car_icon', iconSize: 1.0, iconRotate: _smoothedHeading, )); } else { mapController!.updateSymbol( carSymbol!, SymbolOptions( geometry: myLocation, iconRotate: _smoothedHeading, ), ); } } // ========================================================================== // ── Camera ──────────────────────────────────────────────────────────────── // ========================================================================== void animateCameraToPosition(LatLng position, {double? zoom, double bearing = 0.0, double tilt = 0.0}) { // Guard: skip if the native view is not ready yet if (!_mapReady || mapController == null) return; mapController!.animateCamera( CameraUpdate.newCameraPosition( CameraPosition( target: position, zoom: zoom ?? (isNavigating ? _targetZoom : 16.0), bearing: bearing, tilt: tilt, ), ), ); } /// Safe wrapper for animateCamera Bounds to prevent native std::domain_error crash on iOS. Future _safeAnimateCameraBounds(LatLngBounds? bounds, {double left = 60, double top = 60, double right = 60, double bottom = 60}) async { if (bounds == null || mapController == null) return; try { // Ensure the coordinates are valid (at least a small span) 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) { Log.print( '⚠️ _safeAnimateCameraBounds: Point-sized bounds, zooming to center.'); mapController ?.animateCamera(CameraUpdate.newLatLngZoom(bounds.northeast, 16)); return; } // Small delay for view stabilization await Future.delayed(const Duration(milliseconds: 200)); await mapController?.animateCamera( CameraUpdate.newLatLngBounds( bounds, left: left, top: top, right: right, bottom: bottom, ), ); } catch (e) { Log.print('❌ _safeAnimateCameraBounds CRASH PREVENTED in Nav: $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, // ← use smoothed heading zoom: _targetZoom, tilt: _targetTilt); } update(); } bool get isCameraLocked => _cameraLockedToUser; // ========================================================================== // ── Route polylines ─────────────────────────────────────────────────────── // ========================================================================== 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 { if (mapController == null || !isStyleLoaded) return; if (remainingRouteLine != null) await mapController!.removeLine(remainingRouteLine!); if (traveledRouteLine != null) await mapController!.removeLine(traveledRouteLine!); if (remaining.isNotEmpty) { remainingRouteLine = await mapController!.addLine(LineOptions( geometry: remaining, lineColor: '#1A73E8', lineWidth: 7.0, lineJoin: 'round', )); } if (traveled.isNotEmpty) { traveledRouteLine = await mapController!.addLine(LineOptions( geometry: traveled, lineColor: '#BDBDBD', lineWidth: 5.0, lineJoin: 'round', lineOpacity: 0.6, )); } } // ========================================================================== // ── Routing API ─────────────────────────────────────────────────────────── // ========================================================================== Future getRoute(LatLng origin, LatLng destination) async { // ── Routing Decision: Normal Points -> SaaS, Multi-Stop -> OSRM ── // Note: NavigationController usually handles the active trip (normal points). final Map queryParams = { 'fromLat': origin.latitude.toString(), 'fromLng': origin.longitude.toString(), 'toLat': destination.latitude.toString(), 'toLng': destination.longitude.toString(), }; final saasUri = Uri.parse(AppLink.mapSaasRoute).replace(queryParameters: queryParams); // Fallback OSRM URL final coords = "${origin.longitude},${origin.latitude};" "${destination.longitude},${destination.latitude}"; final osrmUrl = "$_routeApiBaseUrl/$coords?steps=true&overview=full&geometries=polyline"; try { // 1. Try SaaS first http.Response response = await http.get(saasUri, headers: { 'x-api-key': 'intaleq_secret_2026', }); bool useSaaS = response.statusCode == 200; if (!useSaaS) { Log.print("⚠️ SaaS Route failed. Falling back to OSRM..."); response = await http.get(Uri.parse(osrmUrl)); } if (response.statusCode != 200) { mySnackbarWarning('تعذر الاتصال بخدمة التوجيه.'); return; } final data = jsonDecode(response.body); final bool isSaaS = useSaaS; // ── 2. Data Extraction Logic ────────────────────────────────── String pointsString = ""; dynamic mainRoute; if (isSaaS) { pointsString = data['points']?.toString() ?? ""; mainRoute = data; // SaaS structure is top-level } else { if (data['code'] != 'Ok' || (data['routes'] as List).isEmpty) { mySnackbarWarning('لم يتم العثور على مسار.'); return; } mainRoute = data['routes'][0]; pointsString = mainRoute['geometry']?.toString() ?? ""; } if (pointsString.isEmpty) { mySnackbarWarning('لم يتم العثور على مسار.'); return; } _fullRouteCoordinates = await compute>( decodePolylineIsolate, pointsString); _lastTraveledIndexInFullRoute = 0; if (isStyleLoaded) _updatePolylinesSets([], _fullRouteCoordinates); // ── Offline Cache: Ensure destination area is stored in memory/disk ─── if (_fullRouteCoordinates.isNotEmpty) { OfflineMapService.instance .downloadRegion(_fullRouteCoordinates.last, radiusKm: 2.0); } // Handle legs/steps & totals final legs = mainRoute['legs'] as List?; if (legs != null && legs.isNotEmpty) { routeSteps = List>.from(legs[0]['steps'] as List); _routeTotalDistanceM = (legs[0]['distance'] as num).toDouble(); _routeTotalDurationS = (legs[0]['duration'] as num).toDouble(); } else { // Fallback for SaaS which might have top-level distance/duration routeSteps = []; _routeTotalDistanceM = (mainRoute['distance'] as num).toDouble(); _routeTotalDurationS = (mainRoute['duration'] as num).toDouble(); } if (_routeTotalDistanceM > 0) { totalDistanceRemaining = _routeTotalDistanceM > 1000 ? "${(_routeTotalDistanceM / 1000).toStringAsFixed(1)} كم" : "${_routeTotalDistanceM.toStringAsFixed(0)} م"; final minutes = (_routeTotalDurationS / 60).round(); estimatedTimeRemaining = minutes > 60 ? "${(minutes / 60).floor()} س ${minutes % 60} د" : "$minutes د"; } for (final step in routeSteps) { step['instruction_text'] = _createInstructionFromManeuver(step); } currentStepIndex = 0; _nextInstructionSpoken = false; isNavigating = true; _cameraLockedToUser = true; // Reset off-route state after a successful recalculation _offRouteStartTime = null; if (routeSteps.isNotEmpty) { currentInstruction = routeSteps[0]['instruction_text']; nextInstruction = routeSteps.length > 1 ? "ثم ${routeSteps[1]['instruction_text']}" : "الوجهة النهائية"; Get.find().speakText(currentInstruction); } // ── 5. Camera Update (Safe) ─────────────────────────────────── 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: 220, top: 150, left: 50, right: 50); } update(); } catch (e) { Log.print("GetRoute Error: $e"); } } // Crude but fast ETA re-estimate based on fraction of route remaining. void _recomputeETA() { if (_routeTotalDistanceM == 0 || _fullRouteCoordinates.isEmpty) return; final fraction = (_fullRouteCoordinates.length - _lastTraveledIndexInFullRoute) / _fullRouteCoordinates.length; final remainingM = _routeTotalDistanceM * fraction; final remainingS = _routeTotalDurationS * fraction; totalDistanceRemaining = remainingM > 1000 ? "${(remainingM / 1000).toStringAsFixed(1)} كم" : "${remainingM.toStringAsFixed(0)} م"; final minutes = (remainingS / 60).round(); estimatedTimeRemaining = minutes > 60 ? "${(minutes / 60).floor()} س ${minutes % 60} د" : "$minutes د"; } Future startNavigationTo(LatLng destination, {String infoWindowTitle = ''}) async { isLoading = true; update(); try { _finalDestination = destination; await clearRoute(isNewRoute: true); if (isStyleLoaded && mapController != null) { // Destination Marker (B) destinationSymbol = await mapController!.addSymbol(SymbolOptions( geometry: destination, iconImage: 'dest_icon', iconSize: 1.0, textField: infoWindowTitle, textOffset: const Offset(0, 2), )); // Start Marker (A) if (myLocation != null) { originSymbol = await mapController!.addSymbol(SymbolOptions( geometry: myLocation, iconImage: 'start_icon', iconSize: 1.0, )); } } if (myLocation != null) await getRoute(myLocation!, destination); } finally { isLoading = false; update(); } } Future recalculateRoute() async { if (myLocation == null || _finalDestination == null || isLoading) return; isLoading = true; update(); mySnackbarInfo('جاري حساب مسار جديد...'); await getRoute(myLocation!, _finalDestination!); isLoading = false; update(); } Future clearRoute({bool isNewRoute = false}) async { // Reset off-route state whenever the route is cleared _offRouteStartTime = null; _autoRecalcInProgress = false; if (!isNewRoute) { if (destinationSymbol != null && mapController != null) { await mapController!.removeSymbol(destinationSymbol!); destinationSymbol = null; } if (originSymbol != null && mapController != null) { await mapController!.removeSymbol(originSymbol!); originSymbol = null; } if (remainingRouteLine != null && mapController != null) { await mapController!.removeLine(remainingRouteLine!); remainingRouteLine = null; } if (traveledRouteLine != null && mapController != null) { await mapController!.removeLine(traveledRouteLine!); traveledRouteLine = null; } _finalDestination = null; isNavigating = false; // Flush whatever is in the buffer when navigation ends await _flushBufferToServer(); } routeSteps.clear(); _fullRouteCoordinates.clear(); _lastTraveledIndexInFullRoute = 0; currentInstruction = ""; nextInstruction = ""; distanceToNextStep = ""; totalDistanceRemaining = ""; estimatedTimeRemaining = ""; _routeTotalDistanceM = 0; _routeTotalDurationS = 0; 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()); } // ========================================================================== // ── Step tracking & TTS ─────────────────────────────────────────────────── // ========================================================================== void _checkNavigationStep(LatLng pos) { if (routeSteps.isEmpty || currentStepIndex >= routeSteps.length) return; final maneuver = routeSteps[currentStepIndex]['maneuver']; final loc = maneuver['location'] as List; final endLatLng = LatLng(loc[1] as double, loc[0] as double); final distance = Geolocator.distanceBetween( pos.latitude, pos.longitude, endLatLng.latitude, endLatLng.longitude, ); distanceToNextStep = distance > 1000 ? "${(distance / 1000).toStringAsFixed(1)} كم" : "${distance.toStringAsFixed(0)} م"; if (distance < 50 && !_nextInstructionSpoken && nextInstruction.isNotEmpty) { Get.find().speakText(nextInstruction); _nextInstructionSpoken = true; } if (distance < 20) _advanceStep(); } void _advanceStep() { currentStepIndex++; if (currentStepIndex < routeSteps.length) { currentInstruction = routeSteps[currentStepIndex]['instruction_text']; nextInstruction = (currentStepIndex + 1) < routeSteps.length ? "ثم ${routeSteps[currentStepIndex + 1]['instruction_text']}" : "ستصل إلى وجهتك"; _nextInstructionSpoken = false; update(); } else { _finishNavigation(); } } void _finishNavigation() { currentInstruction = "لقد وصلت إلى وجهتك"; nextInstruction = ""; distanceToNextStep = ""; isNavigating = false; Get.find().speakText(currentInstruction); // Final flush on arrival _flushBufferToServer(); update(); } String _createInstructionFromManeuver(Map step) { if (step['maneuver'] == null) return "تابع المسير"; final type = step['maneuver']['type'] ?? 'continue'; final modifier = step['maneuver']['modifier'] ?? 'straight'; final name = step['name'] ?? ''; String instruction = type == 'depart' ? "انطلق" : type == 'arrive' ? "لقد وصلت إلى وجهتك، $name" : _getTurnInstruction(modifier); if (name.isNotEmpty && type != 'arrive') instruction += " نحو $name"; return instruction; } String _getTurnInstruction(String modifier) { switch (modifier) { case 'uturn': return "قم بالاستدارة والعودة"; case 'sharp right': return "انعطف يميناً بحدة"; case 'right': return "انعطف يميناً"; case 'slight right': return "انعطف يميناً قليلاً"; case 'straight': return "استمر للأمام"; case 'slight left': return "انعطف يساراً قليلاً"; case 'left': return "انعطف يساراً"; case 'sharp left': return "انعطف يساراً بحدة"; default: return "اتجه"; } } // ========================================================================== // ── Search ──────────────────────────────────────────────────────────────── // ========================================================================== Future getPlaces() async { final q = placeDestinationController.text.trim(); if (q.length < 3) { placesDestination = []; update(); return; } if (myLocation == null) return; final lat = myLocation!.latitude; final lng = myLocation!.longitude; const radiusKm = 200.0; final payload = { 'query': q, 'lat_min': (lat - _kmToLatDelta(radiusKm)).toString(), 'lat_max': (lat + _kmToLatDelta(radiusKm)).toString(), 'lng_min': (lng - _kmToLngDelta(radiusKm, lat)).toString(), 'lng_max': (lng + _kmToLngDelta(radiusKm, lat)).toString(), }; try { final response = await CRUD().post(link: AppLink.getPlacesSyria, payload: payload); List list; if (response is Map && response['status'] == 'success') list = List.from(response['message'] as List); else if (response is List) list = List.from(response); else return; for (final p in list) { final plat = double.tryParse(p['latitude']?.toString() ?? '0') ?? 0.0; final plng = double.tryParse(p['longitude']?.toString() ?? '0') ?? 0.0; p['distanceKm'] = _haversineKm(lat, lng, plat, plng); } list.sort((a, b) => (a['distanceKm'] as double).compareTo(b['distanceKm'] as double)); placesDestination = list; 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: 700), () => getPlaces()); } // ========================================================================== // ── Geo utils ───────────────────────────────────────────────────────────── // ========================================================================== 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)); } double _kmToLatDelta(double km) => km / 111.32; double _kmToLngDelta(double km, double lat) => km / (111.32 * cos(lat * pi / 180)); 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!)); } }