633 lines
20 KiB
Dart
633 lines
20 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
import 'package:Intaleq/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:maplibre_gl/maplibre_gl.dart'; // Replaced Google Maps
|
|
import 'package:http/http.dart' as http;
|
|
import '../../../constant/box_name.dart';
|
|
import '../../../constant/colors.dart';
|
|
import '../../../constant/country_polygons.dart';
|
|
import '../../../constant/links.dart';
|
|
import '../../../controller/functions/crud.dart';
|
|
import '../../../controller/functions/tts.dart';
|
|
import '../../../controller/home/decode_polyline_isolate.dart';
|
|
import '../../../env/env.dart';
|
|
import '../../../main.dart';
|
|
import '../../../print.dart';
|
|
|
|
class NavigationController extends GetxController {
|
|
bool isLoading = false;
|
|
MaplibreMapController? mapController;
|
|
bool isStyleLoaded = false;
|
|
final TextEditingController placeDestinationController =
|
|
TextEditingController();
|
|
|
|
LatLng? myLocation;
|
|
double heading = 0.0;
|
|
|
|
// MapLibre Object Tracking
|
|
Symbol? carSymbol;
|
|
Symbol? destinationSymbol;
|
|
Line? remainingRouteLine;
|
|
Line? traveledRouteLine;
|
|
|
|
Timer? _locationUpdateTimer;
|
|
final Duration _currentUpdateInterval = const Duration(seconds: 1);
|
|
LatLng? _lastRecordedLocation;
|
|
|
|
List<dynamic> placesDestination = [];
|
|
Timer? _debounce;
|
|
|
|
LatLng? _finalDestination;
|
|
List<Map<String, dynamic>> routeSteps = [];
|
|
List<LatLng> _fullRouteCoordinates = [];
|
|
int _lastTraveledIndexInFullRoute = 0;
|
|
|
|
bool _nextInstructionSpoken = false;
|
|
String currentInstruction = "";
|
|
String nextInstruction = "";
|
|
int currentStepIndex = 0;
|
|
|
|
double currentSpeed = 0.0;
|
|
String distanceToNextStep = "";
|
|
|
|
static final String _routeApiBaseUrl =
|
|
"${AppLink.routesOsm}/route/v1/driving";
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
_initialize();
|
|
}
|
|
|
|
Future<void> _initialize() async {
|
|
await _getCurrentLocationAndStartUpdates();
|
|
if (!Get.isRegistered<TextToSpeechController>()) {
|
|
Get.put(TextToSpeechController());
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
_locationUpdateTimer?.cancel();
|
|
mapController?.dispose();
|
|
_debounce?.cancel();
|
|
placeDestinationController.dispose();
|
|
super.onClose();
|
|
}
|
|
|
|
// =======================================================================
|
|
// Map Initialization & Callbacks
|
|
// =======================================================================
|
|
|
|
void onMapCreated(MaplibreMapController controller) {
|
|
mapController = controller;
|
|
}
|
|
|
|
Future<void> onStyleLoaded() async {
|
|
isStyleLoaded = true;
|
|
await _loadCustomIcons();
|
|
|
|
if (myLocation != null) {
|
|
animateCameraToPosition(myLocation!);
|
|
_updateCarMarker();
|
|
}
|
|
|
|
if (_fullRouteCoordinates.isNotEmpty) {
|
|
_updatePolylinesSets([], _fullRouteCoordinates);
|
|
}
|
|
}
|
|
|
|
Future<void> onMapLongPressed(Point<double> point, LatLng tappedPoint) async {
|
|
Get.dialog(
|
|
AlertDialog(
|
|
title: const Text('بدء الملاحة؟'),
|
|
content: const Text('هل تريد الذهاب إلى هذا الموقع المحدد؟'),
|
|
actionsAlignment: MainAxisAlignment.spaceBetween,
|
|
actions: [
|
|
TextButton(
|
|
child: const Text('إلغاء', style: TextStyle(color: Colors.grey)),
|
|
onPressed: () => Get.back(),
|
|
),
|
|
TextButton(
|
|
child: const Text('اذهب الآن'),
|
|
onPressed: () {
|
|
Get.back();
|
|
startNavigationTo(tappedPoint, infoWindowTitle: 'الموقع المحدد');
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// =======================================================================
|
|
// Location Management
|
|
// =======================================================================
|
|
|
|
Future<void> _getCurrentLocationAndStartUpdates() async {
|
|
try {
|
|
Position position = await Geolocator.getCurrentPosition(
|
|
desiredAccuracy: LocationAccuracy.high);
|
|
myLocation = LatLng(position.latitude, position.longitude);
|
|
update();
|
|
if (isStyleLoaded) animateCameraToPosition(myLocation!);
|
|
_startLocationTimer();
|
|
} catch (e) {
|
|
Log.print("Error getting initial location: $e");
|
|
}
|
|
}
|
|
|
|
void _startLocationTimer() {
|
|
_locationUpdateTimer?.cancel();
|
|
_locationUpdateTimer = Timer.periodic(_currentUpdateInterval, (timer) {
|
|
_updateLocationAndProcess();
|
|
});
|
|
}
|
|
|
|
Future<void> _updateLocationAndProcess() async {
|
|
try {
|
|
final position = await Geolocator.getCurrentPosition(
|
|
desiredAccuracy: LocationAccuracy.high);
|
|
final newLoc = LatLng(position.latitude, position.longitude);
|
|
|
|
if (_lastRecordedLocation != null) {
|
|
double dist = Geolocator.distanceBetween(
|
|
newLoc.latitude,
|
|
newLoc.longitude,
|
|
_lastRecordedLocation!.latitude,
|
|
_lastRecordedLocation!.longitude);
|
|
if (dist < 2.0) return;
|
|
}
|
|
|
|
myLocation = newLoc;
|
|
_lastRecordedLocation = newLoc;
|
|
heading = position.heading;
|
|
currentSpeed = position.speed * 3.6;
|
|
|
|
if (isStyleLoaded) _updateCarMarker();
|
|
|
|
if (_fullRouteCoordinates.isNotEmpty) {
|
|
animateCameraToPosition(myLocation!, bearing: heading, zoom: 18.0);
|
|
_updateTraveledPolylineSmart(myLocation!);
|
|
_checkNavigationStep(myLocation!);
|
|
}
|
|
|
|
update();
|
|
} catch (e) {
|
|
// Log.print("Loc update error: $e");
|
|
}
|
|
}
|
|
|
|
Future<void> _updateCarMarker() async {
|
|
if (myLocation == null || mapController == null || !isStyleLoaded) return;
|
|
|
|
if (carSymbol == null) {
|
|
carSymbol = await mapController!.addSymbol(SymbolOptions(
|
|
geometry: myLocation,
|
|
iconImage: 'car_icon',
|
|
iconSize: 1.0,
|
|
iconRotate: heading,
|
|
));
|
|
} else {
|
|
mapController!.updateSymbol(
|
|
carSymbol!,
|
|
SymbolOptions(
|
|
geometry: myLocation,
|
|
iconRotate: heading,
|
|
));
|
|
}
|
|
}
|
|
|
|
void animateCameraToPosition(LatLng position,
|
|
{double zoom = 17.0, double bearing = 0.0}) {
|
|
mapController?.animateCamera(
|
|
CameraUpdate.newCameraPosition(
|
|
CameraPosition(
|
|
target: position, zoom: zoom, bearing: bearing, tilt: 45.0),
|
|
),
|
|
);
|
|
}
|
|
|
|
// =======================================================================
|
|
// Route Management
|
|
// =======================================================================
|
|
|
|
void _updateTraveledPolylineSmart(LatLng currentPos) {
|
|
if (_fullRouteCoordinates.isEmpty) return;
|
|
|
|
int searchWindow = 60;
|
|
int startIndex = _lastTraveledIndexInFullRoute;
|
|
int endIndex = min(startIndex + searchWindow, _fullRouteCoordinates.length);
|
|
|
|
double minDistance = double.infinity;
|
|
int closestIndex = startIndex;
|
|
bool foundCloser = false;
|
|
|
|
for (int i = startIndex; i < endIndex; i++) {
|
|
final point = _fullRouteCoordinates[i];
|
|
final dist = Geolocator.distanceBetween(currentPos.latitude,
|
|
currentPos.longitude, point.latitude, point.longitude);
|
|
|
|
if (dist < minDistance) {
|
|
minDistance = dist;
|
|
closestIndex = i;
|
|
foundCloser = true;
|
|
}
|
|
}
|
|
|
|
if (foundCloser &&
|
|
minDistance < 50 &&
|
|
closestIndex > _lastTraveledIndexInFullRoute) {
|
|
_lastTraveledIndexInFullRoute = closestIndex;
|
|
final remaining =
|
|
_fullRouteCoordinates.sublist(_lastTraveledIndexInFullRoute);
|
|
final traveled =
|
|
_fullRouteCoordinates.sublist(0, _lastTraveledIndexInFullRoute + 1);
|
|
_updatePolylinesSets(traveled, remaining);
|
|
}
|
|
}
|
|
|
|
Future<void> _updatePolylinesSets(
|
|
List<LatLng> traveled, List<LatLng> remaining) async {
|
|
if (mapController == null || !isStyleLoaded) return;
|
|
|
|
if (remainingRouteLine != null)
|
|
await mapController!.removeLine(remainingRouteLine!);
|
|
if (traveledRouteLine != null)
|
|
await mapController!.removeLine(traveledRouteLine!);
|
|
|
|
if (remaining.isNotEmpty) {
|
|
remainingRouteLine = await mapController!.addLine(LineOptions(
|
|
geometry: remaining,
|
|
lineColor: '#0D47A1',
|
|
lineWidth: 6.0,
|
|
lineJoin: 'round',
|
|
));
|
|
}
|
|
|
|
if (traveled.isNotEmpty) {
|
|
traveledRouteLine = await mapController!.addLine(LineOptions(
|
|
geometry: traveled,
|
|
lineColor: '#BDBDBD',
|
|
lineWidth: 6.0,
|
|
lineJoin: 'round',
|
|
));
|
|
}
|
|
}
|
|
|
|
// =======================================================================
|
|
// Routing API & Navigation
|
|
// =======================================================================
|
|
|
|
Future<void> getRoute(LatLng origin, LatLng destination) async {
|
|
String coords =
|
|
"${origin.longitude},${origin.latitude};${destination.longitude},${destination.latitude}";
|
|
String url =
|
|
"$_routeApiBaseUrl/$coords?steps=true&overview=full&geometries=polyline";
|
|
|
|
try {
|
|
final response = await http.get(Uri.parse(url));
|
|
if (response.statusCode != 200) {
|
|
mySnackbarWarning('تعذر الاتصال بخدمة التوجيه.');
|
|
return;
|
|
}
|
|
|
|
final responseData = jsonDecode(response.body);
|
|
if (responseData['code'] != 'Ok' ||
|
|
(responseData['routes'] as List).isEmpty) {
|
|
mySnackbarWarning('لم يتم العثور على مسار.');
|
|
return;
|
|
}
|
|
|
|
var route = responseData['routes'][0];
|
|
final pointsString = route['geometry'];
|
|
// فك تشفير Polyline بطريقة آمنة نوعياً (Type-Safe)
|
|
_fullRouteCoordinates = await compute<String, List<LatLng>>(
|
|
decodePolylineIsolate, pointsString.toString());
|
|
|
|
_lastTraveledIndexInFullRoute = 0;
|
|
if (isStyleLoaded) _updatePolylinesSets([], _fullRouteCoordinates);
|
|
|
|
var legs = route['legs'] as List;
|
|
if (legs.isNotEmpty) {
|
|
var steps = legs[0]['steps'] as List;
|
|
routeSteps = List<Map<String, dynamic>>.from(steps);
|
|
} else {
|
|
routeSteps = [];
|
|
}
|
|
|
|
for (var step in routeSteps) {
|
|
step['instruction_text'] = _createInstructionFromManeuver(step);
|
|
}
|
|
|
|
currentStepIndex = 0;
|
|
_nextInstructionSpoken = false;
|
|
if (routeSteps.isNotEmpty) {
|
|
currentInstruction = routeSteps[0]['instruction_text'];
|
|
nextInstruction = routeSteps.length > 1
|
|
? "ثم ${routeSteps[1]['instruction_text']}"
|
|
: "الوجهة النهائية";
|
|
Get.find<TextToSpeechController>().speakText(currentInstruction);
|
|
}
|
|
|
|
if (_fullRouteCoordinates.isNotEmpty) {
|
|
final bounds = _boundsFromLatLngList(_fullRouteCoordinates);
|
|
mapController?.animateCamera(CameraUpdate.newLatLngBounds(bounds,
|
|
bottom: 200, top: 150, left: 50, right: 50));
|
|
}
|
|
|
|
update();
|
|
} catch (e) {
|
|
Log.print("GetRoute Error: $e");
|
|
Get.snackbar('خطأ', 'حدث خطأ غير متوقع.');
|
|
}
|
|
}
|
|
|
|
// --- Map Object Handlers ---
|
|
|
|
Future<void> startNavigationTo(LatLng destination,
|
|
{String infoWindowTitle = ''}) async {
|
|
isLoading = true;
|
|
update();
|
|
try {
|
|
_finalDestination = destination;
|
|
await clearRoute(isNewRoute: true);
|
|
|
|
if (isStyleLoaded && mapController != null) {
|
|
destinationSymbol = await mapController!.addSymbol(SymbolOptions(
|
|
geometry: destination,
|
|
iconImage: 'dest_icon',
|
|
iconSize: 1.0,
|
|
textField: infoWindowTitle,
|
|
textOffset: const Offset(0, 2),
|
|
));
|
|
}
|
|
|
|
if (myLocation != null) await getRoute(myLocation!, destination);
|
|
} finally {
|
|
isLoading = false;
|
|
update();
|
|
}
|
|
}
|
|
|
|
Future<void> recalculateRoute() async {
|
|
if (myLocation == null || _finalDestination == null || isLoading) return;
|
|
isLoading = true;
|
|
update();
|
|
Get.snackbar('إعادة التوجيه', 'جاري حساب مسار جديد...',
|
|
backgroundColor: AppColor.goldenBronze);
|
|
await getRoute(myLocation!, _finalDestination!);
|
|
isLoading = false;
|
|
update();
|
|
}
|
|
|
|
Future<void> clearRoute({bool isNewRoute = false}) async {
|
|
if (!isNewRoute) {
|
|
if (destinationSymbol != null && mapController != null) {
|
|
await mapController!.removeSymbol(destinationSymbol!);
|
|
destinationSymbol = null;
|
|
}
|
|
if (remainingRouteLine != null && mapController != null) {
|
|
await mapController!.removeLine(remainingRouteLine!);
|
|
remainingRouteLine = null;
|
|
}
|
|
if (traveledRouteLine != null && mapController != null) {
|
|
await mapController!.removeLine(traveledRouteLine!);
|
|
traveledRouteLine = null;
|
|
}
|
|
_finalDestination = null;
|
|
}
|
|
routeSteps.clear();
|
|
_fullRouteCoordinates.clear();
|
|
_lastTraveledIndexInFullRoute = 0;
|
|
currentInstruction = "";
|
|
nextInstruction = "";
|
|
distanceToNextStep = "";
|
|
update();
|
|
}
|
|
|
|
Future<void> _loadCustomIcons() async {
|
|
if (mapController == null) return;
|
|
|
|
final ByteData carBytes = await rootBundle.load('assets/images/car.png');
|
|
final Uint8List carList = carBytes.buffer.asUint8List();
|
|
await mapController!.addImage('car_icon', carList);
|
|
|
|
final ByteData destBytes = await rootBundle.load('assets/images/b.png');
|
|
final Uint8List destList = destBytes.buffer.asUint8List();
|
|
await mapController!.addImage('dest_icon', destList);
|
|
}
|
|
|
|
// --- Step Tracking & Instructions (Omitted unchanged logic to save space, retain your existing string matchers) ---
|
|
void _checkNavigationStep(LatLng currentPosition) {
|
|
if (routeSteps.isEmpty || currentStepIndex >= routeSteps.length) return;
|
|
final step = routeSteps[currentStepIndex];
|
|
final maneuver = step['maneuver'];
|
|
final List<dynamic> location = maneuver['location'];
|
|
final endLatLng = LatLng(location[1], location[0]);
|
|
|
|
final distance = Geolocator.distanceBetween(
|
|
currentPosition.latitude,
|
|
currentPosition.longitude,
|
|
endLatLng.latitude,
|
|
endLatLng.longitude,
|
|
);
|
|
|
|
distanceToNextStep = distance > 1000
|
|
? "${(distance / 1000).toStringAsFixed(1)} كم"
|
|
: "${distance.toStringAsFixed(0)} متر";
|
|
|
|
if (distance < 50 &&
|
|
!_nextInstructionSpoken &&
|
|
nextInstruction.isNotEmpty) {
|
|
Get.find<TextToSpeechController>().speakText(nextInstruction);
|
|
_nextInstructionSpoken = true;
|
|
}
|
|
|
|
if (distance < 20) _advanceStep();
|
|
}
|
|
|
|
void _advanceStep() {
|
|
currentStepIndex++;
|
|
if (currentStepIndex < routeSteps.length) {
|
|
currentInstruction = routeSteps[currentStepIndex]['instruction_text'];
|
|
nextInstruction = (currentStepIndex + 1) < routeSteps.length
|
|
? "ثم ${routeSteps[currentStepIndex + 1]['instruction_text']}"
|
|
: "ستصل إلى وجهتك";
|
|
_nextInstructionSpoken = false;
|
|
update();
|
|
} else {
|
|
_finishNavigation();
|
|
}
|
|
}
|
|
|
|
void _finishNavigation() {
|
|
currentInstruction = "لقد وصلت إلى وجهتك";
|
|
nextInstruction = "";
|
|
distanceToNextStep = "";
|
|
Get.find<TextToSpeechController>().speakText(currentInstruction);
|
|
update();
|
|
}
|
|
|
|
String _createInstructionFromManeuver(Map<String, dynamic> step) {
|
|
if (step['maneuver'] == null) return "تابع المسير";
|
|
final maneuver = step['maneuver'];
|
|
final type = maneuver['type'] ?? 'continue';
|
|
final modifier = maneuver['modifier'] ?? 'straight';
|
|
final name = step['name'] ?? '';
|
|
String instruction = "";
|
|
|
|
switch (type) {
|
|
case 'depart':
|
|
instruction = "انطلق";
|
|
break;
|
|
case 'arrive':
|
|
return "لقد وصلت إلى وجهتك، $name";
|
|
case 'turn':
|
|
case 'fork':
|
|
case 'roundabout':
|
|
case 'merge':
|
|
case 'on ramp':
|
|
case 'off ramp':
|
|
case 'end of road':
|
|
instruction = _getTurnInstruction(modifier);
|
|
break;
|
|
case 'new name':
|
|
instruction = "تابع المسير";
|
|
break;
|
|
default:
|
|
instruction = "تابع المسير";
|
|
}
|
|
|
|
if (name.isNotEmpty)
|
|
instruction += (type == 'new name' || type == 'continue')
|
|
? " على $name"
|
|
: " نحو $name";
|
|
return instruction;
|
|
}
|
|
|
|
String _getTurnInstruction(String modifier) {
|
|
switch (modifier) {
|
|
case 'uturn':
|
|
return "قم بالاستدارة والعودة";
|
|
case 'sharp right':
|
|
return "انعطف يميناً بحدة";
|
|
case 'right':
|
|
return "انعطف يميناً";
|
|
case 'slight right':
|
|
return "انعطف يميناً قليلاً";
|
|
case 'straight':
|
|
return "استمر للأمام";
|
|
case 'slight left':
|
|
return "انعطف يساراً قليلاً";
|
|
case 'left':
|
|
return "انعطف يساراً";
|
|
case 'sharp left':
|
|
return "انعطف يساراً بحدة";
|
|
default:
|
|
return "اتجه";
|
|
}
|
|
}
|
|
|
|
// --- Search & Utils (Retained entirely, no map logic here) ---
|
|
Future<void> getPlaces() async {
|
|
final q = placeDestinationController.text.trim();
|
|
if (q.length < 3) {
|
|
placesDestination = [];
|
|
update();
|
|
return;
|
|
}
|
|
if (myLocation == null) return;
|
|
|
|
final lat = myLocation!.latitude;
|
|
final lng = myLocation!.longitude;
|
|
const radiusKm = 200.0;
|
|
final payload = {
|
|
'query': q,
|
|
'lat_min': (lat - _kmToLatDelta(radiusKm)).toString(),
|
|
'lat_max': (lat + _kmToLatDelta(radiusKm)).toString(),
|
|
'lng_min': (lng - _kmToLngDelta(radiusKm, lat)).toString(),
|
|
'lng_max': (lng + _kmToLngDelta(radiusKm, lat)).toString(),
|
|
};
|
|
|
|
try {
|
|
final response =
|
|
await CRUD().post(link: AppLink.getPlacesSyria, payload: payload);
|
|
List list;
|
|
if (response is Map && response['status'] == 'success')
|
|
list = List.from(response['message'] as List);
|
|
else if (response is List)
|
|
list = List.from(response);
|
|
else
|
|
return;
|
|
|
|
for (final p in list) {
|
|
final plat = double.tryParse(p['latitude']?.toString() ?? '0.0') ?? 0.0;
|
|
final plng =
|
|
double.tryParse(p['longitude']?.toString() ?? '0.0') ?? 0.0;
|
|
p['distanceKm'] = _haversineKm(lat, lng, plat, plng);
|
|
}
|
|
|
|
list.sort((a, b) =>
|
|
(a['distanceKm'] as double).compareTo(b['distanceKm'] as double));
|
|
placesDestination = list;
|
|
update();
|
|
} catch (e) {
|
|
print('Exception in getPlaces: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> selectDestination(dynamic place) async {
|
|
placeDestinationController.clear();
|
|
placesDestination = [];
|
|
final double lat = double.parse(place['latitude'].toString());
|
|
final double 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: 700), () => 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));
|
|
}
|
|
|
|
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 (LatLng latLng in list) {
|
|
if (x0 == null) {
|
|
x0 = x1 = latLng.latitude;
|
|
y0 = y1 = latLng.longitude;
|
|
} else {
|
|
if (latLng.latitude > x1!) x1 = latLng.latitude;
|
|
if (latLng.latitude < x0) x0 = latLng.latitude;
|
|
if (latLng.longitude > y1!) y1 = latLng.longitude;
|
|
if (latLng.longitude < y0!) y0 = latLng.longitude;
|
|
}
|
|
}
|
|
return LatLngBounds(
|
|
northeast: LatLng(x1!, y1!), southwest: LatLng(x0!, y0!));
|
|
}
|
|
}
|