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

1152 lines
41 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';
import 'dart:ui';
import '../../../services/offline_map_service.dart';
class NavigationController extends GetxController
with GetSingleTickerProviderStateMixin {
// ==========================================================================
// ── 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;
// Animation for smooth tracking
AnimationController? _animController;
LatLng? _oldLoc;
LatLng? _targetLoc;
double currentSpeed = 0.0; // km/h
double totalDistance = 0.0; // metres accumulated this session
// MapLibre objects
Symbol? carSymbol;
Symbol? originSymbol;
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();
_animController = AnimationController(
vsync: this, duration: const Duration(milliseconds: 1000));
_animController!.addListener(() {
if (_oldLoc != null && _targetLoc != null && _mapReady) {
final t = _animController!.value;
final lat = lerpDouble(_oldLoc!.latitude, _targetLoc!.latitude, t)!;
final lng = lerpDouble(_oldLoc!.longitude, _targetLoc!.longitude, t)!;
myLocation = LatLng(lat, lng);
if (isStyleLoaded) {
_updateCarMarker();
if (_fullRouteCoordinates.isNotEmpty && _cameraLockedToUser) {
animateCameraToPosition(myLocation!,
bearing: _smoothedHeading,
zoom: _targetZoom,
tilt: _targetTilt);
}
}
}
});
_initialize();
}
Future<void> _initialize() async {
await _getCurrentLocationAndStartUpdates();
}
@override
void onClose() {
_locationUpdateTimer?.cancel();
_recordTimer?.cancel();
_uploadBatchTimer?.cancel();
_debounce?.cancel();
_animController?.dispose();
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;
_oldLoc = myLocation ?? newLoc;
_targetLoc = newLoc;
_animController?.forward(from: 0.0);
_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;
// Initial visual update if map is fresh
if (isStyleLoaded && myLocation == null) _updateCarMarker();
if (_fullRouteCoordinates.isNotEmpty) {
_updateTraveledPolylineSmart(newLoc);
_checkNavigationStep(newLoc);
_recomputeETA();
// ── Off-route auto-recalculate ─────────────────────────────────────
_checkOffRoute(newLoc);
}
update();
} catch (e) {
Log.print("Error occurred: $e");
}
}
// ==========================================================================
// ── 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,
));
} else {
mapController!.updateSymbol(
carSymbol!,
SymbolOptions(
geometry: myLocation,
iconRotate: _smoothedHeading,
),
);
}
}
// ==========================================================================
// ── 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,
),
),
);
}
/// Safe wrapper for animateCamera Bounds to prevent native std::domain_error crash on iOS.
Future<void> _safeAnimateCameraBounds(LatLngBounds? bounds,
{double left = 60,
double top = 60,
double right = 60,
double bottom = 60}) async {
if (bounds == null || mapController == null) return;
try {
// Ensure the coordinates are valid (at least a small span)
final latSpan =
(bounds.northeast.latitude - bounds.southwest.latitude).abs();
final lngSpan =
(bounds.northeast.longitude - bounds.southwest.longitude).abs();
if (latSpan < 0.0001 && lngSpan < 0.0001) {
Log.print(
'⚠️ _safeAnimateCameraBounds: Point-sized bounds, zooming to center.');
mapController
?.animateCamera(CameraUpdate.newLatLngZoom(bounds.northeast, 16));
return;
}
// Small delay for view stabilization
await Future.delayed(const Duration(milliseconds: 200));
await mapController?.animateCamera(
CameraUpdate.newLatLngBounds(
bounds,
left: left,
top: top,
right: right,
bottom: bottom,
),
);
} catch (e) {
Log.print('❌ _safeAnimateCameraBounds CRASH PREVENTED in Nav: $e');
try {
await mapController
?.animateCamera(CameraUpdate.newLatLngZoom(bounds.northeast, 14));
} catch (_) {}
}
}
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 {
// ── Routing Decision: Normal Points -> SaaS, Multi-Stop -> OSRM ──
// Note: NavigationController usually handles the active trip (normal points).
final Map<String, String> queryParams = {
'fromLat': origin.latitude.toString(),
'fromLng': origin.longitude.toString(),
'toLat': destination.latitude.toString(),
'toLng': destination.longitude.toString(),
};
final saasUri =
Uri.parse(AppLink.mapSaasRoute).replace(queryParameters: queryParams);
// Fallback OSRM URL
final coords = "${origin.longitude},${origin.latitude};"
"${destination.longitude},${destination.latitude}";
final osrmUrl =
"$_routeApiBaseUrl/$coords?steps=true&overview=full&geometries=polyline";
try {
// 1. Try SaaS first
http.Response response = await http.get(saasUri, headers: {
'x-api-key': 'intaleq_secret_2026',
});
bool useSaaS = response.statusCode == 200;
if (!useSaaS) {
Log.print("⚠️ SaaS Route failed. Falling back to OSRM...");
response = await http.get(Uri.parse(osrmUrl));
}
if (response.statusCode != 200) {
mySnackbarWarning('تعذر الاتصال بخدمة التوجيه.');
return;
}
final data = jsonDecode(response.body);
final bool isSaaS = useSaaS;
// ── 2. Data Extraction Logic ──────────────────────────────────
String pointsString = "";
dynamic mainRoute;
if (isSaaS) {
pointsString = data['points']?.toString() ?? "";
mainRoute = data; // SaaS structure is top-level
} else {
if (data['code'] != 'Ok' || (data['routes'] as List).isEmpty) {
mySnackbarWarning('لم يتم العثور على مسار.');
return;
}
mainRoute = data['routes'][0];
pointsString = mainRoute['geometry']?.toString() ?? "";
}
if (pointsString.isEmpty) {
mySnackbarWarning('لم يتم العثور على مسار.');
return;
}
_fullRouteCoordinates = await compute<String, List<LatLng>>(
decodePolylineIsolate, pointsString);
_lastTraveledIndexInFullRoute = 0;
if (isStyleLoaded) _updatePolylinesSets([], _fullRouteCoordinates);
// ── Offline Cache: Ensure destination area is stored in memory/disk ───
if (_fullRouteCoordinates.isNotEmpty) {
OfflineMapService.instance
.downloadRegion(_fullRouteCoordinates.last, radiusKm: 2.0);
}
// Handle legs/steps & totals
final legs = mainRoute['legs'] as List?;
if (legs != null && 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();
} else {
// Fallback for SaaS which might have top-level distance/duration
routeSteps = [];
_routeTotalDistanceM = (mainRoute['distance'] as num).toDouble();
_routeTotalDurationS = (mainRoute['duration'] as num).toDouble();
}
if (_routeTotalDistanceM > 0) {
totalDistanceRemaining = _routeTotalDistanceM > 1000
? "${(_routeTotalDistanceM / 1000).toStringAsFixed(1)} كم"
: "${_routeTotalDistanceM.toStringAsFixed(0)} م";
final minutes = (_routeTotalDurationS / 60).round();
estimatedTimeRemaining = minutes > 60
? "${(minutes / 60).floor()} س ${minutes % 60} د"
: "$minutes د";
}
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);
}
// ── 5. Camera Update (Safe) ───────────────────────────────────
if (_fullRouteCoordinates.length >= 2) {
final bounds =
data['bbox'] != null && (data['bbox'] as List).length == 4
? LatLngBounds(
southwest: LatLng(data['bbox'][1], data['bbox'][0]),
northeast: LatLng(data['bbox'][3], data['bbox'][2]),
)
: _boundsFromLatLngList(_fullRouteCoordinates);
await _safeAnimateCameraBounds(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) {
// Destination Marker (B)
destinationSymbol = await mapController!.addSymbol(SymbolOptions(
geometry: destination,
iconImage: 'dest_icon',
iconSize: 1.0,
textField: infoWindowTitle,
textOffset: const Offset(0, 2),
));
// Start Marker (A)
if (myLocation != null) {
originSymbol = await mapController!.addSymbol(SymbolOptions(
geometry: myLocation,
iconImage: 'start_icon',
iconSize: 1.0,
));
}
}
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 (originSymbol != null && mapController != null) {
await mapController!.removeSymbol(originSymbol!);
originSymbol = 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 startBytes = await rootBundle.load('assets/images/A.png');
final destBytes = await rootBundle.load('assets/images/b.png');
await mapController!.addImage('car_icon', carBytes.buffer.asUint8List());
await mapController!.addImage('start_icon', startBytes.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!));
}
}