Files
intaleq_driver/lib/controller/home/navigation/navigation_controller.dart

1343 lines
41 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:sefer_driver/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:intaleq_maps/intaleq_maps.dart';
import 'package:http/http.dart' as http;
import 'package:sefer_driver/constant/box_name.dart';
import 'package:sefer_driver/constant/links.dart';
import 'package:sefer_driver/controller/functions/crud.dart';
import 'package:sefer_driver/controller/functions/tts.dart';
import 'package:sefer_driver/controller/home/navigation/decode_polyline_isolate.dart';
import 'package:sefer_driver/env/env.dart';
import 'package:sefer_driver/main.dart';
import 'package:sefer_driver/print.dart';
import 'dart:ui';
import 'package:sefer_driver/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;
IntaleqMapController? 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;
Set<Marker> markers = {};
Set<Polyline> polylines = {};
Set<Circle> circles = {};
Set<Polygon> polygons = {};
StreamSubscription<Position>? _locationStreamSubscription;
LatLng? _lastProcessedLocation;
List<dynamic> placesDestination = [];
Timer? _debounce;
// Alternative route handling
bool _hasAlternativeRoutes = false;
DateTime? _lastAutoRerouteTime;
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 = "--:--";
double _routeTotalDistanceM = 0;
double _routeTotalDurationS = 0;
bool isNavigating = false;
bool isMuted = false;
String distanceWithUnit = "";
bool _cameraLockedToUser = true;
bool _mapReady = false;
bool isSelectingPlaceLocation = false;
void togglePlaceSelectionMode() {
isSelectingPlaceLocation = !isSelectingPlaceLocation;
update();
}
Future<void> submitNewPlace(String name, String category) async {
if (mapController == null || name.isEmpty || category.isEmpty) return;
final LatLng pickedPos = mapController!.cameraPosition!.target;
isLoading = true;
update();
final String country =
box.read(BoxName.countryCode) == 'SY' ? 'syria' : 'jordan';
final Map<String, dynamic> payload = {
'name': name,
'category': category,
'lat': pickedPos.latitude,
'lng': pickedPos.longitude,
'country': country,
};
try {
final response = await CRUD().postMapSaas(
link: AppLink.mapSaasPlaces,
payload: payload,
);
isLoading = false;
if (response != null) {
HapticFeedback.lightImpact();
mySnackbarSuccess('Place added successfully! Thanks for your contribution.'.tr);
isSelectingPlaceLocation = false;
} else {
mySnackbarWarning('Failed to add place. Please try again later.'.tr);
}
update();
} catch (e) {
isLoading = false;
mySnackbarWarning('An error occurred while connecting to the server.'.tr);
update();
}
}
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<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 List<Map<String, String>> placeCategories = [
{
'id': 'restaurant',
'en': 'Restaurant',
'ar': 'مطعم',
'icon': 'restaurant'
},
{'id': 'cafe', 'en': 'Cafe', 'ar': 'مقهى', 'icon': 'coffee'},
{
'id': 'supermarket',
'en': 'Supermarket',
'ar': 'سوبر ماركت',
'icon': 'shopping_basket'
},
{
'id': 'pharmacy',
'en': 'Pharmacy',
'ar': 'صيدلية',
'icon': 'local_pharmacy'
},
{
'id': 'gas_station',
'en': 'Gas Station',
'ar': 'محطة وقود',
'icon': 'local_gas_station'
},
{'id': 'atm', 'en': 'ATM', 'ar': 'صراف آلي', 'icon': 'atm'},
{'id': 'bank', 'en': 'Bank', 'ar': 'بنك', 'icon': 'account_balance'},
{'id': 'mosque', 'en': 'Mosque', 'ar': 'مسجد', 'icon': 'mosque'},
{
'id': 'hospital',
'en': 'Hospital',
'ar': 'مستشفى',
'icon': 'local_hospital'
},
{'id': 'school', 'en': 'School', 'ar': 'مدرسة', 'icon': 'school'},
{
'id': 'university',
'en': 'University',
'ar': 'جامعة',
'icon': 'account_balance'
},
{'id': 'park', 'en': 'Park', 'ar': 'منتزه', 'icon': 'park'},
{'id': 'hotel', 'en': 'Hotel', 'ar': 'فندق', 'icon': 'hotel'},
{
'id': 'mall',
'en': 'Shopping Mall',
'ar': 'مركز تسوق',
'icon': 'shopping_mall'
},
{'id': 'gym', 'en': 'Gym', 'ar': 'نادي رياضي', 'icon': 'fitness_center'},
{
'id': 'salon',
'en': 'Beauty Salon',
'ar': 'صالون تجميل',
'icon': 'content_cut'
},
{'id': 'bakery', 'en': 'Bakery', 'ar': 'مخبز', 'icon': 'bakery_dining'},
{
'id': 'laundry',
'ar': 'مصبغة',
'en': 'Laundry',
'icon': 'local_laundry_service'
},
{
'id': 'car_repair',
'en': 'Car Repair',
'ar': 'تصليح سيارات',
'icon': 'build'
},
{
'id': 'government',
'en': 'Government Office',
'ar': 'دائرة حكومية',
'icon': 'gavel'
},
];
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: 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);
_smoothedHeading = _lerpAngle(_oldHeading, _targetHeading, t);
if (isStyleLoaded) {
if (_cameraLockedToUser) {
animateCameraToPosition(myLocation!,
bearing: _smoothedHeading,
zoom: isNavigating ? _targetZoom : 17.0,
tilt: isNavigating ? _targetTilt : 0.0);
}
}
}
});
_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() {
_locationStreamSubscription?.cancel();
_recordTimer?.cancel();
_uploadBatchTimer?.cancel();
_debounce?.cancel();
_animController?.dispose();
mapController = null;
placeDestinationController.dispose();
_flushBufferToServer();
super.onClose();
}
void onMapCreated(IntaleqMapController controller) async {
Log.print("DEBUG: NavigationController.onMapCreated called");
mapController = controller;
await onStyleLoaded();
}
Future<void> onStyleLoaded() async {
Log.print("DEBUG: NavigationController.onStyleLoaded called");
isStyleLoaded = true;
await _loadCustomIcons();
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.delayed(const Duration(milliseconds: 300));
if (!_mapReady) {
Log.print("DEBUG: NavigationController setting _mapReady = true");
_mapReady = true;
if (myLocation != null) {
Log.print("DEBUG: Animating camera to initial location: $myLocation");
animateCameraToPosition(myLocation!);
}
if (_fullRouteCoordinates.isNotEmpty) {
Log.print("DEBUG: Updating initial polylines");
_updatePolylinesSets([], _fullRouteCoordinates);
}
}
});
}
void onMapTapped(Point<double> point, LatLng tappedPoint) {
if (isNavigating || routes.isEmpty) return;
int? bestIndex;
double minDistance = 100.0;
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: Text('Start Navigation?'.tr,
style: const TextStyle(fontWeight: FontWeight.bold)),
content: Text('Do you want to go to this location?'.tr),
actions: [
TextButton(
child: Text('Cancel'.tr, style: const TextStyle(color: Colors.grey)),
onPressed: () => Get.back()),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0D47A1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12))),
child:
Text('Go Now'.tr, style: const TextStyle(color: Colors.white)),
onPressed: () {
Get.back();
startNavigationTo(tappedPoint, infoWindowTitle: 'Selected Location'.tr);
},
),
],
),
);
}
Future<void> _getCurrentLocationAndStartUpdates() async {
try {
Log.print("DEBUG: Getting initial location...");
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high);
myLocation = LatLng(position.latitude, position.longitude);
Log.print("DEBUG: Initial location acquired: $myLocation");
_targetHeading = position.heading;
_oldHeading = position.heading;
_smoothedHeading = position.heading;
update();
if (isStyleLoaded) animateCameraToPosition(myLocation!);
_startLocationStream();
_startBatchTimers();
} catch (e) {
Log.print("DEBUG: Error getting initial location: $e");
}
}
void _startLocationStream() {
_locationStreamSubscription?.cancel();
_locationStreamSubscription = Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 2,
),
).listen(
(Position position) {
_handleLocationUpdate(position);
},
onError: (error) {
Log.print("DEBUG: Location stream error: $error");
},
);
}
bool _isProcessing = false;
Future<void> _handleLocationUpdate(Position position) async {
if (_isProcessing) return;
_isProcessing = true;
try {
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) {
_isProcessing = false;
return;
}
}
Log.print(
"DEBUG: Location update - Speed: ${currentSpeed.toStringAsFixed(1)} km/h, Loc: $newLoc");
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 (_fullRouteCoordinates.isNotEmpty) {
_updateTraveledPolylineSmart(newLoc);
_checkNavigationStep(newLoc);
_recomputeETA();
_checkOffRoute(newLoc);
}
update();
} catch (e) {
Log.print("DEBUG: Error in _handleLocationUpdate: $e");
} finally {
_isProcessing = false;
}
}
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;
_smartRecalculateRoute(pos);
}
}
} else {
_offRouteStartTime = null;
}
}
Future<void> _smartRecalculateRoute(LatLng currentPos) async {
try {
if (routes.isNotEmpty && selectedRouteIndex < routes.length - 1) {
final nextIndex = selectedRouteIndex + 1;
final nextRoute = routes[nextIndex];
double minDist = double.infinity;
for (var coord in nextRoute.coordinates) {
final d = Geolocator.distanceBetween(
currentPos.latitude,
currentPos.longitude,
coord.latitude,
coord.longitude,
);
if (d < minDist) minDist = d;
}
if (minDist < 100) {
selectRoute(nextIndex);
Log.print("DEBUG: Switched to alternative route due to deviation");
_autoRecalcInProgress = false;
return;
}
}
if (_finalDestination != null) {
await recalculateRoute();
}
_autoRecalcInProgress = false;
} catch (e) {
Log.print("DEBUG: Error in smart recalculate: $e");
_autoRecalcInProgress = false;
}
}
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 driverId = (box.read(BoxName.driverID) ?? '').toString();
try {
await CRUD().post(
link: '${AppLink.locationServerSide}/add_batch.php',
payload: {
'driver_id': driverId,
'batch_data': jsonEncode(batch),
'session_dist': totalDistance.toStringAsFixed(1),
},
);
} catch (e) {
_trackBuffer.insertAll(0, batch);
}
}
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 {
Set<Polyline> newPolylines = {};
for (int i = 0; i < routes.length; i++) {
if (i == selectedRouteIndex) continue;
newPolylines.add(Polyline(
polylineId: PolylineId('alt_$i'),
points: routes[i].coordinates,
color: const Color(0xFFB0BEC5).withOpacity(0.8),
width: 6,
));
}
if (remaining.isNotEmpty) {
newPolylines.add(Polyline(
polylineId: const PolylineId('remaining'),
points: remaining,
color: const Color(0xFF00E5FF),
width: 8,
));
}
if (traveled.isNotEmpty) {
newPolylines.add(Polyline(
polylineId: const PolylineId('traveled'),
points: traveled,
color: const Color(0xFFBDBDBD).withOpacity(0.6),
width: 5,
));
}
polylines = newPolylines;
update();
}
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(box.read(BoxName.lang) == 'ar' ? 'الموقع غير متاح حالياً.' : 'Location not available.');
}
}
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);
}
return const LatLng(31.7225, 35.9933);
}
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',
'alternatives': '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(box.read(BoxName.lang) == 'ar' ? 'تعذر الاتصال بخدمة التوجيه.' : 'Failed to connect to routing service.');
return;
}
final data = jsonDecode(response.body);
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,
));
}
if (data['alternatives'] != null && data['alternatives'] is List) {
_hasAlternativeRoutes = (data['alternatives'] as List).isNotEmpty;
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,
));
}
} else {
_hasAlternativeRoutes = false;
}
if (routes.isEmpty) {
isLoading = false;
update();
mySnackbarWarning(box.read(BoxName.lang) == 'ar' ? 'لم يتم العثور على مسار.' : 'No route found.');
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;
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);
}
}
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;
final String langCode = box.read(BoxName.lang) ?? 'ar';
if (remainingM > 1000) {
totalDistanceRemaining = (remainingM / 1000).toStringAsFixed(1);
} else {
totalDistanceRemaining = remainingM.toStringAsFixed(0);
}
distanceWithUnit = _formatDistance(remainingM, langCode);
final minutes = (remainingS / 60).round();
estimatedTimeRemaining = minutes.toString();
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);
markers = markers.where((m) => m.markerId.value == 'car').toSet();
markers.add(Marker(
markerId: const MarkerId('destination'),
position: destination,
icon: InlqBitmap.fromStyleImage('dest_icon'),
infoWindow: infoWindowTitle.isNotEmpty
? InfoWindow(title: infoWindowTitle)
: InfoWindow.noText,
));
if (myLocation != null) {
markers.add(Marker(
markerId: const MarkerId('origin'),
position: myLocation!,
icon: InlqBitmap.fromStyleImage('start_icon'),
));
await getRoute(myLocation!, destination);
}
} finally {
isLoading = false;
update();
}
}
Future<void> recalculateRoute() async {
if (myLocation == null || _finalDestination == null || isLoading) return;
isLoading = true;
update();
mySnackbarInfo(box.read(BoxName.lang) == 'ar' ? 'جاري حساب مسار جديد...' : 'Calculating new route...');
await getRoute(myLocation!, _finalDestination!);
isLoading = false;
update();
}
Future<void> startActiveNavigation() async {
if (routes.isEmpty) {
mySnackbarWarning(box.read(BoxName.lang) == 'ar'
? 'لا يوجد مسار لبدء الملاحة.'
: 'No route to start navigation.');
return;
}
if (isNavigating) return;
isNavigating = true;
_cameraLockedToUser = true;
_recomputeETA();
if (routeSteps.isNotEmpty && currentStepIndex < routeSteps.length) {
currentInstruction = routeSteps[currentStepIndex]['text'] ?? "";
currentManeuverModifier = routeSteps[currentStepIndex]['sign'] ?? 0;
nextInstruction = (currentStepIndex + 1) < routeSteps.length
? (box.read(BoxName.lang) == 'ar'
? "ثم ${routeSteps[currentStepIndex + 1]['text']}"
: "Then ${routeSteps[currentStepIndex + 1]['text']}")
: (box.read(BoxName.lang) == 'ar' ? 'الوجهة' : 'Destination');
if (!isMuted) {
try {
Get.find<TextToSpeechController>().speakText(currentInstruction);
} catch (_) {}
}
}
if (myLocation != null) {
animateCameraToPosition(myLocation!,
bearing: _smoothedHeading, zoom: _targetZoom, tilt: _targetTilt);
}
update();
}
Future<void> clearEverything() async {
placeDestinationController.clear();
placesDestination = [];
await clearRoute();
}
Future<void> clearRoute({bool isNewRoute = false}) async {
_offRouteStartTime = null;
_autoRecalcInProgress = false;
if (!isNewRoute) {
markers = {};
polylines = {};
circles = {};
polygons = {};
_finalDestination = null;
isNavigating = false;
routes = [];
await _flushBufferToServer();
}
routeSteps = [];
_fullRouteCoordinates = [];
_lastTraveledIndexInFullRoute = 0;
currentInstruction = "";
nextInstruction = "";
currentManeuverModifier = "intaleq";
distanceToNextStep = "";
totalDistanceRemaining = "";
estimatedTimeRemaining = "";
arrivalTime = "--:--";
_routeTotalDistanceM = 0;
_routeTotalDurationS = 0;
update();
}
Future<void> _loadCustomIcons() async {
if (mapController == null) return;
try {
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());
} catch (e) {
Log.print("Error loading custom icons: $e");
}
}
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 (mapController == null) return;
try {
final results = await mapController!.searchPlaces(q);
if (myLocation != null) {
for (final p in results) {
final plat = double.tryParse(p['latitude']?.toString() ?? '0') ?? 0.0;
final plng = double.tryParse(p['longitude']?.toString() ?? '0') ?? 0.0;
p['distanceKm'] = _haversineKm(myLocation!.latitude, myLocation!.longitude, plat, plng);
}
results.sort((a, b) =>
(a['distanceKm'] as double).compareTo(b['distanceKm'] as double));
}
placesDestination = results;
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'] ?? (box.read(BoxName.lang) == 'ar' ? 'وجهة' : 'Destination'));
}
void onSearchChanged(String query) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () => 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));
}
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(),
'driver_id': box.read(BoxName.driverID),
};
await CRUD().post(link: AppLink.getPlacesSyria, payload: payload);
mySnackbarInfo(box.read(BoxName.lang) == 'ar'
? "تم استلام اقتراحك! شكراً لمساهمتك."
: "Suggestion received! Thanks for your contribution.");
} finally {
isLoading = false;
update();
}
}
}