1013 lines
36 KiB
Dart
1013 lines
36 KiB
Dart
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!));
|
||
}
|
||
}
|