diff --git a/lib/controller/home/map_passenger_controller.dart b/lib/controller/home/map_passenger_controller.dart index 4c7dbf0..c3d57cd 100644 --- a/lib/controller/home/map_passenger_controller.dart +++ b/lib/controller/home/map_passenger_controller.dart @@ -2995,7 +2995,6 @@ class MapPassengerController extends GetxController { Future?> extractCoordinatesFromLinkAsync( String link) async { try { - // 1. استخراج الرابط فقط من النص (في حال كان هناك نص مع الرابط في الواتساب) int urlStartIndex = link.indexOf(RegExp(r'https?://')); if (urlStartIndex == -1) return null; String cleanLink = link.substring(urlStartIndex).trim(); @@ -3003,67 +3002,44 @@ class MapPassengerController extends GetxController { Uri uri = Uri.parse(cleanLink); String finalUrl = cleanLink; - // 2. فك الروابط المختصرة (Unshorten URLs) + // فك التوجيه للروابط المختصرة if (cleanLink.contains('goo.gl') || - cleanLink.contains('maps.app.goo.gl')) { + cleanLink.contains('maps.google.com')) { try { - // نقوم بطلب HTTP عادي، وhttp يتبع التوجيه التلقائي (Redirects) - var response = await http.get(uri); - // نأخذ الرابط النهائي بعد التوجيه + var response = + await http.get(uri).timeout(const Duration(seconds: 5)); finalUrl = response.request?.url.toString() ?? cleanLink; } catch (e) { - Log.print('Failed to follow redirect: $e'); + Log.print('Redirect logic failed, using original: $e'); } } - Log.print('Final Unshortened URL: $finalUrl'); + // الأنماط المشتركة لخرائط جوجل (تكون دائماً Lat ثم Lng) + RegExp regex = RegExp(r'(-?\d+\.\d+)[,/~](-?\d+\.\d+)'); + var match = regex.firstMatch(finalUrl); - // 3. استخراج الإحداثيات باستخدام تعبيرات نمطية (Regex) قوية تغطي خرائط جوجل وغيرها + if (match != null) { + double lat = double.parse(match.group(1)!); + double lng = double.parse(match.group(2)!); + + // 🔥 منطق التصحيح الذاتي (Smart Swap) للمنطقة (سوريا/الأردن/مصر) + // إذا كان الرقم الأول أكبر من الرقم الثاني بشكل واضح، فهذا يعني أن الرابط مقلوب أو أننا نحتاج للتأكد + // في منطقتنا Latitude حوالي 30-35 و Longitude حوالي 36-44 + if (lat > 40 && lat > lng) { + Log.print("⚠️ Detected Swapped Coordinates in Link. Correcting..."); + double temp = lat; + lat = lng; + lng = temp; + } - // النمط الأول: @lat,lng (الأكثر شيوعاً في خرائط جوجل) - RegExp regexAt = RegExp(r'@(-?\d+\.\d+),(-?\d+\.\d+)'); - var matchAt = regexAt.firstMatch(finalUrl); - if (matchAt != null) { return { - 'latitude': double.parse(matchAt.group(1)!), - 'longitude': double.parse(matchAt.group(2)!), - }; - } - - // النمط الثاني: q=lat,lng أو ll=lat,lng أو query=lat,lng - RegExp regexQuery = - RegExp(r'(?:q|ll|query)=(-?\d+\.\d+)[,~](-?\d+\.\d+)'); - var matchQuery = regexQuery.firstMatch(finalUrl); - if (matchQuery != null) { - return { - 'latitude': double.parse(matchQuery.group(1)!), - 'longitude': double.parse(matchQuery.group(2)!), - }; - } - - // النمط الثالث: search/lat,lng (موجود في بعض أشكال خرائط جوجل) - RegExp regexSearch = RegExp(r'search/(-?\d+\.\d+),(-?\d+\.\d+)'); - var matchSearch = regexSearch.firstMatch(finalUrl); - if (matchSearch != null) { - return { - 'latitude': double.parse(matchSearch.group(1)!), - 'longitude': double.parse(matchSearch.group(2)!), - }; - } - - // النمط الرابع: place/lat,lng (غالباً متواجد في الروابط المشتركة من خرائط جوجل) - RegExp regexPlace = RegExp(r'place/(-?\d+\.\d+),(-?\d+\.\d+)'); - var matchPlace = regexPlace.firstMatch(finalUrl); - if (matchPlace != null) { - return { - 'latitude': double.parse(matchPlace.group(1)!), - 'longitude': double.parse(matchPlace.group(2)!), + 'latitude': lat, + 'longitude': lng, }; } } catch (e) { Log.print('Error parsing location link: $e'); } - return null; } @@ -3087,16 +3063,36 @@ class MapPassengerController extends GetxController { void goToWhatappLocation() async { if (sosFormKey.currentState!.validate()) { - changeIsWhatsAppOrder(true); - Get.back(); - handleWhatsAppLink(whatsAppLocationText.text); - myDestination = LatLng(latitudeWhatsApp, longitudeWhatsApp); - await mapController?.animateCamera(CameraUpdate.newLatLng( - LatLng(passengerLocation.latitude, passengerLocation.longitude))); - changeMainBottomMenuMap(); - passengerStartLocationFromMap = true; - isPickerShown = true; - update(); + // 1. استخراج الإحداثيات أولاً بشكل محلي لضمان عدم حدوث سباق بيانات (Race Condition) + Map? coordinates = + await extractCoordinatesFromLinkAsync(whatsAppLocationText.text); + + if (coordinates != null) { + latitudeWhatsApp = coordinates['latitude']!; + longitudeWhatsApp = coordinates['longitude']!; + + Log.print( + '📍 Final Coordinates for OSM: Lat: $latitudeWhatsApp, Lng: $longitudeWhatsApp'); + + changeIsWhatsAppOrder(true); + Get.back(); + + // إعداد الوجهة + myDestination = LatLng(latitudeWhatsApp, longitudeWhatsApp); + + // تحريك الكاميرا لموقع الراكب (البداية) وليس الوجهة فوراً لضمان تحميل الخريطة + if (passengerLocation != null) { + await mapController?.animateCamera(CameraUpdate.newLatLng( + LatLng(passengerLocation.latitude, passengerLocation.longitude))); + } + + changeMainBottomMenuMap(); + passengerStartLocationFromMap = true; + isPickerShown = true; + update(); + } else { + mySnackbarWarning('لم نتمكن من استخراج الموقع من الرابط'); + } } } diff --git a/lib/views/home/map_widget.dart/left_main_menu_icons.dart b/lib/views/home/map_widget.dart/left_main_menu_icons.dart index ec85eab..f9d81ca 100644 --- a/lib/views/home/map_widget.dart/left_main_menu_icons.dart +++ b/lib/views/home/map_widget.dart/left_main_menu_icons.dart @@ -44,7 +44,7 @@ GetBuilder leftMainMenuIcons() { children: [ // --- تم استخدام دالة مساعدة جديدة للزر --- _buildMapActionButton( - icon: Icons.satellite_alt_outlined, + icon: Icons.near_me_outlined, tooltip: 'Toggle Map Type', onPressed: () => Get.to(() => NavigationView()), ), diff --git a/lib/views/home/navigation/navigation_controller.dart b/lib/views/home/navigation/navigation_controller.dart index f3c37d7..3556411 100644 --- a/lib/views/home/navigation/navigation_controller.dart +++ b/lib/views/home/navigation/navigation_controller.dart @@ -7,20 +7,37 @@ 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'; // Replaced Google Maps +import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:http/http.dart' as http; import '../../../constant/box_name.dart'; -import '../../../constant/colors.dart'; -import '../../../constant/country_polygons.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'; class NavigationController extends GetxController { + // ========================================================================== + // ── 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; + + // ========================================================================== + // ── Map state ───────────────────────────────────────────────────────────── + // ========================================================================== + bool isLoading = false; MaplibreMapController? mapController; bool isStyleLoaded = false; @@ -29,36 +46,88 @@ class NavigationController extends GetxController { LatLng? myLocation; double heading = 0.0; + double currentSpeed = 0.0; // km/h + double totalDistance = 0.0; // metres accumulated this session - // MapLibre Object Tracking + // MapLibre objects Symbol? carSymbol; Symbol? destinationSymbol; Line? remainingRouteLine; Line? traveledRouteLine; + // General location polling Timer? _locationUpdateTimer; - final Duration _currentUpdateInterval = const Duration(seconds: 1); - LatLng? _lastRecordedLocation; + 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; - - double currentSpeed = 0.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; + + // ========================================================================== + // ── 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(); @@ -75,15 +144,20 @@ class NavigationController extends GetxController { @override void onClose() { _locationUpdateTimer?.cancel(); - mapController?.dispose(); + _recordTimer?.cancel(); + _uploadBatchTimer?.cancel(); _debounce?.cancel(); + mapController?.dispose(); placeDestinationController.dispose(); + + // Final flush before closing so no points are lost + _flushBufferToServer(); super.onClose(); } - // ======================================================================= - // Map Initialization & Callbacks - // ======================================================================= + // ========================================================================== + // ── Map callbacks ───────────────────────────────────────────────────────── + // ========================================================================== void onMapCreated(MaplibreMapController controller) { mapController = controller; @@ -104,18 +178,26 @@ class NavigationController extends GetxController { } Future onMapLongPressed(Point point, LatLng tappedPoint) async { + HapticFeedback.mediumImpact(); Get.dialog( AlertDialog( - title: const Text('بدء الملاحة؟'), - content: const Text('هل تريد الذهاب إلى هذا الموقع المحدد؟'), - actionsAlignment: MainAxisAlignment.spaceBetween, + 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(), ), - TextButton( - child: const Text('اذهب الآن'), + 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: 'الموقع المحدد'); @@ -126,18 +208,19 @@ class NavigationController extends GetxController { ); } - // ======================================================================= - // Location Management - // ======================================================================= + // ========================================================================== + // ── Location polling (every second) ────────────────────────────────────── + // ========================================================================== Future _getCurrentLocationAndStartUpdates() async { try { - Position position = await Geolocator.getCurrentPosition( + final position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high); myLocation = LatLng(position.latitude, position.longitude); 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"); } @@ -145,45 +228,159 @@ class NavigationController extends GetxController { void _startLocationTimer() { _locationUpdateTimer?.cancel(); - _locationUpdateTimer = Timer.periodic(_currentUpdateInterval, (timer) { - _updateLocationAndProcess(); - }); + _locationUpdateTimer = + Timer.periodic(const Duration(seconds: 1), (_) => _tick()); } - Future _updateLocationAndProcess() async { + Future _tick() async { try { final position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high); final newLoc = LatLng(position.latitude, position.longitude); - if (_lastRecordedLocation != null) { - double dist = Geolocator.distanceBetween( - newLoc.latitude, - newLoc.longitude, - _lastRecordedLocation!.latitude, - _lastRecordedLocation!.longitude); - if (dist < 2.0) return; + // 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; + myLocation = newLoc; - _lastRecordedLocation = newLoc; + _lastProcessedLocation = newLoc; heading = position.heading; currentSpeed = position.speed * 3.6; if (isStyleLoaded) _updateCarMarker(); if (_fullRouteCoordinates.isNotEmpty) { - animateCameraToPosition(myLocation!, bearing: heading, zoom: 18.0); + if (_cameraLockedToUser) { + animateCameraToPosition(myLocation!, + bearing: heading, zoom: _targetZoom, tilt: _targetTilt); + } _updateTraveledPolylineSmart(myLocation!); _checkNavigationStep(myLocation!); + _recomputeETA(); } update(); + } catch (_) {} + } + + // ========================================================================== + // ── 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) { - // Log.print("Loc update error: $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; @@ -196,60 +393,84 @@ class NavigationController extends GetxController { )); } else { mapController!.updateSymbol( - carSymbol!, - SymbolOptions( - geometry: myLocation, - iconRotate: heading, - )); + carSymbol!, + SymbolOptions(geometry: myLocation, iconRotate: heading), + ); } } + // ========================================================================== + // ── Camera ──────────────────────────────────────────────────────────────── + // ========================================================================== + void animateCameraToPosition(LatLng position, - {double zoom = 17.0, double bearing = 0.0}) { + {double? zoom, double bearing = 0.0, double tilt = 0.0}) { mapController?.animateCamera( CameraUpdate.newCameraPosition( CameraPosition( - target: position, zoom: zoom, bearing: bearing, tilt: 45.0), + target: position, + zoom: zoom ?? (isNavigating ? _targetZoom : 16.0), + bearing: bearing, + tilt: tilt, + ), ), ); } - // ======================================================================= - // Route Management - // ======================================================================= + void onUserPanned() { + _cameraLockedToUser = false; + update(); + } + + void relockCameraToUser() { + _cameraLockedToUser = true; + if (myLocation != null) { + animateCameraToPosition(myLocation!, + bearing: heading, zoom: _targetZoom, tilt: _targetTilt); + } + update(); + } + + bool get isCameraLocked => _cameraLockedToUser; + + // ========================================================================== + // ── Route polylines ─────────────────────────────────────────────────────── + // ========================================================================== void _updateTraveledPolylineSmart(LatLng currentPos) { if (_fullRouteCoordinates.isEmpty) return; - int searchWindow = 60; - int startIndex = _lastTraveledIndexInFullRoute; - int endIndex = min(startIndex + searchWindow, _fullRouteCoordinates.length); + const int searchWindow = 60; + final int startIndex = _lastTraveledIndexInFullRoute; + final int endIndex = + min(startIndex + searchWindow, _fullRouteCoordinates.length); - double minDistance = double.infinity; - int closestIndex = startIndex; + double minDist = double.infinity; + int closestIdx = startIndex; bool foundCloser = false; for (int i = startIndex; i < endIndex; i++) { - final point = _fullRouteCoordinates[i]; - final dist = Geolocator.distanceBetween(currentPos.latitude, - currentPos.longitude, point.latitude, point.longitude); - - if (dist < minDistance) { - minDistance = dist; - closestIndex = 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 && - minDistance < 50 && - closestIndex > _lastTraveledIndexInFullRoute) { - _lastTraveledIndexInFullRoute = closestIndex; - final remaining = - _fullRouteCoordinates.sublist(_lastTraveledIndexInFullRoute); - final traveled = - _fullRouteCoordinates.sublist(0, _lastTraveledIndexInFullRoute + 1); - _updatePolylinesSets(traveled, remaining); + minDist < 50 && + closestIdx > _lastTraveledIndexInFullRoute) { + _lastTraveledIndexInFullRoute = closestIdx; + _updatePolylinesSets( + _fullRouteCoordinates.sublist(0, closestIdx + 1), + _fullRouteCoordinates.sublist(closestIdx), + ); } } @@ -265,8 +486,8 @@ class NavigationController extends GetxController { if (remaining.isNotEmpty) { remainingRouteLine = await mapController!.addLine(LineOptions( geometry: remaining, - lineColor: '#0D47A1', - lineWidth: 6.0, + lineColor: '#1A73E8', + lineWidth: 7.0, lineJoin: 'round', )); } @@ -275,20 +496,21 @@ class NavigationController extends GetxController { traveledRouteLine = await mapController!.addLine(LineOptions( geometry: traveled, lineColor: '#BDBDBD', - lineWidth: 6.0, + lineWidth: 5.0, lineJoin: 'round', + lineOpacity: 0.6, )); } } - // ======================================================================= - // Routing API & Navigation - // ======================================================================= + // ========================================================================== + // ── Routing API ─────────────────────────────────────────────────────────── + // ========================================================================== Future getRoute(LatLng origin, LatLng destination) async { - String coords = - "${origin.longitude},${origin.latitude};${destination.longitude},${destination.latitude}"; - String url = + final coords = "${origin.longitude},${origin.latitude};" + "${destination.longitude},${destination.latitude}"; + final url = "$_routeApiBaseUrl/$coords?steps=true&overview=full&geometries=polyline"; try { @@ -298,36 +520,47 @@ class NavigationController extends GetxController { return; } - final responseData = jsonDecode(response.body); - if (responseData['code'] != 'Ok' || - (responseData['routes'] as List).isEmpty) { + final data = jsonDecode(response.body); + if (data['code'] != 'Ok' || (data['routes'] as List).isEmpty) { mySnackbarWarning('لم يتم العثور على مسار.'); return; } - var route = responseData['routes'][0]; - final pointsString = route['geometry']; - // فك تشفير Polyline بطريقة آمنة نوعياً (Type-Safe) + final route = data['routes'][0]; _fullRouteCoordinates = await compute>( - decodePolylineIsolate, pointsString.toString()); + decodePolylineIsolate, route['geometry'].toString()); _lastTraveledIndexInFullRoute = 0; if (isStyleLoaded) _updatePolylinesSets([], _fullRouteCoordinates); - var legs = route['legs'] as List; + final legs = route['legs'] as List; if (legs.isNotEmpty) { - var steps = legs[0]['steps'] as List; - routeSteps = List>.from(steps); + routeSteps = List>.from(legs[0]['steps'] as List); + + _routeTotalDistanceM = (legs[0]['distance'] as num).toDouble(); + _routeTotalDurationS = (legs[0]['duration'] as num).toDouble(); + + totalDistanceRemaining = _routeTotalDistanceM > 1000 + ? "${(_routeTotalDistanceM / 1000).toStringAsFixed(1)} كم" + : "${_routeTotalDistanceM.toStringAsFixed(0)} م"; + + final minutes = (_routeTotalDurationS / 60).round(); + estimatedTimeRemaining = minutes > 60 + ? "${(minutes / 60).floor()} س ${minutes % 60} د" + : "$minutes د"; } else { routeSteps = []; } - for (var step in routeSteps) { + for (final step in routeSteps) { step['instruction_text'] = _createInstructionFromManeuver(step); } currentStepIndex = 0; _nextInstructionSpoken = false; + isNavigating = true; + _cameraLockedToUser = true; + if (routeSteps.isNotEmpty) { currentInstruction = routeSteps[0]['instruction_text']; nextInstruction = routeSteps.length > 1 @@ -339,17 +572,35 @@ class NavigationController extends GetxController { if (_fullRouteCoordinates.isNotEmpty) { final bounds = _boundsFromLatLngList(_fullRouteCoordinates); mapController?.animateCamera(CameraUpdate.newLatLngBounds(bounds, - bottom: 200, top: 150, left: 50, right: 50)); + bottom: 220, top: 150, left: 50, right: 50)); } update(); } catch (e) { Log.print("GetRoute Error: $e"); - Get.snackbar('خطأ', 'حدث خطأ غير متوقع.'); } } - // --- Map Object Handlers --- + // 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 { @@ -380,8 +631,7 @@ class NavigationController extends GetxController { if (myLocation == null || _finalDestination == null || isLoading) return; isLoading = true; update(); - Get.snackbar('إعادة التوجيه', 'جاري حساب مسار جديد...', - backgroundColor: AppColor.goldenBronze); + mySnackbarInfo('جاري حساب مسار جديد...'); await getRoute(myLocation!, _finalDestination!); isLoading = false; update(); @@ -402,6 +652,10 @@ class NavigationController extends GetxController { traveledRouteLine = null; } _finalDestination = null; + isNavigating = false; + + // Flush whatever is in the buffer when navigation ends + await _flushBufferToServer(); } routeSteps.clear(); _fullRouteCoordinates.clear(); @@ -409,39 +663,42 @@ class NavigationController extends GetxController { currentInstruction = ""; nextInstruction = ""; distanceToNextStep = ""; + totalDistanceRemaining = ""; + estimatedTimeRemaining = ""; + _routeTotalDistanceM = 0; + _routeTotalDurationS = 0; update(); } Future _loadCustomIcons() async { if (mapController == null) return; - - final ByteData carBytes = await rootBundle.load('assets/images/car.png'); - final Uint8List carList = carBytes.buffer.asUint8List(); - await mapController!.addImage('car_icon', carList); - - final ByteData destBytes = await rootBundle.load('assets/images/b.png'); - final Uint8List destList = destBytes.buffer.asUint8List(); - await mapController!.addImage('dest_icon', destList); + final carBytes = await rootBundle.load('assets/images/car.png'); + final destBytes = await rootBundle.load('assets/images/b.png'); + await mapController!.addImage('car_icon', carBytes.buffer.asUint8List()); + await mapController!.addImage('dest_icon', destBytes.buffer.asUint8List()); } - // --- Step Tracking & Instructions (Omitted unchanged logic to save space, retain your existing string matchers) --- - void _checkNavigationStep(LatLng currentPosition) { + // ========================================================================== + // ── Step tracking & TTS ─────────────────────────────────────────────────── + // ========================================================================== + + void _checkNavigationStep(LatLng pos) { if (routeSteps.isEmpty || currentStepIndex >= routeSteps.length) return; - final step = routeSteps[currentStepIndex]; - final maneuver = step['maneuver']; - final List location = maneuver['location']; - final endLatLng = LatLng(location[1], location[0]); + + 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( - currentPosition.latitude, - currentPosition.longitude, + pos.latitude, + pos.longitude, endLatLng.latitude, endLatLng.longitude, ); distanceToNextStep = distance > 1000 ? "${(distance / 1000).toStringAsFixed(1)} كم" - : "${distance.toStringAsFixed(0)} متر"; + : "${distance.toStringAsFixed(0)} م"; if (distance < 50 && !_nextInstructionSpoken && @@ -471,44 +728,26 @@ class NavigationController extends GetxController { 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 maneuver = step['maneuver']; - final type = maneuver['type'] ?? 'continue'; - final modifier = maneuver['modifier'] ?? 'straight'; + final type = step['maneuver']['type'] ?? 'continue'; + final modifier = step['maneuver']['modifier'] ?? 'straight'; final name = step['name'] ?? ''; - String instruction = ""; - switch (type) { - case 'depart': - instruction = "انطلق"; - break; - case 'arrive': - return "لقد وصلت إلى وجهتك، $name"; - case 'turn': - case 'fork': - case 'roundabout': - case 'merge': - case 'on ramp': - case 'off ramp': - case 'end of road': - instruction = _getTurnInstruction(modifier); - break; - case 'new name': - instruction = "تابع المسير"; - break; - default: - instruction = "تابع المسير"; - } + String instruction = type == 'depart' + ? "انطلق" + : type == 'arrive' + ? "لقد وصلت إلى وجهتك، $name" + : _getTurnInstruction(modifier); - if (name.isNotEmpty) - instruction += (type == 'new name' || type == 'continue') - ? " على $name" - : " نحو $name"; + if (name.isNotEmpty && type != 'arrive') instruction += " نحو $name"; return instruction; } @@ -535,7 +774,10 @@ class NavigationController extends GetxController { } } - // --- Search & Utils (Retained entirely, no map logic here) --- + // ========================================================================== + // ── Search ──────────────────────────────────────────────────────────────── + // ========================================================================== + Future getPlaces() async { final q = placeDestinationController.text.trim(); if (q.length < 3) { @@ -548,6 +790,7 @@ class NavigationController extends GetxController { final lat = myLocation!.latitude; final lng = myLocation!.longitude; const radiusKm = 200.0; + final payload = { 'query': q, 'lat_min': (lat - _kmToLatDelta(radiusKm)).toString(), @@ -568,9 +811,8 @@ class NavigationController extends GetxController { return; for (final p in list) { - final plat = double.tryParse(p['latitude']?.toString() ?? '0.0') ?? 0.0; - final plng = - double.tryParse(p['longitude']?.toString() ?? '0.0') ?? 0.0; + 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); } @@ -579,15 +821,15 @@ class NavigationController extends GetxController { placesDestination = list; update(); } catch (e) { - print('Exception in getPlaces: $e'); + Log.print('getPlaces error: $e'); } } Future selectDestination(dynamic place) async { placeDestinationController.clear(); placesDestination = []; - final double lat = double.parse(place['latitude'].toString()); - final double lng = double.parse(place['longitude'].toString()); + final lat = double.parse(place['latitude'].toString()); + final lng = double.parse(place['longitude'].toString()); await startNavigationTo(LatLng(lat, lng), infoWindowTitle: place['name'] ?? 'وجهة'); } @@ -597,6 +839,10 @@ class NavigationController extends GetxController { _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); @@ -615,15 +861,15 @@ class NavigationController extends GetxController { LatLngBounds _boundsFromLatLngList(List list) { double? x0, x1, y0, y1; - for (LatLng latLng in list) { + for (final ll in list) { if (x0 == null) { - x0 = x1 = latLng.latitude; - y0 = y1 = latLng.longitude; + x0 = x1 = ll.latitude; + y0 = y1 = ll.longitude; } else { - if (latLng.latitude > x1!) x1 = latLng.latitude; - if (latLng.latitude < x0) x0 = latLng.latitude; - if (latLng.longitude > y1!) y1 = latLng.longitude; - if (latLng.longitude < y0!) y0 = latLng.longitude; + 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( diff --git a/lib/views/home/navigation/navigation_view.dart b/lib/views/home/navigation/navigation_view.dart index a308e9c..ac6bcfa 100644 --- a/lib/views/home/navigation/navigation_view.dart +++ b/lib/views/home/navigation/navigation_view.dart @@ -1,223 +1,235 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; // Replaced Google Maps import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; import 'navigation_controller.dart'; -const Color kPrimaryColor = Color(0xFF0D47A1); +// ─── Brand colours ─────────────────────────────────────────────────────────── +const Color _kBlue = Color(0xFF1A73E8); +const Color _kBlueDark = Color(0xFF0D47A1); +const Color _kSurface = Color(0xFFFFFFFF); +const Color _kText = Color(0xFF1C1C1E); +const Color _kSubtext = Color(0xFF6B7280); +const Color _kGreen = Color(0xFF34A853); class NavigationView extends StatelessWidget { const NavigationView({super.key}); @override Widget build(BuildContext context) { - final NavigationController controller = Get.put(NavigationController()); + final NavigationController c = Get.put(NavigationController()); - return Scaffold( - body: GetBuilder( - builder: (_) => Stack( - children: [ - // --- الخريطة --- - MapLibreMap( - onMapCreated: controller.onMapCreated, - onStyleLoadedCallback: controller.onStyleLoaded, - onMapLongClick: controller.onMapLongPressed, - styleString: "assets/style.json", - initialCameraPosition: CameraPosition( - target: controller.myLocation ?? const LatLng(33.5138, 36.2765), - zoom: 16.0, - ), - myLocationEnabled: false, - compassEnabled: false, - ), - - // --- واجهة البحث (تصميم زجاجي) --- - _buildGlassSearchUI(controller), - - // --- إرشادات الملاحة (تصميم عائم) --- - if (controller.currentInstruction.isNotEmpty) - _buildFloatingNavigationUI(controller), - - // --- أزرار التحكم (تصميم عائم) --- - _buildFloatingMapControls(controller), - - // --- مؤشر التحميل --- - if (controller.isLoading) - Container( - color: Colors.black.withOpacity(0.5), - child: const Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Colors.white), - strokeWidth: 3, - ), - ), - ), - ], - ), - ), - ); - } - - // --- All UI Sub-Widgets remain identical, simply change the 'if' checks to rely on the new variables --- - - Widget _buildGlassSearchUI(NavigationController controller) { - return Positioned( - top: 0, - left: 0, - right: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), - child: Column( + return AnnotatedRegion( + value: SystemUiOverlayStyle.dark, + child: Scaffold( + backgroundColor: Colors.black, + body: GetBuilder( + builder: (_) => Stack( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(28.0), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), - child: Container( - height: 56, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.85), - borderRadius: BorderRadius.circular(28.0), - border: Border.all(color: Colors.white.withOpacity(0.4)), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 15, - offset: const Offset(0, 5)), - ], - ), - child: Row( - children: [ - const Padding( - padding: EdgeInsets.only(left: 18.0, right: 10.0), - child: Icon(Icons.search, - color: kPrimaryColor, size: 24), - ), - Expanded( - child: TextField( - controller: controller.placeDestinationController, - onChanged: controller.onSearchChanged, - textInputAction: TextInputAction.search, - style: const TextStyle( - fontSize: 16, color: Colors.black87), - decoration: const InputDecoration( - hintText: 'إلى أين تريد الذهاب؟', - hintStyle: TextStyle( - color: Colors.black45, fontSize: 16), - border: InputBorder.none, - contentPadding: EdgeInsets.only(bottom: 2), - ), - ), - ), - if (controller - .placeDestinationController.text.isNotEmpty) - _buildClearButton(controller) - else if (controller.destinationSymbol != - null) // Changed condition here - _buildCancelRouteButton(controller), - ], - ), - ), + // ── Map ──────────────────────────────────────────────────── + MapLibreMap( + onMapCreated: c.onMapCreated, + onStyleLoadedCallback: c.onStyleLoaded, + onMapLongClick: c.onMapLongPressed, + styleString: "assets/style.json", + initialCameraPosition: CameraPosition( + target: c.myLocation ?? const LatLng(33.5138, 36.2765), + zoom: 16.0, ), + myLocationEnabled: false, + compassEnabled: false, + trackCameraPosition: true, ), - const SizedBox(height: 10), - if (controller.placesDestination.isNotEmpty) - _buildSearchResultsList(controller), + + // ── Top: search bar (always visible) ────────────────────── + if (!c.isNavigating) _SearchBar(controller: c), + + // ── Top: turn banner (navigation only) ──────────────────── + if (c.isNavigating && c.currentInstruction.isNotEmpty) + _TurnBanner(controller: c), + + // ── Right: floating map controls ────────────────────────── + _MapControls(controller: c), + + // ── Bottom: route summary card ──────────────────────────── + if (!c.isNavigating && c.destinationSymbol != null) + _RouteSummaryCard(controller: c), + + // ── Bottom: navigation HUD ──────────────────────────────── + if (c.isNavigating) _NavigationHUD(controller: c), + + // ── Search results overlay ──────────────────────────────── + if (c.placesDestination.isNotEmpty && !c.isNavigating) + _SearchResults(controller: c), + + // ── Speed badge (navigating) ────────────────────────────── + if (c.isNavigating) _SpeedBadge(speed: c.currentSpeed), + + // ── Loading overlay ─────────────────────────────────────── + if (c.isLoading) const _LoadingOverlay(), ], ), ), ), ); } +} - Widget _buildClearButton(NavigationController controller) { - return IconButton( - icon: const Icon(Icons.clear, color: Colors.grey, size: 22), - onPressed: () { - controller.placeDestinationController.clear(); - controller.placesDestination = []; - controller.update(); - }, - ); - } +// ───────────────────────────────────────────────────────────────────────────── +// Search Bar +// ───────────────────────────────────────────────────────────────────────────── +class _SearchBar extends StatelessWidget { + final NavigationController controller; + const _SearchBar({required this.controller}); - Widget _buildCancelRouteButton(NavigationController controller) { - return IconButton( - tooltip: 'إلغاء المسار', - icon: const Icon(Icons.close, color: Colors.redAccent, size: 22), - onPressed: () => controller.clearRoute(), - ); - } - - Widget _buildSearchResultsList(NavigationController controller) { - return ClipRRect( - borderRadius: BorderRadius.circular(24.0), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), - child: Container( - constraints: const BoxConstraints(maxHeight: 220), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.85), - borderRadius: BorderRadius.circular(24.0), - border: Border.all(color: Colors.white.withOpacity(0.4)), + @override + Widget build(BuildContext context) { + return Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _GlassCard( + padding: EdgeInsets.zero, + borderRadius: 18, + child: Row( + children: [ + const SizedBox(width: 16), + Icon(Icons.search_rounded, color: _kBlue, size: 22), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: controller.placeDestinationController, + onChanged: controller.onSearchChanged, + textInputAction: TextInputAction.search, + style: const TextStyle( + fontSize: 16, + color: _kText, + fontWeight: FontWeight.w500), + decoration: InputDecoration( + hintText: 'إلى أين تريد الذهاب؟', + hintStyle: TextStyle(color: _kSubtext, fontSize: 15), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ), + if (controller.placeDestinationController.text.isNotEmpty) + _IconBtn( + icon: Icons.close_rounded, + color: _kSubtext, + onTap: () { + controller.placeDestinationController.clear(); + controller.placesDestination = []; + controller.update(); + }, + ) + else if (controller.destinationSymbol != null) + _IconBtn( + icon: Icons.close_rounded, + color: Colors.redAccent, + onTap: () => controller.clearRoute(), + ), + const SizedBox(width: 4), + ], + ), ), - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8.0), - itemCount: controller.placesDestination.length, - itemBuilder: (context, index) { - final place = controller.placesDestination[index]; - final distance = place['distanceKm'] as double?; - final address = (place['address'] ?? '').toString(); + ), + ), + ); + } +} - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => controller.selectDestination(place), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, vertical: 12.0), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: kPrimaryColor.withOpacity(0.1), - shape: BoxShape.circle), - child: const Icon(Icons.location_on_outlined, - color: kPrimaryColor, size: 20), +// ───────────────────────────────────────────────────────────────────────────── +// Search Results +// ───────────────────────────────────────────────────────────────────────────── +class _SearchResults extends StatelessWidget { + final NavigationController controller; + const _SearchResults({required this.controller}); + + @override + Widget build(BuildContext context) { + return Positioned( + top: MediaQuery.of(context).padding.top + 76, + left: 16, + right: 16, + child: _GlassCard( + borderRadius: 18, + padding: const EdgeInsets.symmetric(vertical: 6), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 260), + child: ListView.separated( + shrinkWrap: true, + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: controller.placesDestination.length, + separatorBuilder: (_, __) => + Divider(height: 1, color: Colors.grey[100], indent: 56), + itemBuilder: (_, i) { + final place = controller.placesDestination[i]; + final dist = place['distanceKm'] as double?; + return InkWell( + onTap: () => controller.selectDestination(place), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: _kBlue.withOpacity(0.08), + shape: BoxShape.circle, ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(place['name'] ?? 'اسم غير معروف', - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, - color: Colors.black87), + child: const Icon(Icons.place_rounded, + color: _kBlue, size: 18), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(place['name'] ?? '', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14.5, + color: _kText), + maxLines: 1, + overflow: TextOverflow.ellipsis), + if ((place['address'] ?? '').isNotEmpty) + Text(place['address'], + style: TextStyle( + fontSize: 12.5, color: _kSubtext), maxLines: 1, overflow: TextOverflow.ellipsis), - if (address.isNotEmpty) - Text(address, - style: const TextStyle( - color: Colors.black54, fontSize: 13), - maxLines: 1, - overflow: TextOverflow.ellipsis), - ], + ], + ), + ), + if (dist != null) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: _kBlue.withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${dist.toStringAsFixed(1)} كم', + style: const TextStyle( + color: _kBlue, + fontSize: 12, + fontWeight: FontWeight.w600), ), ), - const SizedBox(width: 10), - if (distance != null) - Text('${distance.toStringAsFixed(1)} كم', - style: const TextStyle( - color: kPrimaryColor, - fontWeight: FontWeight.w500, - fontSize: 13)), ], - ), + ], ), ), ); @@ -227,142 +239,583 @@ class NavigationView extends StatelessWidget { ), ); } +} - Widget _buildFloatingMapControls(NavigationController controller) { - return Positioned( - bottom: controller.currentInstruction.isNotEmpty ? 190 : 24, - right: 16, - child: Column( - children: [ - if (controller.destinationSymbol != null) ...[ - // Changed condition - FloatingActionButton( - heroTag: 'rerouteBtn', - backgroundColor: Colors.white, - elevation: 6, - onPressed: () => controller.recalculateRoute(), - tooltip: 'إعادة حساب المسار', - child: const Icon(Icons.sync_alt, color: kPrimaryColor, size: 24), - ), - const SizedBox(height: 12), - ], - FloatingActionButton( - heroTag: 'gpsBtn', - backgroundColor: Colors.white, - elevation: 6, - onPressed: () { - if (controller.myLocation != null) { - controller.animateCameraToPosition(controller.myLocation!, - bearing: controller.heading, zoom: 18.5); - } - }, - child: const Icon(Icons.gps_fixed, color: Colors.black54, size: 24), - ), - ], - ), - ); - } +// ───────────────────────────────────────────────────────────────────────────── +// Turn Banner (top card during navigation — like Google Maps) +// ───────────────────────────────────────────────────────────────────────────── +class _TurnBanner extends StatelessWidget { + final NavigationController controller; + const _TurnBanner({required this.controller}); - Widget _buildFloatingNavigationUI(NavigationController controller) { + @override + Widget build(BuildContext context) { return Positioned( - bottom: 16, - left: 16, - right: 16, - child: Container( - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF1E88E5), Color(0xFF0D47A1)], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - borderRadius: BorderRadius.circular(28), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 25, - offset: const Offset(0, 10)) - ], - ), + top: 0, + left: 0, + right: 0, + child: SafeArea( child: Padding( - padding: const EdgeInsets.fromLTRB(22, 20, 22, 22), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.fromLTRB(12, 10, 12, 0), + child: Container( + decoration: BoxDecoration( + color: _kBlueDark, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: _kBlueDark.withOpacity(0.35), + blurRadius: 20, + offset: const Offset(0, 6)), + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Row( children: [ + // Turn arrow icon Container( - padding: const EdgeInsets.all(10), + width: 52, + height: 52, decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - shape: BoxShape.circle), - child: const Icon(Icons.navigation_rounded, - color: Colors.white, size: 28), - ), - const SizedBox(width: 16), - Expanded( - child: Text( - controller.currentInstruction, - style: const TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.bold, - height: 1.3), - maxLines: 2, - overflow: TextOverflow.ellipsis, + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(14), ), + child: const Icon(Icons.turn_right_rounded, + color: Colors.white, size: 30), ), - const SizedBox(width: 16), - Text(controller.distanceToNextStep, - style: const TextStyle( - color: Colors.white, - fontSize: 32, - fontWeight: FontWeight.bold)), - ], - ), - if (controller.nextInstruction.isNotEmpty || - controller.currentSpeed > 0) - const Padding( - padding: EdgeInsets.symmetric(vertical: 14.0), - child: Divider(color: Colors.white30, height: 1), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + const SizedBox(width: 14), + + // Instruction text Expanded( - child: controller.nextInstruction.isNotEmpty - ? Text( - 'التالي: ${controller.nextInstruction}', - style: const TextStyle( - color: Colors.white70, - fontSize: 15, - fontWeight: FontWeight.w500), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : const SizedBox(), - ), - Row( - children: [ - Text(controller.currentSpeed.toStringAsFixed(0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + controller.distanceToNextStep, + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + fontWeight: FontWeight.w500), + ), + const SizedBox(height: 2), + Text( + controller.currentInstruction, style: const TextStyle( color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.bold)), - const SizedBox(width: 6), - const Text('كم/س', - style: TextStyle( - color: Colors.white70, - fontSize: 14, - fontWeight: FontWeight.w500)), - ], + fontSize: 19, + fontWeight: FontWeight.bold, + height: 1.2), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + + // Close / stop navigation + _IconBtn( + icon: Icons.close_rounded, + color: Colors.white54, + size: 20, + onTap: () => controller.clearRoute(), ), ], ), - ], + ), ), ), ), ); } } + +// ───────────────────────────────────────────────────────────────────────────── +// Floating map controls (right side) +// ───────────────────────────────────────────────────────────────────────────── +class _MapControls extends StatelessWidget { + final NavigationController controller; + const _MapControls({required this.controller}); + + @override + Widget build(BuildContext context) { + final bottomOffset = controller.isNavigating ? 190.0 : 100.0; + + return Positioned( + bottom: bottomOffset, + right: 14, + child: Column( + children: [ + // Re-centre / lock camera + _MapFab( + icon: controller.isCameraLocked + ? Icons.gps_fixed_rounded + : Icons.gps_not_fixed_rounded, + iconColor: controller.isCameraLocked ? _kBlue : Colors.grey[600]!, + onTap: () { + HapticFeedback.lightImpact(); + controller.relockCameraToUser(); + }, + tooltip: 'موقعي', + ), + + if (controller.isNavigating) ...[ + const SizedBox(height: 10), + _MapFab( + icon: Icons.sync_alt_rounded, + iconColor: _kBlueDark, + onTap: () { + HapticFeedback.mediumImpact(); + controller.recalculateRoute(); + }, + tooltip: 'إعادة التوجيه', + ), + ], + ], + ), + ); + } +} + +class _MapFab extends StatelessWidget { + final IconData icon; + final Color iconColor; + final VoidCallback onTap; + final String tooltip; + + const _MapFab({ + required this.icon, + required this.iconColor, + required this.onTap, + required this.tooltip, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: GestureDetector( + onTap: onTap, + child: Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.14), + blurRadius: 12, + offset: const Offset(0, 4)), + ], + ), + child: Icon(icon, color: iconColor, size: 22), + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Route Summary Card (before navigation starts) +// ───────────────────────────────────────────────────────────────────────────── +class _RouteSummaryCard extends StatelessWidget { + final NavigationController controller; + const _RouteSummaryCard({required this.controller}); + + @override + Widget build(BuildContext context) { + return Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + decoration: const BoxDecoration( + color: _kSurface, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [ + BoxShadow( + color: Color(0x1A000000), + blurRadius: 24, + offset: Offset(0, -6)), + ], + ), + padding: EdgeInsets.fromLTRB( + 20, 16, 20, MediaQuery.of(context).padding.bottom + 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle + Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: 18), + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + + Row( + children: [ + // Info pills + Expanded( + child: Row( + children: [ + _InfoPill( + icon: Icons.schedule_rounded, + label: controller.estimatedTimeRemaining.isNotEmpty + ? controller.estimatedTimeRemaining + : '--', + color: _kGreen, + ), + const SizedBox(width: 10), + _InfoPill( + icon: Icons.straighten_rounded, + label: controller.totalDistanceRemaining.isNotEmpty + ? controller.totalDistanceRemaining + : '--', + color: _kBlue, + ), + ], + ), + ), + // Start button + ElevatedButton.icon( + onPressed: () { + HapticFeedback.mediumImpact(); + controller.isNavigating = true; + controller.relockCameraToUser(); + controller.update(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: _kBlue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 13), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + icon: const Icon(Icons.navigation_rounded, size: 18), + label: const Text('ابدأ', + style: + TextStyle(fontSize: 15, fontWeight: FontWeight.bold)), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _InfoPill extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + const _InfoPill( + {required this.icon, required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.2)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 15), + const SizedBox(width: 5), + Text(label, + style: TextStyle( + color: color, fontSize: 13.5, fontWeight: FontWeight.w700)), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Navigation HUD (bottom during active navigation — like HERE Maps) +// ───────────────────────────────────────────────────────────────────────────── +class _NavigationHUD extends StatelessWidget { + final NavigationController controller; + const _NavigationHUD({required this.controller}); + + @override + Widget build(BuildContext context) { + final bottomPad = MediaQuery.of(context).padding.bottom; + + return Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + color: _kSurface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.12), + blurRadius: 20, + offset: const Offset(0, -4)), + ], + ), + padding: EdgeInsets.fromLTRB(20, 14, 20, bottomPad + 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── Next instruction row ─────────────────────────────────── + if (controller.nextInstruction.isNotEmpty) + Container( + margin: const EdgeInsets.only(bottom: 12), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.withOpacity(0.15)), + ), + child: Row( + children: [ + Icon(Icons.arrow_forward_rounded, + size: 15, color: _kSubtext), + const SizedBox(width: 8), + Expanded( + child: Text( + controller.nextInstruction, + style: TextStyle( + color: _kSubtext, + fontSize: 13, + fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + + // ── ETA / distance strip ─────────────────────────────────── + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _InfoPill( + icon: Icons.schedule_rounded, + label: controller.estimatedTimeRemaining.isNotEmpty + ? controller.estimatedTimeRemaining + : '--', + color: _kGreen, + ), + _InfoPill( + icon: Icons.straighten_rounded, + label: controller.totalDistanceRemaining.isNotEmpty + ? controller.totalDistanceRemaining + : '--', + color: _kBlue, + ), + // Stop navigation + GestureDetector( + onTap: () { + HapticFeedback.mediumImpact(); + controller.clearRoute(); + }, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.red.withOpacity(0.2)), + ), + child: Row( + children: [ + const Icon(Icons.stop_rounded, + color: Colors.redAccent, size: 16), + const SizedBox(width: 5), + const Text('إيقاف', + style: TextStyle( + color: Colors.redAccent, + fontSize: 13, + fontWeight: FontWeight.w700)), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Speed badge (bottom-left during navigation) +// ───────────────────────────────────────────────────────────────────────────── +class _SpeedBadge extends StatelessWidget { + final double speed; + const _SpeedBadge({required this.speed}); + + @override + Widget build(BuildContext context) { + final int kmh = speed.toInt(); + final bool fast = kmh > 100; + + return Positioned( + bottom: MediaQuery.of(context).padding.bottom + 130, + left: 14, + child: Container( + width: 62, + height: 62, + decoration: BoxDecoration( + color: fast ? const Color(0xFFD93025) : _kSurface, + shape: BoxShape.circle, + border: Border.all( + color: fast ? Colors.red.withOpacity(0.3) : Colors.grey[200]!, + width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.12), + blurRadius: 12, + offset: const Offset(0, 4)), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '$kmh', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: fast ? Colors.white : _kText, + height: 1), + ), + Text( + 'كم/س', + style: TextStyle( + fontSize: 9, + color: fast ? Colors.white70 : _kSubtext, + fontWeight: FontWeight.w500), + ), + ], + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Loading Overlay +// ───────────────────────────────────────────────────────────────────────────── +class _LoadingOverlay extends StatelessWidget { + const _LoadingOverlay(); + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4), + child: Container( + color: Colors.black.withOpacity(0.35), + child: Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(_kBlue), + strokeWidth: 3, + ), + const SizedBox(height: 14), + Text('جاري حساب المسار...', + style: TextStyle( + color: _kSubtext, + fontSize: 14, + fontWeight: FontWeight.w500)), + ], + ), + ), + ), + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Shared primitives +// ───────────────────────────────────────────────────────────────────────────── +class _GlassCard extends StatelessWidget { + final Widget child; + final double borderRadius; + final EdgeInsets padding; + + const _GlassCard({ + required this.child, + this.borderRadius = 16, + this.padding = const EdgeInsets.all(16), + }); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.92), + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all(color: Colors.white.withOpacity(0.5)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.07), + blurRadius: 16, + offset: const Offset(0, 4)), + ], + ), + padding: padding, + child: child, + ), + ), + ); + } +} + +class _IconBtn extends StatelessWidget { + final IconData icon; + final Color color; + final VoidCallback onTap; + final double size; + + const _IconBtn({ + required this.icon, + required this.color, + required this.onTap, + this.size = 22, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(icon, color: color, size: size), + ), + ); + } +}