Files
intaleq/lib/views/home/navigation/navigation_controller.dart
2026-04-16 19:45:03 +03:00

1177 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 '../../../env/env.dart';
import '../../../main.dart';
import '../../../print.dart';
import 'dart:ui';
import '../../../services/offline_map_service.dart';
class RouteData {
final List<LatLng> coordinates;
final List<Map<String, dynamic>> steps;
final double distanceM;
final double durationS;
final String points;
RouteData({
required this.coordinates,
required this.steps,
required this.distanceM,
required this.durationS,
required this.points,
});
}
class NavigationController extends GetxController
with GetSingleTickerProviderStateMixin {
static const Duration _recordInterval = Duration(seconds: 4);
static const Duration _uploadInterval = Duration(minutes: 2);
static const double _minMoveToRecord = 10.0;
static const double _minMoveToProcess = 2.0;
static const double _offRouteThresholdM = 25.0;
static const int _offRouteTriggerSeconds = 6;
bool isLoading = false;
MaplibreMapController? mapController;
bool isStyleLoaded = false;
final TextEditingController placeDestinationController =
TextEditingController();
LatLng? myLocation;
double _oldHeading = 0.0;
double _targetHeading = 0.0;
double _smoothedHeading = 0.0;
AnimationController? _animController;
LatLng? _oldLoc;
LatLng? _targetLoc;
double currentSpeed = 0.0;
double totalDistance = 0.0;
Symbol? carSymbol;
Symbol? originSymbol;
Symbol? destinationSymbol;
Line? remainingRouteLine;
Line? traveledRouteLine;
Timer? _locationUpdateTimer;
LatLng? _lastProcessedLocation;
List<dynamic> placesDestination = [];
Timer? _debounce;
LatLng? _finalDestination;
LatLng? _intermediateStop;
List<Map<String, dynamic>> routeSteps = [];
List<LatLng> _fullRouteCoordinates = [];
int _lastTraveledIndexInFullRoute = 0;
bool _nextInstructionSpoken = false;
String currentInstruction = "";
String nextInstruction = "";
int currentStepIndex = 0;
String distanceToNextStep = "";
String totalDistanceRemaining = "";
String estimatedTimeRemaining = "";
dynamic currentManeuverModifier = 0;
String arrivalTime = "--:--"; // NEW: For the active navigation HUD
double _routeTotalDistanceM = 0;
double _routeTotalDurationS = 0;
bool isNavigating = false;
bool isMuted = false; // Sound toggle state
String distanceWithUnit = "";
bool _cameraLockedToUser = true;
bool _mapReady = false;
DateTime? _offRouteStartTime;
bool _autoRecalcInProgress = false;
final List<Map<String, dynamic>> _trackBuffer = [];
Timer? _recordTimer;
Timer? _uploadBatchTimer;
LatLng? _lastBufferedLocation;
DateTime? _lastBufferedTime;
LatLng? _lastDistanceLocation;
List<RouteData> routes = [];
int selectedRouteIndex = 0;
List<Line> alternativeRouteLines = [];
List<Map<String, dynamic>> recentLocations = [];
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";
IconData get currentManeuverIcon {
switch (currentManeuverModifier) {
case 4: // Arrive
return Icons.place_rounded;
case 6: // Roundabout
return Icons.roundabout_right_rounded;
case 2: // Right
return Icons.turn_right_rounded;
case 3: // Slight Right
return Icons.turn_slight_right_rounded;
case -2: // Left
return Icons.turn_left_rounded;
case -1: // Slight Left
return Icons.turn_slight_left_rounded;
case 7: // Keep Right
return Icons.turn_right_rounded;
case -7: // Keep Left
return Icons.turn_left_rounded;
case 0: // Straight
return Icons.straight_rounded;
default:
return Icons.straight_rounded;
}
}
void toggleMute() {
isMuted = !isMuted;
update();
}
@override
void onInit() {
super.onInit();
_animController = AnimationController(
vsync: this, duration: const Duration(milliseconds: 3800));
_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);
_smoothedHeading = _lerpAngle(_oldHeading, _targetHeading, t);
if (isStyleLoaded) {
_updateCarMarker();
if (_fullRouteCoordinates.isNotEmpty && _cameraLockedToUser) {
animateCameraToPosition(myLocation!,
bearing: _smoothedHeading,
zoom: _targetZoom,
tilt: _targetTilt);
}
}
}
});
_initialize();
}
Future<void> _initialize() async {
_loadRecentLocations();
await _getCurrentLocationAndStartUpdates();
}
void _loadRecentLocations() {
final dynamic stored = box.read(BoxName.recentLocations);
if (stored != null) {
try {
List<dynamic> parsed;
if (stored is String) {
parsed = jsonDecode(stored);
} else if (stored is List) {
parsed = stored;
} else {
parsed = [];
}
recentLocations = parsed
.map((e) => Map<String, dynamic>.from(e))
.toList()
.reversed // Most recent first
.take(3)
.toList();
} catch (e) {
Log.print("Error decoding recent locations: $e");
recentLocations = [];
}
} else {
recentLocations = [];
}
update();
}
@override
void onClose() {
_locationUpdateTimer?.cancel();
_recordTimer?.cancel();
_uploadBatchTimer?.cancel();
_debounce?.cancel();
_animController?.dispose();
mapController = null;
placeDestinationController.dispose();
_flushBufferToServer();
super.onClose();
}
void onMapCreated(MaplibreMapController controller) {
mapController = controller;
}
Future<void> onStyleLoaded() async {
isStyleLoaded = true;
await _loadCustomIcons();
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);
}
}
});
}
void onMapTapped(Point<double> point, LatLng tappedPoint) {
if (isNavigating || routes.isEmpty) return;
int? bestIndex;
double minDistance = 100.0; // 100 meters threshold for tap
for (int i = 0; i < routes.length; i++) {
for (var coord in routes[i].coordinates) {
final dist = Geolocator.distanceBetween(
tappedPoint.latitude,
tappedPoint.longitude,
coord.latitude,
coord.longitude,
);
if (dist < minDistance) {
minDistance = dist;
bestIndex = i;
}
}
}
if (bestIndex != null && bestIndex != selectedRouteIndex) {
HapticFeedback.selectionClick();
selectRoute(bestIndex);
}
}
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: 'الموقع المحدد');
},
),
],
),
);
}
Future<void> _getCurrentLocationAndStartUpdates() async {
try {
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high);
myLocation = LatLng(position.latitude, position.longitude);
_targetHeading = position.heading;
_oldHeading = position.heading;
_smoothedHeading = position.heading;
update();
if (isStyleLoaded) animateCameraToPosition(myLocation!);
_startLocationTimer();
_startBatchTimers();
} catch (e) {
Log.print("Error getting initial location: $e");
}
}
void _startLocationTimer() {
_locationUpdateTimer?.cancel();
_locationUpdateTimer =
Timer.periodic(const Duration(seconds: 4), (_) => _tick());
}
Future<void> _tick() async {
try {
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high);
final newLoc = LatLng(position.latitude, position.longitude);
currentSpeed = position.speed * 3.6;
if (_lastProcessedLocation != null) {
final d = Geolocator.distanceBetween(
newLoc.latitude,
newLoc.longitude,
_lastProcessedLocation!.latitude,
_lastProcessedLocation!.longitude,
);
if (d < _minMoveToProcess) return;
}
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;
_oldHeading = _smoothedHeading;
if (currentSpeed > 1.5 && _oldLoc != null) {
_targetHeading = Geolocator.bearingBetween(
_oldLoc!.latitude,
_oldLoc!.longitude,
_targetLoc!.latitude,
_targetLoc!.longitude,
);
} else {
_targetHeading = position.heading;
}
_animController?.forward(from: 0.0);
_lastProcessedLocation = newLoc;
if (isStyleLoaded) _updateCarMarker();
if (_fullRouteCoordinates.isNotEmpty) {
_updateTraveledPolylineSmart(newLoc);
_checkNavigationStep(newLoc);
_recomputeETA();
_checkOffRoute(newLoc);
}
update();
} catch (e) {
Log.print("Error occurred: $e");
}
}
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;
}
void _checkOffRoute(LatLng pos) {
if (_autoRecalcInProgress || isLoading) return;
if (_fullRouteCoordinates.isEmpty) return;
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) {
if (_offRouteStartTime == null) {
_offRouteStartTime = DateTime.now();
} else {
final elapsed =
DateTime.now().difference(_offRouteStartTime!).inSeconds;
if (elapsed >= _offRouteTriggerSeconds) {
_offRouteStartTime = null;
_autoRecalcInProgress = true;
recalculateRoute().then((_) => _autoRecalcInProgress = false);
}
}
} else {
_offRouteStartTime = null;
}
}
void _startBatchTimers() {
_recordTimer?.cancel();
_uploadBatchTimer?.cancel();
_recordTimer = Timer.periodic(_recordInterval, (_) => _recordToBuffer());
_uploadBatchTimer =
Timer.periodic(_uploadInterval, (_) => _flushBufferToServer());
}
void _recordToBuffer() {
if (myLocation == null ||
(myLocation!.latitude == 0 && myLocation!.longitude == 0)) {
return;
}
final now = DateTime.now();
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;
_trackBuffer.add({
'lat': double.parse(myLocation!.latitude.toStringAsFixed(6)),
'lng': double.parse(myLocation!.longitude.toStringAsFixed(6)),
'spd': double.parse(currentSpeed.toStringAsFixed(1)),
'head': _smoothedHeading.toStringAsFixed(0),
'dist': double.parse(totalDistance.toStringAsFixed(1)),
'ts': now.toIso8601String(),
});
}
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();
try {
await CRUD().post(
link: '${AppLink.locationServerSide}/add_batch.php',
payload: {
'driver_id': passengerId,
'batch_data': jsonEncode(batch),
'session_dist': totalDistance.toStringAsFixed(1),
},
);
} catch (e) {
_trackBuffer.insertAll(0, batch);
}
}
Future<void> _updateCarMarker() async {
if (myLocation == null || mapController == null || !isStyleLoaded) return;
// Check if symbol still exists in map controller's internal list
bool exists =
carSymbol != null && mapController!.symbols.contains(carSymbol);
if (!exists) {
if (carSymbol != null) {
try {
await mapController!.removeSymbol(carSymbol!);
} catch (_) {}
}
carSymbol = await mapController!.addSymbol(SymbolOptions(
geometry: myLocation,
iconImage: 'car_icon',
iconSize: 1.6,
iconRotate: _smoothedHeading,
));
} else {
mapController!.updateSymbol(
carSymbol!,
SymbolOptions(
geometry: myLocation,
iconRotate: _smoothedHeading,
));
}
}
void animateCameraToPosition(LatLng position,
{double? zoom, double bearing = 0.0, double tilt = 0.0}) {
if (!_mapReady || mapController == null) return;
mapController!.animateCamera(CameraUpdate.newCameraPosition(CameraPosition(
target: position,
zoom: zoom ?? (isNavigating ? _targetZoom : 16.0),
bearing: bearing,
tilt: tilt)));
}
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 {
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) {
mapController
?.animateCamera(CameraUpdate.newLatLngZoom(bounds.northeast, 16));
return;
}
await Future.delayed(const Duration(milliseconds: 200));
await mapController?.animateCamera(CameraUpdate.newLatLngBounds(bounds,
left: left, top: top, right: right, bottom: bottom));
} catch (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, zoom: _targetZoom, tilt: _targetTilt);
}
update();
}
bool get isCameraLocked => _cameraLockedToUser;
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;
// Clear old alternative lines
for (var line in alternativeRouteLines) {
await mapController!.removeLine(line);
}
alternativeRouteLines.clear();
if (remainingRouteLine != null) {
await mapController!.removeLine(remainingRouteLine!);
}
if (traveledRouteLine != null) {
await mapController!.removeLine(traveledRouteLine!);
}
// Render Alternative Routes first (so they are below)
for (int i = 0; i < routes.length; i++) {
if (i == selectedRouteIndex) continue;
final altLine = await mapController!.addLine(LineOptions(
geometry: routes[i].coordinates,
lineColor: '#B0BEC5', // Soft gray for alternatives
lineWidth: 6.0,
lineJoin: 'round',
lineOpacity: 0.8,
));
alternativeRouteLines.add(altLine);
}
if (remaining.isNotEmpty) {
remainingRouteLine = await mapController!.addLine(LineOptions(
geometry: remaining,
lineColor: '#00e5ff', // Cyan/Blue for selected
lineWidth: 8.0,
lineJoin: 'round'));
}
if (traveled.isNotEmpty) {
traveledRouteLine = await mapController!.addLine(LineOptions(
geometry: traveled,
lineColor: '#BDBDBD',
lineWidth: 5.0,
lineJoin: 'round',
lineOpacity: 0.6));
}
}
void selectRoute(int index) {
if (index < 0 || index >= routes.length) return;
selectedRouteIndex = index;
final r = routes[index];
_fullRouteCoordinates = r.coordinates;
routeSteps = r.steps;
_routeTotalDistanceM = r.distanceM;
_routeTotalDurationS = r.durationS;
_lastTraveledIndexInFullRoute = 0;
_recomputeETA();
_updatePolylinesSets([], _fullRouteCoordinates);
update();
}
void goToFavorite(String type) {
LatLng? dest;
switch (type) {
case 'home':
dest = getHomeLatLng();
break;
case 'work':
dest = getWorkLatLng();
break;
case 'airport':
dest = getAirportLatLng();
break;
}
if (dest != null && myLocation != null) {
getRoute(myLocation!, dest);
} else {
mySnackbarWarning('الموقع غير متاح حالياً.');
}
}
LatLng? getHomeLatLng() {
final dynamic stored = box.read(BoxName.addHome);
if (stored != null && stored is String && stored.contains(',')) {
final parts = stored.split(',');
return LatLng(double.parse(parts[0]), double.parse(parts[1]));
}
return null;
}
LatLng? getWorkLatLng() {
final dynamic stored = box.read(BoxName.addWork);
if (stored != null && stored is String && stored.contains(',')) {
final parts = stored.split(',');
return LatLng(double.parse(parts[0]), double.parse(parts[1]));
}
return null;
}
LatLng getAirportLatLng() {
final String country = box.read(BoxName.countryCode) ?? 'JO';
if (country == 'SY') {
return const LatLng(33.4111, 36.5147); // Damascus Airport
}
return const LatLng(31.7225, 35.9933); // Queen Alia Airport (JO)
}
Future<void> getRoute(LatLng origin, LatLng destination) async {
isLoading = true;
update();
final String langCode = box.read(BoxName.lang) ?? 'ar';
final Map<String, String> queryParams = {
'fromLat': origin.latitude.toString(),
'fromLng': origin.longitude.toString(),
'toLat': destination.latitude.toString(),
'toLng': destination.longitude.toString(),
'steps': 'true',
'locale': langCode,
};
if (_intermediateStop != null) {
queryParams['stop1Lat'] = _intermediateStop!.latitude.toString();
queryParams['stop1Lng'] = _intermediateStop!.longitude.toString();
}
final saasUri =
Uri.parse(AppLink.mapSaasRoute).replace(queryParameters: queryParams);
try {
final response =
await http.get(saasUri, headers: {'x-api-key': Env.mapSaasKey});
if (response.statusCode != 200) {
isLoading = false;
update();
mySnackbarWarning('تعذر الاتصال بخدمة التوجيه.');
return;
}
final data = jsonDecode(response.body);
// ── Parse primary route (top-level in response) ──
routes.clear();
final primaryPts = data['points']?.toString() ?? "";
if (primaryPts.isNotEmpty) {
final coords = await compute<String, List<LatLng>>(decodePolylineIsolate, primaryPts);
routes.add(RouteData(
coordinates: coords,
steps: List<Map<String, dynamic>>.from(data['instructions'] ?? []),
distanceM: (data['distance'] as num).toDouble(),
durationS: (data['duration'] as num).toDouble(),
points: primaryPts,
));
}
// ── Parse alternative routes (in data['alternatives']) ──
if (data['alternatives'] != null && data['alternatives'] is List) {
for (var alt in data['alternatives']) {
final altPts = alt['points']?.toString() ?? "";
if (altPts.isEmpty) continue;
final altCoords = await compute<String, List<LatLng>>(decodePolylineIsolate, altPts);
routes.add(RouteData(
coordinates: altCoords,
steps: List<Map<String, dynamic>>.from(alt['instructions'] ?? []),
distanceM: (alt['distance'] as num).toDouble(),
durationS: (alt['duration'] as num).toDouble(),
points: altPts,
));
}
}
if (routes.isEmpty) {
isLoading = false;
update();
mySnackbarWarning('لم يتم العثور على مسار.');
return;
}
selectedRouteIndex = 0;
final selected = routes[0];
_fullRouteCoordinates = selected.coordinates;
routeSteps = selected.steps;
_routeTotalDistanceM = selected.distanceM;
_routeTotalDurationS = selected.durationS;
_lastTraveledIndexInFullRoute = 0;
if (isStyleLoaded) _updatePolylinesSets([], _fullRouteCoordinates);
if (_fullRouteCoordinates.isNotEmpty) {
OfflineMapService.instance
.downloadRegion(_fullRouteCoordinates.last, radiusKm: 2.0);
}
_recomputeETA();
currentStepIndex = 0;
_nextInstructionSpoken = false;
// Don't start navigating immediately, wait for user to press Start
isNavigating = false;
_cameraLockedToUser = false;
_offRouteStartTime = null;
isLoading = false;
update();
if (routeSteps.isNotEmpty) {
currentInstruction = routeSteps[0]['text'] ?? "";
currentManeuverModifier = routeSteps[0]['sign'] ?? 0;
nextInstruction = routeSteps.length > 1
? (langCode == 'ar'
? "ثم ${routeSteps[1]['text']}"
: "Then ${routeSteps[1]['text']}")
: (langCode == 'ar' ? "الوجهة النهائية" : "Destination");
if (!isMuted) {
Get.find<TextToSpeechController>().speakText(currentInstruction);
}
}
// Re-add car marker after polyline updates (ensures it stays on top)
if (isStyleLoaded) _updateCarMarker();
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: 320, top: 150, left: 50, right: 50);
}
update();
} catch (e) {
isLoading = false;
update();
Log.print("GetRoute Error: $e");
}
}
void _recomputeETA() {
if (_routeTotalDistanceM == 0 || _fullRouteCoordinates.isEmpty) return;
final fraction =
(_fullRouteCoordinates.length - _lastTraveledIndexInFullRoute) /
_fullRouteCoordinates.length;
final remainingM = _routeTotalDistanceM * fraction;
final remainingS = _routeTotalDurationS * fraction;
// Distance
final String langCode = box.read(BoxName.lang) ?? 'ar';
if (remainingM > 1000) {
totalDistanceRemaining = (remainingM / 1000).toStringAsFixed(1);
// We will handle the unit in the view or provide a unit string here
} else {
totalDistanceRemaining = remainingM.toStringAsFixed(0);
}
// New variable to hold formatted distance with unit
distanceWithUnit = _formatDistance(remainingM, langCode);
// Time Remaining
final minutes = (remainingS / 60).round();
estimatedTimeRemaining = minutes.toString();
// Arrival Time Calculation
final arrival = DateTime.now().add(Duration(seconds: remainingS.toInt()));
final h = arrival.hour > 12
? arrival.hour - 12
: (arrival.hour == 0 ? 12 : arrival.hour);
final m = arrival.minute.toString().padLeft(2, '0');
final ampm = arrival.hour >= 12 ? 'PM' : 'AM';
arrivalTime = "$h:$m $ampm";
}
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) {
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 {
_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;
await _flushBufferToServer();
}
routeSteps.clear();
_fullRouteCoordinates.clear();
_lastTraveledIndexInFullRoute = 0;
currentInstruction = "";
nextInstruction = "";
currentManeuverModifier = "straight";
distanceToNextStep = "";
totalDistanceRemaining = "";
estimatedTimeRemaining = "";
arrivalTime = "--:--";
_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());
}
void _checkNavigationStep(LatLng pos) {
if (routeSteps.isEmpty || currentStepIndex >= routeSteps.length) return;
final interval = routeSteps[currentStepIndex]['interval'] as List;
final endIdx = interval[1] as int;
if (endIdx >= _fullRouteCoordinates.length) return;
final endLatLng = _fullRouteCoordinates[endIdx];
final distance = Geolocator.distanceBetween(
pos.latitude, pos.longitude, endLatLng.latitude, endLatLng.longitude);
distanceToNextStep = distance > 1000
? "${(distance / 1000).toStringAsFixed(1)} km"
: "${distance.toStringAsFixed(0)} m";
if (distance < 50 &&
!_nextInstructionSpoken &&
nextInstruction.isNotEmpty) {
if (!isMuted) {
Get.find<TextToSpeechController>().speakText(nextInstruction);
}
_nextInstructionSpoken = true;
}
if (distance < 20) _advanceStep();
}
void _advanceStep() {
currentStepIndex++;
final String langCode = box.read(BoxName.lang) ?? 'ar';
if (currentStepIndex < routeSteps.length) {
currentInstruction = routeSteps[currentStepIndex]['text'] ?? "";
currentManeuverModifier = routeSteps[currentStepIndex]['sign'] ?? 0;
nextInstruction = (currentStepIndex + 1) < routeSteps.length
? (langCode == 'ar'
? "ثم ${routeSteps[currentStepIndex + 1]['text']}"
: "Then ${routeSteps[currentStepIndex + 1]['text']}")
: (langCode == 'ar' ? "ستصل إلى وجهتك" : "Arriving soon");
_nextInstructionSpoken = false;
update();
} else {
_finishNavigation();
}
}
void _finishNavigation() {
final String langCode = box.read(BoxName.lang) ?? 'ar';
currentInstruction =
langCode == 'ar' ? "لقد وصلت إلى وجهتك" : "You have arrived";
currentManeuverModifier = 4;
nextInstruction = "";
distanceToNextStep = "";
isNavigating = false;
if (!isMuted) {
Get.find<TextToSpeechController>().speakText(currentInstruction);
}
_flushBufferToServer();
update();
}
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());
}
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!));
}
void setIntermediateStop(LatLng stop) {
_intermediateStop = stop;
if (myLocation != null && _finalDestination != null) {
getRoute(myLocation!, _finalDestination!);
}
update();
}
void clearIntermediateStop() {
_intermediateStop = null;
if (myLocation != null && _finalDestination != null) {
getRoute(myLocation!, _finalDestination!);
}
update();
}
String _formatDistance(double meters, String lang) {
if (meters >= 1000) {
return "${(meters / 1000).toStringAsFixed(1)} ${lang == 'ar' ? 'كم' : 'km'}";
} else {
return "${meters.toStringAsFixed(0)} ${lang == 'ar' ? 'م' : 'm'}";
}
}
Future<void> submitPlaceSuggestion(String name) async {
if (name.trim().isEmpty || myLocation == null) return;
isLoading = true;
update();
try {
final payload = {
'name': name,
'lat': myLocation!.latitude.toString(),
'lng': myLocation!.longitude.toString(),
'passenger_id': box.read(BoxName.passengerID),
};
await CRUD().post(link: AppLink.getPlacesSyria, payload: payload);
mySnackbarInfo(box.read(BoxName.lang) == 'ar'
? "تم استلام اقتراحك! مكافأتك: +٥٠ نقطة"
: "Suggestion received! Reward: +50 points");
} finally {
isLoading = false;
update();
}
}
}