Files
intaleq_driver/lib/controller/home/navigation/navigation_controller.dart
Hamza-Ayed a69e4c6912 25-10-11/1
2025-11-06 12:29:17 +03:00

839 lines
27 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:convert'; // <<<--- إضافة جديدة
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/crud.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: 2);
// --- متغيرات البحث عن الأماكن ---
List<dynamic> placesDestination = [];
Timer? _debounce;
// --- متغيرات الملاحة (Navigation) ---
LatLng? _finalDestination;
List<Map<String, dynamic>> routeSteps = [];
List<LatLng> _fullRouteCoordinates = [];
List<List<LatLng>> _stepPolylines = [];
bool _nextInstructionSpoken = false;
String currentInstruction = "";
String nextInstruction = "";
int currentStepIndex = 0;
// <<<--- [تعديل] ---: متغير جديد لتتبع المسار المقطوع بدقة
int _lastTraveledIndexInFullRoute = 0;
double currentSpeed = 0.0;
String distanceToNextStep = "";
final List<LatLngBounds> _stepBounds = [];
// --- ثوابت الـ API الجديد ---
static const String _routeApiBaseUrl = 'https://routec.intaleq.xyz/';
static final String _routeApiKey = Env.mapKeyOsm;
@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();
}
// =======================================================================
// ١. النظام الذكي لتحديد الموقع والتحديث
// =======================================================================
// Helper function to check if a ray from the point intersects with a polygon segment
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;
// Check if the point is outside the vertical bounds of the segment
if ((py < v1y && py < v2y) || (py > v1y && py > v2y)) {
return false;
}
// Calculate the intersection of the ray and the segment
double intersectX = v1x + (py - v1y) * (v2x - v1x) / (v2y - v1y);
// Check if the intersection is to the right of the point
return intersectX > px;
}
// Function to check if the point is inside the polygon
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]; // Loop back to the start
if (_rayIntersectsSegment(point, vertex1, vertex2)) {
intersections++;
}
}
// If the number of intersections is odd, the point is inside
return intersections % 2 != 0;
}
String getLocationArea(double latitude, double longitude) {
LatLng passengerPoint = LatLng(latitude, longitude);
// 1. فحص الأردن
if (isPointInPolygon(passengerPoint, CountryPolygons.jordanBoundary)) {
box.write(BoxName.countryCode, 'Jordan');
// يمكنك تعيين AppLink.endPoint هنا إذا كان منطقك الداخلي لا يزال يعتمد عليه
// box.write(BoxName.serverChosen,
// AppLink.IntaleqSyriaServer); // مثال: اختر سيرفر سوريا للبيانات
return 'Jordan';
}
// 2. فحص سوريا
if (isPointInPolygon(passengerPoint, CountryPolygons.syriaBoundary)) {
box.write(BoxName.countryCode, 'Syria');
// box.write(BoxName.serverChosen, AppLink.IntaleqSyriaServer);
return 'Syria';
}
// 3. فحص مصر
if (isPointInPolygon(passengerPoint, CountryPolygons.egyptBoundary)) {
box.write(BoxName.countryCode, 'Egypt');
// box.write(BoxName.serverChosen, AppLink.IntaleqAlexandriaServer);
return 'Egypt';
}
// 4. الافتراضي (إذا كان خارج المناطق المخدومة)
box.write(BoxName.countryCode, 'Jordan');
// box.write(BoxName.serverChosen, AppLink.IntaleqSyriaServer);
return 'Unknown Location (Defaulting to Jordan)';
}
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) {
print("Error getting 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);
myLocation = LatLng(position.latitude, position.longitude);
heading = position.heading;
currentSpeed = position.speed * 3.6;
_updateCarMarker();
if (polylines.isNotEmpty && myLocation != null) {
animateCameraToPosition(
myLocation!,
bearing: heading,
zoom: 18.5,
);
_checkNavigationStep(myLocation!);
}
update();
} catch (e) {
print("Error in _updateLocationAndProcess: $e");
}
}
void _adjustUpdateInterval() {
if (currentStepIndex >= routeSteps.length) return;
final currentStepDistance = routeSteps[currentStepIndex]['distance'];
if (currentStepDistance > 1500) {
_currentUpdateInterval = const Duration(seconds: 4);
} else {
_currentUpdateInterval = const Duration(seconds: 2);
}
_startLocationTimer();
}
void onMapCreated(GoogleMapController controller) {
mapController = controller;
if (myLocation != null) {
animateCameraToPosition(myLocation!);
}
}
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,
),
);
}
void animateCameraToPosition(LatLng position,
{double zoom = 16.0, double bearing = 0.0}) {
mapController?.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: position, zoom: zoom, bearing: bearing, tilt: 45.0),
),
);
}
// =======================================================================
// ٢. الملاحة والتحقق من الخطوات
// =======================================================================
void _checkNavigationStep(LatLng currentPosition) {
if (routeSteps.isEmpty ||
currentStepIndex >= routeSteps.length ||
_finalDestination == null) return;
// <<<--- [تعديل] ---: استدعاء الدالة الجديدة والمحسنة
_updateTraveledPolyline(currentPosition);
final step = routeSteps[currentStepIndex];
final endLatLng =
LatLng(step['end_location']['lat'], step['end_location']['lng']);
final distance = Geolocator.distanceBetween(
currentPosition.latitude,
currentPosition.longitude,
endLatLng.latitude,
endLatLng.longitude,
);
distanceToNextStep = (distance > 1000)
? "${(distance / 1000).toStringAsFixed(1)} كم"
: "${distance.toStringAsFixed(0)} متر";
if (distance < 30 &&
!_nextInstructionSpoken &&
nextInstruction.isNotEmpty) {
Get.find<TextToSpeechController>().speakText(nextInstruction);
_nextInstructionSpoken = true;
}
if (distance < 20) {
_advanceStep();
}
}
void _advanceStep() {
currentStepIndex++;
if (currentStepIndex < routeSteps.length) {
currentInstruction = routeSteps[currentStepIndex]['instruction_text'];
nextInstruction = ((currentStepIndex + 1) < routeSteps.length)
? routeSteps[currentStepIndex + 1]['instruction_text']
: "الوجهة النهائية";
_nextInstructionSpoken = false;
_adjustUpdateInterval();
if (currentStepIndex < _stepBounds.length) {
mapController?.animateCamera(
CameraUpdate.newLatLngBounds(_stepBounds[currentStepIndex], 70.0));
}
update();
} else {
currentInstruction = "لقد وصلت إلى وجهتك.";
nextInstruction = "";
distanceToNextStep = "";
_locationUpdateTimer?.cancel();
Get.find<TextToSpeechController>().speakText(currentInstruction);
update();
}
}
// =======================================================================
// ٣. تحسين خوارزمية البحث ورسم المسار المقطوع
// =======================================================================
// <<<--- [تعديل] ---: تم إعادة كتابة الدالة بالكامل
void _updateTraveledPolyline(LatLng currentPosition) {
// 1. التأكد من أن المسار الكامل محمل
if (_fullRouteCoordinates.isEmpty) return;
double minDistance = double.infinity;
// 2. ابدأ البحث دائماً من النقطة الأخيرة التي تم الوصول إليها
int newClosestIndex = _lastTraveledIndexInFullRoute;
// 3. ابحث للأمام فقط (من آخر نقطة مسجلة إلى نهاية المسار)
for (int i = _lastTraveledIndexInFullRoute;
i < _fullRouteCoordinates.length;
i++) {
final point = _fullRouteCoordinates[i];
final distance = Geolocator.distanceBetween(
currentPosition.latitude,
currentPosition.longitude,
point.latitude,
point.longitude,
);
if (distance < minDistance) {
minDistance = distance;
newClosestIndex = i;
} else if (distance > minDistance + 50) {
// 4. تحسين: إذا بدأت المسافة بالزيادة، فتوقف عن البحث
// هذا يعني أننا تجاوزنا أقرب نقطة (50 متر هامش أمان)
break;
}
}
// 5. قم بتحديث آخر نقطة مسجلة
_lastTraveledIndexInFullRoute = newClosestIndex;
// 6. ارسم المسار المقطوع (من البداية إلى أقرب نقطة)
List<LatLng> traveledPoints =
_fullRouteCoordinates.sublist(0, newClosestIndex + 1);
traveledPoints.add(currentPosition); // أضف الموقع الحالي لنعومة الخط
// 7. ارسم المسار المتبقي (من أقرب نقطة إلى النهاية)
List<LatLng> remainingPoints =
_fullRouteCoordinates.sublist(newClosestIndex);
remainingPoints.insert(0, currentPosition); // ابدأ من الموقع الحالي
// 8. تحديث الخطوط على الخريطة
polylines.removeWhere((p) => p.polylineId.value == 'traveled_route');
polylines.add(Polyline(
polylineId: const PolylineId('traveled_route'),
points: traveledPoints,
color: Colors.grey.shade600,
width: 7,
));
polylines.removeWhere((p) => p.polylineId.value == 'remaining_route');
polylines.add(Polyline(
polylineId: const PolylineId('remaining_route'),
points: remainingPoints,
color: const Color(0xFF4A80F0),
width: 7,
));
}
// <<<--- [إلغاء] ---: لم نعد بحاجة لهذه الدالة المعقدة
// int _getOverallIndex(int stepIndex, int pointInStepIndex) {
// int overallIndex = 0;
// for (int i = 0; i < stepIndex; i++) {
// overallIndex += _stepPolylines[i].length;
// }
// return overallIndex + pointInStepIndex;
// }
// =======================================================================
// ٤. دوال مساعدة وتجهيز البيانات
// =======================================================================
Future<void> _prepareStepData() async {
_stepBounds.clear();
_stepPolylines.clear();
if (routeSteps.isEmpty) return;
for (final step in routeSteps) {
final pointsString = step['geometry'];
final List<LatLng> polylineCoordinates = await compute(
decodePolylineIsolate as ComputeCallback<dynamic, List<LatLng>>,
pointsString);
_stepPolylines.add(polylineCoordinates);
_stepBounds.add(_boundsFromLatLngList(polylineCoordinates));
}
}
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 LatLng destination = LatLng(lat, lng);
await startNavigationTo(destination,
infoWindowTitle: place['name'] ?? 'وجهة محددة');
}
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: 'الموقع المحدد');
},
),
],
),
);
}
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);
} catch (e) {
Get.snackbar('خطأ', 'حدث خطأ أثناء تحديد الوجهة.');
print("Error starting navigation: $e");
} finally {
isLoading = false;
update();
}
}
// --- (تعديل) ---: تم تعديل الدالة لاستخدام http.get
Future<void> getRoute(LatLng origin, LatLng destination) async {
final url =
'$_routeApiBaseUrl/route?origin=${origin.latitude},${origin.longitude}&destination=${destination.latitude},${destination.longitude}&steps=true&overview=full';
try {
final response = await http.get(
Uri.parse(url),
headers: {'X-API-KEY': _routeApiKey},
);
if (response.statusCode != 200) {
print("Error from route API: ${response.statusCode}");
Get.snackbar('خطأ', 'لم يتم العثور على مسار.');
return;
}
final responseData = jsonDecode(response.body);
if (responseData == null || responseData['status'] != 'ok') {
Get.snackbar('خطأ', 'لم يتم العثور على مسار.');
return;
}
polylines.clear();
final pointsString = responseData['polyline'];
_fullRouteCoordinates = await compute(
decodePolylineIsolate as ComputeCallback<dynamic, List<LatLng>>,
pointsString);
// <<<--- [تعديل] ---: تصفير الـ index عند بدء مسار جديد
_lastTraveledIndexInFullRoute = 0;
polylines.add(
Polyline(
polylineId: const PolylineId('remaining_route'),
points: _fullRouteCoordinates,
color: const Color(0xFF4A80F0),
width: 7,
startCap: Cap.roundCap,
endCap: Cap.roundCap,
),
);
polylines.add(
const Polyline(
polylineId: PolylineId('traveled_route'),
points: [],
color: Colors.grey,
width: 7,
),
);
routeSteps = List<Map<String, dynamic>>.from(responseData['steps']);
await _prepareStepData();
for (int i = 0; i < routeSteps.length; i++) {
var step = routeSteps[i];
if (i < _stepPolylines.length && _stepPolylines[i].isNotEmpty) {
LatLng endLocation = _stepPolylines[i].last;
step['end_location'] = {
'lat': endLocation.latitude,
'lng': endLocation.longitude
};
} else {
var loc = step['maneuver']['location']; // [lng, lat]
step['end_location'] = {'lat': loc[1], 'lng': loc[0]};
}
step['instruction_text'] = _createInstructionFromManeuver(step);
}
currentStepIndex = 0;
_nextInstructionSpoken = false;
if (routeSteps.isNotEmpty) {
currentInstruction = routeSteps[0]['instruction_text'];
nextInstruction = (routeSteps.length > 1)
? routeSteps[1]['instruction_text']
: "الوجهة النهائية";
Get.find<TextToSpeechController>().speakText(currentInstruction);
}
_adjustUpdateInterval();
if (_fullRouteCoordinates.isNotEmpty) {
final bounds = _boundsFromLatLngList(_fullRouteCoordinates);
mapController
?.animateCamera(CameraUpdate.newLatLngBounds(bounds, 100.0));
}
} catch (e) {
print("Exception in getRoute: $e");
Get.snackbar('خطأ', 'حدث خطأ في الشبكة.');
}
}
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}) {
polylines.clear();
if (!isNewRoute) {
markers.removeWhere((m) => m.markerId.value == 'destination');
_finalDestination = null;
}
routeSteps.clear();
currentInstruction = "";
nextInstruction = "";
distanceToNextStep = "";
currentSpeed = 0.0;
_stepBounds.clear();
_fullRouteCoordinates.clear();
_stepPolylines.clear();
_nextInstructionSpoken = false;
_locationUpdateTimer?.cancel();
// <<<--- [تعديل] ---: تصفير الـ index عند إلغاء المسار
_lastTraveledIndexInFullRoute = 0;
update();
}
LatLngBounds _boundsFromLatLngList(List<LatLng> list) {
assert(list.isNotEmpty);
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!));
}
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(25, 25)), 'assets/images/b.png');
}
String _createInstructionFromManeuver(Map<String, dynamic> step) {
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':
instruction = "لقد وصلت إلى وجهتك";
if (name.isNotEmpty) instruction += "، $name";
return instruction;
case 'turn':
case 'fork':
case 'off ramp':
case 'on ramp':
case 'roundabout':
instruction = _getTurnInstruction(modifier);
break;
case 'continue':
instruction = "استمر";
break;
default:
instruction = "اتجه";
}
if (name.isNotEmpty) {
if (instruction == "استمر") {
instruction += " على $name";
} else {
instruction += " إلى $name";
}
} else if (type == 'continue' && modifier == 'straight') {
instruction = "استمر بشكل مستقيم";
}
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 "اتجه";
}
}
// =======================================================================
// ٥. دالة البحث عن الأماكن المحدثة والدوال المساعدة لها
// =======================================================================
// --- (تعديل) ---: تم تعديل الدالة لاستخدام http.post
Future<void> getPlaces() async {
final q = placeDestinationController.text.trim();
if (q.isEmpty || q.length < 3) {
placesDestination = [];
update();
return;
}
if (myLocation == null) {
print('myLocation is null, cannot search for places.');
return;
}
final lat = myLocation!.latitude;
final lng = myLocation!.longitude;
const radiusKm = 200.0;
final latDelta = _kmToLatDelta(radiusKm);
final lngDelta = _kmToLngDelta(radiusKm, lat);
final latMin = lat - latDelta;
final latMax = lat + latDelta;
final lngMin = lng - lngDelta;
final lngMax = lng + lngDelta;
// تجهيز البيانات لإرسالها كـ JSON
final payload = {
'query': q,
'lat_min': latMin.toString(),
'lat_max': latMax.toString(),
'lng_min': lngMin.toString(),
'lng_max': lngMax.toString(),
};
try {
final response =
await CRUD().post(link: AppLink.getPlacesSyria, payload: payload);
// إرسال البيانات كـ JSON
final responseData = (response);
List list;
if (responseData is Map) {
if (responseData['status'] == 'success' &&
responseData['message'] is List) {
list = List.from(responseData['message'] as List);
} else if (responseData['status'] == 'failure') {
print('Server Error: ${responseData['message']}');
return;
} else {
print('Unexpected Map shape from server');
return;
}
} else if (responseData is List) {
list = List.from(responseData);
} else {
print('Unexpected response shape from server');
return;
}
String _bestName(Map p) {
return (p['name_ar'] ?? p['name'] ?? p['name_en'] ?? '').toString();
}
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);
final relevance = _relevanceScore(_bestName(p), q);
final score = (1.0 / (1.0 + distance)) * (1.0 + relevance);
p['distanceKm'] = distance;
p['relevance'] = relevance;
p['score'] = score;
}
list.sort((a, b) {
final sa = (a['score'] ?? 0.0) as double;
final sb = (b['score'] ?? 0.0) as double;
return sb.compareTo(sa);
});
placesDestination = list;
Log.print('Updated places: $placesDestination');
update();
} catch (e) {
print('Exception in getPlaces: $e');
}
}
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 rLat1 = lat1 * (pi / 180.0);
final rLat2 = lat2 * (pi / 180.0);
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(rLat1) * cos(rLat2) * sin(dLon / 2) * sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return R * c;
}
double _relevanceScore(String placeName, String query) {
if (placeName.isEmpty || query.isEmpty) return 0.0;
final pLower = placeName.toLowerCase();
final qLower = query.toLowerCase();
if (pLower.startsWith(qLower)) return 1.0;
if (pLower.contains(qLower)) return 0.5;
return 0.0;
}
double _kmToLatDelta(double km) {
const kmInDegree = 111.32;
return km / kmInDegree;
}
double _kmToLngDelta(double km, double latitude) {
const kmInDegree = 111.32;
return km / (kmInDegree * cos(latitude * (pi / 180.0)));
}
}