1379 lines
44 KiB
Dart
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();
|
|
}
|
|
}
|
|
}
|