1343 lines
41 KiB
Dart
1343 lines
41 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
import 'package:sefer_driver/views/widgets/error_snakbar.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:intaleq_maps/intaleq_maps.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:sefer_driver/constant/box_name.dart';
|
|
import 'package:sefer_driver/constant/links.dart';
|
|
import 'package:sefer_driver/controller/functions/crud.dart';
|
|
import 'package:sefer_driver/controller/functions/tts.dart';
|
|
import 'package:sefer_driver/controller/home/navigation/decode_polyline_isolate.dart';
|
|
import 'package:sefer_driver/env/env.dart';
|
|
import 'package:sefer_driver/main.dart';
|
|
import 'package:sefer_driver/print.dart';
|
|
import 'dart:ui';
|
|
import 'package:sefer_driver/services/offline_map_service.dart';
|
|
|
|
class RouteData {
|
|
final List<LatLng> coordinates;
|
|
final List<Map<String, dynamic>> steps;
|
|
final double distanceM;
|
|
final double durationS;
|
|
final String points;
|
|
|
|
RouteData({
|
|
required this.coordinates,
|
|
required this.steps,
|
|
required this.distanceM,
|
|
required this.durationS,
|
|
required this.points,
|
|
});
|
|
}
|
|
|
|
class NavigationController extends GetxController
|
|
with GetSingleTickerProviderStateMixin {
|
|
static const Duration _recordInterval = Duration(seconds: 4);
|
|
static const Duration _uploadInterval = Duration(minutes: 2);
|
|
static const double _minMoveToRecord = 10.0;
|
|
static const double _minMoveToProcess = 2.0;
|
|
static const double _offRouteThresholdM = 25.0;
|
|
static const int _offRouteTriggerSeconds = 6;
|
|
|
|
bool isLoading = false;
|
|
IntaleqMapController? mapController;
|
|
bool isStyleLoaded = false;
|
|
final TextEditingController placeDestinationController =
|
|
TextEditingController();
|
|
|
|
LatLng? myLocation;
|
|
|
|
double _oldHeading = 0.0;
|
|
double _targetHeading = 0.0;
|
|
double _smoothedHeading = 0.0;
|
|
|
|
AnimationController? _animController;
|
|
LatLng? _oldLoc;
|
|
LatLng? _targetLoc;
|
|
|
|
double currentSpeed = 0.0;
|
|
double totalDistance = 0.0;
|
|
|
|
Set<Marker> markers = {};
|
|
Set<Polyline> polylines = {};
|
|
Set<Circle> circles = {};
|
|
Set<Polygon> polygons = {};
|
|
|
|
StreamSubscription<Position>? _locationStreamSubscription;
|
|
LatLng? _lastProcessedLocation;
|
|
|
|
List<dynamic> placesDestination = [];
|
|
Timer? _debounce;
|
|
|
|
// Alternative route handling
|
|
bool _hasAlternativeRoutes = false;
|
|
DateTime? _lastAutoRerouteTime;
|
|
|
|
LatLng? _finalDestination;
|
|
LatLng? _intermediateStop;
|
|
List<Map<String, dynamic>> routeSteps = [];
|
|
List<LatLng> _fullRouteCoordinates = [];
|
|
int _lastTraveledIndexInFullRoute = 0;
|
|
|
|
bool _nextInstructionSpoken = false;
|
|
String currentInstruction = "";
|
|
String nextInstruction = "";
|
|
int currentStepIndex = 0;
|
|
String distanceToNextStep = "";
|
|
String totalDistanceRemaining = "";
|
|
String estimatedTimeRemaining = "";
|
|
dynamic currentManeuverModifier = 0;
|
|
String arrivalTime = "--:--";
|
|
|
|
double _routeTotalDistanceM = 0;
|
|
double _routeTotalDurationS = 0;
|
|
|
|
bool isNavigating = false;
|
|
bool isMuted = false;
|
|
String distanceWithUnit = "";
|
|
bool _cameraLockedToUser = true;
|
|
bool _mapReady = false;
|
|
|
|
bool isSelectingPlaceLocation = false;
|
|
|
|
void togglePlaceSelectionMode() {
|
|
isSelectingPlaceLocation = !isSelectingPlaceLocation;
|
|
update();
|
|
}
|
|
|
|
Future<void> submitNewPlace(String name, String category) async {
|
|
if (mapController == null || name.isEmpty || category.isEmpty) return;
|
|
|
|
final LatLng pickedPos = mapController!.cameraPosition!.target;
|
|
|
|
isLoading = true;
|
|
update();
|
|
|
|
final String country =
|
|
box.read(BoxName.countryCode) == 'SY' ? 'syria' : 'jordan';
|
|
|
|
final Map<String, dynamic> payload = {
|
|
'name': name,
|
|
'category': category,
|
|
'lat': pickedPos.latitude,
|
|
'lng': pickedPos.longitude,
|
|
'country': country,
|
|
};
|
|
|
|
try {
|
|
final response = await CRUD().postMapSaas(
|
|
link: AppLink.mapSaasPlaces,
|
|
payload: payload,
|
|
);
|
|
|
|
isLoading = false;
|
|
if (response != null) {
|
|
HapticFeedback.lightImpact();
|
|
mySnackbarSuccess('Place added successfully! Thanks for your contribution.'.tr);
|
|
isSelectingPlaceLocation = false;
|
|
} else {
|
|
mySnackbarWarning('Failed to add place. Please try again later.'.tr);
|
|
}
|
|
update();
|
|
} catch (e) {
|
|
isLoading = false;
|
|
mySnackbarWarning('An error occurred while connecting to the server.'.tr);
|
|
update();
|
|
}
|
|
}
|
|
|
|
DateTime? _offRouteStartTime;
|
|
bool _autoRecalcInProgress = false;
|
|
|
|
final List<Map<String, dynamic>> _trackBuffer = [];
|
|
Timer? _recordTimer;
|
|
Timer? _uploadBatchTimer;
|
|
LatLng? _lastBufferedLocation;
|
|
DateTime? _lastBufferedTime;
|
|
LatLng? _lastDistanceLocation;
|
|
|
|
List<RouteData> routes = [];
|
|
int selectedRouteIndex = 0;
|
|
|
|
List<Map<String, dynamic>> recentLocations = [];
|
|
|
|
double get _targetZoom {
|
|
if (currentSpeed < 15) return 19.0;
|
|
if (currentSpeed < 40) return 18.0;
|
|
if (currentSpeed < 70) return 17.0;
|
|
if (currentSpeed < 100) return 16.0;
|
|
return 15.0;
|
|
}
|
|
|
|
double get _targetTilt {
|
|
if (currentSpeed < 10) return 0.0;
|
|
if (currentSpeed < 40) return 40.0;
|
|
return 55.0;
|
|
}
|
|
|
|
static final List<Map<String, String>> placeCategories = [
|
|
{
|
|
'id': 'restaurant',
|
|
'en': 'Restaurant',
|
|
'ar': 'مطعم',
|
|
'icon': 'restaurant'
|
|
},
|
|
{'id': 'cafe', 'en': 'Cafe', 'ar': 'مقهى', 'icon': 'coffee'},
|
|
{
|
|
'id': 'supermarket',
|
|
'en': 'Supermarket',
|
|
'ar': 'سوبر ماركت',
|
|
'icon': 'shopping_basket'
|
|
},
|
|
{
|
|
'id': 'pharmacy',
|
|
'en': 'Pharmacy',
|
|
'ar': 'صيدلية',
|
|
'icon': 'local_pharmacy'
|
|
},
|
|
{
|
|
'id': 'gas_station',
|
|
'en': 'Gas Station',
|
|
'ar': 'محطة وقود',
|
|
'icon': 'local_gas_station'
|
|
},
|
|
{'id': 'atm', 'en': 'ATM', 'ar': 'صراف آلي', 'icon': 'atm'},
|
|
{'id': 'bank', 'en': 'Bank', 'ar': 'بنك', 'icon': 'account_balance'},
|
|
{'id': 'mosque', 'en': 'Mosque', 'ar': 'مسجد', 'icon': 'mosque'},
|
|
{
|
|
'id': 'hospital',
|
|
'en': 'Hospital',
|
|
'ar': 'مستشفى',
|
|
'icon': 'local_hospital'
|
|
},
|
|
{'id': 'school', 'en': 'School', 'ar': 'مدرسة', 'icon': 'school'},
|
|
{
|
|
'id': 'university',
|
|
'en': 'University',
|
|
'ar': 'جامعة',
|
|
'icon': 'account_balance'
|
|
},
|
|
{'id': 'park', 'en': 'Park', 'ar': 'منتزه', 'icon': 'park'},
|
|
{'id': 'hotel', 'en': 'Hotel', 'ar': 'فندق', 'icon': 'hotel'},
|
|
{
|
|
'id': 'mall',
|
|
'en': 'Shopping Mall',
|
|
'ar': 'مركز تسوق',
|
|
'icon': 'shopping_mall'
|
|
},
|
|
{'id': 'gym', 'en': 'Gym', 'ar': 'نادي رياضي', 'icon': 'fitness_center'},
|
|
{
|
|
'id': 'salon',
|
|
'en': 'Beauty Salon',
|
|
'ar': 'صالون تجميل',
|
|
'icon': 'content_cut'
|
|
},
|
|
{'id': 'bakery', 'en': 'Bakery', 'ar': 'مخبز', 'icon': 'bakery_dining'},
|
|
{
|
|
'id': 'laundry',
|
|
'ar': 'مصبغة',
|
|
'en': 'Laundry',
|
|
'icon': 'local_laundry_service'
|
|
},
|
|
{
|
|
'id': 'car_repair',
|
|
'en': 'Car Repair',
|
|
'ar': 'تصليح سيارات',
|
|
'icon': 'build'
|
|
},
|
|
{
|
|
'id': 'government',
|
|
'en': 'Government Office',
|
|
'ar': 'دائرة حكومية',
|
|
'icon': 'gavel'
|
|
},
|
|
];
|
|
|
|
IconData get currentManeuverIcon {
|
|
switch (currentManeuverModifier) {
|
|
case 4: // Arrive
|
|
return Icons.place_rounded;
|
|
case 6: // Roundabout
|
|
return Icons.roundabout_right_rounded;
|
|
case 2: // Right
|
|
return Icons.turn_right_rounded;
|
|
case 3: // Slight Right
|
|
return Icons.turn_slight_right_rounded;
|
|
case -2: // Left
|
|
return Icons.turn_left_rounded;
|
|
case -1: // Slight Left
|
|
return Icons.turn_slight_left_rounded;
|
|
case 7: // Keep Right
|
|
return Icons.turn_right_rounded;
|
|
case -7: // Keep Left
|
|
return Icons.turn_left_rounded;
|
|
case 0: // Straight
|
|
return Icons.straight_rounded;
|
|
default:
|
|
return Icons.straight_rounded;
|
|
}
|
|
}
|
|
|
|
void toggleMute() {
|
|
isMuted = !isMuted;
|
|
update();
|
|
}
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
_animController = AnimationController(
|
|
vsync: this, duration: const Duration(milliseconds: 1000));
|
|
_animController!.addListener(() {
|
|
if (_oldLoc != null && _targetLoc != null && _mapReady) {
|
|
final t = _animController!.value;
|
|
final lat = lerpDouble(_oldLoc!.latitude, _targetLoc!.latitude, t)!;
|
|
final lng = lerpDouble(_oldLoc!.longitude, _targetLoc!.longitude, t)!;
|
|
myLocation = LatLng(lat, lng);
|
|
_smoothedHeading = _lerpAngle(_oldHeading, _targetHeading, t);
|
|
|
|
if (isStyleLoaded) {
|
|
if (_cameraLockedToUser) {
|
|
animateCameraToPosition(myLocation!,
|
|
bearing: _smoothedHeading,
|
|
zoom: isNavigating ? _targetZoom : 17.0,
|
|
tilt: isNavigating ? _targetTilt : 0.0);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
_initialize();
|
|
}
|
|
|
|
Future<void> _initialize() async {
|
|
_loadRecentLocations();
|
|
await _getCurrentLocationAndStartUpdates();
|
|
}
|
|
|
|
void _loadRecentLocations() {
|
|
final dynamic stored = box.read(BoxName.recentLocations);
|
|
if (stored != null) {
|
|
try {
|
|
List<dynamic> parsed;
|
|
if (stored is String) {
|
|
parsed = jsonDecode(stored);
|
|
} else if (stored is List) {
|
|
parsed = stored;
|
|
} else {
|
|
parsed = [];
|
|
}
|
|
|
|
recentLocations = parsed
|
|
.map((e) => Map<String, dynamic>.from(e))
|
|
.toList()
|
|
.reversed // Most recent first
|
|
.take(3)
|
|
.toList();
|
|
} catch (e) {
|
|
Log.print("Error decoding recent locations: $e");
|
|
recentLocations = [];
|
|
}
|
|
} else {
|
|
recentLocations = [];
|
|
}
|
|
update();
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
_locationStreamSubscription?.cancel();
|
|
_recordTimer?.cancel();
|
|
_uploadBatchTimer?.cancel();
|
|
_debounce?.cancel();
|
|
_animController?.dispose();
|
|
mapController = null;
|
|
placeDestinationController.dispose();
|
|
_flushBufferToServer();
|
|
super.onClose();
|
|
}
|
|
|
|
void onMapCreated(IntaleqMapController controller) async {
|
|
Log.print("DEBUG: NavigationController.onMapCreated called");
|
|
mapController = controller;
|
|
await onStyleLoaded();
|
|
}
|
|
|
|
Future<void> onStyleLoaded() async {
|
|
Log.print("DEBUG: NavigationController.onStyleLoaded called");
|
|
isStyleLoaded = true;
|
|
await _loadCustomIcons();
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
await Future.delayed(const Duration(milliseconds: 300));
|
|
if (!_mapReady) {
|
|
Log.print("DEBUG: NavigationController setting _mapReady = true");
|
|
_mapReady = true;
|
|
if (myLocation != null) {
|
|
Log.print("DEBUG: Animating camera to initial location: $myLocation");
|
|
animateCameraToPosition(myLocation!);
|
|
}
|
|
if (_fullRouteCoordinates.isNotEmpty) {
|
|
Log.print("DEBUG: Updating initial polylines");
|
|
_updatePolylinesSets([], _fullRouteCoordinates);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
void onMapTapped(Point<double> point, LatLng tappedPoint) {
|
|
if (isNavigating || routes.isEmpty) return;
|
|
|
|
int? bestIndex;
|
|
double minDistance = 100.0;
|
|
|
|
for (int i = 0; i < routes.length; i++) {
|
|
for (var coord in routes[i].coordinates) {
|
|
final dist = Geolocator.distanceBetween(
|
|
tappedPoint.latitude,
|
|
tappedPoint.longitude,
|
|
coord.latitude,
|
|
coord.longitude,
|
|
);
|
|
if (dist < minDistance) {
|
|
minDistance = dist;
|
|
bestIndex = i;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bestIndex != null && bestIndex != selectedRouteIndex) {
|
|
HapticFeedback.selectionClick();
|
|
selectRoute(bestIndex);
|
|
}
|
|
}
|
|
|
|
Future<void> onMapLongPressed(Point<double> point, LatLng tappedPoint) async {
|
|
HapticFeedback.mediumImpact();
|
|
Get.dialog(
|
|
AlertDialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
title: Text('Start Navigation?'.tr,
|
|
style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
content: Text('Do you want to go to this location?'.tr),
|
|
actions: [
|
|
TextButton(
|
|
child: Text('Cancel'.tr, style: const TextStyle(color: Colors.grey)),
|
|
onPressed: () => Get.back()),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: const Color(0xFF0D47A1),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12))),
|
|
child:
|
|
Text('Go Now'.tr, style: const TextStyle(color: Colors.white)),
|
|
onPressed: () {
|
|
Get.back();
|
|
startNavigationTo(tappedPoint, infoWindowTitle: 'Selected Location'.tr);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _getCurrentLocationAndStartUpdates() async {
|
|
try {
|
|
Log.print("DEBUG: Getting initial location...");
|
|
final position = await Geolocator.getCurrentPosition(
|
|
desiredAccuracy: LocationAccuracy.high);
|
|
myLocation = LatLng(position.latitude, position.longitude);
|
|
Log.print("DEBUG: Initial location acquired: $myLocation");
|
|
_targetHeading = position.heading;
|
|
_oldHeading = position.heading;
|
|
_smoothedHeading = position.heading;
|
|
update();
|
|
if (isStyleLoaded) animateCameraToPosition(myLocation!);
|
|
_startLocationStream();
|
|
_startBatchTimers();
|
|
} catch (e) {
|
|
Log.print("DEBUG: Error getting initial location: $e");
|
|
}
|
|
}
|
|
|
|
void _startLocationStream() {
|
|
_locationStreamSubscription?.cancel();
|
|
_locationStreamSubscription = Geolocator.getPositionStream(
|
|
locationSettings: const LocationSettings(
|
|
accuracy: LocationAccuracy.high,
|
|
distanceFilter: 2,
|
|
),
|
|
).listen(
|
|
(Position position) {
|
|
_handleLocationUpdate(position);
|
|
},
|
|
onError: (error) {
|
|
Log.print("DEBUG: Location stream error: $error");
|
|
},
|
|
);
|
|
}
|
|
|
|
bool _isProcessing = false;
|
|
Future<void> _handleLocationUpdate(Position position) async {
|
|
if (_isProcessing) return;
|
|
_isProcessing = true;
|
|
|
|
try {
|
|
final newLoc = LatLng(position.latitude, position.longitude);
|
|
currentSpeed = position.speed * 3.6;
|
|
|
|
if (_lastProcessedLocation != null) {
|
|
final d = Geolocator.distanceBetween(
|
|
newLoc.latitude,
|
|
newLoc.longitude,
|
|
_lastProcessedLocation!.latitude,
|
|
_lastProcessedLocation!.longitude,
|
|
);
|
|
if (d < _minMoveToProcess) {
|
|
_isProcessing = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
Log.print(
|
|
"DEBUG: Location update - Speed: ${currentSpeed.toStringAsFixed(1)} km/h, Loc: $newLoc");
|
|
|
|
if (_lastDistanceLocation != null) {
|
|
final d = Geolocator.distanceBetween(
|
|
_lastDistanceLocation!.latitude,
|
|
_lastDistanceLocation!.longitude,
|
|
newLoc.latitude,
|
|
newLoc.longitude,
|
|
);
|
|
if (d > 5.0) totalDistance += d;
|
|
}
|
|
_lastDistanceLocation = newLoc;
|
|
|
|
_oldLoc = myLocation ?? newLoc;
|
|
_targetLoc = newLoc;
|
|
|
|
_oldHeading = _smoothedHeading;
|
|
if (currentSpeed > 1.5 && _oldLoc != null) {
|
|
_targetHeading = Geolocator.bearingBetween(
|
|
_oldLoc!.latitude,
|
|
_oldLoc!.longitude,
|
|
_targetLoc!.latitude,
|
|
_targetLoc!.longitude,
|
|
);
|
|
} else {
|
|
_targetHeading = position.heading;
|
|
}
|
|
|
|
_animController?.forward(from: 0.0);
|
|
_lastProcessedLocation = newLoc;
|
|
|
|
if (_fullRouteCoordinates.isNotEmpty) {
|
|
_updateTraveledPolylineSmart(newLoc);
|
|
_checkNavigationStep(newLoc);
|
|
_recomputeETA();
|
|
_checkOffRoute(newLoc);
|
|
}
|
|
update();
|
|
} catch (e) {
|
|
Log.print("DEBUG: Error in _handleLocationUpdate: $e");
|
|
} finally {
|
|
_isProcessing = false;
|
|
}
|
|
}
|
|
|
|
double _lerpAngle(double from, double to, double t) {
|
|
final double diff = ((to - from + 540.0) % 360.0) - 180.0;
|
|
return (from + diff * t + 360.0) % 360.0;
|
|
}
|
|
|
|
void _checkOffRoute(LatLng pos) {
|
|
if (_autoRecalcInProgress || isLoading) return;
|
|
if (_fullRouteCoordinates.isEmpty) return;
|
|
|
|
const int searchWindow = 80;
|
|
final int start = _lastTraveledIndexInFullRoute;
|
|
final int end = min(start + searchWindow, _fullRouteCoordinates.length);
|
|
|
|
double minDist = double.infinity;
|
|
for (int i = start; i < end; i++) {
|
|
final d = Geolocator.distanceBetween(
|
|
pos.latitude,
|
|
pos.longitude,
|
|
_fullRouteCoordinates[i].latitude,
|
|
_fullRouteCoordinates[i].longitude,
|
|
);
|
|
if (d < minDist) minDist = d;
|
|
}
|
|
|
|
if (minDist > _offRouteThresholdM) {
|
|
if (_offRouteStartTime == null) {
|
|
_offRouteStartTime = DateTime.now();
|
|
} else {
|
|
final elapsed =
|
|
DateTime.now().difference(_offRouteStartTime!).inSeconds;
|
|
if (elapsed >= _offRouteTriggerSeconds) {
|
|
_offRouteStartTime = null;
|
|
_autoRecalcInProgress = true;
|
|
_smartRecalculateRoute(pos);
|
|
}
|
|
}
|
|
} else {
|
|
_offRouteStartTime = null;
|
|
}
|
|
}
|
|
|
|
Future<void> _smartRecalculateRoute(LatLng currentPos) async {
|
|
try {
|
|
if (routes.isNotEmpty && selectedRouteIndex < routes.length - 1) {
|
|
final nextIndex = selectedRouteIndex + 1;
|
|
final nextRoute = routes[nextIndex];
|
|
|
|
double minDist = double.infinity;
|
|
for (var coord in nextRoute.coordinates) {
|
|
final d = Geolocator.distanceBetween(
|
|
currentPos.latitude,
|
|
currentPos.longitude,
|
|
coord.latitude,
|
|
coord.longitude,
|
|
);
|
|
if (d < minDist) minDist = d;
|
|
}
|
|
|
|
if (minDist < 100) {
|
|
selectRoute(nextIndex);
|
|
Log.print("DEBUG: Switched to alternative route due to deviation");
|
|
_autoRecalcInProgress = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (_finalDestination != null) {
|
|
await recalculateRoute();
|
|
}
|
|
_autoRecalcInProgress = false;
|
|
} catch (e) {
|
|
Log.print("DEBUG: Error in smart recalculate: $e");
|
|
_autoRecalcInProgress = false;
|
|
}
|
|
}
|
|
|
|
void _startBatchTimers() {
|
|
_recordTimer?.cancel();
|
|
_uploadBatchTimer?.cancel();
|
|
_recordTimer = Timer.periodic(_recordInterval, (_) => _recordToBuffer());
|
|
_uploadBatchTimer =
|
|
Timer.periodic(_uploadInterval, (_) => _flushBufferToServer());
|
|
}
|
|
|
|
void _recordToBuffer() {
|
|
if (myLocation == null ||
|
|
(myLocation!.latitude == 0 && myLocation!.longitude == 0)) {
|
|
return;
|
|
}
|
|
final now = DateTime.now();
|
|
final distFromLast = _lastBufferedLocation == null
|
|
? 999.0
|
|
: Geolocator.distanceBetween(
|
|
_lastBufferedLocation!.latitude,
|
|
_lastBufferedLocation!.longitude,
|
|
myLocation!.latitude,
|
|
myLocation!.longitude);
|
|
final bool moved = distFromLast > _minMoveToRecord && currentSpeed > 0.5;
|
|
final bool timeForced = _lastBufferedTime == null ||
|
|
now.difference(_lastBufferedTime!).inSeconds >= 60;
|
|
if (!moved && !timeForced) return;
|
|
|
|
_lastBufferedLocation = myLocation;
|
|
_lastBufferedTime = now;
|
|
|
|
_trackBuffer.add({
|
|
'lat': double.parse(myLocation!.latitude.toStringAsFixed(6)),
|
|
'lng': double.parse(myLocation!.longitude.toStringAsFixed(6)),
|
|
'spd': double.parse(currentSpeed.toStringAsFixed(1)),
|
|
'head': _smoothedHeading.toStringAsFixed(0),
|
|
'dist': double.parse(totalDistance.toStringAsFixed(1)),
|
|
'ts': now.toIso8601String(),
|
|
});
|
|
}
|
|
|
|
Future<void> _flushBufferToServer() async {
|
|
if (_trackBuffer.isEmpty) return;
|
|
final batch = List<Map<String, dynamic>>.from(_trackBuffer);
|
|
_trackBuffer.clear();
|
|
final String driverId = (box.read(BoxName.driverID) ?? '').toString();
|
|
|
|
try {
|
|
await CRUD().post(
|
|
link: '${AppLink.locationServerSide}/add_batch.php',
|
|
payload: {
|
|
'driver_id': driverId,
|
|
'batch_data': jsonEncode(batch),
|
|
'session_dist': totalDistance.toStringAsFixed(1),
|
|
},
|
|
);
|
|
} catch (e) {
|
|
_trackBuffer.insertAll(0, batch);
|
|
}
|
|
}
|
|
|
|
void animateCameraToPosition(LatLng position,
|
|
{double? zoom, double bearing = 0.0, double tilt = 0.0}) {
|
|
if (!_mapReady || mapController == null) return;
|
|
mapController!.animateCamera(CameraUpdate.newCameraPosition(CameraPosition(
|
|
target: position,
|
|
zoom: zoom ?? (isNavigating ? _targetZoom : 16.0),
|
|
bearing: bearing,
|
|
tilt: tilt)));
|
|
}
|
|
|
|
Future<void> _safeAnimateCameraBounds(LatLngBounds? bounds,
|
|
{double left = 60,
|
|
double top = 60,
|
|
double right = 60,
|
|
double bottom = 60}) async {
|
|
if (bounds == null || mapController == null) return;
|
|
try {
|
|
final latSpan =
|
|
(bounds.northeast.latitude - bounds.southwest.latitude).abs();
|
|
final lngSpan =
|
|
(bounds.northeast.longitude - bounds.southwest.longitude).abs();
|
|
if (latSpan < 0.0001 && lngSpan < 0.0001) {
|
|
mapController
|
|
?.animateCamera(CameraUpdate.newLatLngZoom(bounds.northeast, 16));
|
|
return;
|
|
}
|
|
await Future.delayed(const Duration(milliseconds: 200));
|
|
await mapController?.animateCamera(CameraUpdate.newLatLngBounds(bounds,
|
|
left: left, top: top, right: right, bottom: bottom));
|
|
} catch (e) {
|
|
try {
|
|
await mapController
|
|
?.animateCamera(CameraUpdate.newLatLngZoom(bounds!.northeast, 14));
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
void onUserPanned() {
|
|
_cameraLockedToUser = false;
|
|
update();
|
|
}
|
|
|
|
void relockCameraToUser() {
|
|
_cameraLockedToUser = true;
|
|
if (myLocation != null) {
|
|
animateCameraToPosition(myLocation!,
|
|
bearing: _smoothedHeading, zoom: _targetZoom, tilt: _targetTilt);
|
|
}
|
|
update();
|
|
}
|
|
|
|
bool get isCameraLocked => _cameraLockedToUser;
|
|
|
|
void _updateTraveledPolylineSmart(LatLng currentPos) {
|
|
if (_fullRouteCoordinates.isEmpty) return;
|
|
const int searchWindow = 60;
|
|
final int startIndex = _lastTraveledIndexInFullRoute;
|
|
final int endIndex =
|
|
min(startIndex + searchWindow, _fullRouteCoordinates.length);
|
|
|
|
double minDist = double.infinity;
|
|
int closestIdx = startIndex;
|
|
bool foundCloser = false;
|
|
|
|
for (int i = startIndex; i < endIndex; i++) {
|
|
final d = Geolocator.distanceBetween(
|
|
currentPos.latitude,
|
|
currentPos.longitude,
|
|
_fullRouteCoordinates[i].latitude,
|
|
_fullRouteCoordinates[i].longitude,
|
|
);
|
|
if (d < minDist) {
|
|
minDist = d;
|
|
closestIdx = i;
|
|
foundCloser = true;
|
|
}
|
|
}
|
|
|
|
if (foundCloser &&
|
|
minDist < 50 &&
|
|
closestIdx > _lastTraveledIndexInFullRoute) {
|
|
_lastTraveledIndexInFullRoute = closestIdx;
|
|
_updatePolylinesSets(_fullRouteCoordinates.sublist(0, closestIdx + 1),
|
|
_fullRouteCoordinates.sublist(closestIdx));
|
|
}
|
|
}
|
|
|
|
Future<void> _updatePolylinesSets(
|
|
List<LatLng> traveled, List<LatLng> remaining) async {
|
|
Set<Polyline> newPolylines = {};
|
|
|
|
for (int i = 0; i < routes.length; i++) {
|
|
if (i == selectedRouteIndex) continue;
|
|
newPolylines.add(Polyline(
|
|
polylineId: PolylineId('alt_$i'),
|
|
points: routes[i].coordinates,
|
|
color: const Color(0xFFB0BEC5).withOpacity(0.8),
|
|
width: 6,
|
|
));
|
|
}
|
|
|
|
if (remaining.isNotEmpty) {
|
|
newPolylines.add(Polyline(
|
|
polylineId: const PolylineId('remaining'),
|
|
points: remaining,
|
|
color: const Color(0xFF00E5FF),
|
|
width: 8,
|
|
));
|
|
}
|
|
|
|
if (traveled.isNotEmpty) {
|
|
newPolylines.add(Polyline(
|
|
polylineId: const PolylineId('traveled'),
|
|
points: traveled,
|
|
color: const Color(0xFFBDBDBD).withOpacity(0.6),
|
|
width: 5,
|
|
));
|
|
}
|
|
|
|
polylines = newPolylines;
|
|
update();
|
|
}
|
|
|
|
void selectRoute(int index) {
|
|
if (index < 0 || index >= routes.length) return;
|
|
selectedRouteIndex = index;
|
|
final r = routes[index];
|
|
_fullRouteCoordinates = r.coordinates;
|
|
routeSteps = r.steps;
|
|
_routeTotalDistanceM = r.distanceM;
|
|
_routeTotalDurationS = r.durationS;
|
|
|
|
_lastTraveledIndexInFullRoute = 0;
|
|
_recomputeETA();
|
|
_updatePolylinesSets([], _fullRouteCoordinates);
|
|
update();
|
|
}
|
|
|
|
void goToFavorite(String type) {
|
|
LatLng? dest;
|
|
switch (type) {
|
|
case 'home':
|
|
dest = getHomeLatLng();
|
|
break;
|
|
case 'work':
|
|
dest = getWorkLatLng();
|
|
break;
|
|
case 'airport':
|
|
dest = getAirportLatLng();
|
|
break;
|
|
}
|
|
|
|
if (dest != null && myLocation != null) {
|
|
getRoute(myLocation!, dest);
|
|
} else {
|
|
mySnackbarWarning(box.read(BoxName.lang) == 'ar' ? 'الموقع غير متاح حالياً.' : 'Location not available.');
|
|
}
|
|
}
|
|
|
|
LatLng? getHomeLatLng() {
|
|
final dynamic stored = box.read(BoxName.addHome);
|
|
if (stored != null && stored is String && stored.contains(',')) {
|
|
final parts = stored.split(',');
|
|
return LatLng(double.parse(parts[0]), double.parse(parts[1]));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
LatLng? getWorkLatLng() {
|
|
final dynamic stored = box.read(BoxName.addWork);
|
|
if (stored != null && stored is String && stored.contains(',')) {
|
|
final parts = stored.split(',');
|
|
return LatLng(double.parse(parts[0]), double.parse(parts[1]));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
LatLng getAirportLatLng() {
|
|
final String country = box.read(BoxName.countryCode) ?? 'JO';
|
|
if (country == 'SY') {
|
|
return const LatLng(33.4111, 36.5147);
|
|
}
|
|
return const LatLng(31.7225, 35.9933);
|
|
}
|
|
|
|
Future<void> getRoute(LatLng origin, LatLng destination) async {
|
|
isLoading = true;
|
|
update();
|
|
|
|
final String langCode = box.read(BoxName.lang) ?? 'ar';
|
|
final Map<String, String> queryParams = {
|
|
'fromLat': origin.latitude.toString(),
|
|
'fromLng': origin.longitude.toString(),
|
|
'toLat': destination.latitude.toString(),
|
|
'toLng': destination.longitude.toString(),
|
|
'steps': 'true',
|
|
'alternatives': 'true',
|
|
'locale': langCode,
|
|
};
|
|
|
|
if (_intermediateStop != null) {
|
|
queryParams['stop1Lat'] = _intermediateStop!.latitude.toString();
|
|
queryParams['stop1Lng'] = _intermediateStop!.longitude.toString();
|
|
}
|
|
final saasUri =
|
|
Uri.parse(AppLink.mapSaasRoute).replace(queryParameters: queryParams);
|
|
|
|
try {
|
|
final response =
|
|
await http.get(saasUri, headers: {'x-api-key': Env.mapSaasKey});
|
|
|
|
if (response.statusCode != 200) {
|
|
isLoading = false;
|
|
update();
|
|
mySnackbarWarning(box.read(BoxName.lang) == 'ar' ? 'تعذر الاتصال بخدمة التوجيه.' : 'Failed to connect to routing service.');
|
|
return;
|
|
}
|
|
|
|
final data = jsonDecode(response.body);
|
|
|
|
routes.clear();
|
|
final primaryPts = data['points']?.toString() ?? "";
|
|
if (primaryPts.isNotEmpty) {
|
|
final coords = await compute<String, List<LatLng>>(
|
|
decodePolylineIsolate, primaryPts);
|
|
routes.add(RouteData(
|
|
coordinates: coords,
|
|
steps: List<Map<String, dynamic>>.from(data['instructions'] ?? []),
|
|
distanceM: (data['distance'] as num).toDouble(),
|
|
durationS: (data['duration'] as num).toDouble(),
|
|
points: primaryPts,
|
|
));
|
|
}
|
|
|
|
if (data['alternatives'] != null && data['alternatives'] is List) {
|
|
_hasAlternativeRoutes = (data['alternatives'] as List).isNotEmpty;
|
|
for (var alt in data['alternatives']) {
|
|
final altPts = alt['points']?.toString() ?? "";
|
|
if (altPts.isEmpty) continue;
|
|
final altCoords = await compute<String, List<LatLng>>(
|
|
decodePolylineIsolate, altPts);
|
|
routes.add(RouteData(
|
|
coordinates: altCoords,
|
|
steps: List<Map<String, dynamic>>.from(alt['instructions'] ?? []),
|
|
distanceM: (alt['distance'] as num).toDouble(),
|
|
durationS: (alt['duration'] as num).toDouble(),
|
|
points: altPts,
|
|
));
|
|
}
|
|
} else {
|
|
_hasAlternativeRoutes = false;
|
|
}
|
|
|
|
if (routes.isEmpty) {
|
|
isLoading = false;
|
|
update();
|
|
mySnackbarWarning(box.read(BoxName.lang) == 'ar' ? 'لم يتم العثور على مسار.' : 'No route found.');
|
|
return;
|
|
}
|
|
|
|
selectedRouteIndex = 0;
|
|
final selected = routes[0];
|
|
_fullRouteCoordinates = selected.coordinates;
|
|
routeSteps = selected.steps;
|
|
_routeTotalDistanceM = selected.distanceM;
|
|
_routeTotalDurationS = selected.durationS;
|
|
|
|
_lastTraveledIndexInFullRoute = 0;
|
|
if (isStyleLoaded) _updatePolylinesSets([], _fullRouteCoordinates);
|
|
|
|
if (_fullRouteCoordinates.isNotEmpty) {
|
|
OfflineMapService.instance
|
|
.downloadRegion(_fullRouteCoordinates.last, radiusKm: 2.0);
|
|
}
|
|
|
|
_recomputeETA();
|
|
|
|
currentStepIndex = 0;
|
|
_nextInstructionSpoken = false;
|
|
|
|
isNavigating = false;
|
|
_cameraLockedToUser = false;
|
|
_offRouteStartTime = null;
|
|
isLoading = false;
|
|
|
|
update();
|
|
|
|
if (routeSteps.isNotEmpty) {
|
|
currentInstruction = routeSteps[0]['text'] ?? "";
|
|
currentManeuverModifier = routeSteps[0]['sign'] ?? 0;
|
|
nextInstruction = routeSteps.length > 1
|
|
? (langCode == 'ar'
|
|
? "ثم ${routeSteps[1]['text']}"
|
|
: "Then ${routeSteps[1]['text']}")
|
|
: (langCode == 'ar' ? "الوجهة النهائية" : "Destination");
|
|
|
|
if (!isMuted) {
|
|
Get.find<TextToSpeechController>().speakText(currentInstruction);
|
|
}
|
|
}
|
|
|
|
if (_fullRouteCoordinates.length >= 2) {
|
|
final bounds =
|
|
data['bbox'] != null && (data['bbox'] as List).length == 4
|
|
? LatLngBounds(
|
|
southwest: LatLng(data['bbox'][1], data['bbox'][0]),
|
|
northeast: LatLng(data['bbox'][3], data['bbox'][2]))
|
|
: _boundsFromLatLngList(_fullRouteCoordinates);
|
|
await _safeAnimateCameraBounds(bounds,
|
|
bottom: 320, top: 150, left: 50, right: 50);
|
|
}
|
|
update();
|
|
} catch (e) {
|
|
isLoading = false;
|
|
update();
|
|
Log.print("GetRoute Error: $e");
|
|
}
|
|
}
|
|
|
|
void _recomputeETA() {
|
|
if (_routeTotalDistanceM == 0 || _fullRouteCoordinates.isEmpty) return;
|
|
final fraction =
|
|
(_fullRouteCoordinates.length - _lastTraveledIndexInFullRoute) /
|
|
_fullRouteCoordinates.length;
|
|
final remainingM = _routeTotalDistanceM * fraction;
|
|
final remainingS = _routeTotalDurationS * fraction;
|
|
|
|
final String langCode = box.read(BoxName.lang) ?? 'ar';
|
|
if (remainingM > 1000) {
|
|
totalDistanceRemaining = (remainingM / 1000).toStringAsFixed(1);
|
|
} else {
|
|
totalDistanceRemaining = remainingM.toStringAsFixed(0);
|
|
}
|
|
distanceWithUnit = _formatDistance(remainingM, langCode);
|
|
|
|
final minutes = (remainingS / 60).round();
|
|
estimatedTimeRemaining = minutes.toString();
|
|
|
|
final arrival = DateTime.now().add(Duration(seconds: remainingS.toInt()));
|
|
final h = arrival.hour > 12
|
|
? arrival.hour - 12
|
|
: (arrival.hour == 0 ? 12 : arrival.hour);
|
|
final m = arrival.minute.toString().padLeft(2, '0');
|
|
final ampm = arrival.hour >= 12 ? 'PM' : 'AM';
|
|
arrivalTime = "$h:$m $ampm";
|
|
}
|
|
|
|
Future<void> startNavigationTo(LatLng destination,
|
|
{String infoWindowTitle = ''}) async {
|
|
isLoading = true;
|
|
update();
|
|
try {
|
|
_finalDestination = destination;
|
|
await clearRoute(isNewRoute: true);
|
|
|
|
markers = markers.where((m) => m.markerId.value == 'car').toSet();
|
|
|
|
markers.add(Marker(
|
|
markerId: const MarkerId('destination'),
|
|
position: destination,
|
|
icon: InlqBitmap.fromStyleImage('dest_icon'),
|
|
infoWindow: infoWindowTitle.isNotEmpty
|
|
? InfoWindow(title: infoWindowTitle)
|
|
: InfoWindow.noText,
|
|
));
|
|
|
|
if (myLocation != null) {
|
|
markers.add(Marker(
|
|
markerId: const MarkerId('origin'),
|
|
position: myLocation!,
|
|
icon: InlqBitmap.fromStyleImage('start_icon'),
|
|
));
|
|
await getRoute(myLocation!, destination);
|
|
}
|
|
} finally {
|
|
isLoading = false;
|
|
update();
|
|
}
|
|
}
|
|
|
|
Future<void> recalculateRoute() async {
|
|
if (myLocation == null || _finalDestination == null || isLoading) return;
|
|
isLoading = true;
|
|
update();
|
|
mySnackbarInfo(box.read(BoxName.lang) == 'ar' ? 'جاري حساب مسار جديد...' : 'Calculating new route...');
|
|
await getRoute(myLocation!, _finalDestination!);
|
|
isLoading = false;
|
|
update();
|
|
}
|
|
|
|
Future<void> startActiveNavigation() async {
|
|
if (routes.isEmpty) {
|
|
mySnackbarWarning(box.read(BoxName.lang) == 'ar'
|
|
? 'لا يوجد مسار لبدء الملاحة.'
|
|
: 'No route to start navigation.');
|
|
return;
|
|
}
|
|
if (isNavigating) return;
|
|
|
|
isNavigating = true;
|
|
_cameraLockedToUser = true;
|
|
|
|
_recomputeETA();
|
|
|
|
if (routeSteps.isNotEmpty && currentStepIndex < routeSteps.length) {
|
|
currentInstruction = routeSteps[currentStepIndex]['text'] ?? "";
|
|
currentManeuverModifier = routeSteps[currentStepIndex]['sign'] ?? 0;
|
|
nextInstruction = (currentStepIndex + 1) < routeSteps.length
|
|
? (box.read(BoxName.lang) == 'ar'
|
|
? "ثم ${routeSteps[currentStepIndex + 1]['text']}"
|
|
: "Then ${routeSteps[currentStepIndex + 1]['text']}")
|
|
: (box.read(BoxName.lang) == 'ar' ? 'الوجهة' : 'Destination');
|
|
|
|
if (!isMuted) {
|
|
try {
|
|
Get.find<TextToSpeechController>().speakText(currentInstruction);
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
if (myLocation != null) {
|
|
animateCameraToPosition(myLocation!,
|
|
bearing: _smoothedHeading, zoom: _targetZoom, tilt: _targetTilt);
|
|
}
|
|
|
|
update();
|
|
}
|
|
|
|
Future<void> clearEverything() async {
|
|
placeDestinationController.clear();
|
|
placesDestination = [];
|
|
await clearRoute();
|
|
}
|
|
|
|
Future<void> clearRoute({bool isNewRoute = false}) async {
|
|
_offRouteStartTime = null;
|
|
_autoRecalcInProgress = false;
|
|
if (!isNewRoute) {
|
|
markers = {};
|
|
polylines = {};
|
|
circles = {};
|
|
polygons = {};
|
|
_finalDestination = null;
|
|
isNavigating = false;
|
|
routes = [];
|
|
await _flushBufferToServer();
|
|
}
|
|
routeSteps = [];
|
|
_fullRouteCoordinates = [];
|
|
_lastTraveledIndexInFullRoute = 0;
|
|
currentInstruction = "";
|
|
nextInstruction = "";
|
|
currentManeuverModifier = "intaleq";
|
|
distanceToNextStep = "";
|
|
totalDistanceRemaining = "";
|
|
estimatedTimeRemaining = "";
|
|
arrivalTime = "--:--";
|
|
_routeTotalDistanceM = 0;
|
|
_routeTotalDurationS = 0;
|
|
|
|
update();
|
|
}
|
|
|
|
Future<void> _loadCustomIcons() async {
|
|
if (mapController == null) return;
|
|
try {
|
|
final carBytes = await rootBundle.load('assets/images/car.png');
|
|
final startBytes = await rootBundle.load('assets/images/A.png');
|
|
final destBytes = await rootBundle.load('assets/images/b.png');
|
|
await mapController!.addImage('car_icon', carBytes.buffer.asUint8List());
|
|
await mapController!
|
|
.addImage('start_icon', startBytes.buffer.asUint8List());
|
|
await mapController!.addImage('dest_icon', destBytes.buffer.asUint8List());
|
|
} catch (e) {
|
|
Log.print("Error loading custom icons: $e");
|
|
}
|
|
}
|
|
|
|
void _checkNavigationStep(LatLng pos) {
|
|
if (routeSteps.isEmpty || currentStepIndex >= routeSteps.length) return;
|
|
|
|
final interval = routeSteps[currentStepIndex]['interval'] as List;
|
|
final endIdx = interval[1] as int;
|
|
|
|
if (endIdx >= _fullRouteCoordinates.length) return;
|
|
|
|
final endLatLng = _fullRouteCoordinates[endIdx];
|
|
final distance = Geolocator.distanceBetween(
|
|
pos.latitude, pos.longitude, endLatLng.latitude, endLatLng.longitude);
|
|
|
|
distanceToNextStep = distance > 1000
|
|
? "${(distance / 1000).toStringAsFixed(1)} km"
|
|
: "${distance.toStringAsFixed(0)} m";
|
|
|
|
if (distance < 50 &&
|
|
!_nextInstructionSpoken &&
|
|
nextInstruction.isNotEmpty) {
|
|
if (!isMuted) {
|
|
Get.find<TextToSpeechController>().speakText(nextInstruction);
|
|
}
|
|
_nextInstructionSpoken = true;
|
|
}
|
|
if (distance < 20) _advanceStep();
|
|
}
|
|
|
|
void _advanceStep() {
|
|
currentStepIndex++;
|
|
final String langCode = box.read(BoxName.lang) ?? 'ar';
|
|
if (currentStepIndex < routeSteps.length) {
|
|
currentInstruction = routeSteps[currentStepIndex]['text'] ?? "";
|
|
currentManeuverModifier = routeSteps[currentStepIndex]['sign'] ?? 0;
|
|
nextInstruction = (currentStepIndex + 1) < routeSteps.length
|
|
? (langCode == 'ar'
|
|
? "ثم ${routeSteps[currentStepIndex + 1]['text']}"
|
|
: "Then ${routeSteps[currentStepIndex + 1]['text']}")
|
|
: (langCode == 'ar' ? "ستصل إلى وجهتك" : "Arriving soon");
|
|
_nextInstructionSpoken = false;
|
|
update();
|
|
} else {
|
|
_finishNavigation();
|
|
}
|
|
}
|
|
|
|
void _finishNavigation() {
|
|
final String langCode = box.read(BoxName.lang) ?? 'ar';
|
|
currentInstruction =
|
|
langCode == 'ar' ? "لقد وصلت إلى وجهتك" : "You have arrived";
|
|
currentManeuverModifier = 4;
|
|
nextInstruction = "";
|
|
distanceToNextStep = "";
|
|
isNavigating = false;
|
|
if (!isMuted) {
|
|
Get.find<TextToSpeechController>().speakText(currentInstruction);
|
|
}
|
|
_flushBufferToServer();
|
|
update();
|
|
}
|
|
|
|
Future<void> getPlaces() async {
|
|
final q = placeDestinationController.text.trim();
|
|
if (q.length < 3) {
|
|
placesDestination = [];
|
|
update();
|
|
return;
|
|
}
|
|
if (mapController == null) return;
|
|
|
|
try {
|
|
final results = await mapController!.searchPlaces(q);
|
|
|
|
if (myLocation != null) {
|
|
for (final p in results) {
|
|
final plat = double.tryParse(p['latitude']?.toString() ?? '0') ?? 0.0;
|
|
final plng = double.tryParse(p['longitude']?.toString() ?? '0') ?? 0.0;
|
|
p['distanceKm'] = _haversineKm(myLocation!.latitude, myLocation!.longitude, plat, plng);
|
|
}
|
|
results.sort((a, b) =>
|
|
(a['distanceKm'] as double).compareTo(b['distanceKm'] as double));
|
|
}
|
|
|
|
placesDestination = results;
|
|
update();
|
|
} catch (e) {
|
|
Log.print('getPlaces error: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> selectDestination(dynamic place) async {
|
|
placeDestinationController.clear();
|
|
placesDestination = [];
|
|
final lat = double.parse(place['latitude'].toString());
|
|
final lng = double.parse(place['longitude'].toString());
|
|
await startNavigationTo(LatLng(lat, lng),
|
|
infoWindowTitle: place['name'] ?? (box.read(BoxName.lang) == 'ar' ? 'وجهة' : 'Destination'));
|
|
}
|
|
|
|
void onSearchChanged(String query) {
|
|
if (_debounce?.isActive ?? false) _debounce!.cancel();
|
|
_debounce = Timer(const Duration(milliseconds: 500), () => getPlaces());
|
|
}
|
|
|
|
double _haversineKm(double lat1, double lon1, double lat2, double lon2) {
|
|
const R = 6371.0;
|
|
final dLat = (lat2 - lat1) * (pi / 180.0);
|
|
final dLon = (lon2 - lon1) * (pi / 180.0);
|
|
final a = sin(dLat / 2) * sin(dLat / 2) +
|
|
cos(lat1 * pi / 180) *
|
|
cos(lat2 * pi / 180) *
|
|
sin(dLon / 2) *
|
|
sin(dLon / 2);
|
|
return R * 2 * atan2(sqrt(a), sqrt(1 - a));
|
|
}
|
|
|
|
LatLngBounds _boundsFromLatLngList(List<LatLng> list) {
|
|
double? x0, x1, y0, y1;
|
|
for (final ll in list) {
|
|
if (x0 == null) {
|
|
x0 = x1 = ll.latitude;
|
|
y0 = y1 = ll.longitude;
|
|
} else {
|
|
if (ll.latitude > x1!) x1 = ll.latitude;
|
|
if (ll.latitude < x0) x0 = ll.latitude;
|
|
if (ll.longitude > y1!) y1 = ll.longitude;
|
|
if (ll.longitude < y0!) y0 = ll.longitude;
|
|
}
|
|
}
|
|
return LatLngBounds(
|
|
northeast: LatLng(x1!, y1!), southwest: LatLng(x0!, y0!));
|
|
}
|
|
|
|
void setIntermediateStop(LatLng stop) {
|
|
_intermediateStop = stop;
|
|
if (myLocation != null && _finalDestination != null) {
|
|
getRoute(myLocation!, _finalDestination!);
|
|
}
|
|
update();
|
|
}
|
|
|
|
void clearIntermediateStop() {
|
|
_intermediateStop = null;
|
|
if (myLocation != null && _finalDestination != null) {
|
|
getRoute(myLocation!, _finalDestination!);
|
|
}
|
|
update();
|
|
}
|
|
|
|
String _formatDistance(double meters, String lang) {
|
|
if (meters >= 1000) {
|
|
return "${(meters / 1000).toStringAsFixed(1)} ${lang == 'ar' ? 'كم' : 'km'}";
|
|
} else {
|
|
return "${meters.toStringAsFixed(0)} ${lang == 'ar' ? 'م' : 'm'}";
|
|
}
|
|
}
|
|
|
|
Future<void> submitPlaceSuggestion(String name) async {
|
|
if (name.trim().isEmpty || myLocation == null) return;
|
|
isLoading = true;
|
|
update();
|
|
try {
|
|
final payload = {
|
|
'name': name,
|
|
'lat': myLocation!.latitude.toString(),
|
|
'lng': myLocation!.longitude.toString(),
|
|
'driver_id': box.read(BoxName.driverID),
|
|
};
|
|
await CRUD().post(link: AppLink.getPlacesSyria, payload: payload);
|
|
mySnackbarInfo(box.read(BoxName.lang) == 'ar'
|
|
? "تم استلام اقتراحك! شكراً لمساهمتك."
|
|
: "Suggestion received! Thanks for your contribution.");
|
|
} finally {
|
|
isLoading = false;
|
|
update();
|
|
}
|
|
}
|
|
}
|