feat: N1d/N2/N3 + قانون فلاتر وإعادة ضبط تطبيق السائق
الباك إند: - تعيين أول أدمن صار يُنشئ المستخدم إن لم يوجد — كان يشترط دخولاً سابقاً، وهي بيضة ودجاجة تمنع الدخول إلى لوحة أي مستأجر جديد أصلاً. - OTP_DEV_MODE كان يفشل مفتوحاً (`!== 'false'`): غياب المتغيّر أو خطأ مطبعي يترك الإنتاج برمز ثابت يفتح كل حساب، ويحرس السحب المالي كذلك. صار `=== 'true'` في الإعداد وفي قارئَيه، مع تحذير عند الإقلاع. - N2: التحقّق من اللوغو عند الرفع (PNG/JPEG · 512+ · مربّع · سقف 5MB) عبر قراءة الترويسة بلا اعتمادية — بدل اكتشاف أيقونة ممطوطة بعد النشر. N3: السكربت يولّد build_config.dart بأعلام const (طبقات docs/22 §1.5)، ويضبط bundle IDs واسم التطبيق والأيقونات/splash، و--build يشغّل Shorebird. فلاتر: - docs/23: قانون مُلزِم للتطبيقين (البنية · Cubit · طبقة الشبكة · الأعلام). - السائق: أُزيل Dart القديم (GetX) مع الإبقاء على الأصيل والإضافات وشهادات التوقيع وShorebird وFirebase الخاص به، وأُعيد هيكلته مطابقاً للراكب. - طبقة الشبكة ترسل x-device-id وتجدّد التوكن عند 401 مرة واحدة فقط. - الحزم موحّدة بين التطبيقين، والـAPI https حصراً في الاثنين. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3ab74a13a8
commit
de4bbd00ac
@@ -1,41 +0,0 @@
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
|
||||
// تم تعديل الدالة لتقبل وسيط من نوع `dynamic` لحل مشكلة عدم تطابق الأنواع مع دالة `compute`.
|
||||
// هذه الدالة لا تزال تعمل كدالة من المستوى الأعلى (Top-level function)
|
||||
// وهو شرط أساسي لاستخدامها مع دالة compute.
|
||||
List<LatLng> decodePolylineIsolate(dynamic encodedMessage) {
|
||||
// التأكد من أن الرسالة المستقبلة هي من نوع String
|
||||
if (encodedMessage is! String) {
|
||||
// إرجاع قائمة فارغة أو إظهار خطأ إذا كان النوع غير صحيح
|
||||
return [];
|
||||
}
|
||||
final String encoded = encodedMessage;
|
||||
|
||||
List<LatLng> points = [];
|
||||
int index = 0, len = encoded.length;
|
||||
int lat = 0, lng = 0;
|
||||
|
||||
while (index < len) {
|
||||
int b, shift = 0, result = 0;
|
||||
do {
|
||||
b = encoded.codeUnitAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
int dlat = ((result & 1) != 0 ? ~(result >> 1) : (result >> 1));
|
||||
lat += dlat;
|
||||
|
||||
shift = 0;
|
||||
result = 0;
|
||||
do {
|
||||
b = encoded.codeUnitAt(index++) - 63;
|
||||
result |= (b & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x20);
|
||||
int dlng = ((result & 1) != 0 ? ~(result >> 1) : (result >> 1));
|
||||
lng += dlng;
|
||||
|
||||
points.add(LatLng(lat / 1E5, lng / 1E5));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
@@ -1,705 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:sefer_driver/constant/colors.dart';
|
||||
import 'package:sefer_driver/controller/functions/crud.dart';
|
||||
|
||||
import '../../../constant/box_name.dart';
|
||||
import '../../../constant/country_polygons.dart';
|
||||
import '../../../constant/links.dart';
|
||||
import '../../../env/env.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../../print.dart';
|
||||
import '../../functions/tts.dart';
|
||||
import 'decode_polyline_isolate.dart';
|
||||
|
||||
class NavigationController extends GetxController {
|
||||
// --- متغيرات الحالة العامة ---
|
||||
bool isLoading = false;
|
||||
GoogleMapController? mapController;
|
||||
final TextEditingController placeDestinationController =
|
||||
TextEditingController();
|
||||
|
||||
// --- متغيرات الخريطة والموقع ---
|
||||
LatLng? myLocation;
|
||||
double heading = 0.0;
|
||||
final Set<Marker> markers = {};
|
||||
final Set<Polyline> polylines = {};
|
||||
|
||||
BitmapDescriptor carIcon = BitmapDescriptor.defaultMarker;
|
||||
BitmapDescriptor destinationIcon = BitmapDescriptor.defaultMarker;
|
||||
|
||||
// --- متغيرات النظام الذكي للتحديث ---
|
||||
Timer? _locationUpdateTimer;
|
||||
// تحديث كل ثانية لضمان سلاسة الحركة
|
||||
Duration _currentUpdateInterval = const Duration(seconds: 1);
|
||||
LatLng? _lastRecordedLocation;
|
||||
|
||||
// --- متغيرات البحث ---
|
||||
List<dynamic> placesDestination = [];
|
||||
Timer? _debounce;
|
||||
|
||||
// --- متغيرات الملاحة (Navigation) ---
|
||||
LatLng? _finalDestination;
|
||||
List<Map<String, dynamic>> routeSteps = [];
|
||||
|
||||
List<LatLng> _fullRouteCoordinates = [];
|
||||
int _lastTraveledIndexInFullRoute = 0;
|
||||
|
||||
bool _nextInstructionSpoken = false;
|
||||
String currentInstruction = "";
|
||||
String nextInstruction = "";
|
||||
int currentStepIndex = 0;
|
||||
|
||||
double currentSpeed = 0.0;
|
||||
String distanceToNextStep = "";
|
||||
|
||||
// الرابط الجديد
|
||||
static const String _routeApiBaseUrl =
|
||||
"https://routesy.intaleq.xyz/route/v1/driving";
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_initialize();
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
await _loadCustomIcons();
|
||||
await _getCurrentLocationAndStartUpdates();
|
||||
if (!Get.isRegistered<TextToSpeechController>()) {
|
||||
Get.put(TextToSpeechController());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_locationUpdateTimer?.cancel();
|
||||
mapController?.dispose();
|
||||
_debounce?.cancel();
|
||||
placeDestinationController.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// ✅ دوال الخريطة الأساسية
|
||||
// =======================================================================
|
||||
|
||||
void onMapCreated(GoogleMapController controller) {
|
||||
mapController = controller;
|
||||
if (myLocation != null) {
|
||||
animateCameraToPosition(myLocation!);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onMapLongPressed(LatLng tappedPoint) async {
|
||||
Get.dialog(
|
||||
AlertDialog(
|
||||
title: const Text('بدء الملاحة؟'),
|
||||
content: const Text('هل تريد الذهاب إلى هذا الموقع المحدد؟'),
|
||||
actionsAlignment: MainAxisAlignment.spaceBetween,
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('إلغاء', style: TextStyle(color: Colors.grey)),
|
||||
onPressed: () => Get.back(),
|
||||
),
|
||||
TextButton(
|
||||
child: const Text('اذهب الآن'),
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
startNavigationTo(tappedPoint, infoWindowTitle: 'الموقع المحدد');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// ١. النظام الذكي للموقع (Optimized)
|
||||
// =======================================================================
|
||||
|
||||
Future<void> _getCurrentLocationAndStartUpdates() async {
|
||||
try {
|
||||
Position position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high);
|
||||
myLocation = LatLng(position.latitude, position.longitude);
|
||||
getLocationArea(myLocation!.latitude, myLocation!.longitude);
|
||||
update();
|
||||
animateCameraToPosition(myLocation!);
|
||||
_startLocationTimer();
|
||||
} catch (e) {
|
||||
Log.print("Error getting initial location: $e");
|
||||
}
|
||||
}
|
||||
|
||||
void _startLocationTimer() {
|
||||
_locationUpdateTimer?.cancel();
|
||||
_locationUpdateTimer = Timer.periodic(_currentUpdateInterval, (timer) {
|
||||
_updateLocationAndProcess();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _updateLocationAndProcess() async {
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high);
|
||||
|
||||
final newLoc = LatLng(position.latitude, position.longitude);
|
||||
|
||||
// فلتر الاهتزاز (Jitter Filter)
|
||||
if (_lastRecordedLocation != null) {
|
||||
double dist = Geolocator.distanceBetween(
|
||||
newLoc.latitude,
|
||||
newLoc.longitude,
|
||||
_lastRecordedLocation!.latitude,
|
||||
_lastRecordedLocation!.longitude);
|
||||
if (dist < 2.0) return; // تجاهل الحركة الأقل من 2 متر
|
||||
}
|
||||
|
||||
myLocation = newLoc;
|
||||
_lastRecordedLocation = newLoc;
|
||||
heading = position.heading;
|
||||
currentSpeed = position.speed * 3.6;
|
||||
|
||||
_updateCarMarker();
|
||||
|
||||
if (polylines.isNotEmpty && _fullRouteCoordinates.isNotEmpty) {
|
||||
animateCameraToPosition(
|
||||
myLocation!,
|
||||
bearing: heading,
|
||||
zoom: 18.0,
|
||||
);
|
||||
|
||||
_updateTraveledPolylineSmart(myLocation!);
|
||||
_checkNavigationStep(myLocation!);
|
||||
}
|
||||
|
||||
update();
|
||||
} catch (e) {
|
||||
// Log.print("Loc update error: $e");
|
||||
}
|
||||
}
|
||||
|
||||
void _updateCarMarker() {
|
||||
if (myLocation == null) return;
|
||||
markers.removeWhere((m) => m.markerId.value == 'myLocation');
|
||||
markers.add(
|
||||
Marker(
|
||||
markerId: const MarkerId('myLocation'),
|
||||
position: myLocation!,
|
||||
icon: carIcon,
|
||||
rotation: heading,
|
||||
anchor: const Offset(0.5, 0.5),
|
||||
flat: true,
|
||||
zIndex: 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void animateCameraToPosition(LatLng position,
|
||||
{double zoom = 17.0, double bearing = 0.0}) {
|
||||
mapController?.animateCamera(
|
||||
CameraUpdate.newCameraPosition(
|
||||
CameraPosition(
|
||||
target: position, zoom: zoom, bearing: bearing, tilt: 45.0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// ٢. خوارزمية رسم المسار الذكية (Sliding Window) - مخففة للجهاز
|
||||
// =======================================================================
|
||||
|
||||
void _updateTraveledPolylineSmart(LatLng currentPos) {
|
||||
if (_fullRouteCoordinates.isEmpty) return;
|
||||
|
||||
// نبحث فقط في الـ 60 نقطة القادمة لتخفيف الحمل على المعالج
|
||||
int searchWindow = 60;
|
||||
int startIndex = _lastTraveledIndexInFullRoute;
|
||||
int endIndex = min(startIndex + searchWindow, _fullRouteCoordinates.length);
|
||||
|
||||
double minDistance = double.infinity;
|
||||
int closestIndex = 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;
|
||||
foundCloser = true;
|
||||
}
|
||||
}
|
||||
|
||||
// شرط التحديث: وجدنا نقطة أقرب، وهي أمامنا (وليست خلفنا)، والمسافة منطقية
|
||||
if (foundCloser &&
|
||||
minDistance < 50 &&
|
||||
closestIndex > _lastTraveledIndexInFullRoute) {
|
||||
_lastTraveledIndexInFullRoute = closestIndex;
|
||||
|
||||
// استخدام sublist وهو سريع جداً في Dart
|
||||
final remaining =
|
||||
_fullRouteCoordinates.sublist(_lastTraveledIndexInFullRoute);
|
||||
final traveled =
|
||||
_fullRouteCoordinates.sublist(0, _lastTraveledIndexInFullRoute + 1);
|
||||
|
||||
_updatePolylinesSets(traveled, remaining);
|
||||
}
|
||||
}
|
||||
|
||||
void _updatePolylinesSets(List<LatLng> traveled, List<LatLng> remaining) {
|
||||
// إزالة الخطوط القديمة وإضافة الجديدة بدلاً من تحديث الكل
|
||||
polylines.removeWhere((p) => p.polylineId.value == 'remaining_route');
|
||||
polylines.removeWhere((p) => p.polylineId.value == 'traveled_route');
|
||||
|
||||
// المسار المتبقي (أزرق واضح)
|
||||
polylines.add(Polyline(
|
||||
polylineId: const PolylineId('remaining_route'),
|
||||
points: remaining,
|
||||
color: const Color(0xFF0D47A1),
|
||||
width: 6,
|
||||
startCap: Cap.roundCap,
|
||||
endCap: Cap.roundCap,
|
||||
jointType: JointType.round,
|
||||
));
|
||||
|
||||
// المسار المقطوع (رمادي)
|
||||
polylines.add(Polyline(
|
||||
polylineId: const PolylineId('traveled_route'),
|
||||
points: traveled,
|
||||
color: Colors.grey.shade400,
|
||||
width: 6,
|
||||
jointType: JointType.round,
|
||||
));
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// ٣. التحكم في التوجيهات
|
||||
// =======================================================================
|
||||
|
||||
void _checkNavigationStep(LatLng currentPosition) {
|
||||
if (routeSteps.isEmpty || currentStepIndex >= routeSteps.length) return;
|
||||
|
||||
final step = routeSteps[currentStepIndex];
|
||||
// في OSRM، يجب التأكد من وجود maneuver location
|
||||
final maneuver = step['maneuver'];
|
||||
final List<dynamic> location = maneuver['location']; // [lng, lat]
|
||||
final endLatLng = LatLng(location[1], location[0]);
|
||||
|
||||
final distance = Geolocator.distanceBetween(
|
||||
currentPosition.latitude,
|
||||
currentPosition.longitude,
|
||||
endLatLng.latitude,
|
||||
endLatLng.longitude,
|
||||
);
|
||||
|
||||
distanceToNextStep = distance > 1000
|
||||
? "${(distance / 1000).toStringAsFixed(1)} كم"
|
||||
: "${distance.toStringAsFixed(0)} متر";
|
||||
|
||||
// نطق التعليمات قبل 50 متر
|
||||
if (distance < 50 &&
|
||||
!_nextInstructionSpoken &&
|
||||
nextInstruction.isNotEmpty) {
|
||||
Get.find<TextToSpeechController>().speakText(nextInstruction);
|
||||
_nextInstructionSpoken = true;
|
||||
}
|
||||
|
||||
// الانتقال للخطوة التالية عند الاقتراب (20 متر)
|
||||
if (distance < 20) {
|
||||
_advanceStep();
|
||||
}
|
||||
}
|
||||
|
||||
void _advanceStep() {
|
||||
currentStepIndex++;
|
||||
if (currentStepIndex < routeSteps.length) {
|
||||
currentInstruction = routeSteps[currentStepIndex]['instruction_text'];
|
||||
|
||||
// تجهيز التعليمات القادمة
|
||||
if ((currentStepIndex + 1) < routeSteps.length) {
|
||||
nextInstruction =
|
||||
"ثم ${routeSteps[currentStepIndex + 1]['instruction_text']}";
|
||||
} else {
|
||||
nextInstruction = "ستصل إلى وجهتك";
|
||||
}
|
||||
|
||||
_nextInstructionSpoken = false;
|
||||
update();
|
||||
} else {
|
||||
_finishNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
void _finishNavigation() {
|
||||
currentInstruction = "لقد وصلت إلى وجهتك";
|
||||
nextInstruction = "";
|
||||
distanceToNextStep = "";
|
||||
Get.find<TextToSpeechController>().speakText(currentInstruction);
|
||||
update();
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// ٤. جلب المسار (🔥 تم التحديث للرابط الجديد 🔥)
|
||||
// =======================================================================
|
||||
|
||||
Future<void> getRoute(LatLng origin, LatLng destination) async {
|
||||
// 🔥 بناء الرابط حسب التنسيق: /driving/lng,lat;lng,lat
|
||||
String coords =
|
||||
"${origin.longitude},${origin.latitude};${destination.longitude},${destination.latitude}";
|
||||
|
||||
// 🔥 تفعيل steps=true لجلب الخطوات
|
||||
String url =
|
||||
"$_routeApiBaseUrl/$coords?steps=true&overview=full&geometries=polyline";
|
||||
|
||||
try {
|
||||
// الرابط الجديد قد لا يحتاج API Key في الهيدر، ولكن نتركه إذا كان السيرفر يطلبه
|
||||
// إذا كان الرابط عام، يمكن إزالة الهيدر
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
Get.snackbar('تنبيه', 'تعذر الاتصال بخدمة التوجيه.');
|
||||
return;
|
||||
}
|
||||
|
||||
final responseData = jsonDecode(response.body);
|
||||
|
||||
// التحقق حسب هيكلية OSRM
|
||||
if (responseData['code'] != 'Ok' ||
|
||||
(responseData['routes'] as List).isEmpty) {
|
||||
Get.snackbar('عذراً', 'لم يتم العثور على مسار.');
|
||||
return;
|
||||
}
|
||||
|
||||
var route = responseData['routes'][0];
|
||||
|
||||
// 1. فك تشفير Polyline
|
||||
final pointsString = route['geometry'];
|
||||
_fullRouteCoordinates = await compute(
|
||||
decodePolylineIsolate as ComputeCallback<dynamic, List<LatLng>>,
|
||||
pointsString);
|
||||
|
||||
// تهيئة الرسم
|
||||
_lastTraveledIndexInFullRoute = 0;
|
||||
_updatePolylinesSets([], _fullRouteCoordinates);
|
||||
|
||||
// 2. استخراج الخطوات (داخل Legs في OSRM)
|
||||
var legs = route['legs'] as List;
|
||||
if (legs.isNotEmpty) {
|
||||
var steps = legs[0]['steps'] as List;
|
||||
routeSteps = List<Map<String, dynamic>>.from(steps);
|
||||
} else {
|
||||
routeSteps = [];
|
||||
}
|
||||
|
||||
// 3. معالجة النصوص العربية للخطوات
|
||||
for (var step in routeSteps) {
|
||||
step['instruction_text'] = _createInstructionFromManeuver(step);
|
||||
}
|
||||
|
||||
// 4. بدء الملاحة
|
||||
currentStepIndex = 0;
|
||||
_nextInstructionSpoken = false;
|
||||
if (routeSteps.isNotEmpty) {
|
||||
currentInstruction = routeSteps[0]['instruction_text'];
|
||||
if (routeSteps.length > 1) {
|
||||
nextInstruction = "ثم ${routeSteps[1]['instruction_text']}";
|
||||
} else {
|
||||
nextInstruction = "الوجهة النهائية";
|
||||
}
|
||||
Get.find<TextToSpeechController>().speakText(currentInstruction);
|
||||
}
|
||||
|
||||
// 5. ضبط الكاميرا
|
||||
if (_fullRouteCoordinates.isNotEmpty) {
|
||||
final bounds = _boundsFromLatLngList(_fullRouteCoordinates);
|
||||
mapController
|
||||
?.animateCamera(CameraUpdate.newLatLngBounds(bounds, 80.0));
|
||||
}
|
||||
|
||||
update();
|
||||
} catch (e) {
|
||||
Log.print("GetRoute Error: $e");
|
||||
Get.snackbar('خطأ', 'حدث خطأ غير متوقع.');
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 تحويل تعليمات OSRM إلى العربية
|
||||
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 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 = "تابع المسير";
|
||||
}
|
||||
|
||||
if (name.isNotEmpty) {
|
||||
if (type == 'new name' || type == 'continue') {
|
||||
instruction += " على $name";
|
||||
} else {
|
||||
instruction += " نحو $name";
|
||||
}
|
||||
}
|
||||
return instruction;
|
||||
}
|
||||
|
||||
String _getTurnInstruction(String modifier) {
|
||||
switch (modifier) {
|
||||
case 'uturn':
|
||||
return "قم بالاستدارة والعودة";
|
||||
case 'sharp right':
|
||||
return "انعطف يميناً بحدة";
|
||||
case 'right':
|
||||
return "انعطف يميناً";
|
||||
case 'slight right':
|
||||
return "انعطف يميناً قليلاً";
|
||||
case 'straight':
|
||||
return "استمر للأمام";
|
||||
case 'slight left':
|
||||
return "انعطف يساراً قليلاً";
|
||||
case 'left':
|
||||
return "انعطف يساراً";
|
||||
case 'sharp left':
|
||||
return "انعطف يساراً بحدة";
|
||||
default:
|
||||
return "اتجه";
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// ٥. أدوات مساعدة
|
||||
// =======================================================================
|
||||
|
||||
Future<void> startNavigationTo(LatLng destination,
|
||||
{String infoWindowTitle = ''}) async {
|
||||
isLoading = true;
|
||||
update();
|
||||
try {
|
||||
_finalDestination = destination;
|
||||
clearRoute(isNewRoute: true);
|
||||
|
||||
markers.add(Marker(
|
||||
markerId: const MarkerId('destination'),
|
||||
position: destination,
|
||||
icon: destinationIcon,
|
||||
infoWindow: InfoWindow(title: infoWindowTitle),
|
||||
));
|
||||
|
||||
await getRoute(myLocation!, destination);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> recalculateRoute() async {
|
||||
if (myLocation == null || _finalDestination == null || isLoading) return;
|
||||
isLoading = true;
|
||||
update();
|
||||
Get.snackbar('إعادة التوجيه', 'جاري حساب مسار جديد...',
|
||||
backgroundColor: AppColor.goldenBronze);
|
||||
await getRoute(myLocation!, _finalDestination!);
|
||||
isLoading = false;
|
||||
update();
|
||||
}
|
||||
|
||||
void clearRoute({bool isNewRoute = false}) {
|
||||
if (!isNewRoute) {
|
||||
markers.removeWhere((m) => m.markerId.value == 'destination');
|
||||
_finalDestination = null;
|
||||
polylines.clear();
|
||||
}
|
||||
routeSteps.clear();
|
||||
_fullRouteCoordinates.clear();
|
||||
_lastTraveledIndexInFullRoute = 0;
|
||||
currentInstruction = "";
|
||||
nextInstruction = "";
|
||||
distanceToNextStep = "";
|
||||
update();
|
||||
}
|
||||
|
||||
Future<void> _loadCustomIcons() async {
|
||||
carIcon = await BitmapDescriptor.fromAssetImage(
|
||||
const ImageConfiguration(size: Size(40, 40)), 'assets/images/car.png');
|
||||
destinationIcon = await BitmapDescriptor.fromAssetImage(
|
||||
const ImageConfiguration(size: Size(30, 30)), 'assets/images/b.png');
|
||||
}
|
||||
|
||||
Future<void> getPlaces() async {
|
||||
final q = placeDestinationController.text.trim();
|
||||
if (q.length < 3) {
|
||||
placesDestination = [];
|
||||
update();
|
||||
return;
|
||||
}
|
||||
|
||||
if (myLocation == null) return;
|
||||
|
||||
final lat = myLocation!.latitude;
|
||||
final lng = myLocation!.longitude;
|
||||
const radiusKm = 200.0;
|
||||
final latDelta = _kmToLatDelta(radiusKm);
|
||||
final lngDelta = _kmToLngDelta(radiusKm, lat);
|
||||
|
||||
final payload = {
|
||||
'query': q,
|
||||
'lat_min': (lat - latDelta).toString(),
|
||||
'lat_max': (lat + latDelta).toString(),
|
||||
'lng_min': (lng - lngDelta).toString(),
|
||||
'lng_max': (lng + lngDelta).toString(),
|
||||
};
|
||||
|
||||
try {
|
||||
final response =
|
||||
await CRUD().post(link: AppLink.getPlacesSyria, payload: payload);
|
||||
final responseData = (response);
|
||||
|
||||
List list;
|
||||
if (responseData is Map && responseData['status'] == 'success') {
|
||||
list = List.from(responseData['message'] as List);
|
||||
} else if (responseData is List) {
|
||||
list = List.from(responseData);
|
||||
} else {
|
||||
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 distance = _haversineKm(lat, lng, plat, plng);
|
||||
p['distanceKm'] = distance;
|
||||
}
|
||||
|
||||
list.sort((a, b) =>
|
||||
(a['distanceKm'] as double).compareTo(b['distanceKm'] as double));
|
||||
placesDestination = list;
|
||||
update();
|
||||
} catch (e) {
|
||||
print('Exception in getPlaces: $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());
|
||||
await startNavigationTo(LatLng(lat, lng),
|
||||
infoWindowTitle: place['name'] ?? 'وجهة');
|
||||
}
|
||||
|
||||
void onSearchChanged(String query) {
|
||||
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 700), () => getPlaces());
|
||||
}
|
||||
|
||||
// --- دوال مساعدة للحسابات ---
|
||||
double _haversineKm(double lat1, double lon1, double lat2, double lon2) {
|
||||
const R = 6371.0;
|
||||
final dLat = (lat2 - lat1) * (pi / 180.0);
|
||||
final dLon = (lon2 - lon1) * (pi / 180.0);
|
||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
||||
cos(lat1 * pi / 180) *
|
||||
cos(lat2 * pi / 180) *
|
||||
sin(dLon / 2) *
|
||||
sin(dLon / 2);
|
||||
return R * 2 * atan2(sqrt(a), sqrt(1 - a));
|
||||
}
|
||||
|
||||
double _kmToLatDelta(double km) => km / 111.32;
|
||||
double _kmToLngDelta(double km, double lat) =>
|
||||
km / (111.32 * cos(lat * pi / 180));
|
||||
|
||||
LatLngBounds _boundsFromLatLngList(List<LatLng> list) {
|
||||
double? x0, x1, y0, y1;
|
||||
for (LatLng latLng in list) {
|
||||
if (x0 == null) {
|
||||
x0 = x1 = latLng.latitude;
|
||||
y0 = y1 = latLng.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;
|
||||
}
|
||||
}
|
||||
return LatLngBounds(
|
||||
northeast: LatLng(x1!, y1!), southwest: LatLng(x0!, y0!));
|
||||
}
|
||||
|
||||
// --- دوال التحقق من الدولة ---
|
||||
String getLocationArea(double latitude, double longitude) {
|
||||
LatLng p = LatLng(latitude, longitude);
|
||||
if (isPointInPolygon(p, CountryPolygons.jordanBoundary)) {
|
||||
box.write(BoxName.countryCode, 'Jordan');
|
||||
return 'Jordan';
|
||||
}
|
||||
if (isPointInPolygon(p, CountryPolygons.syriaBoundary)) {
|
||||
box.write(BoxName.countryCode, 'Syria');
|
||||
return 'Syria';
|
||||
}
|
||||
if (isPointInPolygon(p, CountryPolygons.egyptBoundary)) {
|
||||
box.write(BoxName.countryCode, 'Egypt');
|
||||
return 'Egypt';
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
bool isPointInPolygon(LatLng point, List<LatLng> polygon) {
|
||||
int intersections = 0;
|
||||
for (int i = 0; i < polygon.length; i++) {
|
||||
LatLng vertex1 = polygon[i];
|
||||
LatLng vertex2 = polygon[(i + 1) % polygon.length];
|
||||
if (_rayIntersectsSegment(point, vertex1, vertex2)) intersections++;
|
||||
}
|
||||
return intersections % 2 != 0;
|
||||
}
|
||||
|
||||
bool _rayIntersectsSegment(LatLng point, LatLng vertex1, LatLng vertex2) {
|
||||
double px = point.longitude;
|
||||
double py = point.latitude;
|
||||
double v1x = vertex1.longitude;
|
||||
double v1y = vertex1.latitude;
|
||||
double v2x = vertex2.longitude;
|
||||
double v2y = vertex2.latitude;
|
||||
|
||||
if ((py < v1y && py < v2y) || (py > v1y && py > v2y)) return false;
|
||||
double intersectX = v1x + (py - v1y) * (v2x - v1x) / (v2y - v1y);
|
||||
return intersectX > px;
|
||||
}
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
import 'dart:ui'; // For BackdropFilter
|
||||
|
||||
import 'navigation_controller.dart';
|
||||
|
||||
// ملاحظة: افترضتُ أن لديك لوناً أساسياً في هذا الملف
|
||||
// import 'package:sefer_driver/constant/colors.dart';
|
||||
// سأستخدم اللون الأزرق كبديل مؤقت
|
||||
const Color kPrimaryColor = Color(0xFF0D47A1);
|
||||
|
||||
class NavigationView extends StatelessWidget {
|
||||
const NavigationView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// استخدام Get.find() بدلاً من Get.put() لضمان أن الكونترولر مُهيأ مسبقاً
|
||||
// إذا كانت هذه هي نقطة الدخول، Get.put() صحيح.
|
||||
final NavigationController controller = Get.put(NavigationController());
|
||||
|
||||
return Scaffold(
|
||||
body: GetBuilder<NavigationController>(
|
||||
builder: (_) => Stack(
|
||||
children: [
|
||||
// --- الخريطة ---
|
||||
GoogleMap(
|
||||
onMapCreated: controller.onMapCreated,
|
||||
onLongPress: controller.onMapLongPressed,
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: controller.myLocation ??
|
||||
const LatLng(33.5138, 36.2765), // Default to Damascus
|
||||
zoom: 16.0,
|
||||
),
|
||||
markers: controller.markers,
|
||||
polylines: controller.polylines,
|
||||
myLocationEnabled: false,
|
||||
myLocationButtonEnabled: false,
|
||||
compassEnabled: false,
|
||||
zoomControlsEnabled: false, buildingsEnabled: false,
|
||||
// تعديل الـ padding لإعطاء مساحة للعناصر العلوية والسفلية
|
||||
// مساحة أكبر في الأعلى للبحث + النتائج، ومساحة أكبر بالأسفل للملاحة
|
||||
padding: EdgeInsets.only(
|
||||
bottom: controller.currentInstruction.isNotEmpty ? 170 : 0,
|
||||
top: 150,
|
||||
),
|
||||
),
|
||||
|
||||
// --- واجهة البحث (تصميم زجاجي) ---
|
||||
_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<Color>(Colors.white),
|
||||
strokeWidth: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// --- 1. واجهة البحث بالتصميم الزجاجي المطور ---
|
||||
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(
|
||||
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: InputDecoration(
|
||||
hintText: 'إلى أين تريد الذهاب؟',
|
||||
hintStyle: const TextStyle(
|
||||
color: Colors.black45, fontSize: 16),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.only(bottom: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
// زر المسح أو إلغاء المسار
|
||||
if (controller
|
||||
.placeDestinationController.text.isNotEmpty)
|
||||
_buildClearButton(controller)
|
||||
else if (controller.polylines.isNotEmpty)
|
||||
_buildCancelRouteButton(controller),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// --- قائمة النتائج ---
|
||||
if (controller.placesDestination.isNotEmpty)
|
||||
_buildSearchResultsList(controller),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildClearButton(NavigationController controller) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.clear, color: Colors.grey, size: 22),
|
||||
onPressed: () {
|
||||
controller.placeDestinationController.clear();
|
||||
controller.placesDestination = [];
|
||||
controller.update();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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)),
|
||||
),
|
||||
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),
|
||||
),
|
||||
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),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (address.isNotEmpty)
|
||||
Text(
|
||||
address,
|
||||
style: const TextStyle(
|
||||
color: Colors.black54, fontSize: 13),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// المسافة
|
||||
if (distance != null)
|
||||
Text(
|
||||
'${distance.toStringAsFixed(1)} كم',
|
||||
style: const TextStyle(
|
||||
color: kPrimaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// --- 2. أزرار التحكم بالتصميم العائم ---
|
||||
Widget _buildFloatingMapControls(NavigationController controller) {
|
||||
return Positioned(
|
||||
// اجعلها تطفو فوق لوحة الملاحة
|
||||
bottom: controller.currentInstruction.isNotEmpty ? 190 : 24,
|
||||
right: 16,
|
||||
child: Column(
|
||||
children: [
|
||||
if (controller.polylines.isNotEmpty) ...[
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// --- 3. واجهة الملاحة بالتصميم العائم المطور ---
|
||||
Widget _buildFloatingNavigationUI(NavigationController controller) {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(22, 20, 22, 22),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// --- الصف العلوي: الإرشاد والمسافة ---
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// الأيقونة
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
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,
|
||||
),
|
||||
),
|
||||
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: [
|
||||
// الإرشاد التالي
|
||||
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),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
// lib/controllers/navigation/route_matcher_worker.dart
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
import 'dart:math';
|
||||
|
||||
/// Worker entrypoint (spawnUri/spawn).
|
||||
/// Messages:
|
||||
/// - init: {'type':'init','coords': Float64List}
|
||||
/// - match: {'type':'match','id': int, 'lat': double, 'lng': double, 'lastIndex': int, 'window': int}
|
||||
/// - dispose: {'type':'dispose'}
|
||||
///
|
||||
/// Responses are sent back as Map via SendPort:
|
||||
/// - {'type':'ready'}
|
||||
/// - {'type':'matchResult','id': id, 'index': overallIndex, 'lat': lat, 'lng': lng, 'dist': meters}
|
||||
void routeMatcherIsolateEntry(SendPort sendPort) {
|
||||
final ReceivePort port = ReceivePort();
|
||||
sendPort.send({'type': 'ready', 'port': port.sendPort});
|
||||
|
||||
Float64List? flat; // [lat,lng,lat,lng,...]
|
||||
int nPoints = 0;
|
||||
|
||||
port.listen((dynamic message) {
|
||||
try {
|
||||
if (message is Map<String, dynamic>) {
|
||||
final type = message['type'] as String? ?? '';
|
||||
if (type == 'init') {
|
||||
final data = message['coords'] as Float64List?;
|
||||
if (data != null) {
|
||||
flat = data;
|
||||
nPoints = flat!.length ~/ 2;
|
||||
sendPort.send({'type': 'inited', 'points': nPoints});
|
||||
} else {
|
||||
sendPort.send({'type': 'error', 'message': 'init missing coords'});
|
||||
}
|
||||
} else if (type == 'match') {
|
||||
if (flat == null) {
|
||||
sendPort.send({'type': 'error', 'message': 'not inited'});
|
||||
return;
|
||||
}
|
||||
final int id = message['id'] as int;
|
||||
final double lat = (message['lat'] as num).toDouble();
|
||||
final double lng = (message['lng'] as num).toDouble();
|
||||
final int lastIndex = (message['lastIndex'] as int?) ?? 0;
|
||||
final int window = (message['window'] as int?) ?? 120;
|
||||
|
||||
final result =
|
||||
_findClosestWindowInternal(flat!, lat, lng, lastIndex, window);
|
||||
sendPort.send({
|
||||
'type': 'matchResult',
|
||||
'id': id,
|
||||
'index': result['index'],
|
||||
'lat': result['lat'],
|
||||
'lng': result['lng'],
|
||||
'dist': result['dist']
|
||||
});
|
||||
} else if (type == 'dispose') {
|
||||
port.close();
|
||||
sendPort.send({'type': 'disposed'});
|
||||
} else {
|
||||
sendPort.send({'type': 'error', 'message': 'unknown message type'});
|
||||
}
|
||||
}
|
||||
} catch (e, st) {
|
||||
sendPort.send(
|
||||
{'type': 'error', 'message': e.toString(), 'stack': st.toString()});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Internal helper: projection on segments, windowed search.
|
||||
/// Returns Map {index, lat, lng, dist}
|
||||
Map<String, dynamic> _findClosestWindowInternal(
|
||||
Float64List flat, double lat, double lng, int lastIndex, int window) {
|
||||
final int n = flat.length ~/ 2;
|
||||
final int start = max(0, lastIndex - window);
|
||||
final int end = min(n - 1, lastIndex + window);
|
||||
|
||||
double minDist = double.infinity;
|
||||
int bestIdx = lastIndex;
|
||||
double bestLat = flat[lastIndex * 2];
|
||||
double bestLng = flat[lastIndex * 2 + 1];
|
||||
|
||||
for (int i = start; i < end; i++) {
|
||||
final double aLat = flat[i * 2];
|
||||
final double aLng = flat[i * 2 + 1];
|
||||
final double bLat = flat[(i + 1) * 2];
|
||||
final double bLng = flat[(i + 1) * 2 + 1];
|
||||
|
||||
final proj = _closestPointOnSegmentLatLng(lat, lng, aLat, aLng, bLat, bLng);
|
||||
final double d = proj['dist'] as double;
|
||||
if (d < minDist) {
|
||||
minDist = d;
|
||||
bestLat = proj['lat'] as double;
|
||||
bestLng = proj['lng'] as double;
|
||||
// choose overall index: i or i+1 depending on t
|
||||
final double t = proj['t'] as double;
|
||||
bestIdx = i + (t > 0.5 ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
return {'index': bestIdx, 'lat': bestLat, 'lng': bestLng, 'dist': minDist};
|
||||
}
|
||||
|
||||
/// Projection math on geodetic points approximated in degrees (good for short distances).
|
||||
Map<String, dynamic> _closestPointOnSegmentLatLng(
|
||||
double px, double py, double ax, double ay, double bx, double by) {
|
||||
// Here px=lat, py=lng; ax=lat, ay=lng, etc.
|
||||
final double x0 = px;
|
||||
final double y0 = py;
|
||||
final double x1 = ax;
|
||||
final double y1 = ay;
|
||||
final double x2 = bx;
|
||||
final double y2 = by;
|
||||
|
||||
final double dx = x2 - x1;
|
||||
final double dy = y2 - y1;
|
||||
double t = 0.0;
|
||||
final double len2 = dx * dx + dy * dy;
|
||||
if (len2 > 0) {
|
||||
t = ((x0 - x1) * dx + (y0 - y1) * dy) / len2;
|
||||
if (t < 0) t = 0;
|
||||
if (t > 1) t = 1;
|
||||
}
|
||||
final double projX = x1 + t * dx;
|
||||
final double projY = y1 + t * dy;
|
||||
|
||||
final double distMeters = _haversineDistanceMeters(x0, y0, projX, projY);
|
||||
return {'lat': projX, 'lng': projY, 't': t, 'dist': distMeters};
|
||||
}
|
||||
|
||||
/// Haversine distance (meters)
|
||||
double _haversineDistanceMeters(
|
||||
double lat1, double lng1, double lat2, double lng2) {
|
||||
final double R = 6371000.0;
|
||||
final double dLat = _deg2rad(lat2 - lat1);
|
||||
final double dLon = _deg2rad(lng2 - lng1);
|
||||
final double a = sin(dLat / 2) * sin(dLat / 2) +
|
||||
cos(_deg2rad(lat1)) * cos(_deg2rad(lat2)) * sin(dLon / 2) * sin(dLon / 2);
|
||||
final double c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
double _deg2rad(double deg) => deg * pi / 180.0;
|
||||
Reference in New Issue
Block a user