Files
Siro/siro_rider/lib/views/home/navigation/navigation_controller.dart
2026-06-21 02:07:00 +03:00

1435 lines
44 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:siro_rider/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 '../../../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 '../../../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,
});
}
enum ManeuverSign {
straight(0),
slightRight(3),
right(2),
sharpRight(1),
slightLeft(-3),
left(-2),
sharpLeft(-1),
keepRight(7),
keepLeft(-7),
arrive(4),
roundabout(6),
unknown(0);
final int value;
const ManeuverSign(this.value);
static ManeuverSign fromValue(dynamic v) {
return ManeuverSign.values.firstWhere(
(e) => e.value == v,
orElse: () => ManeuverSign.unknown,
);
}
IconData get icon {
switch (this) {
case ManeuverSign.arrive:
return Icons.place_rounded;
case ManeuverSign.roundabout:
return Icons.roundabout_right_rounded;
case ManeuverSign.right:
case ManeuverSign.keepRight:
return Icons.turn_right_rounded;
case ManeuverSign.slightRight:
return Icons.turn_slight_right_rounded;
case ManeuverSign.left:
case ManeuverSign.keepLeft:
return Icons.turn_left_rounded;
case ManeuverSign.slightLeft:
return Icons.turn_slight_left_rounded;
case ManeuverSign.straight:
case ManeuverSign.unknown:
return Icons.straight_rounded;
case ManeuverSign.sharpRight:
return Icons.turn_sharp_right_rounded;
case ManeuverSign.sharpLeft:
return Icons.turn_sharp_left_rounded;
}
}
}
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 = "";
ManeuverSign currentManeuverModifier = ManeuverSign.straight;
String arrivalTime = "--:--";
double _routeTotalDistanceM = 0;
double _routeTotalDurationS = 0;
bool isNavigating = false;
bool isMuted = false; // Sound toggle state
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;
// Get current center of the map as the picked location
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(box.read(BoxName.lang) == 'ar'
? 'تمت إضافة المكان بنجاح! شكراً لمساهمتك.'
: 'Place added successfully! Thanks for your contribution.');
isSelectingPlaceLocation = false;
} else {
mySnackbarWarning(box.read(BoxName.lang) == 'ar'
? 'تعذر إضافة المكان. يرجى المحاولة لاحقاً.'
: 'Failed to add place. Please try again later.');
}
update();
} catch (e) {
isLoading = false;
mySnackbarWarning(box.read(BoxName.lang) == 'ar'
? 'حدث خطأ أثناء الاتصال بالخادم.'
: 'An error occurred while connecting to the server.');
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;
}
// Categories list for the picker
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 => currentManeuverModifier.icon;
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) {
_updateCarMarker();
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;
}
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!);
_updateCarMarker();
}
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; // 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();
final langCode = box.read(BoxName.lang) ?? 'ar';
Get.dialog(
AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text(langCode == 'ar' ? 'بدء الملاحة؟' : 'Start Navigation?',
style: const TextStyle(fontWeight: FontWeight.bold)),
content: Text(langCode == 'ar'
? 'هل تريد الذهاب إلى هذا الموقع؟'
: 'Go to this location?'),
actions: [
TextButton(
child: Text(langCode == 'ar' ? 'إلغاء' : 'Cancel',
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(langCode == 'ar' ? 'اذهب الآن' : 'Go now',
style: const TextStyle(color: Colors.white)),
onPressed: () {
Get.back();
startNavigationTo(tappedPoint,
infoWindowTitle:
langCode == 'ar' ? 'الموقع المحدد' : 'Selected location');
},
),
],
),
);
}
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!);
// Start the Location Stream for real-time updates
_startLocationStream();
_startBatchTimers();
} catch (e) {
Log.print("DEBUG: Error getting initial location: $e");
}
}
void _startLocationStream() {
_locationStreamSubscription?.cancel();
// Listen to location updates with minimum distance filter of 2 meters
// This provides real-time updates without the 3-4 second delay
_locationStreamSubscription = Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 2, // Update every 2 meters
),
).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; // Convert m/s to km/h
// Skip if movement is too small
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");
// Update total distance
if (_lastDistanceLocation != null) {
final d = Geolocator.distanceBetween(
_lastDistanceLocation!.latitude,
_lastDistanceLocation!.longitude,
newLoc.latitude,
newLoc.longitude,
);
if (d > 5.0) totalDistance += d;
}
_lastDistanceLocation = newLoc;
_oldLoc = myLocation ?? newLoc;
_targetLoc = newLoc;
_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("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 (!isNavigating || _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;
}
}
/// Recalculate immediately from the latest GPS point to the destination.
Future<void> _smartRecalculateRoute(LatLng currentPos) async {
try {
if (_finalDestination != null) {
await recalculateRoute(origin: currentPos, keepNavigationActive: true);
}
_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 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 || !isStyleLoaded) return;
markers.removeWhere((m) => m.markerId.value == 'car');
markers.add(Marker(
markerId: const MarkerId('car'),
position: myLocation!,
icon: InlqBitmap.fromStyleImage('car_icon'),
anchor: const Offset(0.5, 0.5),
flat: true,
rotation: _smoothedHeading,
zIndex: 100,
));
}
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 {
Log.print(
"DEBUG: Updating polylines. Traveled: ${traveled.length}, Remaining: ${remaining.length}");
Set<Polyline> newPolylines = {};
// Render Alternative Routes first
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('الموقع غير متاح حالياً.');
}
}
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,
{bool keepNavigationActive = false, int retryCount = 0}) 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})
.timeout(const Duration(seconds: 15));
if (response.statusCode != 200) {
if (retryCount < 2) {
await Future.delayed(const Duration(seconds: 2));
return getRoute(origin, destination,
keepNavigationActive: keepNavigationActive,
retryCount: retryCount + 1);
}
isLoading = false;
update();
mySnackbarWarning(langCode == 'ar'
? 'تعذر الاتصال بخدمة التوجيه.'
: 'Route service unavailable.');
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']) ──
// إذا كان هناك routes بديلة متاحة من API
if (data['alternatives'] != null && data['alternatives'] is List) {
_hasAlternativeRoutes = data['alternatives'].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,
));
}
if (_hasAlternativeRoutes) {
Log.print("DEBUG: ${routes.length - 1} alternative routes available");
}
} else {
_hasAlternativeRoutes = false;
}
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;
isNavigating = keepNavigationActive;
_cameraLockedToUser = keepNavigationActive;
_offRouteStartTime = null;
isLoading = false;
update();
if (routeSteps.isNotEmpty) {
currentInstruction = routeSteps[0]['text'] ?? "";
currentManeuverModifier =
ManeuverSign.fromValue(routeSteps[0]['sign']);
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 (keepNavigationActive && myLocation != null) {
animateCameraToPosition(myLocation!,
bearing: _smoothedHeading, zoom: _targetZoom, tilt: _targetTilt);
} else 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;
// Time remaining: use current speed if moving, fall back to route estimate
double remainingS = _routeTotalDurationS * fraction;
if (currentSpeed > 5.0) {
final speedEstimate = currentSpeed / 3.6; // km/h → m/s
remainingS = remainingM / speedEstimate;
}
final String langCode = box.read(BoxName.lang) ?? 'ar';
totalDistanceRemaining = remainingM > 1000
? (remainingM / 1000).toStringAsFixed(1)
: 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);
// Preserve car marker if it exists
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(
{LatLng? origin, bool keepNavigationActive = false}) async {
final LatLng? routeOrigin = origin ?? myLocation;
if (routeOrigin == null || _finalDestination == null || isLoading) return;
isLoading = true;
update();
markers = markers.where((m) => m.markerId.value != 'origin').toSet();
markers.add(Marker(
markerId: const MarkerId('origin'),
position: routeOrigin,
icon: InlqBitmap.fromStyleImage('start_icon'),
));
await getRoute(routeOrigin, _finalDestination!,
keepNavigationActive: keepNavigationActive);
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;
// Ensure ETA and distances are up-to-date
_lastTraveledIndexInFullRoute = _lastTraveledIndexInFullRoute;
_recomputeETA();
// Initialize current instruction if available
if (routeSteps.isNotEmpty && currentStepIndex < routeSteps.length) {
currentInstruction = routeSteps[currentStepIndex]['text'] ?? "";
currentManeuverModifier =
ManeuverSign.fromValue(routeSteps[currentStepIndex]['sign']);
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 (_) {}
}
}
// Center camera on user for navigation mode
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 = ManeuverSign.straight;
distanceToNextStep = "";
totalDistanceRemaining = "";
estimatedTimeRemaining = "";
arrivalTime = "--:--";
_routeTotalDistanceM = 0;
_routeTotalDurationS = 0;
if (!isNewRoute) {
await _updateCarMarker();
}
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 =
ManeuverSign.fromValue(routeSteps[currentStepIndex]['sign']);
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 = ManeuverSign.arrive;
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 {
// ✅ Use searchPlaces from intaleq_maps SDK
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'] ?? 'وجهة');
}
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(),
'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();
}
}
}