Files
Siro/siro_driver/lib/controller/home/navigation/navigation_controller.dart
T

1379 lines
44 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:siro_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:siro_driver/constant/box_name.dart';
import 'package:siro_driver/constant/links.dart';
import 'package:siro_driver/controller/car_platform_bridge.dart';
import 'package:siro_driver/controller/functions/crud.dart';
import 'package:siro_driver/controller/functions/location_controller.dart';
import 'package:siro_driver/controller/functions/tts.dart';
import 'package:siro_driver/controller/home/navigation/decode_polyline_isolate.dart';
import 'package:siro_driver/env/env.dart';
import 'package:siro_driver/main.dart';
import 'package:siro_driver/print.dart';
import 'dart:ui';
import 'package:siro_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 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 = "";
double _distanceToNextStepMeters = 0.0;
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;
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;
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 {
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();
CarPlatformBridge.ensureInitialized();
_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();
_debounce?.cancel();
_animController?.dispose();
mapController = null;
placeDestinationController.dispose();
_flushTracksViaCentral();
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");
// Register the custom icons (car/start/dest) BEFORE marking the style as
// ready. Otherwise a location tick can fire _updateCarMarker() in the gap
// and reference 'car_icon' before it exists in the style → blank marker.
await _loadCustomIcons();
isStyleLoaded = true;
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();
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 {
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();
} catch (e) {
Log.print("DEBUG: Error getting initial location: $e");
}
}
void _startLocationStream() {
// Location stream is now centralized in LocationController to prevent device hanging.
// LocationController will call handleLocationUpdateFromCentral directly.
}
bool _isProcessing = false;
Future<void> handleLocationUpdateFromCentral(
LatLng newLoc, double locSpeed, double locHeading) async {
if (_isProcessing) return;
_isProcessing = true;
try {
// LocationController forwards raw speed in m/s; convert to km/h.
currentSpeed = locSpeed * 3.6;
// 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 = locHeading;
}
_animController?.forward(from: 0.0);
_lastProcessedLocation = newLoc;
if (isStyleLoaded) _updateCarMarker();
if (_fullRouteCoordinates.isNotEmpty) {
_updateTraveledPolylineSmart(newLoc);
_checkNavigationStep(newLoc);
_recomputeETA();
_checkOffRoute(newLoc);
}
_pushCarBridgeUpdate();
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;
}
}
// ‏رفع مسار القيادة (add_batch) مركزيّ في LocationController وحده.
// ‏كان الملّاح يحتفظ بذاكرة ومؤقّتات رفع خاصة به ويُرسل إلى نفس الواجهة
// ‏ونفس الخادم، فيُكتب مسار السائق الواحد مرتين ما دامت شاشة الملاحة
// ‏مفتوحة — ولأن add_batch.php يراكم زمن العمل اليومي من نفس الجدول،
// ‏كان ذلك يضاعف ساعات العمل المحسوبة. مصدر الموقع واحد فالرفع واحد.
// ‏الرفع مركزيّ في LocationController — نطلب منه دفع ما تراكم فقط
void _flushTracksViaCentral() {
if (!Get.isRegistered<LocationController>()) return;
Get.find<LocationController>().flushTrackBufferNow();
}
Future<void> _updateCarMarker() async {
if (myLocation == null || !isStyleLoaded || mapController == null) return;
// Draw the driver's car as a single imperatively-managed puck. It moves on
// every location tick, so it must NOT live in the declarative `markers` set
// (in-place mutation of that Set is invisible to the map widget's diff and
// makes the car freeze or never render). setUserMarker updates one native
// symbol in place instead.
await mapController!.setUserMarker(Marker(
markerId: const MarkerId('car'),
position: myLocation!,
icon: InlqBitmap.fromStyleImage('car_icon', size: 2.3),
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}) 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('تعذر الاتصال بخدمة التوجيه.');
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 = 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 (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;
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);
// The car puck is managed imperatively (setUserMarker), not via this set,
// so start from a clean declarative set holding only origin/destination.
markers = <Marker>{};
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 = 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 (_) {}
}
}
// Center camera on user for navigation mode
if (myLocation != null) {
animateCameraToPosition(myLocation!,
bearing: _smoothedHeading, zoom: _targetZoom, tilt: _targetTilt);
}
_pushCarBridgeUpdate();
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 = [];
CarPlatformBridge.stopNavigation();
_flushTracksViaCentral();
}
routeSteps = [];
_fullRouteCoordinates = [];
_lastTraveledIndexInFullRoute = 0;
currentInstruction = "";
nextInstruction = "";
currentManeuverModifier = "siro";
distanceToNextStep = "";
_distanceToNextStepMeters = 0.0;
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);
_distanceToNextStepMeters = distance;
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;
CarPlatformBridge.updateInstruction(
instruction: currentInstruction,
maneuver: currentManeuverModifier is int
? currentManeuverModifier as int
: int.tryParse(currentManeuverModifier.toString()) ?? 0,
distanceToStep: 0.0,
);
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);
}
CarPlatformBridge.stopNavigation();
_flushTracksViaCentral();
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));
}
void _pushCarBridgeUpdate() {
if (!isNavigating) return;
final etaSeconds = _routeTotalDurationS > 0
? (_fullRouteCoordinates.isNotEmpty
? _routeTotalDurationS *
(_fullRouteCoordinates.length - _lastTraveledIndexInFullRoute) /
_fullRouteCoordinates.length
: 0.0)
: 0.0;
CarPlatformBridge.updateNavState(
lat: myLocation?.latitude ?? 0.0,
lng: myLocation?.longitude ?? 0.0,
bearing: _smoothedHeading,
speed: currentSpeed,
instruction: currentInstruction,
distanceToStep: _distanceToNextStepMeters,
totalDistance: _routeTotalDistanceM * (_fullRouteCoordinates.isNotEmpty
? (_fullRouteCoordinates.length - _lastTraveledIndexInFullRoute) /
_fullRouteCoordinates.length
: 0.0),
eta: etaSeconds,
maneuver: currentManeuverModifier is int
? currentManeuverModifier as int
: int.tryParse(currentManeuverModifier.toString()) ?? 0,
isNavigating: true,
isMapDarkMode: box.read('isMapDarkMode') ?? false,
);
}
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();
}
}
}