2026-04-05-add navigation page and correct whatsapp link
This commit is contained in:
@@ -2995,7 +2995,6 @@ class MapPassengerController extends GetxController {
|
||||
Future<Map<String, double>?> 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<String, double>? 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('لم نتمكن من استخراج الموقع من الرابط');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ GetBuilder<MapPassengerController> leftMainMenuIcons() {
|
||||
children: [
|
||||
// --- تم استخدام دالة مساعدة جديدة للزر ---
|
||||
_buildMapActionButton(
|
||||
icon: Icons.satellite_alt_outlined,
|
||||
icon: Icons.near_me_outlined,
|
||||
tooltip: 'Toggle Map Type',
|
||||
onPressed: () => Get.to(() => NavigationView()),
|
||||
),
|
||||
|
||||
@@ -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<dynamic> placesDestination = [];
|
||||
Timer? _debounce;
|
||||
|
||||
// Route
|
||||
LatLng? _finalDestination;
|
||||
List<Map<String, dynamic>> routeSteps = [];
|
||||
List<LatLng> _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<Map<String, dynamic>> _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<void> onMapLongPressed(Point<double> 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<void> _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<void> _updateLocationAndProcess() async {
|
||||
Future<void> _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<void> _flushBufferToServer() async {
|
||||
if (_trackBuffer.isEmpty) return;
|
||||
|
||||
final batch = List<Map<String, dynamic>>.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<void> _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<void> 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<String, List<LatLng>>(
|
||||
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<Map<String, dynamic>>.from(steps);
|
||||
routeSteps = List<Map<String, dynamic>>.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<void> 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<void> _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<dynamic> 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<TextToSpeechController>().speakText(currentInstruction);
|
||||
// Final flush on arrival
|
||||
_flushBufferToServer();
|
||||
update();
|
||||
}
|
||||
|
||||
String _createInstructionFromManeuver(Map<String, dynamic> 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<void> 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<void> 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<LatLng> 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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user