Files
intaleq/lib/views/home/navigation/navigation_controller.dart

879 lines
30 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:convert';
import 'dart:math';
import 'package:Intaleq/views/widgets/error_snakbar.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:geolocator/geolocator.dart';
import 'package:get/get.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:http/http.dart' as http;
import '../../../constant/box_name.dart';
import '../../../constant/links.dart';
import '../../../controller/functions/crud.dart';
import '../../../controller/functions/tts.dart';
import '../../../controller/home/decode_polyline_isolate.dart';
import '../../../main.dart';
import '../../../print.dart';
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;
final TextEditingController placeDestinationController =
TextEditingController();
LatLng? myLocation;
double heading = 0.0;
double currentSpeed = 0.0; // km/h
double totalDistance = 0.0; // metres accumulated this session
// MapLibre objects
Symbol? carSymbol;
Symbol? destinationSymbol;
Line? remainingRouteLine;
Line? traveledRouteLine;
// General location polling
Timer? _locationUpdateTimer;
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;
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();
_initialize();
}
Future<void> _initialize() async {
await _getCurrentLocationAndStartUpdates();
if (!Get.isRegistered<TextToSpeechController>()) {
Get.put(TextToSpeechController());
}
}
@override
void onClose() {
_locationUpdateTimer?.cancel();
_recordTimer?.cancel();
_uploadBatchTimer?.cancel();
_debounce?.cancel();
mapController?.dispose();
placeDestinationController.dispose();
// Final flush before closing so no points are lost
_flushBufferToServer();
super.onClose();
}
// ==========================================================================
// ── Map callbacks ─────────────────────────────────────────────────────────
// ==========================================================================
void onMapCreated(MaplibreMapController controller) {
mapController = controller;
}
Future<void> onStyleLoaded() async {
isStyleLoaded = true;
await _loadCustomIcons();
if (myLocation != null) {
animateCameraToPosition(myLocation!);
_updateCarMarker();
}
if (_fullRouteCoordinates.isNotEmpty) {
_updatePolylinesSets([], _fullRouteCoordinates);
}
}
Future<void> onMapLongPressed(Point<double> point, LatLng tappedPoint) async {
HapticFeedback.mediumImpact();
Get.dialog(
AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text('بدء الملاحة؟',
style: TextStyle(fontWeight: FontWeight.bold)),
content: const Text('هل تريد الذهاب إلى هذا الموقع؟'),
actions: [
TextButton(
child: const Text('إلغاء', style: TextStyle(color: Colors.grey)),
onPressed: () => Get.back(),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0D47A1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
child:
const Text('اذهب الآن', style: TextStyle(color: Colors.white)),
onPressed: () {
Get.back();
startNavigationTo(tappedPoint, infoWindowTitle: 'الموقع المحدد');
},
),
],
),
);
}
// ==========================================================================
// ── Location polling (every second) ──────────────────────────────────────
// ==========================================================================
Future<void> _getCurrentLocationAndStartUpdates() async {
try {
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");
}
}
void _startLocationTimer() {
_locationUpdateTimer?.cancel();
_locationUpdateTimer =
Timer.periodic(const Duration(seconds: 1), (_) => _tick());
}
Future<void> _tick() async {
try {
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high);
final newLoc = LatLng(position.latitude, position.longitude);
// Gate: ignore micro-jitter
if (_lastProcessedLocation != null) {
final d = Geolocator.distanceBetween(
newLoc.latitude,
newLoc.longitude,
_lastProcessedLocation!.latitude,
_lastProcessedLocation!.longitude,
);
if (d < _minMoveToProcess) return;
}
// Accumulate session distance
if (_lastDistanceLocation != null) {
final d = Geolocator.distanceBetween(
_lastDistanceLocation!.latitude,
_lastDistanceLocation!.longitude,
newLoc.latitude,
newLoc.longitude,
);
if (d > 5.0) totalDistance += d;
}
_lastDistanceLocation = newLoc;
myLocation = newLoc;
_lastProcessedLocation = newLoc;
heading = position.heading;
currentSpeed = position.speed * 3.6;
if (isStyleLoaded) _updateCarMarker();
if (_fullRouteCoordinates.isNotEmpty) {
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) {
// 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;
if (carSymbol == null) {
carSymbol = await mapController!.addSymbol(SymbolOptions(
geometry: myLocation,
iconImage: 'car_icon',
iconSize: 1.0,
iconRotate: heading,
));
} else {
mapController!.updateSymbol(
carSymbol!,
SymbolOptions(geometry: myLocation, iconRotate: heading),
);
}
}
// ==========================================================================
// ── Camera ────────────────────────────────────────────────────────────────
// ==========================================================================
void animateCameraToPosition(LatLng position,
{double? zoom, double bearing = 0.0, double tilt = 0.0}) {
mapController?.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: position,
zoom: zoom ?? (isNavigating ? _targetZoom : 16.0),
bearing: bearing,
tilt: tilt,
),
),
);
}
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;
const int searchWindow = 60;
final int startIndex = _lastTraveledIndexInFullRoute;
final int endIndex =
min(startIndex + searchWindow, _fullRouteCoordinates.length);
double minDist = double.infinity;
int closestIdx = startIndex;
bool foundCloser = false;
for (int i = startIndex; i < endIndex; i++) {
final d = Geolocator.distanceBetween(
currentPos.latitude,
currentPos.longitude,
_fullRouteCoordinates[i].latitude,
_fullRouteCoordinates[i].longitude,
);
if (d < minDist) {
minDist = d;
closestIdx = i;
foundCloser = true;
}
}
if (foundCloser &&
minDist < 50 &&
closestIdx > _lastTraveledIndexInFullRoute) {
_lastTraveledIndexInFullRoute = closestIdx;
_updatePolylinesSets(
_fullRouteCoordinates.sublist(0, closestIdx + 1),
_fullRouteCoordinates.sublist(closestIdx),
);
}
}
Future<void> _updatePolylinesSets(
List<LatLng> traveled, List<LatLng> remaining) async {
if (mapController == null || !isStyleLoaded) return;
if (remainingRouteLine != null)
await mapController!.removeLine(remainingRouteLine!);
if (traveledRouteLine != null)
await mapController!.removeLine(traveledRouteLine!);
if (remaining.isNotEmpty) {
remainingRouteLine = await mapController!.addLine(LineOptions(
geometry: remaining,
lineColor: '#1A73E8',
lineWidth: 7.0,
lineJoin: 'round',
));
}
if (traveled.isNotEmpty) {
traveledRouteLine = await mapController!.addLine(LineOptions(
geometry: traveled,
lineColor: '#BDBDBD',
lineWidth: 5.0,
lineJoin: 'round',
lineOpacity: 0.6,
));
}
}
// ==========================================================================
// ── Routing API ───────────────────────────────────────────────────────────
// ==========================================================================
Future<void> getRoute(LatLng origin, LatLng destination) async {
final coords = "${origin.longitude},${origin.latitude};"
"${destination.longitude},${destination.latitude}";
final url =
"$_routeApiBaseUrl/$coords?steps=true&overview=full&geometries=polyline";
try {
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
mySnackbarWarning('تعذر الاتصال بخدمة التوجيه.');
return;
}
final data = jsonDecode(response.body);
if (data['code'] != 'Ok' || (data['routes'] as List).isEmpty) {
mySnackbarWarning('لم يتم العثور على مسار.');
return;
}
final route = data['routes'][0];
_fullRouteCoordinates = await compute<String, List<LatLng>>(
decodePolylineIsolate, route['geometry'].toString());
_lastTraveledIndexInFullRoute = 0;
if (isStyleLoaded) _updatePolylinesSets([], _fullRouteCoordinates);
final legs = route['legs'] as List;
if (legs.isNotEmpty) {
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 (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
? "ثم ${routeSteps[1]['instruction_text']}"
: "الوجهة النهائية";
Get.find<TextToSpeechController>().speakText(currentInstruction);
}
if (_fullRouteCoordinates.isNotEmpty) {
final bounds = _boundsFromLatLngList(_fullRouteCoordinates);
mapController?.animateCamera(CameraUpdate.newLatLngBounds(bounds,
bottom: 220, top: 150, left: 50, right: 50));
}
update();
} catch (e) {
Log.print("GetRoute Error: $e");
}
}
// Crude but fast ETA re-estimate based on fraction of route remaining.
void _recomputeETA() {
if (_routeTotalDistanceM == 0 || _fullRouteCoordinates.isEmpty) return;
final fraction =
(_fullRouteCoordinates.length - _lastTraveledIndexInFullRoute) /
_fullRouteCoordinates.length;
final remainingM = _routeTotalDistanceM * fraction;
final remainingS = _routeTotalDurationS * fraction;
totalDistanceRemaining = remainingM > 1000
? "${(remainingM / 1000).toStringAsFixed(1)} كم"
: "${remainingM.toStringAsFixed(0)} م";
final minutes = (remainingS / 60).round();
estimatedTimeRemaining = minutes > 60
? "${(minutes / 60).floor()} س ${minutes % 60} د"
: "$minutes د";
}
Future<void> startNavigationTo(LatLng destination,
{String infoWindowTitle = ''}) async {
isLoading = true;
update();
try {
_finalDestination = destination;
await clearRoute(isNewRoute: true);
if (isStyleLoaded && mapController != null) {
destinationSymbol = await mapController!.addSymbol(SymbolOptions(
geometry: destination,
iconImage: 'dest_icon',
iconSize: 1.0,
textField: infoWindowTitle,
textOffset: const Offset(0, 2),
));
}
if (myLocation != null) await getRoute(myLocation!, destination);
} finally {
isLoading = false;
update();
}
}
Future<void> recalculateRoute() async {
if (myLocation == null || _finalDestination == null || isLoading) return;
isLoading = true;
update();
mySnackbarInfo('جاري حساب مسار جديد...');
await getRoute(myLocation!, _finalDestination!);
isLoading = false;
update();
}
Future<void> clearRoute({bool isNewRoute = false}) async {
if (!isNewRoute) {
if (destinationSymbol != null && mapController != null) {
await mapController!.removeSymbol(destinationSymbol!);
destinationSymbol = null;
}
if (remainingRouteLine != null && mapController != null) {
await mapController!.removeLine(remainingRouteLine!);
remainingRouteLine = null;
}
if (traveledRouteLine != null && mapController != null) {
await mapController!.removeLine(traveledRouteLine!);
traveledRouteLine = null;
}
_finalDestination = null;
isNavigating = false;
// Flush whatever is in the buffer when navigation ends
await _flushBufferToServer();
}
routeSteps.clear();
_fullRouteCoordinates.clear();
_lastTraveledIndexInFullRoute = 0;
currentInstruction = "";
nextInstruction = "";
distanceToNextStep = "";
totalDistanceRemaining = "";
estimatedTimeRemaining = "";
_routeTotalDistanceM = 0;
_routeTotalDurationS = 0;
update();
}
Future<void> _loadCustomIcons() async {
if (mapController == null) return;
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 & TTS ───────────────────────────────────────────────────
// ==========================================================================
void _checkNavigationStep(LatLng pos) {
if (routeSteps.isEmpty || currentStepIndex >= routeSteps.length) return;
final maneuver = routeSteps[currentStepIndex]['maneuver'];
final loc = maneuver['location'] as List;
final endLatLng = LatLng(loc[1] as double, loc[0] as double);
final distance = Geolocator.distanceBetween(
pos.latitude,
pos.longitude,
endLatLng.latitude,
endLatLng.longitude,
);
distanceToNextStep = distance > 1000
? "${(distance / 1000).toStringAsFixed(1)} كم"
: "${distance.toStringAsFixed(0)} م";
if (distance < 50 &&
!_nextInstructionSpoken &&
nextInstruction.isNotEmpty) {
Get.find<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;
update();
} else {
_finishNavigation();
}
}
void _finishNavigation() {
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 type = step['maneuver']['type'] ?? 'continue';
final modifier = step['maneuver']['modifier'] ?? 'straight';
final name = step['name'] ?? '';
String instruction = type == 'depart'
? "انطلق"
: type == 'arrive'
? "لقد وصلت إلى وجهتك، $name"
: _getTurnInstruction(modifier);
if (name.isNotEmpty && type != 'arrive') instruction += " نحو $name";
return instruction;
}
String _getTurnInstruction(String modifier) {
switch (modifier) {
case 'uturn':
return "قم بالاستدارة والعودة";
case 'sharp right':
return "انعطف يميناً بحدة";
case 'right':
return "انعطف يميناً";
case 'slight right':
return "انعطف يميناً قليلاً";
case 'straight':
return "استمر للأمام";
case 'slight left':
return "انعطف يساراً قليلاً";
case 'left':
return "انعطف يساراً";
case 'sharp left':
return "انعطف يساراً بحدة";
default:
return "اتجه";
}
}
// ==========================================================================
// ── Search ────────────────────────────────────────────────────────────────
// ==========================================================================
Future<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 payload = {
'query': q,
'lat_min': (lat - _kmToLatDelta(radiusKm)).toString(),
'lat_max': (lat + _kmToLatDelta(radiusKm)).toString(),
'lng_min': (lng - _kmToLngDelta(radiusKm, lat)).toString(),
'lng_max': (lng + _kmToLngDelta(radiusKm, lat)).toString(),
};
try {
final response =
await CRUD().post(link: AppLink.getPlacesSyria, payload: payload);
List list;
if (response is Map && response['status'] == 'success')
list = List.from(response['message'] as List);
else if (response is List)
list = List.from(response);
else
return;
for (final p in list) {
final plat = double.tryParse(p['latitude']?.toString() ?? '0') ?? 0.0;
final plng = double.tryParse(p['longitude']?.toString() ?? '0') ?? 0.0;
p['distanceKm'] = _haversineKm(lat, lng, plat, plng);
}
list.sort((a, b) =>
(a['distanceKm'] as double).compareTo(b['distanceKm'] as double));
placesDestination = list;
update();
} catch (e) {
Log.print('getPlaces error: $e');
}
}
Future<void> selectDestination(dynamic place) async {
placeDestinationController.clear();
placesDestination = [];
final lat = double.parse(place['latitude'].toString());
final lng = double.parse(place['longitude'].toString());
await startNavigationTo(LatLng(lat, lng),
infoWindowTitle: place['name'] ?? 'وجهة');
}
void onSearchChanged(String query) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 700), () => getPlaces());
}
// ==========================================================================
// ── Geo utils ─────────────────────────────────────────────────────────────
// ==========================================================================
double _haversineKm(double lat1, double lon1, double lat2, double lon2) {
const R = 6371.0;
final dLat = (lat2 - lat1) * (pi / 180.0);
final dLon = (lon2 - lon1) * (pi / 180.0);
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) *
sin(dLon / 2) *
sin(dLon / 2);
return R * 2 * atan2(sqrt(a), sqrt(1 - a));
}
double _kmToLatDelta(double km) => km / 111.32;
double _kmToLngDelta(double km, double lat) =>
km / (111.32 * cos(lat * pi / 180));
LatLngBounds _boundsFromLatLngList(List<LatLng> list) {
double? x0, x1, y0, y1;
for (final ll in list) {
if (x0 == null) {
x0 = x1 = ll.latitude;
y0 = y1 = ll.longitude;
} else {
if (ll.latitude > x1!) x1 = ll.latitude;
if (ll.latitude < x0) x0 = ll.latitude;
if (ll.longitude > y1!) y1 = ll.longitude;
if (ll.longitude < y0!) y0 = ll.longitude;
}
}
return LatLngBounds(
northeast: LatLng(x1!, y1!), southwest: LatLng(x0!, y0!));
}
}