Files
intaleq/lib/views/home/navigation/navigation_controller.dart
2026-04-06 22:00:13 +03:00

1013 lines
36 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;
/// Metres off-route before the auto-recalculate countdown starts.
static const double _offRouteThresholdM = 25.0;
/// Seconds the user must remain off-route before auto-recalculate fires.
static const int _offRouteTriggerSeconds = 6;
// ==========================================================================
// ── Map state ─────────────────────────────────────────────────────────────
// ==========================================================================
bool isLoading = false;
MaplibreMapController? mapController;
bool isStyleLoaded = false;
final TextEditingController placeDestinationController =
TextEditingController();
LatLng? myLocation;
double heading = 0.0;
/// Smoothed heading used for the car icon and camera bearing.
/// Updated every tick via angle-aware lerp to eliminate snap/jitter.
double _smoothedHeading = 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;
bool _mapReady = false; // true only after layout has settled
// ==========================================================================
// ── Off-route auto-recalculate ────────────────────────────────────────────
// ==========================================================================
/// Wall-clock time when the user first went more than [_offRouteThresholdM]
/// metres away from the nearest route point. Null means on-route.
DateTime? _offRouteStartTime;
/// True while an auto-recalculate triggered from off-route detection is in
/// progress — prevents a second trigger from firing.
bool _autoRecalcInProgress = false;
// ==========================================================================
// ── 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();
// Wait one full frame for the native MapLibre view to finish layout.
// Without this, ANY animateCamera call throws std::domain_error on iOS
// because the view still has zero pixel dimensions at this point.
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.delayed(const Duration(milliseconds: 300));
if (!_mapReady) {
_mapReady = true;
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);
_smoothedHeading = position.heading; // seed so first lerp is instant
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;
// ── Smooth the heading with an angle-aware exponential lerp ──────────
// Factor 0.25 means ~75 % of the old angle is kept each tick, giving a
// ~4-tick (≈4 s) settling time — smooth enough to look fluid on screen
// while still reacting quickly to real turns.
_smoothedHeading = _lerpAngle(_smoothedHeading, heading, 0.25);
currentSpeed = position.speed * 3.6;
if (isStyleLoaded) _updateCarMarker();
if (_fullRouteCoordinates.isNotEmpty) {
if (_cameraLockedToUser) {
animateCameraToPosition(myLocation!,
bearing: _smoothedHeading, zoom: _targetZoom, tilt: _targetTilt);
}
_updateTraveledPolylineSmart(myLocation!);
_checkNavigationStep(myLocation!);
_recomputeETA();
// ── Off-route auto-recalculate ─────────────────────────────────────
_checkOffRoute(myLocation!);
}
update();
} catch (_) {}
}
// ==========================================================================
// ── Heading utilities ─────────────────────────────────────────────────────
// ==========================================================================
/// Lerps from [from] to [to] by factor [t], correctly handling the 0/360
/// wrap-around so we never spin the wrong way (e.g. 350° → 10° goes +20°,
/// not 340°).
double _lerpAngle(double from, double to, double t) {
final double diff = ((to - from + 540.0) % 360.0) - 180.0;
return (from + diff * t + 360.0) % 360.0;
}
// ==========================================================================
// ── Off-route detection ───────────────────────────────────────────────────
// ==========================================================================
/// Called every tick while navigating. Measures the distance from [pos] to
/// the nearest upcoming route coordinate. If the driver stays more than
/// [_offRouteThresholdM] metres away for at least [_offRouteTriggerSeconds]
/// seconds, an automatic route recalculation is triggered.
void _checkOffRoute(LatLng pos) {
if (_autoRecalcInProgress || isLoading) return;
if (_fullRouteCoordinates.isEmpty) return;
// Search a window ahead of the last tracked index for the nearest point.
const int searchWindow = 80;
final int start = _lastTraveledIndexInFullRoute;
final int end = min(start + searchWindow, _fullRouteCoordinates.length);
double minDist = double.infinity;
for (int i = start; i < end; i++) {
final d = Geolocator.distanceBetween(
pos.latitude,
pos.longitude,
_fullRouteCoordinates[i].latitude,
_fullRouteCoordinates[i].longitude,
);
if (d < minDist) minDist = d;
}
if (minDist > _offRouteThresholdM) {
// Driver is off the route.
if (_offRouteStartTime == null) {
_offRouteStartTime = DateTime.now();
Log.print('⚠️ Off-route detected (${minDist.toStringAsFixed(0)} m). '
'Countdown started.');
} else {
final elapsed =
DateTime.now().difference(_offRouteStartTime!).inSeconds;
if (elapsed >= _offRouteTriggerSeconds) {
Log.print('🔄 Auto-recalculate triggered after ${elapsed}s '
'off-route (${minDist.toStringAsFixed(0)} m).');
_offRouteStartTime = null;
_autoRecalcInProgress = true;
recalculateRoute().then((_) => _autoRecalcInProgress = false);
}
}
} else {
// Back on (or close enough to) the route — reset the clock.
if (_offRouteStartTime != null) {
Log.print('✅ Back on route — off-route timer reset.');
}
_offRouteStartTime = null;
}
}
// ==========================================================================
// ── 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: _smoothedHeading, // ← use smoothed heading
));
} else {
mapController!.updateSymbol(
carSymbol!,
SymbolOptions(
geometry: myLocation,
iconRotate: _smoothedHeading, // ← use smoothed heading
),
);
}
}
// ==========================================================================
// ── Camera ────────────────────────────────────────────────────────────────
// ==========================================================================
void animateCameraToPosition(LatLng position,
{double? zoom, double bearing = 0.0, double tilt = 0.0}) {
// Guard: skip if the native view is not ready yet
if (!_mapReady || mapController == null) return;
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: _smoothedHeading, // ← use smoothed 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;
// Reset off-route state after a successful recalculation
_offRouteStartTime = null;
if (routeSteps.isNotEmpty) {
currentInstruction = routeSteps[0]['instruction_text'];
nextInstruction = routeSteps.length > 1
? "ثم ${routeSteps[1]['instruction_text']}"
: "الوجهة النهائية";
Get.find<TextToSpeechController>().speakText(currentInstruction);
}
if (_fullRouteCoordinates.length >= 2) {
final bounds = _boundsFromLatLngList(_fullRouteCoordinates);
final latDiff =
(bounds.northeast.latitude - bounds.southwest.latitude).abs();
final lngDiff =
(bounds.northeast.longitude - bounds.southwest.longitude).abs();
if (latDiff > 0.0001 || lngDiff > 0.0001) {
mapController?.animateCamera(CameraUpdate.newLatLngBounds(bounds,
bottom: 220, top: 150, left: 50, right: 50));
} else {
animateCameraToPosition(_fullRouteCoordinates.first, zoom: 15.0);
}
}
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 {
// Reset off-route state whenever the route is cleared
_offRouteStartTime = null;
_autoRecalcInProgress = false;
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!));
}
}