1177 lines
36 KiB
Dart
1177 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 '../../../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();
|
||
}
|
||
}
|
||
}
|