2889 lines
103 KiB
Dart
Executable File
2889 lines
103 KiB
Dart
Executable File
import 'dart:async';
|
||
import 'dart:ui';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'dart:math';
|
||
import 'dart:math' as math;
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:siro_driver/controller/home/captin/home_captain_controller.dart';
|
||
import 'package:siro_driver/views/widgets/error_snakbar.dart';
|
||
import 'package:siro_driver/views/widgets/mydialoug.dart';
|
||
import 'package:bubble_head/bubble.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:geolocator/geolocator.dart' as geo;
|
||
import 'package:geolocator/geolocator.dart';
|
||
import 'package:get/get.dart';
|
||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
|
||
import '../../../constant/box_name.dart';
|
||
import '../../../constant/colors.dart';
|
||
import '../../../constant/country_polygons.dart';
|
||
import '../../../constant/links.dart';
|
||
import '../../../constant/table_names.dart';
|
||
import '../../../env/env.dart';
|
||
import '../../../views/home/Captin/orderCaptin/marker_generator.dart';
|
||
import '../../../main.dart';
|
||
import '../../../print.dart';
|
||
import '../../../views/Rate/rate_passenger.dart';
|
||
import '../../../views/home/Captin/home_captain/home_captin.dart';
|
||
import '../../firebase/firbase_messge.dart';
|
||
import '../../firebase/notification_service.dart';
|
||
import '../../functions/crud.dart';
|
||
import '../../functions/location_controller.dart';
|
||
import '../../functions/tts.dart';
|
||
import '../../functions/audio_recorder_controller.dart';
|
||
import 'behavior_controller.dart';
|
||
|
||
class MapDriverController extends GetxController
|
||
with GetSingleTickerProviderStateMixin {
|
||
bool isLoading = true;
|
||
final formKey1 = GlobalKey<FormState>();
|
||
final formKey2 = GlobalKey<FormState>();
|
||
final formKeyCancel = GlobalKey<FormState>();
|
||
final messageToPassenger = TextEditingController();
|
||
final sosEmergincyNumberCotroller = TextEditingController();
|
||
final cancelTripCotroller = TextEditingController();
|
||
List data = [];
|
||
List dataDestination = [];
|
||
LatLngBounds? boundsData;
|
||
InlqBitmap carIcon = InlqBitmap.defaultMarker;
|
||
InlqBitmap passengerIcon = InlqBitmap.defaultMarker;
|
||
InlqBitmap startIcon = InlqBitmap.defaultMarker;
|
||
InlqBitmap endIcon = InlqBitmap.defaultMarker;
|
||
InlqBitmap? walkIcon;
|
||
final List<LatLng> polylineCoordinates = [];
|
||
final List<LatLng> polylineCoordinatesDestination = [];
|
||
List<Polyline> polyLines = [];
|
||
List<Polyline> polyLinesDestination = [];
|
||
Set<Marker> markers = {};
|
||
String passengerLocation = '';
|
||
String passengerDestination = '';
|
||
// [Fix N-4] استبدال 5 متغيرات منفصلة بقائمة واحدة
|
||
List<String> steps = ['', '', '', '', ''];
|
||
String passengerWalletBurc = '';
|
||
String timeOfOrder = '';
|
||
String duration = '';
|
||
String totalCost = '';
|
||
String distance = '0';
|
||
String? passengerName;
|
||
String passengerEmail = '';
|
||
String totalPricePassenger = '';
|
||
String passengerPhone = '';
|
||
String rideId = '';
|
||
String isHaveSteps = '';
|
||
String paymentAmount = '0';
|
||
String paymentMethod = '';
|
||
String passengerId = '';
|
||
String driverId = '';
|
||
String tokenPassenger = '';
|
||
String durationToPassenger = '100';
|
||
String walletChecked = '';
|
||
String direction = '';
|
||
String durationOfRideValue = '';
|
||
String status = '';
|
||
int timeWaitingPassenger = 5; //5 miniute
|
||
bool isPassengerInfoWindow = false;
|
||
bool isBtnRideBegin = false;
|
||
bool isArrivedSend = true;
|
||
bool isdriverWaitTimeEnd = false;
|
||
bool isRideFinished = false;
|
||
bool isRideStarted = false;
|
||
bool isPriceWindow = false;
|
||
double passengerInfoWindowHeight = Get.height * .38;
|
||
double driverEndPage = 100;
|
||
double progress = 0;
|
||
double progressToPassenger = 0;
|
||
double progressInPassengerLocationFromDriver = 0;
|
||
bool isRideBegin = false;
|
||
int progressTimerToShowPassengerInfoWindowFromDriver = 25;
|
||
int remainingTimeToShowPassengerInfoWindowFromDriver = 25;
|
||
int remainingTimeToPassenger = 60;
|
||
int remainingTimeInPassengerLocatioWait = 60;
|
||
|
||
// [Fix P-1] Gatekeeper لمنع الاستدعاءات المتكررة لـ getRoute()
|
||
bool _isRouteRequested = false;
|
||
// 🔥 [Fix Double-Init] Debounce لمنع التنفيذ المتزامن/المتسارع لـ argumentLoading()
|
||
// StatelessWidget يُسجّل addPostFrameCallback في كل build() — هذا يمنع التنفيذ المتكرر
|
||
DateTime? _lastArgumentLoadingTime;
|
||
bool _argumentLoadingInProgress = false;
|
||
Completer<void>? _mapReadyCompleter;
|
||
|
||
// [Fix P-2] Throttle لتقليل تحديثات UI من مستمع GPS
|
||
DateTime _lastUIUpdate = DateTime.fromMillisecondsSinceEpoch(0);
|
||
static const _uiThrottleMs = 400;
|
||
|
||
// ─── Navigation & Smoothing ──────────────────────────────────────────
|
||
AnimationController? _animController;
|
||
LatLng? smoothedLocation;
|
||
double smoothedHeading = 0.0;
|
||
LatLng? _oldLoc;
|
||
LatLng? _targetLoc;
|
||
double _oldHeading = 0.0;
|
||
double _targetHeading = 0.0;
|
||
|
||
List<Map<String, dynamic>> routeSteps = [];
|
||
int currentStepIndex = 0;
|
||
String currentInstruction = "";
|
||
String nextInstruction = "";
|
||
String distanceToNextStep = "";
|
||
int currentManeuverModifier = 0;
|
||
bool _nextInstructionSpoken = false;
|
||
bool isTtsEnabled = true;
|
||
StreamSubscription<geo.Position>? _locationSubscription;
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
bool isDriverNearPassengerStart = false;
|
||
IntaleqMapController? mapController;
|
||
LatLng myLocation = LatLng(0, 0);
|
||
int remainingTimeTimerRideBegin = 60;
|
||
String stringRemainingTimeRideBegin = '';
|
||
String stringRemainingTimeRideBegin1 = '';
|
||
double progressTimerRideBegin = 0;
|
||
Timer? timer;
|
||
String? mapAPIKEY;
|
||
final zones = <Zone>[];
|
||
String canelString = 'yet';
|
||
LatLng latLngPassengerLocation = LatLng(0, 0);
|
||
LatLng latLngPassengerDestination = LatLng(0, 0);
|
||
|
||
// في MapDriverController
|
||
|
||
void toggleTts() {
|
||
isTtsEnabled = !isTtsEnabled;
|
||
if (!isTtsEnabled && Get.isRegistered<TextToSpeechController>()) {
|
||
Get.find<TextToSpeechController>().stop();
|
||
}
|
||
update();
|
||
}
|
||
|
||
void playVoiceInstruction(String text) {
|
||
if (!isTtsEnabled) return;
|
||
if (Get.isRegistered<TextToSpeechController>()) {
|
||
Get.find<TextToSpeechController>().speakText(text);
|
||
}
|
||
}
|
||
|
||
/// [Fix M-5] disposeEverything يجب أن يتوقف عن استدعاء onClose مباشرة
|
||
/// لأن GetX يتولى ذلك تلقائياً. نستخدم _stopAllServices فقط.
|
||
void disposeEverything() {
|
||
print("--- KILLING ALL DRIVER TIMERS (via disposeEverything) ---");
|
||
_stopAllServices();
|
||
}
|
||
|
||
@override
|
||
void onClose() {
|
||
print("--- KILLING ALL DRIVER TIMERS ---");
|
||
_rideTimer?.cancel();
|
||
_rideTimer = null;
|
||
_passengerTimer?.cancel();
|
||
_passengerTimer = null;
|
||
_waitingTimer?.cancel();
|
||
_waitingTimer = null;
|
||
timer?.cancel();
|
||
timer = null;
|
||
|
||
_navigationTimer?.cancel();
|
||
_navigationTimer = null;
|
||
|
||
_posSub?.cancel();
|
||
_posSub = null;
|
||
|
||
_animController?.dispose();
|
||
_locationSubscription?.cancel();
|
||
|
||
super.onClose();
|
||
}
|
||
|
||
void onMapCreated(IntaleqMapController controller) {
|
||
mapController = controller;
|
||
if (Get.isRegistered<LocationController>()) {
|
||
myLocation = Get.find<LocationController>().myLocation;
|
||
controller.animateCamera(CameraUpdate.newLatLngZoom(myLocation, 16));
|
||
}
|
||
|
||
// [Fix P-1] إكمال الـ Completer لتحرير argumentLoading من الانتظار
|
||
_mapReadyCompleter?.complete();
|
||
_mapReadyCompleter = null;
|
||
|
||
if (!_isRouteRequested) {
|
||
// أول مرة — argumentLoading لم تكتمل بعد، ارسم المسار الأولي
|
||
// startListeningStepNavigation() محذوفة من هنا عمداً:
|
||
// تُستدعى من argumentLoading() بعد اكتمال getRoute() وتحميل البيانات
|
||
if (isRideStarted) {
|
||
getRoute(
|
||
origin: myLocation,
|
||
destination: latLngPassengerDestination,
|
||
routeColor: Colors.blue,
|
||
);
|
||
} else {
|
||
getRoute(
|
||
origin: myLocation,
|
||
destination: latLngPassengerLocation,
|
||
routeColor: Colors.yellow,
|
||
);
|
||
}
|
||
} else {
|
||
// 🔥 [Fix Rebuild] إعادة بناء الخريطة (مثلاً تغيير theme أو update())
|
||
// أعد رسم البوليلاين الموجود وأعد تشغيل الملاحة إذا لزم
|
||
_redrawExistingPolylines();
|
||
if (_navigationTimer == null || !_navigationTimer!.isActive) {
|
||
startListeningStepNavigation();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// إعادة رسم البوليلاين الموجود بعد إعادة بناء الخريطة (GetBuilder rebuild)
|
||
/// يُستدعى فقط من onMapCreated() عند _isRouteRequested == true
|
||
void _redrawExistingPolylines() {
|
||
if (upcomingPathPoints.isEmpty) {
|
||
// لا يوجد مسار محمّل — لا شيء نرسمه
|
||
return;
|
||
}
|
||
|
||
final remaining = upcomingPathPoints.sublist(_lastTraveledIndex);
|
||
polyLines.clear();
|
||
polyLines.add(
|
||
Polyline(
|
||
polylineId: const PolylineId("upcoming_route"),
|
||
points: remaining,
|
||
width: 8,
|
||
color: isRideStarted ? Colors.blue : Colors.yellow,
|
||
),
|
||
);
|
||
|
||
// إعادة رسم المسار المقطوع (رمادي) إذا وجد
|
||
if (_lastTraveledIndex > 0) {
|
||
final traveled = upcomingPathPoints.sublist(0, _lastTraveledIndex + 1);
|
||
polyLines.add(
|
||
Polyline(
|
||
polylineId: const PolylineId('traveled_route'),
|
||
points: traveled,
|
||
color: Colors.grey.withValues(alpha: 0.8),
|
||
width: 7,
|
||
zIndex: 1,
|
||
),
|
||
);
|
||
}
|
||
|
||
_updatePassengerWalkLine();
|
||
|
||
Log.print(
|
||
"🔄 [onMapCreated] Redrawn ${polyLines.length} polylines after map rebuild.");
|
||
update();
|
||
}
|
||
|
||
bool isCameraLocked = true; // للتحكم في تتبع الكاميرا
|
||
Timer? _cameraLockTimer;
|
||
|
||
void onUserMapInteraction() {
|
||
if (isCameraLocked) {
|
||
isCameraLocked = false;
|
||
update();
|
||
}
|
||
|
||
_cameraLockTimer?.cancel();
|
||
_cameraLockTimer = Timer(const Duration(seconds: 5), () {
|
||
if (!isClosed) {
|
||
isCameraLocked = true;
|
||
if (myLocation != null && mapController != null) {
|
||
if (Get.isRegistered<LocationController>()) {
|
||
final locCtrl = Get.find<LocationController>();
|
||
final double speedKmh = locCtrl.speed * 3.6;
|
||
final double bearing = speedKmh > 5 ? locCtrl.heading : 0.0;
|
||
_animateCameraToNavigationMode(myLocation!, bearing);
|
||
} else {
|
||
_animateCameraToNavigationMode(myLocation!, 0.0);
|
||
}
|
||
}
|
||
update();
|
||
}
|
||
});
|
||
}
|
||
|
||
int _walkLineUpdateCounter = 0;
|
||
|
||
Future<void> startListeningStepNavigation() async {
|
||
// Cancel any previous listener
|
||
_navigationTimer?.cancel();
|
||
|
||
// Reuse LocationController's GPS stream instead of opening a
|
||
// second GPS channel — eliminates duplicate battery/CPU drain.
|
||
// Lightweight Timer-based polling at 500ms (was a full GPS stream).
|
||
_navigationTimer = Timer.periodic(
|
||
const Duration(milliseconds: 500),
|
||
(_) {
|
||
if (isClosed || mapController == null) return;
|
||
|
||
final locCtrl = Get.isRegistered<LocationController>()
|
||
? Get.find<LocationController>()
|
||
: null;
|
||
if (locCtrl == null || locCtrl.myLocation.latitude == 0) return;
|
||
|
||
final LatLng newLoc = locCtrl.myLocation;
|
||
final double heading = locCtrl.heading;
|
||
final double speedKmh = locCtrl.speed * 3.6;
|
||
|
||
// Jitter filter
|
||
if (_lastRecordedLocation != null) {
|
||
final double dist = Geolocator.distanceBetween(
|
||
newLoc.latitude,
|
||
newLoc.longitude,
|
||
_lastRecordedLocation!.latitude,
|
||
_lastRecordedLocation!.longitude,
|
||
);
|
||
if (dist < 3.0) return;
|
||
}
|
||
|
||
_lastRecordedLocation = newLoc;
|
||
myLocation = newLoc;
|
||
|
||
_oldLoc = smoothedLocation ?? newLoc;
|
||
_targetLoc = newLoc;
|
||
_oldHeading = smoothedHeading;
|
||
_targetHeading = speedKmh > 0.5 ? heading : _oldHeading;
|
||
_animController?.forward(from: 0.0);
|
||
|
||
if (!isClosed) {
|
||
updateMarker();
|
||
if (upcomingPathPoints.isNotEmpty) {
|
||
_updateTraveledPolylineSmart(myLocation);
|
||
}
|
||
// تحديث خط المشي المنقط كل 3 ثوانٍ أثناء التنقل (كل 6 دورات × 500ms)
|
||
_walkLineUpdateCounter++;
|
||
if (_walkLineUpdateCounter % 6 == 0) {
|
||
_updatePassengerWalkLine();
|
||
}
|
||
|
||
if (isCameraLocked) {
|
||
final double bearing = speedKmh > 5 ? heading : 0.0;
|
||
_animateCameraToNavigationMode(newLoc, bearing);
|
||
}
|
||
checkForNextStep(myLocation);
|
||
checkDestinationProximity();
|
||
|
||
final now = DateTime.now();
|
||
if (now.difference(_lastUIUpdate).inMilliseconds > _uiThrottleMs) {
|
||
_lastUIUpdate = now;
|
||
update();
|
||
}
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
void changeStatusDriver() {
|
||
status = 'On';
|
||
update();
|
||
}
|
||
|
||
AudioRecorderController _getAudioController() {
|
||
if (!Get.isRegistered<AudioRecorderController>()) {
|
||
Get.put(AudioRecorderController(), permanent: true);
|
||
}
|
||
return Get.find<AudioRecorderController>();
|
||
}
|
||
|
||
void changeDriverEndPage() {
|
||
driverEndPage = remainingTimeTimerRideBegin < 60 ? 160 : 100;
|
||
update();
|
||
}
|
||
|
||
takeSnapMap() {
|
||
// mapController!.takeSnapshot();
|
||
}
|
||
|
||
// [Fix P-3] إزالة dispose() المكررة — GetX يستدعي onClose() تلقائياً
|
||
|
||
void _stopAllServices() {
|
||
_rideTimer?.cancel();
|
||
_passengerTimer?.cancel();
|
||
_waitingTimer?.cancel();
|
||
_posSub?.cancel();
|
||
}
|
||
|
||
Future openGoogleMapFromDriverToPassenger() async {
|
||
var endLat = latLngPassengerLocation.latitude;
|
||
var endLng = latLngPassengerLocation.longitude;
|
||
|
||
var startLat = Get.find<LocationController>().myLocation.latitude;
|
||
var startLng = Get.find<LocationController>().myLocation.longitude;
|
||
|
||
/// [Fix N-1] تصحيح رابط Google Maps: & → ?
|
||
String url =
|
||
'https://www.google.com/maps/dir/$startLat,$startLng/$endLat,$endLng/?directionsmode=driving';
|
||
if (await canLaunchUrl(Uri.parse(url))) {
|
||
await launchUrl(Uri.parse(url));
|
||
} else {
|
||
throw 'Could not launch google maps';
|
||
}
|
||
}
|
||
|
||
void clearPolyline() {
|
||
polyLines = [];
|
||
polyLinesDestination = [];
|
||
polylineCoordinates.clear();
|
||
polylineCoordinatesDestination.clear();
|
||
update();
|
||
}
|
||
|
||
void changeRideToBeginToPassenger() {
|
||
isRideBegin = true;
|
||
passengerInfoWindowHeight = Get.height * .22;
|
||
update();
|
||
}
|
||
|
||
// متغير لمنع التكرار
|
||
bool _isCancelReceived = false;
|
||
|
||
/// **معالجة إلغاء الراكب الموحدة (Gatekeeper)**
|
||
void processRideCancelledByPassenger(
|
||
String reason, {
|
||
String source = "Unknown",
|
||
}) {
|
||
if (_isCancelReceived) return;
|
||
_isCancelReceived = true;
|
||
|
||
// [Fix 2] إيقاف جميع التايمرات النشطة فوراً عند إلغاء الراكب.
|
||
// كانت هذه التايمرات تستمر حتى onClose() مما يسبب تسرب في الموارد.
|
||
_rideTimer?.cancel();
|
||
_rideTimer = null;
|
||
_passengerTimer?.cancel();
|
||
_passengerTimer = null;
|
||
_waitingTimer?.cancel();
|
||
_waitingTimer = null;
|
||
Log.print("🛑 All ride timers cancelled due to passenger cancellation.");
|
||
|
||
try {
|
||
final AudioRecorderController audioRecorderController =
|
||
_getAudioController();
|
||
if (audioRecorderController.isRecording) {
|
||
audioRecorderController.stopRecording();
|
||
}
|
||
} catch (e) {
|
||
Log.print("Error stopping audio recording: $e");
|
||
}
|
||
|
||
Log.print("🚫 Ride Cancelled by Passenger via $source. Reason: $reason");
|
||
|
||
// 1. إيقاف التوجيه والتايمرات
|
||
// stopNavigation();
|
||
// stopAllTimers();
|
||
|
||
// 2. تنظيف الحالة
|
||
box.write(BoxName.rideStatus, 'Canceled'); // أو الحالة الافتراضية
|
||
box.remove(BoxName.rideArguments);
|
||
box.remove(BoxName.passengerID);
|
||
box.remove(BoxName.rideId);
|
||
|
||
// 3. عرض رسالة للسائق
|
||
if (Get.isDialogOpen == true) {
|
||
Get.back();
|
||
}
|
||
|
||
Get.defaultDialog(
|
||
title: "تم إلغاء الرحلة".tr,
|
||
titleStyle: TextStyle(color: Colors.red),
|
||
barrierDismissible: false,
|
||
content: Column(
|
||
children: [
|
||
Icon(Icons.person_off, size: 50, color: Colors.orange),
|
||
SizedBox(height: 10),
|
||
Text(
|
||
"${"Passenger cancelled the ride.".tr}\n${"Reason".tr}: $reason",
|
||
textAlign: TextAlign.center,
|
||
),
|
||
],
|
||
),
|
||
confirm: ElevatedButton(
|
||
onPressed: () {
|
||
Get.back(); // إغلاق الديالوج
|
||
Get.delete<
|
||
HomeCaptainController>(); // clear old controller to fix map generation
|
||
Get.offAll(() => HomeCaptain()); // العودة للرئيسية
|
||
},
|
||
child: Text("OK".tr),
|
||
),
|
||
);
|
||
|
||
// تشغيل صوت تنبيه للإلغاء
|
||
// AudioService.playCancelSound();
|
||
}
|
||
|
||
Future<void> cancelTripFromDriverAfterApplied() async {
|
||
if (formKeyCancel.currentState!.validate()) {
|
||
Get.dialog(
|
||
const Center(child: CircularProgressIndicator()),
|
||
barrierDismissible: false,
|
||
);
|
||
|
||
try {
|
||
// 1. استدعاء السيرفر
|
||
var response = await CRUD().post(
|
||
link: "${AppLink.ride}/rides/cancel_ride_by_driver.php",
|
||
payload: {
|
||
"ride_id": (rideId).toString(),
|
||
"driver_id": box.read(BoxName.driverID).toString(),
|
||
"reason": (cancelTripCotroller.text) ?? '',
|
||
"passenger_token": tokenPassenger.toString(),
|
||
},
|
||
);
|
||
|
||
if (response['status'] == 'success') {
|
||
// 🔥🔥 معالجة الحظر (The Penalty Logic) 🔥🔥
|
||
bool isBlocked = response['is_blocked'] ?? false;
|
||
|
||
if (isBlocked) {
|
||
String blockExpiryStr =
|
||
response['block_until']; // "2024-10-10 14:30:00"
|
||
|
||
// حفظ تاريخ فك الحظر في الذاكرة المحلية
|
||
box.write(BoxName.blockUntilDate, blockExpiryStr);
|
||
|
||
// تحويل الحالة لأوفلاين إجبارياً
|
||
box.write(BoxName.statusDriverLocation, 'blocked');
|
||
|
||
// عرض رسالة العقوبة
|
||
mySnackeBarError(
|
||
"Due to excessive cancellations (3 times), receiving orders has been suspended for 4 hours."
|
||
.tr,
|
||
);
|
||
} else {
|
||
// تحذير فقط
|
||
int count = response['cancel_count'] ?? 0;
|
||
mySnackbarWarning(
|
||
"لقد ألغيت $count رحلات اليوم. الوصول لـ 3 سيعرضك للإيقاف المؤقت."
|
||
.tr,
|
||
);
|
||
}
|
||
|
||
// تنظيف البيانات
|
||
try {
|
||
final AudioRecorderController audioRecorderController =
|
||
_getAudioController();
|
||
if (audioRecorderController.isRecording) {
|
||
await audioRecorderController.stopRecording();
|
||
}
|
||
} catch (e) {
|
||
Log.print("Error stopping audio recording: $e");
|
||
}
|
||
box.remove('cached_trip_route');
|
||
box.remove(BoxName.rideArgumentsFromBackground);
|
||
box.remove(BoxName.rideArguments);
|
||
box.remove(BoxName.passengerID);
|
||
box.remove(BoxName.rideId);
|
||
box.write(BoxName.rideStatus, 'Cancel');
|
||
|
||
// تسجيل محلي (اختياري)
|
||
try {
|
||
await sql.insertData({
|
||
'order_id': rideId,
|
||
'created_at': DateTime.now().toString(),
|
||
'driver_id': box.read(BoxName.driverID),
|
||
}, TableName.driverOrdersRefuse);
|
||
} catch (_) {}
|
||
|
||
if (Get.isRegistered<HomeCaptainController>()) {
|
||
Get.find<HomeCaptainController>().getRefusedOrderByCaptain();
|
||
} else {
|
||
// في حال لم يكن مسجل (جاي من background)
|
||
Get.put(HomeCaptainController(), permanent: true)
|
||
.getRefusedOrderByCaptain();
|
||
}
|
||
|
||
if (Get.isDialogOpen == true) {
|
||
Get.back();
|
||
}
|
||
Get.delete<
|
||
HomeCaptainController>(); // clear old controller to fix map generation
|
||
Get.offAll(
|
||
() => HomeCaptain(),
|
||
); // العودة للرئيسية ليتم تطبيق الحظر هناك
|
||
} else {
|
||
if (Get.isDialogOpen == true) {
|
||
Get.back();
|
||
}
|
||
mySnackeBarError("Failed to cancel ride".tr);
|
||
}
|
||
} catch (e) {
|
||
if (Get.isDialogOpen == true) {
|
||
Get.back();
|
||
}
|
||
Log.print("Error: $e");
|
||
}
|
||
}
|
||
}
|
||
|
||
Timer? _passengerTimer; // مرجع للتايمر لإيقافه لاحقاً
|
||
/// **بدء مؤقت الوصول للراكب (Passenger Arrival Timer)**
|
||
///
|
||
/// تقوم هذه الدالة بإدارة العد التنازلي للوقت المتوقع لوصول السائق إلى موقع الراكب.
|
||
///
|
||
/// **آلية العمل:**
|
||
/// 1. تتحقق من حالة الرحلة؛ إذا كانت قد بدأت بالفعل ('Begin')، تخفي نافذة المعلومات وتوقف العمل.
|
||
/// 2. تحسب المدة الإجمالية من [durationToPassenger].
|
||
/// 3. تستخدم [Timer.periodic] لتحديث الواجهة كل ثانية بدقة عالية.
|
||
/// 4. تقوم بحساب النسبة المئوية [progressToPassenger] وتنسيق الوقت المتبقي [stringRemainingTimeToPassenger].
|
||
/// 5. عند انتهاء الوقت، تُفعل زر بدء الرحلة [isBtnRideBegin].
|
||
void startTimerToShowPassengerInfoWindowFromDriver() {
|
||
// 1. تنظيف أي تايمر سابق لضمان عدم تداخل العدادات
|
||
_passengerTimer?.cancel();
|
||
|
||
// 2. التحقق من حالة الرحلة
|
||
String currentStatus = box.read(BoxName.rideStatus) ?? '';
|
||
if (currentStatus == 'Begin') {
|
||
Log.print('rideStatus from map: $currentStatus - Hiding Info Window');
|
||
isPassengerInfoWindow = false;
|
||
update();
|
||
return;
|
||
}
|
||
|
||
// 3. تهيئة المتغيرات
|
||
isPassengerInfoWindow = true;
|
||
final int totalDuration =
|
||
int.tryParse(durationToPassenger.toString()) ?? 60;
|
||
|
||
// استخدام DateTime لضمان دقة الوقت وعدم التأثر ببطء الجهاز
|
||
final DateTime endTime = DateTime.now().add(
|
||
Duration(seconds: totalDuration),
|
||
);
|
||
|
||
// 4. بدء التايمر الدوري
|
||
_passengerTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||
// أمان: إيقاف التايمر إذا تم إغلاق الكنترولر
|
||
if (isClosed) {
|
||
timer.cancel();
|
||
return;
|
||
}
|
||
|
||
final DateTime now = DateTime.now();
|
||
final int remainingSeconds = endTime.difference(now).inSeconds;
|
||
|
||
// أ) تحديث القيم
|
||
if (remainingSeconds <= 0) {
|
||
// انتهى الوقت
|
||
remainingTimeToPassenger = 0;
|
||
progressToPassenger = 1.0;
|
||
stringRemainingTimeToPassenger = "00:00";
|
||
isBtnRideBegin = true;
|
||
|
||
timer.cancel(); // إيقاف التايمر
|
||
} else {
|
||
// ما زال العد مستمراً
|
||
remainingTimeToPassenger = remainingSeconds;
|
||
|
||
// حساب النسبة المئوية (مع منع القسمة على صفر)
|
||
progressToPassenger =
|
||
totalDuration > 0 ? 1 - (remainingSeconds / totalDuration) : 0.0;
|
||
|
||
// تنسيق الوقت (دقائق:ثواني)
|
||
final int minutes = (remainingSeconds / 60).floor();
|
||
final int seconds = remainingSeconds % 60;
|
||
stringRemainingTimeToPassenger =
|
||
'$minutes:${seconds.toString().padLeft(2, '0')}';
|
||
}
|
||
|
||
// ب) تحديث الواجهة
|
||
update();
|
||
});
|
||
}
|
||
|
||
String stringRemainingTimeToPassenger = '';
|
||
|
||
String stringRemainingTimeWaitingPassenger = '';
|
||
Timer? _waitingTimer; // متغير للتحكم في تايمر الانتظار
|
||
/// **بدء مؤقت انتظار الراكب (Waiting For Passenger Timer)**
|
||
///
|
||
/// تحسب الوقت الذي ينتظره السائق عند نقطة الانطلاق.
|
||
///
|
||
/// **التحسينات:**
|
||
/// 1. استبدال الحلقة التكرارية بـ [Timer.periodic] لأداء أفضل.
|
||
/// 2. استخدام [DateTime] لحساب الوقت المتبقي بدقة حتى لو كان التطبيق في الخلفية.
|
||
/// 3. إدارة حالات الخروج (بدء الرحلة أو انتهاء وقت الانتظار) بشكل أنظف.
|
||
void startTimerToShowDriverWaitPassengerDuration() {
|
||
// 1. تنظيف أي تايمر سابق
|
||
_waitingTimer?.cancel();
|
||
|
||
// 2. حساب المدة الكلية بالثواني
|
||
final int totalDurationSeconds = timeWaitingPassenger * 60;
|
||
|
||
// 3. تحديد وقت الانتهاء المتوقع (للدقة)
|
||
final DateTime endTime = DateTime.now().add(
|
||
Duration(seconds: totalDurationSeconds),
|
||
);
|
||
|
||
Log.print("⏳ Driver Waiting Timer Started: $totalDurationSeconds seconds");
|
||
|
||
// 4. بدء التايمر الدوري
|
||
_waitingTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||
// أ) التحقق من إغلاق الكنترولر
|
||
if (isClosed) {
|
||
timer.cancel();
|
||
return;
|
||
}
|
||
|
||
// ب) شرط الإيقاف الفوري: إذا بدأت الرحلة فعلياً
|
||
if (isRideBegin) {
|
||
remainingTimeInPassengerLocatioWait = 0;
|
||
stringRemainingTimeWaitingPassenger = "00:00";
|
||
timer.cancel();
|
||
update();
|
||
return;
|
||
}
|
||
|
||
// ج) حساب الوقت المتبقي بناءً على الساعة الحالية
|
||
final DateTime now = DateTime.now();
|
||
final int remainingSeconds = endTime.difference(now).inSeconds;
|
||
|
||
// د) تحديث المتغيرات
|
||
if (remainingSeconds <= 0) {
|
||
// انتهى وقت الانتظار
|
||
remainingTimeInPassengerLocatioWait = 0;
|
||
progressInPassengerLocationFromDriver = 1.0;
|
||
stringRemainingTimeWaitingPassenger = "00:00";
|
||
isdriverWaitTimeEnd = true; // تفعيل زر الإلغاء المدفوع
|
||
|
||
timer.cancel();
|
||
} else {
|
||
// ما زال الانتظار جارياً
|
||
remainingTimeInPassengerLocatioWait = remainingSeconds;
|
||
|
||
// حساب التقدم (من 0.0 إلى 1.0)
|
||
// Elapsed = Total - Remaining
|
||
final int elapsed = totalDurationSeconds - remainingSeconds;
|
||
progressInPassengerLocationFromDriver =
|
||
totalDurationSeconds > 0 ? (elapsed / totalDurationSeconds) : 1.0;
|
||
|
||
// تنسيق النص
|
||
final int minutes = (remainingSeconds / 60).floor();
|
||
final int seconds = remainingSeconds % 60;
|
||
stringRemainingTimeWaitingPassenger =
|
||
'$minutes:${seconds.toString().padLeft(2, '0')}';
|
||
}
|
||
|
||
update();
|
||
});
|
||
}
|
||
|
||
bool isSocialPressed = false;
|
||
// نغير نوع الإرجاع إلى Future<bool>
|
||
Future<bool> driverCallPassenger() async {
|
||
try {
|
||
// نضع الكود داخل try لضمان عدم توقف التطبيق عند انقطاع النت
|
||
String scam = await getDriverScam();
|
||
int scamCount = int.tryParse(scam) ?? 0;
|
||
|
||
// 1. منطق الحظر
|
||
if (scamCount > 3) {
|
||
box.write(BoxName.statusDriverLocation, 'on');
|
||
Get.find<LocationController>().stopLocationUpdates();
|
||
|
||
// إرسال تنبيه (لا ننتظره await لأنه لا يؤثر على المنع)
|
||
CRUD().post(
|
||
link: AppLink.addNotificationCaptain,
|
||
payload: {
|
||
'driverID': box.read(BoxName.driverID),
|
||
'title': 'scams operations'.tr,
|
||
'body':
|
||
'you have connect to passengers and let them cancel the order'
|
||
.tr,
|
||
},
|
||
);
|
||
|
||
// نرجع false لمنع الاتصال
|
||
return false;
|
||
}
|
||
|
||
// [Fix M-7] استخدام isNotEmpty بدلاً من != null لأن passengerId و rideId من نوع String غير قابل للـ null
|
||
// لو كانا فارغين '' يمر الشرط ويُرسل بيانات فارغة للسيرفر
|
||
if (isSocialPressed == true &&
|
||
passengerId.isNotEmpty &&
|
||
rideId.isNotEmpty) {
|
||
box.write(BoxName.statusDriverLocation, 'off');
|
||
|
||
// لا نستخدم await هنا لكي لا نؤخر فتح الهاتف
|
||
CRUD().post(
|
||
link: AppLink.addDriverScam,
|
||
payload: {
|
||
'driverID': box.read(BoxName.driverID),
|
||
'passengerID': passengerId,
|
||
'rideID': rideId,
|
||
'isDriverCallPassenger': 'true',
|
||
},
|
||
);
|
||
}
|
||
|
||
// نسمح بالاتصال
|
||
return true;
|
||
} catch (e) {
|
||
// في حال حدث خطأ في النت أو السيرفر، هل تريد السماح له بالاتصال؟
|
||
// الأفضل نعم، حتى لا تتعطل الخدمة بسبب خطأ تقني، ونسجل الخطأ في اللوج
|
||
print("Error in scam check: $e");
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// دالة مساعدة لحماية التطبيق من كراش الخرائط
|
||
Future<void> safeAnimateCamera(CameraUpdate cameraUpdate) async {
|
||
if (isClosed || mapController == null) return;
|
||
try {
|
||
await mapController!.animateCamera(cameraUpdate);
|
||
} catch (e) {
|
||
Log.print("Camera error ignored");
|
||
}
|
||
}
|
||
|
||
Future<String> getDriverScam() async {
|
||
var res = await CRUD().post(
|
||
link: AppLink.getDriverScam,
|
||
payload: {'driverID': box.read(BoxName.driverID)},
|
||
);
|
||
|
||
if (res == 'failure') {
|
||
box.write(BoxName.statusDriverLocation, 'off');
|
||
return '0';
|
||
}
|
||
var d = (res);
|
||
Log.print('d: ${d}');
|
||
|
||
// 1. Check if the response status is 'failure' (API level check)
|
||
if (d['status'] == 'failure') {
|
||
// If the API status is failure, the message is a String (e.g., 'No ride scam record found')
|
||
// and there's no 'count' array to read.
|
||
return '0';
|
||
}
|
||
|
||
// 2. Safely access the List/Map structure for 'count'
|
||
// This assumes a successful response looks like:
|
||
// {'status': 'success', 'message': [{'count': '12'}]}
|
||
var messageData = d['message'];
|
||
|
||
// Check if messageData is actually a List before accessing index [0]
|
||
if (messageData is List &&
|
||
messageData.isNotEmpty &&
|
||
messageData[0] is Map) {
|
||
return messageData[0]['count'];
|
||
}
|
||
|
||
// Fallback if the successful data structure is unexpected
|
||
return '0';
|
||
|
||
// --- FIX END ---
|
||
}
|
||
|
||
void startRideFromStartApp() async {
|
||
// if (box.read(BoxName.rideStatus) == 'Begin') {
|
||
changeRideToBeginToPassenger();
|
||
isPassengerInfoWindow = false;
|
||
isRideStarted = true; // يجب أن يكون true قبل getRoute لتفعيل وضع الملاحة
|
||
isRideFinished = false;
|
||
remainingTimeInPassengerLocatioWait = 0;
|
||
timeWaitingPassenger = 0;
|
||
box.write(BoxName.statusDriverLocation, 'on');
|
||
update();
|
||
// }
|
||
|
||
// Immediately fetch high-accuracy location for navigation origin
|
||
try {
|
||
Position currentPos = await Geolocator.getCurrentPosition(
|
||
desiredAccuracy: LocationAccuracy.bestForNavigation);
|
||
myLocation = LatLng(currentPos.latitude, currentPos.longitude);
|
||
smoothedLocation = myLocation;
|
||
smoothedHeading = currentPos.heading;
|
||
} catch (e) {
|
||
Log.print(
|
||
"Error getting current position on start ride from start app: $e");
|
||
}
|
||
|
||
// التحقق من صحة إحداثيات الهدف قبل الرسم
|
||
if (latLngPassengerDestination.latitude == 0 ||
|
||
latLngPassengerDestination.longitude == 0) {
|
||
Log.print(
|
||
"⚠️ startRideFromStartApp: destination is (0,0) — skipping getRoute");
|
||
return;
|
||
}
|
||
|
||
// ── إعادة رسم المسار إلى وجهة الراكب النهائية ────────────────
|
||
await getRoute(
|
||
origin: myLocation.latitude == 0
|
||
? Get.find<LocationController>().myLocation
|
||
: myLocation,
|
||
destination: latLngPassengerDestination,
|
||
routeColor: Colors.blue,
|
||
);
|
||
|
||
updateMarker();
|
||
|
||
// بدء الخدمات (الملاحة والعداد)
|
||
await startListeningStepNavigation();
|
||
|
||
rideIsBeginPassengerTimer();
|
||
try {
|
||
final AudioRecorderController audioRecorderController =
|
||
_getAudioController();
|
||
audioRecorderController.startRecording(rideId: rideId.toString());
|
||
} catch (e) {
|
||
Log.print("Error starting audio recording: $e");
|
||
}
|
||
}
|
||
|
||
Position? currentPosition;
|
||
|
||
/// **بدء الرحلة من طرف السائق (Start Ride)**
|
||
///
|
||
/// تقوم هذه الدالة بالتحقق من المسافة بين السائق والراكب، فإذا كانت قريبة (< 400م)،
|
||
/// ترسل طلباً للسيرفر لبدء الرحلة رسمياً.
|
||
///
|
||
/// * السيرفر سيقوم بإرسال الإشعارات (Socket + FCM) للراكب تلقائياً.
|
||
/// **بدء الرحلة (Start Ride)**
|
||
///
|
||
/// التحسينات:
|
||
/// 1. إظهار Loading لمنع تكرار الضغط.
|
||
/// 2. استخدام التحديث الموجه (Targeted Update) لمنع وميض الشاشة.
|
||
/// 3. معالجة فشل السيرفر (Revert Logic) لضمان عدم ضياع حالة الرحلة.
|
||
Future<void> startRideFromDriver() async {
|
||
// 1. إظهار مؤشر تحميل فوري (Blocking)
|
||
Get.dialog(
|
||
const Center(child: CircularProgressIndicator()),
|
||
barrierDismissible: false,
|
||
);
|
||
|
||
try {
|
||
// 2. التحقق من المسافة (Async)
|
||
double distanceToPassenger =
|
||
await calculateDistanceBetweenDriverAndPassengerLocation();
|
||
|
||
// إغلاق مؤشر التحميل لأننا حصلنا على النتيجة
|
||
if (Get.isDialogOpen == true) {
|
||
Get.back();
|
||
}
|
||
|
||
if (distanceToPassenger < 150) {
|
||
// زدت المسافة قليلاً لمرونة أكبر (150م)
|
||
|
||
// --- أ) تحديث الحالة المحلية (Optimistic Update) ---
|
||
changeRideToBeginToPassenger(); // تغيير المتغيرات الداخلية
|
||
isPassengerInfoWindow = false;
|
||
isRideStarted = true;
|
||
isRideFinished = false;
|
||
remainingTimeInPassengerLocatioWait = 0;
|
||
timeWaitingPassenger = 0;
|
||
|
||
// الحفظ في التخزين المحلي
|
||
box.write(BoxName.statusDriverLocation, 'on');
|
||
box.write(BoxName.rideStatus, 'Begin');
|
||
|
||
// Immediately fetch high-accuracy location for navigation origin
|
||
try {
|
||
Position currentPos = await Geolocator.getCurrentPosition(
|
||
desiredAccuracy: LocationAccuracy.bestForNavigation);
|
||
myLocation = LatLng(currentPos.latitude, currentPos.longitude);
|
||
smoothedLocation = myLocation;
|
||
smoothedHeading = currentPos.heading;
|
||
} catch (e) {
|
||
Log.print("Error getting current position on start ride: $e");
|
||
}
|
||
|
||
// 🔥 [Fix Polyline] مسح المسار الأصفر (للراكب) فوراً قبل رسم الأزرق (للوجهة)
|
||
// بدون هذا قد يبقى الخط الأصفر ظاهراً إذا تأخر getRoute() أو أُعيد بناء الخريطة
|
||
clearPolyline();
|
||
|
||
// ── إعادة رسم المسار إلى وجهة الراكب النهائية ────────────────
|
||
String? cachedRoute = box.read('cached_trip_route');
|
||
|
||
await getRoute(
|
||
origin: myLocation.latitude == 0
|
||
? Get.find<LocationController>().myLocation
|
||
: myLocation,
|
||
destination: latLngPassengerDestination,
|
||
routeColor: Colors
|
||
.black, // Color for the actual trip (black as user prefers solid)
|
||
cachedResponse: cachedRoute,
|
||
);
|
||
|
||
updateMarker();
|
||
|
||
// بدء الخدمات (الملاحة والعداد)
|
||
await startListeningStepNavigation();
|
||
rideIsBeginPassengerTimer();
|
||
try {
|
||
final AudioRecorderController audioRecorderController =
|
||
_getAudioController();
|
||
audioRecorderController.startRecording(rideId: rideId.toString());
|
||
} catch (e) {
|
||
Log.print("Error starting audio recording: $e");
|
||
}
|
||
|
||
// --- ب) تحديث الواجهة (Targeted Updates Only) ---
|
||
// نحدث فقط الأجزاء التي تتغير، بدلاً من إعادة رسم الخريطة كاملة
|
||
// update(['PassengerInfo']); // لإخفاء نافذة معلومات الراكب
|
||
// update(['DriverEndBar']); // لإظهار شريط إنهاء الرحلة
|
||
// update(['SosConnect']); // لتفعيل زر الطوارئ (تأكد من وضع ID للودجت)
|
||
update();
|
||
// --- ج) إرسال الطلب للسيرفر (Background) ---
|
||
// لا ننتظر النتيجة لتعطيل الواجهة، لكن نعالج الخطأ إن حدث
|
||
CRUD().post(
|
||
link: "${AppLink.server}/ride/rides/start_ride.php",
|
||
payload: {
|
||
'id': rideId.toString(),
|
||
'driver_id': box.read(BoxName.driverID).toString(),
|
||
'status': 'Begin',
|
||
"passengerToken": tokenPassenger.toString(),
|
||
},
|
||
).then((response) {
|
||
if (response['status'] == 'failure') {
|
||
Log.print("Server failed to start ride!");
|
||
isRideStarted = false;
|
||
isRideBegin = false;
|
||
box.write(BoxName.rideStatus, 'Apply');
|
||
isPassengerInfoWindow = true;
|
||
stopListeningStepNavigation();
|
||
mySnackeBarError("Failed to start ride. Please try again.");
|
||
update();
|
||
}
|
||
}).catchError((error) {
|
||
Log.print("Network/Server error starting ride: $error");
|
||
isRideStarted = false;
|
||
isRideBegin = false;
|
||
box.write(BoxName.rideStatus, 'Apply');
|
||
isPassengerInfoWindow = true;
|
||
stopListeningStepNavigation();
|
||
mySnackeBarError("Network error. Failed to start ride.");
|
||
update();
|
||
});
|
||
} else {
|
||
// --- حالة الرفض (بعيد جداً) ---
|
||
showDialog(
|
||
context: Get.context!,
|
||
builder: (context) => AlertDialog(
|
||
title: Text('You are far from passenger location'.tr),
|
||
content: Text(
|
||
'Please go closer to the passenger location (less than 150m)'.tr,
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: Text('OK'.tr),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
// تنظيف اللودينج في حال حدوث خطأ غير متوقع
|
||
if (Get.isDialogOpen == true) {
|
||
Get.back();
|
||
}
|
||
Log.print("Error starting ride: $e");
|
||
mySnackeBarError("Could not start ride. Please check internet.".tr);
|
||
}
|
||
}
|
||
|
||
calculateDistanceInMeter(LatLng prev, LatLng current) async {
|
||
double distance2 = Geolocator.distanceBetween(
|
||
prev.latitude,
|
||
prev.longitude,
|
||
current.latitude,
|
||
current.longitude,
|
||
);
|
||
return distance2;
|
||
}
|
||
|
||
double speedoMeter = 0;
|
||
Timer? _updateLocationTimer;
|
||
|
||
/// [Fix C-1] استبدال for loop الحلقة التكرارية بـ Timer.periodic
|
||
/// لمنع تسرب الذاكرة وufeni الـ Stack Overflow من الاستدعاء الذاتي المتكرر.
|
||
void startUpdateLocationTimer() {
|
||
_updateLocationTimer?.cancel();
|
||
_updateLocationTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
|
||
if (isClosed || !isRideBegin) {
|
||
timer.cancel();
|
||
return;
|
||
}
|
||
try {
|
||
safeAnimateCamera(
|
||
CameraUpdate.newCameraPosition(
|
||
CameraPosition(
|
||
bearing: Get.find<LocationController>().heading,
|
||
target: myLocation,
|
||
zoom: 17,
|
||
),
|
||
),
|
||
);
|
||
update();
|
||
} catch (error) {
|
||
debugPrint('Error listening to GPS: $error');
|
||
}
|
||
});
|
||
}
|
||
|
||
void stopUpdateLocationTimer() {
|
||
_updateLocationTimer?.cancel();
|
||
_updateLocationTimer = null;
|
||
}
|
||
|
||
Future<double> calculateDistanceBetweenDriverAndPassengerLocation() async {
|
||
final locationController = Get.isRegistered<LocationController>()
|
||
? Get.find<LocationController>()
|
||
: Get.put(LocationController());
|
||
try {
|
||
var res = await CRUD().get(
|
||
link: AppLink.getLatestLocationPassenger,
|
||
payload: {'rideId': (rideId)},
|
||
).timeout(const Duration(seconds: 5));
|
||
if (res != 'failure') {
|
||
var passengerLatestLocationString = jsonDecode(res)['message'];
|
||
|
||
double distance2 = Geolocator.distanceBetween(
|
||
double.parse(passengerLatestLocationString[0]['lat'].toString()),
|
||
double.parse(passengerLatestLocationString[0]['lng'].toString()),
|
||
locationController.myLocation.latitude,
|
||
locationController.myLocation.longitude,
|
||
);
|
||
return distance2;
|
||
}
|
||
} catch (_) {}
|
||
|
||
// Fallback to local coordinates in case of error/timeout
|
||
double distance2 = Geolocator.distanceBetween(
|
||
latLngPassengerLocation.latitude,
|
||
latLngPassengerLocation.longitude,
|
||
locationController.myLocation.latitude,
|
||
locationController.myLocation.longitude,
|
||
);
|
||
return distance2;
|
||
}
|
||
|
||
// [Fix C-3] دالة مساعدة مشتركة لتحليل المسافة النصية إلى متر
|
||
// تُستخدم في كلا الدالتين: finishRideFromDriver و _validateTripDistance
|
||
double _parseDistanceToMeters() {
|
||
String cleanDistance = distance.toString().replaceAll(
|
||
RegExp(r'[^0-9.]'),
|
||
'',
|
||
);
|
||
if (cleanDistance.isEmpty) cleanDistance = "0.0";
|
||
double numericDistance = double.parse(cleanDistance);
|
||
|
||
bool isMeters = distance.toString().contains('m') &&
|
||
!distance.toString().contains('km');
|
||
if (!isMeters && numericDistance > 100) isMeters = true;
|
||
|
||
return isMeters ? numericDistance : numericDistance * 1000;
|
||
}
|
||
|
||
/// [Fix M-6] دالة مساعدة لحساب التكلفة
|
||
/// ⚠️ ملاحظة: distanceBetweenDriverAndPassengerWhenConfirm بالكيلومتر
|
||
double _calculateWaitingCost() {
|
||
bool isEgypt = box.read(BoxName.countryCode) == 'Egypt';
|
||
double waitingMinutes = 5.0;
|
||
|
||
if (isEgypt) {
|
||
// معادلة مصر: (المسافة كم * 0.08) + (5 دقائق * 1)
|
||
return (distanceBetweenDriverAndPassengerWhenConfirm * 0.08) +
|
||
(waitingMinutes * 1.0);
|
||
} else {
|
||
// معادلة الأردن/أخرى: (المسافة كم * 11) + (5 دقائق * 0.06)
|
||
return (distanceBetweenDriverAndPassengerWhenConfirm * 11) +
|
||
(waitingMinutes * 0.06);
|
||
}
|
||
}
|
||
|
||
Future<void> addWaitingTimeCostFromPassengerToDriverWallet() async {
|
||
// ... (فحص المسافة واللودينج كما هو) ...
|
||
Get.dialog(
|
||
const Center(child: CircularProgressIndicator()),
|
||
barrierDismissible: false,
|
||
);
|
||
|
||
try {
|
||
// 1. حساب التكلفة
|
||
double costOfWaiting = _calculateWaitingCost();
|
||
double costForPassenger = costOfWaiting * -1; // بالسالب
|
||
|
||
// 2. تحديث البيانات التشغيلية (Main Server)
|
||
// هذا الطلب لا يحتاج توكنات مالية، فقط تحديث حالة
|
||
await CRUD().post(
|
||
link: "${AppLink.ride}/rides/update_ride_cancel_wait.php",
|
||
payload: {
|
||
'ride_id': rideId.toString(),
|
||
'driver_id': box.read(BoxName.driverID).toString(),
|
||
},
|
||
);
|
||
|
||
// 3. توليد التوكنات (Server-Side Logic Security)
|
||
// نحتاج توكن للسائق وتوكن للراكب
|
||
final tokens = await Future.wait([
|
||
generateTokenDriver(costOfWaiting.toString()),
|
||
generateTokenPassenger(costForPassenger.toString()),
|
||
]);
|
||
|
||
// 4. تنفيذ العملية المالية الموحدة (Payment Server)
|
||
var paymentResponse = await CRUD().postWallet(
|
||
link:
|
||
"${AppLink.paymentServer}/ride/passengerWallet/process_wait_compensation.php", // الرابط الجديد
|
||
payload: {
|
||
'ride_id': rideId.toString(),
|
||
'driver_id': box.read(BoxName.driverID).toString(),
|
||
'passenger_id': passengerId.toString(),
|
||
'amount': costOfWaiting.toString(), // المبلغ الموجب
|
||
'amount_passenger': costForPassenger.toString(), // المبلغ السالب
|
||
'token_driver': tokens[0],
|
||
'token_passenger': tokens[1],
|
||
},
|
||
);
|
||
|
||
if (paymentResponse['status'] == 'success') {
|
||
// النجاح
|
||
if (Get.isDialogOpen == true) Get.back();
|
||
|
||
mySnackbarSuccess(
|
||
'${'You gained'.tr} ${costOfWaiting.toStringAsFixed(2)} ${'in your wallet'.tr}',
|
||
);
|
||
|
||
box.write(BoxName.statusDriverLocation, 'off');
|
||
Get.delete<
|
||
HomeCaptainController>(); // clear old controller to fix map generation
|
||
Get.offAll(() => HomeCaptain());
|
||
} else {
|
||
throw Exception("Payment Transaction Failed");
|
||
}
|
||
} catch (e) {
|
||
if (Get.isDialogOpen == true) Get.back();
|
||
Log.print("Error: $e");
|
||
mySnackeBarError("Transaction failed, please try again.".tr);
|
||
}
|
||
}
|
||
|
||
/// **التحقق من إنهاء الرحلة (Validate & Finish Ride)**
|
||
///
|
||
/// تقوم هذه الدالة بحماية الرحلة من الإنهاء المبكر الخاطئ.
|
||
///
|
||
/// **آلية العمل:**
|
||
/// 1. تحسب المسافة الخطية (Displacement) التي قطعها السائق بعيداً عن نقطة الانطلاق.
|
||
/// 2. تقارن هذه المسافة مع "عتبة دنيا" (Minimum Threshold) وهي (1/5) من إجمالي مسافة الرحلة.
|
||
/// 3. **إذا تحقق الشرط:** تظهر نافذة تأكيد الإنهاء وتستدعي [finishRideFromDriver1].
|
||
/// 4. **إذا فشل الشرط:** تمنع الإنهاء وتصدر تنبيهاً صوتياً ونصياً بأن المسافة المقطوعة غير كافية.
|
||
/// **التحقق من إنهاء الرحلة (Validate & Finish Ride)**
|
||
///
|
||
/// [isFromSlider]: إذا كانت القيمة true، فهذا يعني أن السائق سحب الشريط
|
||
/// وبالتالي هو موافق ضمنياً، فلا داعي لعرض ديالوج "هل أنت متأكد؟"
|
||
Future<void> finishRideFromDriver({bool isFromSlider = false}) async {
|
||
// [Fix C-3] استخدام الدالة المساعدة المشتركة لتحليل المسافة
|
||
final double totalTripDistanceMeters = _parseDistanceToMeters();
|
||
// 2. حساب المسافة المقطوعة
|
||
final double displacementMeters = Geolocator.distanceBetween(
|
||
latLngPassengerLocation.latitude,
|
||
latLngPassengerLocation.longitude,
|
||
Get.find<LocationController>().myLocation.latitude,
|
||
Get.find<LocationController>().myLocation.longitude,
|
||
);
|
||
|
||
// 3. تحديد الحد الأدنى (الخمس)
|
||
final double minimumDistanceThreshold = totalTripDistanceMeters / 5;
|
||
|
||
Log.print('📏 Total Distance (m): $totalTripDistanceMeters');
|
||
Log.print('🚗 Moved Displacement (m): $displacementMeters');
|
||
|
||
// 4. اتخاذ القرار
|
||
if (displacementMeters > minimumDistanceThreshold) {
|
||
// ✅ الحالة سليمة
|
||
|
||
if (isFromSlider) {
|
||
// إذا جاء من السلايدر، نفذ فوراً
|
||
finishRideFromDriver1();
|
||
} else {
|
||
// [Fix C-3 v2] بعد التأكيد، نمرّر isFromSlider=true لتجنب الديالوج المكرر
|
||
MyDialog().getDialog('Are you sure to exit ride?'.tr, '', () {
|
||
Get.back();
|
||
finishRideFromDriver1(isFromSlider: true);
|
||
});
|
||
}
|
||
} else {
|
||
// ❌ الحالة مرفوضة: المسافة غير كافية
|
||
final textToSpeechController = Get.put(TextToSpeechController());
|
||
|
||
// إظهار رسالة خطأ حتى لو سحب السلايدر
|
||
if (Get.isDialogOpen == true) Get.back(); // إغلاق أي ديالوج سابق
|
||
|
||
MyDialog().getDialog(
|
||
"You haven't moved sufficiently!".tr,
|
||
"Please complete more distance before ending.".tr,
|
||
() => Get.back(),
|
||
);
|
||
|
||
await textToSpeechController.speakText(
|
||
"You haven't moved sufficiently!".tr,
|
||
);
|
||
}
|
||
}
|
||
|
||
String paymentToken = '';
|
||
Future<String> generateTokenDriver(String amount) async {
|
||
var res = await CRUD().postWallet(
|
||
link: AppLink.addPaymentTokenDriver,
|
||
payload: {
|
||
'driverID': box.read(BoxName.driverID).toString(),
|
||
'amount': amount.toString(),
|
||
},
|
||
);
|
||
var d = (res);
|
||
return d['message'];
|
||
}
|
||
|
||
String paymentTokenPassenger = '';
|
||
Future<String> generateTokenPassenger(String amount) async {
|
||
var res = await CRUD().postWallet(
|
||
link: AppLink.addPaymentTokenPassenger,
|
||
payload: {'passengerId': passengerId, 'amount': amount.toString()},
|
||
);
|
||
var d = (res);
|
||
return d['message'];
|
||
}
|
||
|
||
// ... other controller code ...
|
||
|
||
/// **إنهاء الرحلة ومعالجة المدفوعات (Finish Ride & Process Payments)**
|
||
///
|
||
/// تقوم هذه الدالة بإدارة عملية إنهاء الرحلة كاملة من جانب السائق.
|
||
///
|
||
/// **آلية العمل:**
|
||
/// 1. **تحديث الواجهة:** تخفي نافذة السعر وتعرض مؤشر تحميل لمنع التكرار.
|
||
/// 2. **حساب التكلفة:** تحسب السعر النهائي بناءً على نوع السيارة والمسافة، مع مراعاة الحد الأدنى للأجور.
|
||
/// 3. **تجهيز البيانات:** تحضر البيانات اللازمة لتحديث حالة الرحلة (`rides`) وتسجيل الدفع (`payments`).
|
||
/// 4. **التنفيذ المتوازي (Parallel Execution):** تستخدم `Future.wait` لإرسال طلب التحديث وطلب الدفع في آن واحد للسيرفر لتقليل وقت الانتظار.
|
||
/// 5. **التحقق (Validation):** تتأكد من نجاح كلا العمليتين (التحديث والدفع) قبل الانتقال.
|
||
/// 6. **الختام:** في حال النجاح، تحذف البيانات المؤقتة وتوجه السائق لصفحة التقييم. في حال الفشل، تعيد حالة الواجهة ليتمكن السائق من المحاولة مجدداً.
|
||
/// **إنهاء الرحلة (Finish Ride)**
|
||
///
|
||
/// التحسينات:
|
||
/// 1. التحقق من المسافة المقطوعة (Anti-Fraud).
|
||
/// 2. استخدام Future.wait لتنفيذ الطلبات بالتوازي (سرعة مضاعفة).
|
||
/// 3. استخدام `finally` لضمان إغلاق اللودينج دائماً.
|
||
Future<void> finishRideFromDriver1({bool isFromSlider = false}) async {
|
||
// 1. التحقق الأمني: هل تحرك السائق فعلاً؟
|
||
// (هذا الكود يجب أن يكون سريعاً ولا يعطل الواجهة)
|
||
if (!await _validateTripDistance(isFromSlider)) return;
|
||
|
||
// 2. إظهار لودينج (Blocking) لمنع التكرار
|
||
Get.dialog(
|
||
const Center(child: CircularProgressIndicator()),
|
||
barrierDismissible: false,
|
||
);
|
||
|
||
try {
|
||
try {
|
||
final AudioRecorderController audioRecorderController =
|
||
_getAudioController();
|
||
if (audioRecorderController.isRecording) {
|
||
await audioRecorderController.stopRecording();
|
||
}
|
||
} catch (e) {
|
||
Log.print("Error stopping audio recording: $e");
|
||
}
|
||
|
||
// تحديث الحالة المحلية لمنع أي تفاعلات أخرى أثناء الطلب
|
||
isRideFinished = true;
|
||
isRideStarted = false;
|
||
isPriceWindow = false;
|
||
box.write(BoxName.rideStatus, 'Finished');
|
||
box.write(BoxName.statusDriverLocation, 'off');
|
||
box.remove(BoxName.passengerID);
|
||
box.remove(BoxName.rideId);
|
||
|
||
// تجهيز البيانات الخام الموحدة للسيرفر ليقوم بمعالجة الدفع والإنهاء معاً بنظام المعاملة الواحدة
|
||
final finishPayload = {
|
||
'rideId': rideId.toString(),
|
||
'driver_id': box.read(BoxName.driverID).toString(),
|
||
'passengerId': passengerId.toString(),
|
||
'status': 'Finished',
|
||
'actualDistance': distance.toString(),
|
||
'actualDuration': duration.toString(),
|
||
'walletChecked': walletChecked.toString(),
|
||
'passengerWalletBurc': passengerWalletBurc.toString(),
|
||
'passengerToken': tokenPassenger.toString(),
|
||
'driver_token': box.read(BoxName.tokenDriver).toString(),
|
||
};
|
||
|
||
// إرسال طلب واحد موحد للسيرفر الرئيسي
|
||
final response = await CRUD().post(
|
||
link: "${AppLink.ride}/rides/finish_ride_updates.php",
|
||
payload: finishPayload,
|
||
);
|
||
|
||
if (response['status'] == 'success') {
|
||
// تنظيف البيانات محلياً عند النجاح الكامل
|
||
box.remove(BoxName.rideArguments);
|
||
box.remove(BoxName.rideArgumentsFromBackground);
|
||
box.remove('cached_trip_route');
|
||
|
||
// إغلاق اللودينج
|
||
if (Get.isDialogOpen == true) Get.back();
|
||
|
||
// إرسال تقرير السلوك (Fire and forget)
|
||
Get.put(
|
||
DriverBehaviorController(),
|
||
).sendSummaryToServer(driverId, rideId);
|
||
|
||
// الانتقال لصفحة التقييم بالسعر الذي حدده وحسبه السيرفر بأمان
|
||
Get.off(
|
||
() => RatePassenger(),
|
||
arguments: {
|
||
'passengerId': passengerId,
|
||
'rideId': rideId,
|
||
'price': response['price']?.toString() ?? paymentAmount.toString(),
|
||
'walletChecked': walletChecked.toString() ?? 'false',
|
||
},
|
||
);
|
||
} else {
|
||
throw Exception(response['error'] ?? "Unknown backend error");
|
||
}
|
||
} catch (e) {
|
||
if (Get.isDialogOpen == true) Get.back(); // إغلاق اللودينج
|
||
Log.print("Error finishing ride: $e");
|
||
mySnackeBarError("Failed to finish ride: $e");
|
||
|
||
// إعادة الحالة محلياً للسماح للمستخدم بالمحاولة مرة أخرى بأمان
|
||
isRideFinished = false;
|
||
isRideStarted = true;
|
||
box.write(BoxName.rideStatus, 'Begin');
|
||
update(); // تحديث الشريط ليعود للوضع النشط
|
||
}
|
||
}
|
||
|
||
// --- دوال مساعدة (Helpers) لتنظيف الكود ---
|
||
|
||
Future<bool> _validateTripDistance(bool isFromSlider) async {
|
||
// [Fix C-3] استخدام الدالة المساعدة المشتركة لتحليل المسافة
|
||
final double totalTripDistanceMeters = _parseDistanceToMeters();
|
||
|
||
final double displacementMeters = Geolocator.distanceBetween(
|
||
latLngPassengerLocation.latitude,
|
||
latLngPassengerLocation.longitude,
|
||
Get.find<LocationController>().myLocation.latitude,
|
||
Get.find<LocationController>().myLocation.longitude,
|
||
);
|
||
|
||
final double minimumThreshold = totalTripDistanceMeters / 5;
|
||
|
||
if (displacementMeters > minimumThreshold || isFromSlider) {
|
||
if (isFromSlider) return true;
|
||
|
||
// [Fix C-2 v2] استخدام AlertDialog مباشرة مع حماية Deadlock
|
||
// إذا أغلق المستخدم الديالوج بالزر الخلفي، نُكمل بـ false
|
||
final completer = Completer<bool>();
|
||
Get.dialog(
|
||
AlertDialog(
|
||
title: Text('Exit Ride?'.tr),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () {
|
||
if (!completer.isCompleted) completer.complete(true);
|
||
Get.back();
|
||
},
|
||
child: Text('OK'.tr),
|
||
),
|
||
],
|
||
),
|
||
barrierDismissible: true,
|
||
).then((_) {
|
||
if (!completer.isCompleted) completer.complete(false);
|
||
});
|
||
return await completer.future;
|
||
} else {
|
||
Get.find<TextToSpeechController>().speakText(
|
||
"You haven't moved sufficiently!".tr,
|
||
);
|
||
MyDialog().getDialog(
|
||
"Warning".tr,
|
||
"You haven't moved sufficiently!".tr,
|
||
() => Get.back(),
|
||
);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
void cancelCheckRideFromPassenger() async {
|
||
try {
|
||
var res = await CRUD().get(
|
||
link: "${AppLink.endPoint}/ride/driver_order/getOrderCancelStatus.php",
|
||
payload: {'order_id': (rideId)},
|
||
);
|
||
if (res == 'failure' || res.isEmpty) return;
|
||
var response = jsonDecode(res);
|
||
if (response == null || response['data'] == null) return;
|
||
canelString = response['data']['status']?.toString() ?? 'yet';
|
||
update();
|
||
if (canelString == 'Cancel') {
|
||
remainingTimeTimerRideBegin = 0;
|
||
remainingTimeToShowPassengerInfoWindowFromDriver = 0;
|
||
remainingTimeToPassenger = 0;
|
||
isRideStarted = false;
|
||
isRideFinished = false;
|
||
isPassengerInfoWindow = false;
|
||
clearPolyline();
|
||
update();
|
||
box.remove('cached_trip_route');
|
||
MyDialog().getDialog(
|
||
'Order Cancelled'.tr,
|
||
'Order Cancelled by Passenger'.tr,
|
||
() {
|
||
Get.delete<
|
||
HomeCaptainController>(); // clear old controller to fix map generation
|
||
Get.offAll(HomeCaptain());
|
||
},
|
||
);
|
||
}
|
||
} catch (e) {
|
||
Log.print("Error checking ride cancel status: $e");
|
||
}
|
||
}
|
||
|
||
int rideTimerFromBegin = 0;
|
||
double price = 0;
|
||
DateTime currentTime = DateTime.now();
|
||
|
||
/// أثناء الرحلة: نعرض السعر لحظياً بدون مضاعفة العمولة في كل ثانية.
|
||
/// - نستخدم سعر الدقيقة حسب الوقت، مع قواعد الرحلات البعيدة:
|
||
/// >25كم أو >35كم => دقيقة = 600، سقف 60 دقيقة، ومع >35كم عفو 10 دقائق.
|
||
/// - سرعة طويلة: لو المسافة المخططة > 40كم نستخدم 2600 ل.س/كم للـ Speed،
|
||
//// ونطبق نفس نسبة التخفيض على Comfort/Electric/Van.
|
||
/// - نضيف فقط "الزيادة" فوق التسعيرة المقتبسة (وقت زائد + كم زائد).
|
||
/// - نعكس العمولة kazán مرة واحدة على الزيادة (وليس كل ثانية).
|
||
|
||
// =========================================================
|
||
// الدالة الرئيسية المعدلة (العداد)
|
||
// =========================================================
|
||
// متغيرات العداد الجديد
|
||
Timer? _rideTimer;
|
||
DateTime? _rideStartTime;
|
||
DateTime? get rideStartTime => _rideStartTime; // إضافة هذا السطر
|
||
double currentRideDistanceKm = 0.0; // لحساب المسافة
|
||
// متغيرات المراقبة
|
||
|
||
// ===========================================================================
|
||
// 4. منطق العداد والتسعير (The Engine)
|
||
// ===========================================================================
|
||
|
||
void rideIsBeginPassengerTimer() {
|
||
_rideTimer?.cancel();
|
||
_rideStartTime = DateTime.now();
|
||
currentRideDistanceKm = 0.0;
|
||
|
||
// جلب الاعتماديات
|
||
final loc = Get.find<LocationController>();
|
||
final hc = Get.find<HomeCaptainController>();
|
||
|
||
// إعداد متغيرات التسعير
|
||
final double perKmSpeedBase = hc.speedPrice;
|
||
final double perKmComfortRaw = hc.comfortPrice;
|
||
final double perKmDelivery = hc.deliveryPrice;
|
||
final double perKmVanRaw = hc.familyPrice;
|
||
const double electricUpliftKm = 400;
|
||
final double perKmElectricRaw = perKmComfortRaw + electricUpliftKm;
|
||
|
||
final double perMinNature = hc.naturePrice;
|
||
final double perMinLate = hc.latePrice;
|
||
final double perMinHeavy = hc.heavyPrice;
|
||
|
||
// القيم الأولية
|
||
final double basePassengerQuote = safeParseDouble(totalCost);
|
||
final int quotedMinutes =
|
||
safeParseInt(duration) != 0 ? safeParseInt(duration) : 20;
|
||
final double kazanPct = safeParseDouble(kazan) / 100.0;
|
||
|
||
double plannedKm = safeParseDouble(distance);
|
||
final double startKm = loc.totalDistance / 1000;
|
||
if (plannedKm <= 0) plannedKm = (startKm > 0) ? startKm : 0.0;
|
||
|
||
bool isAirport(String s) =>
|
||
s.toLowerCase().contains('airport') || s.contains('مطار');
|
||
final bool isAirportContext =
|
||
isAirport(startNameLocation ?? '') || isAirport(endNameLocation ?? '');
|
||
|
||
double lastKmForNoise = loc.totalDistance / 1000;
|
||
const double jitterKm = 0.01; // 10 متر = 0.01 كم [Fix M-1]
|
||
|
||
_rideTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||
if (box.read(BoxName.rideStatus) != 'Begin') {
|
||
timer.cancel();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
final now = DateTime.now();
|
||
final int elapsedSeconds = now.difference(_rideStartTime!).inSeconds;
|
||
|
||
// أ) حساب المسافة المقطوعة (Logic)
|
||
double currentTotalKm = loc.totalDistance / 1000;
|
||
double delta = currentTotalKm - lastKmForNoise;
|
||
|
||
if (delta.abs() > jitterKm) {
|
||
currentRideDistanceKm += delta;
|
||
lastKmForNoise = currentTotalKm;
|
||
}
|
||
|
||
// ب) حساب السعر (Brain)
|
||
price = _calculateCurrentPrice(
|
||
now: now,
|
||
elapsedSeconds: elapsedSeconds,
|
||
liveKm: currentRideDistanceKm,
|
||
plannedKm: plannedKm,
|
||
startKm: startKm,
|
||
quotedMinutes: quotedMinutes,
|
||
basePassengerQuote: basePassengerQuote,
|
||
kazanPct: kazanPct,
|
||
isAirportContext: isAirportContext,
|
||
perKmSpeedBase: perKmSpeedBase,
|
||
perKmComfortRaw: perKmComfortRaw,
|
||
perKmDelivery: perKmDelivery,
|
||
perKmVanRaw: perKmVanRaw,
|
||
perKmElectricRaw: perKmElectricRaw,
|
||
perMinNature: perMinNature,
|
||
perMinLate: perMinLate,
|
||
perMinHeavy: perMinHeavy,
|
||
);
|
||
|
||
// ج) تحديث واجهة المستخدم
|
||
stringRemainingTimeRideBegin =
|
||
"${currentRideDistanceKm.toStringAsFixed(2)} km";
|
||
speed = loc.speed * 3.6;
|
||
|
||
update(); // تحديث الشريط فقط
|
||
} catch (e) {
|
||
print("Timer Error: $e");
|
||
}
|
||
});
|
||
}
|
||
|
||
double _calculateCurrentPrice({
|
||
required DateTime now,
|
||
required int elapsedSeconds,
|
||
required double liveKm,
|
||
required double plannedKm,
|
||
required double startKm,
|
||
required int quotedMinutes,
|
||
required double basePassengerQuote,
|
||
required double kazanPct,
|
||
required bool isAirportContext,
|
||
required double perKmSpeedBase,
|
||
required double perKmComfortRaw,
|
||
required double perKmDelivery,
|
||
required double perKmVanRaw,
|
||
required double perKmElectricRaw,
|
||
required double perMinNature,
|
||
required double perMinLate,
|
||
required double perMinHeavy,
|
||
}) {
|
||
// 🛑 1. قاعدة السعر الثابت: تجميد السعر
|
||
if (carType == 'Speed' ||
|
||
carType == 'Fixed Price' ||
|
||
carType == 'Awfar Car') {
|
||
return basePassengerQuote;
|
||
}
|
||
|
||
// 🟢 2. المنطق المتغير
|
||
const double longTripPerMin = 600.0;
|
||
|
||
const double longDistThresholdKm = 35.0;
|
||
|
||
// نسبة التخفيض
|
||
double reductionPct = 0.0;
|
||
if (liveKm > 40.0 && perKmComfortRaw > 0) {
|
||
double r40 = (1.0 - (2600.0 / (perKmComfortRaw * 1.2))).clamp(0.0, 0.35);
|
||
reductionPct = r40;
|
||
if (liveKm > 100.0) {
|
||
reductionPct = (r40 + 0.07).clamp(0.0, 0.35);
|
||
}
|
||
}
|
||
|
||
// سعر الكيلومتر
|
||
double finalPerKmRate;
|
||
switch (carType) {
|
||
case 'Comfort':
|
||
case 'Mishwar Vip':
|
||
case 'Lady':
|
||
finalPerKmRate = perKmComfortRaw * (1.0 - reductionPct);
|
||
break;
|
||
case 'Electric':
|
||
finalPerKmRate = perKmElectricRaw * (1.0 - reductionPct);
|
||
break;
|
||
case 'Van':
|
||
finalPerKmRate = perKmVanRaw * (1.0 - reductionPct);
|
||
break;
|
||
case 'Delivery':
|
||
finalPerKmRate = perKmDelivery;
|
||
break;
|
||
default:
|
||
finalPerKmRate = perKmComfortRaw * (1.0 - reductionPct);
|
||
}
|
||
|
||
// سعر الدقيقة
|
||
double perMinRate;
|
||
if (liveKm > longDistThresholdKm) {
|
||
perMinRate = longTripPerMin;
|
||
} else {
|
||
final h = now.hour;
|
||
if (isAirportContext || h >= 21 || h < 1) {
|
||
perMinRate = perMinLate;
|
||
} else if (h >= 14 && h <= 17) {
|
||
perMinRate = perMinHeavy;
|
||
} else {
|
||
perMinRate = perMinNature;
|
||
}
|
||
}
|
||
|
||
// الفروقات
|
||
final int elapsedMinutes = (elapsedSeconds ~/ 60);
|
||
int extraMinutes = elapsedMinutes - quotedMinutes;
|
||
if (extraMinutes < 0) extraMinutes = 0;
|
||
|
||
double liveCostCalculation =
|
||
(liveKm * finalPerKmRate) + (elapsedMinutes * perMinRate);
|
||
double totalLivePrice = liveCostCalculation * (1.0 + kazanPct);
|
||
|
||
// القرار النهائي (الأعلى بين المتفق عليه والمحسوب)
|
||
return (totalLivePrice > basePassengerQuote)
|
||
? totalLivePrice
|
||
: basePassengerQuote;
|
||
}
|
||
|
||
double recentDistanceToDash = 0;
|
||
double recentAngelToMarker = 0;
|
||
double speed = 0;
|
||
void updateMarker() {
|
||
// 🔥 Car icon as a Map Marker — moves with GPS location on the map.
|
||
// MarkerId 'MyLocation' must match exactly with google_driver_map_page.dart.
|
||
markers.removeWhere((m) => m.markerId.value == 'MyLocation');
|
||
final locCtrl = Get.find<LocationController>();
|
||
myLocation = locCtrl.myLocation;
|
||
|
||
markers.add(
|
||
Marker(
|
||
markerId: const MarkerId('MyLocation'),
|
||
position: myLocation,
|
||
icon: carIcon,
|
||
rotation: locCtrl.heading,
|
||
anchor: const Offset(0.5, 0.5),
|
||
flat: true,
|
||
zIndex: 100,
|
||
),
|
||
);
|
||
update();
|
||
}
|
||
|
||
void addCustomCarIcon() {
|
||
carIcon = InlqBitmap.fromAsset('assets/images/car.png');
|
||
update();
|
||
}
|
||
|
||
void addCustomStartIcon() async {
|
||
startIcon = InlqBitmap.fromAsset('assets/images/A.png');
|
||
update();
|
||
}
|
||
|
||
void addCustomEndIcon() {
|
||
endIcon = InlqBitmap.fromAsset('assets/images/b.png');
|
||
update();
|
||
}
|
||
|
||
void addCustomPassengerIcon() {
|
||
passengerIcon = InlqBitmap.fromAsset('assets/images/picker.png');
|
||
update();
|
||
}
|
||
|
||
var activeRouteSteps = <Map<String, dynamic>>[];
|
||
var traveledPathPoints = <LatLng>[]; // المسار المقطوع (رمادي)
|
||
var upcomingPathPoints = <LatLng>[]; // المسار المتبقي (أزرق/أحمر)
|
||
|
||
// --- متغيرات الأيقونات والمواقع ---
|
||
var heading = 0.0;
|
||
// ... يمكنك إضافة أيقونات البداية والنهاية هنا
|
||
|
||
// --- متغيرات الأداء ---
|
||
var updateInterval = 5.obs; // القيمة الافتراضية
|
||
|
||
// --- متغيرات داخلية للملاحة ---
|
||
var _stepBounds = <LatLngBounds>[];
|
||
var _stepEndPoints = <LatLng>[];
|
||
var _allPointsForActiveRoute = <LatLng>[];
|
||
bool _rayIntersectsSegment(LatLng point, LatLng vertex1, LatLng vertex2) {
|
||
double px = point.longitude;
|
||
double py = point.latitude;
|
||
|
||
double v1x = vertex1.longitude;
|
||
double v1y = vertex1.latitude;
|
||
double v2x = vertex2.longitude;
|
||
double v2y = vertex2.latitude;
|
||
|
||
// Check if the point is outside the vertical bounds of the segment
|
||
if ((py < v1y && py < v2y) || (py > v1y && py > v2y)) {
|
||
return false;
|
||
}
|
||
|
||
// Calculate the intersection of the ray and the segment
|
||
double intersectX = v1x + (py - v1y) * (v2x - v1x) / (v2y - v1y);
|
||
|
||
// Check if the intersection is to the right of the point
|
||
return intersectX > px;
|
||
}
|
||
|
||
// Function to check if the point is inside the polygon
|
||
bool isPointInPolygon(LatLng point, List<LatLng> polygon) {
|
||
int intersections = 0;
|
||
for (int i = 0; i < polygon.length; i++) {
|
||
LatLng vertex1 = polygon[i];
|
||
LatLng vertex2 =
|
||
polygon[(i + 1) % polygon.length]; // Loop back to the start
|
||
|
||
if (_rayIntersectsSegment(point, vertex1, vertex2)) {
|
||
intersections++;
|
||
}
|
||
}
|
||
|
||
// If the number of intersections is odd, the point is inside
|
||
return intersections % 2 != 0;
|
||
}
|
||
|
||
String getLocationArea(double latitude, double longitude) {
|
||
LatLng passengerPoint = LatLng(latitude, longitude);
|
||
|
||
// 1. فحص الأردن
|
||
if (isPointInPolygon(passengerPoint, CountryPolygons.jordanBoundary)) {
|
||
box.write(BoxName.countryCode, 'Jordan');
|
||
update(); // [Fix N-5]
|
||
// box.write(BoxName.serverChosen,
|
||
// AppLink.SiroSyriaServer); // مثال: اختر سيرفر سوريا للبيانات
|
||
return 'Jordan';
|
||
}
|
||
|
||
// 2. فحص سوريا
|
||
if (isPointInPolygon(passengerPoint, CountryPolygons.syriaBoundary)) {
|
||
box.write(BoxName.countryCode, 'Syria');
|
||
update(); // [Fix N-5]
|
||
return 'Syria';
|
||
}
|
||
|
||
// 3. فحص مصر
|
||
if (isPointInPolygon(passengerPoint, CountryPolygons.egyptBoundary)) {
|
||
box.write(BoxName.countryCode, 'Egypt');
|
||
update(); // [Fix N-5]
|
||
return 'Egypt';
|
||
}
|
||
|
||
// 4. الافتراضي (إذا كان خارج المناطق المخدومة)
|
||
box.write(BoxName.countryCode, 'Jordan');
|
||
update(); // [Fix N-5]
|
||
return 'Unknown Location (Defaulting to Jordan)';
|
||
}
|
||
|
||
/// **جلب ورسم المسار (OSRM - New Standard System)**
|
||
///
|
||
/// تستخدم السيرفر الجديد: https://routesy.intaleq.xyz/route/v1/driving
|
||
Future<void> getRoute({
|
||
required LatLng origin,
|
||
required LatLng destination,
|
||
required Color routeColor,
|
||
String? cachedResponse,
|
||
}) async {
|
||
if (mapController == null) return;
|
||
|
||
try {
|
||
dynamic response;
|
||
|
||
if (cachedResponse != null && cachedResponse.isNotEmpty) {
|
||
response = jsonDecode(cachedResponse);
|
||
Log.print("✅ Using cached route response to save API calls");
|
||
} else {
|
||
// 1. طلب المسار من السيرفر الموحد (SaaS) لضمان الدقة وتفادي الـ 401
|
||
final saasUrl = Uri.parse(AppLink.mapSaasRoute).replace(
|
||
queryParameters: {
|
||
'fromLat': origin.latitude.toString(),
|
||
'fromLng': origin.longitude.toString(),
|
||
'toLat': destination.latitude.toString(),
|
||
'toLng': destination.longitude.toString(),
|
||
'steps': 'true', // نحتاجها للملاحة والتوجيه
|
||
'alternatives': 'false',
|
||
'locale': 'ar',
|
||
},
|
||
);
|
||
|
||
final httpResponse = await http.get(
|
||
saasUrl,
|
||
headers: {
|
||
'x-api-key': Env.mapSaasKey,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
);
|
||
|
||
if (httpResponse.statusCode != 200) {
|
||
throw Exception("Routing request failed: ${httpResponse.statusCode}");
|
||
}
|
||
|
||
response = jsonDecode(httpResponse.body);
|
||
}
|
||
|
||
// 2. التعامل مع الـ JSON المباشر (الذي أرسله المستخدم)
|
||
// إذا كان الـ response يحتوي على الحقول مباشرة في الجذر
|
||
final String? encodedPoints = response['points'];
|
||
|
||
if (encodedPoints == null) {
|
||
mySnackeBarError("No route points found".tr);
|
||
return;
|
||
}
|
||
|
||
// 🔥 فك التشفير باستخدام compute لضمان أداء ممتاز
|
||
List<LatLng> fullRoute = await compute(
|
||
PolylineUtils.decode,
|
||
encodedPoints,
|
||
);
|
||
|
||
if (fullRoute.isEmpty) {
|
||
mySnackeBarError("Failed to process route points".tr);
|
||
return;
|
||
}
|
||
|
||
// تحديث المسافة والوقت من الـ JSON الجديد
|
||
distance = (response['distance'] ?? 0).toString();
|
||
duration = (response['duration'] ?? 0).toString();
|
||
|
||
// ب) تهيئة المتغيرات
|
||
upcomingPathPoints.assignAll(fullRoute);
|
||
traveledPathPoints.clear();
|
||
_lastTraveledIndex = 0;
|
||
|
||
// ج) رسم المسار الأولي
|
||
polyLines.clear();
|
||
polyLines.add(
|
||
Polyline(
|
||
polylineId: const PolylineId("upcoming_route"),
|
||
points: fullRoute,
|
||
width: 8,
|
||
color: routeColor,
|
||
),
|
||
);
|
||
|
||
_updatePassengerWalkLine();
|
||
|
||
// د) معالجة الخطوات (Instructions) للسيرفر الموحد
|
||
final List<dynamic> instructions = response['instructions'] ?? [];
|
||
if (instructions.isNotEmpty) {
|
||
routeSteps = List<Map<String, dynamic>>.from(
|
||
instructions.map((e) {
|
||
int endIdx = (e['interval'] as List)[1];
|
||
// التأكد من أن الـ index لا يتجاوز طول المسار
|
||
if (endIdx >= fullRoute.length) endIdx = fullRoute.length - 1;
|
||
|
||
return {
|
||
'html_instructions': e['text'] ?? "",
|
||
'sign': e['sign'] ?? 0,
|
||
'end_location': {
|
||
'lat': fullRoute[endIdx].latitude,
|
||
'lng': fullRoute[endIdx].longitude,
|
||
},
|
||
};
|
||
}),
|
||
);
|
||
|
||
currentStepIndex = 0;
|
||
currentInstruction = routeSteps[0]['html_instructions'];
|
||
currentManeuverModifier = routeSteps[0]['sign'];
|
||
_nextInstructionSpoken = false;
|
||
|
||
if (Get.isRegistered<TextToSpeechController>() && isTtsEnabled) {
|
||
Get.find<TextToSpeechController>().speakText(currentInstruction);
|
||
}
|
||
} else {
|
||
routeSteps = [];
|
||
currentInstruction = "";
|
||
currentManeuverModifier = 0;
|
||
}
|
||
|
||
// هـ) تحريك الكاميرا لتشمل المسار أو الدخول في وضع الملاحة
|
||
if (isRideStarted) {
|
||
final locCtrl = Get.find<LocationController>();
|
||
safeAnimateCamera(
|
||
CameraUpdate.newCameraPosition(
|
||
CameraPosition(
|
||
target: origin,
|
||
zoom: 17.5,
|
||
bearing: locCtrl.heading,
|
||
tilt: 60,
|
||
),
|
||
),
|
||
);
|
||
} else if (fullRoute.isNotEmpty) {
|
||
final bounds = _boundsFromLatLngList(fullRoute);
|
||
final double distOriginDest = Geolocator.distanceBetween(
|
||
origin.latitude,
|
||
origin.longitude,
|
||
destination.latitude,
|
||
destination.longitude,
|
||
);
|
||
// When driver & passenger are on the same device, the
|
||
// C++ map engine crashes with std::domain_error because
|
||
// bounds have near-zero span. Show a dialog to inform
|
||
// the driver, then use a padded safe zoom instead.
|
||
// 🔥 [Fix Dialog] لا تُظهر التحذير إذا كانت الرحلة قد بدأت (isRideStarted)
|
||
// لأن عند بدء الرحلة، origin=موقع السائق و destination=وجهة الراكب
|
||
// وقد تكون بعيدة جداً — التحذير لا معنى له هنا
|
||
if (distOriginDest < 10 && !isRideStarted) {
|
||
_showSameDeviceWarning();
|
||
safeAnimateCamera(
|
||
CameraUpdate.newLatLngZoom(
|
||
LatLng(
|
||
(origin.latitude + destination.latitude) / 2,
|
||
(origin.longitude + destination.longitude) / 2,
|
||
),
|
||
17,
|
||
),
|
||
);
|
||
} else if (distOriginDest < 10 && isRideStarted) {
|
||
// نفس الجهاز لكن الرحلة بدأت — فقط zoom بدون تحذير
|
||
safeAnimateCamera(
|
||
CameraUpdate.newLatLngZoom(
|
||
LatLng(
|
||
(origin.latitude + destination.latitude) / 2,
|
||
(origin.longitude + destination.longitude) / 2,
|
||
),
|
||
15,
|
||
),
|
||
);
|
||
} else {
|
||
safeAnimateCamera(
|
||
CameraUpdate.newLatLngBounds(
|
||
bounds,
|
||
left: 80,
|
||
top: 80,
|
||
right: 80,
|
||
bottom: 80,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
update();
|
||
} catch (e) {
|
||
Log.print("Route Error: $e");
|
||
}
|
||
}
|
||
|
||
LatLngBounds _boundsFromLatLngList(List<LatLng> list) {
|
||
assert(list.isNotEmpty);
|
||
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;
|
||
}
|
||
}
|
||
// Guard against zero-span bounds which crash the native C++ map engine
|
||
// with std::domain_error when passed to CameraUpdate.newLatLngBounds.
|
||
double latSpan = (x1! - x0!).abs();
|
||
double lngSpan = (y1! - y0!).abs();
|
||
const double minSpan = 0.002; // ~220 m at equator
|
||
if (latSpan < minSpan) {
|
||
final double pad = (minSpan - latSpan) / 2;
|
||
x0 = x0! - pad;
|
||
x1 = x1! + pad;
|
||
}
|
||
if (lngSpan < minSpan) {
|
||
final double pad = (minSpan - lngSpan) / 2;
|
||
y0 = y0! - pad;
|
||
y1 = y1! + pad;
|
||
}
|
||
return LatLngBounds(
|
||
northeast: LatLng(x1!, y1!),
|
||
southwest: LatLng(x0!, y0!),
|
||
);
|
||
}
|
||
|
||
// داخل MapDriverController
|
||
|
||
Future<void> markDriverAsArrived() async {
|
||
// 1. إظهار لودينج فوراً لمنع التكرار وإشعار السائق
|
||
Get.dialog(
|
||
const Center(child: CircularProgressIndicator(color: AppColor.gold)),
|
||
barrierDismissible: false,
|
||
);
|
||
|
||
try {
|
||
// [Fix M-2] تغيير اسم المتغير المحلي لتجنب Variable Shadowing مع distance العام
|
||
double distToPassenger =
|
||
await calculateDistanceBetweenDriverAndPassengerLocation();
|
||
|
||
if (distToPassenger < 100) {
|
||
// 2. طلب الـ API مع مهلة 15 ثانية كأمان
|
||
await CRUD().post(
|
||
link: "${AppLink.ride}/rides/arrive_ride.php",
|
||
payload: {
|
||
"ride_id": rideId,
|
||
"driver_id": box.read(BoxName.driverID),
|
||
"passengerToken": tokenPassenger,
|
||
},
|
||
).timeout(const Duration(seconds: 15));
|
||
|
||
// 3. إغلاق اللودينج وتحديث الواجهة
|
||
if (Get.isDialogOpen == true) Get.back();
|
||
|
||
// منطق بدء العداد
|
||
startTimerToShowDriverWaitPassengerDuration();
|
||
isArrivedSend = false;
|
||
|
||
// تحديث فقط الجزء الخاص بمعلومات الراكب
|
||
update();
|
||
|
||
// رسم المسار للوجهة
|
||
getRoute(
|
||
origin: latLngPassengerLocation,
|
||
destination: latLngPassengerDestination,
|
||
routeColor: Colors.blue,
|
||
);
|
||
} else {
|
||
if (Get.isDialogOpen == true) Get.back();
|
||
mySnackeBarError("You must be closer than 100 meters to arrive".tr);
|
||
}
|
||
} catch (e) {
|
||
if (Get.isDialogOpen == true) Get.back();
|
||
mySnackeBarError("A connection error occurred".tr);
|
||
}
|
||
}
|
||
|
||
// [Fix P-3] _suggestOptimization() أزيلت — كانت ميتة ولا يستدعيها أحد
|
||
|
||
// =================================================================
|
||
// 5. دوال مساعدة (Helper Functions)
|
||
// =================================================================
|
||
|
||
String _parseInstruction(String html) =>
|
||
html.replaceAll(RegExp(r'<[^>]*>'), '');
|
||
|
||
/// Show a warning dialog when the driver and passenger are on the
|
||
/// same device (distance < 10 m) — the C++ map engine would crash with
|
||
/// std::domain_error if we tried to fit zero-span bounds.
|
||
void _showSameDeviceWarning() {
|
||
Get.dialog(
|
||
AlertDialog(
|
||
icon: const Icon(Icons.warning_amber_rounded,
|
||
color: Colors.orange, size: 48),
|
||
title: Text("Same device detected".tr),
|
||
content: Text(
|
||
"The rider and driver locations are very close (possibly on the same phone). "
|
||
"The map will show an approximate view."
|
||
.tr,
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Get.back(),
|
||
child: Text("OK".tr),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _fitToBounds(LatLngBounds b, {double padding = 60}) async {
|
||
// Guard against zero-span bounds (crashes native C++ engine)
|
||
final double latSpan = (b.northeast.latitude - b.southwest.latitude).abs();
|
||
final double lngSpan =
|
||
(b.northeast.longitude - b.southwest.longitude).abs();
|
||
if (latSpan < 0.002 && lngSpan < 0.002) {
|
||
// Bounds are a single point — zoom instead of fit
|
||
await safeAnimateCamera(
|
||
CameraUpdate.newLatLngZoom(b.northeast, 16),
|
||
);
|
||
return;
|
||
}
|
||
await safeAnimateCamera(
|
||
CameraUpdate.newLatLngBounds(
|
||
b,
|
||
left: padding,
|
||
top: padding,
|
||
right: padding,
|
||
bottom: padding,
|
||
),
|
||
);
|
||
}
|
||
|
||
double distanceBetweenDriverAndPassengerWhenConfirm = 0;
|
||
|
||
/// [Fix M-4] هذه الدالة مكررة مع _checkNavigationStep().
|
||
/// نحتفظ بها للاستدعاء الخارجي (startListeningStepNavigation) ولكن logica مدمج.
|
||
void checkForNextStep(LatLng currentPosition) {
|
||
_checkNavigationStep(currentPosition);
|
||
}
|
||
|
||
/// Calculates the distance in meters between two latitude/longitude points.
|
||
double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
|
||
const double earthRadius = 6371000; // meters
|
||
double dLat = _degreesToRadians(lat2 - lat1);
|
||
double dLon = _degreesToRadians(lon2 - lon1);
|
||
|
||
double a = (sin(dLat / 2) * sin(dLat / 2)) +
|
||
cos(_degreesToRadians(lat1)) *
|
||
cos(_degreesToRadians(lat2)) *
|
||
(sin(dLon / 2) * sin(dLon / 2));
|
||
double c = 2 * atan2(sqrt(a), sqrt(1 - a));
|
||
double distance = earthRadius * c;
|
||
return distance;
|
||
}
|
||
|
||
double _degreesToRadians(double degrees) {
|
||
return degrees * (3.1415926535897932 / 180.0);
|
||
}
|
||
|
||
bool isNearDestinationNotified = false;
|
||
|
||
// فحص الوصول للوجهة للإشعارات
|
||
void checkDestinationProximity() {
|
||
if (isNearDestinationNotified) return;
|
||
if (myLocation.latitude == 0 ||
|
||
myLocation.longitude == 0 ||
|
||
latLngPassengerDestination.latitude == 0 ||
|
||
latLngPassengerDestination.longitude == 0) return;
|
||
|
||
double dist = Geolocator.distanceBetween(
|
||
myLocation.latitude,
|
||
myLocation.longitude,
|
||
latLngPassengerDestination.latitude,
|
||
latLngPassengerDestination.longitude,
|
||
);
|
||
|
||
if (dist < 300) {
|
||
isNearDestinationNotified = true;
|
||
NotificationService.sendNotification(
|
||
target: tokenPassenger.toString(),
|
||
title: "You are near the destination".tr,
|
||
body: "The driver is approaching.".tr,
|
||
isTopic: false,
|
||
tone: 'ding',
|
||
driverList: [],
|
||
category: "Destination Proximity",
|
||
);
|
||
}
|
||
}
|
||
|
||
List<LatLngBounds> stepBounds = [];
|
||
List<LatLng> stepEndPoints = [];
|
||
List<String> stepInstructions = [];
|
||
StreamSubscription<Position>? _posSub;
|
||
DateTime _lastCameraUpdateTs = DateTime.fromMillisecondsSinceEpoch(0);
|
||
|
||
LatLngBounds _boundsFromPoints(List<LatLng> pts) {
|
||
double? minLat, maxLat, minLng, maxLng;
|
||
for (final p in pts) {
|
||
minLat = (minLat == null) ? p.latitude : math.min(minLat, p.latitude);
|
||
maxLat = (maxLat == null) ? p.latitude : math.max(maxLat, p.latitude);
|
||
minLng = (minLng == null) ? p.longitude : math.min(minLng, p.longitude);
|
||
maxLng = (maxLng == null) ? p.longitude : math.max(maxLng, p.longitude);
|
||
}
|
||
return LatLngBounds(
|
||
southwest: LatLng(minLat ?? 0, minLng ?? 0),
|
||
northeast: LatLng(maxLat ?? 0, maxLng ?? 0),
|
||
);
|
||
}
|
||
|
||
double _distanceMeters(LatLng a, LatLng b) {
|
||
// هافرساين مبسطة
|
||
const R = 6371000.0; // m
|
||
final dLat = _deg2rad(b.latitude - a.latitude);
|
||
final dLng = _deg2rad(b.longitude - a.longitude);
|
||
final s1 = math.sin(dLat / 2);
|
||
final s2 = math.sin(dLng / 2);
|
||
final aa = s1 * s1 +
|
||
math.cos(_deg2rad(a.latitude)) *
|
||
math.cos(_deg2rad(b.latitude)) *
|
||
s2 *
|
||
s2;
|
||
final c = 2 * math.atan2(math.sqrt(aa), math.sqrt(1 - aa));
|
||
return R * c;
|
||
}
|
||
|
||
double _deg2rad(double d) => d * math.pi / 180.0;
|
||
|
||
void updateCameraFromBoundsAfterGetMap(dynamic response) {
|
||
final bounds = response["routes"][0]["bounds"];
|
||
LatLng northeast = LatLng(
|
||
bounds['northeast']['lat'],
|
||
bounds['northeast']['lng'],
|
||
);
|
||
LatLng southwest = LatLng(
|
||
bounds['southwest']['lat'],
|
||
bounds['southwest']['lng'],
|
||
);
|
||
|
||
// Create the LatLngBounds object
|
||
LatLngBounds boundsData = LatLngBounds(
|
||
northeast: northeast,
|
||
southwest: southwest,
|
||
);
|
||
|
||
final latDiff =
|
||
(boundsData.northeast.latitude - boundsData.southwest.latitude).abs();
|
||
final lngDiff =
|
||
(boundsData.northeast.longitude - boundsData.southwest.longitude).abs();
|
||
|
||
if (latDiff < 0.0001 || lngDiff < 0.0001) {
|
||
final center = LatLng(
|
||
(boundsData.northeast.latitude + boundsData.southwest.latitude) / 2,
|
||
(boundsData.northeast.longitude + boundsData.southwest.longitude) / 2,
|
||
);
|
||
safeAnimateCamera(CameraUpdate.newCameraPosition(
|
||
CameraPosition(target: center, zoom: 17),
|
||
));
|
||
} else {
|
||
// Fit the camera to the bounds
|
||
var cameraUpdate = CameraUpdate.newLatLngBounds(
|
||
boundsData,
|
||
left: 140,
|
||
top: 140,
|
||
right: 140,
|
||
bottom: 140,
|
||
);
|
||
safeAnimateCamera(cameraUpdate);
|
||
}
|
||
}
|
||
|
||
void changePassengerInfoWindow() {
|
||
isPassengerInfoWindow = !isPassengerInfoWindow;
|
||
passengerInfoWindowHeight = isPassengerInfoWindow == true ? 200 : 0;
|
||
update();
|
||
}
|
||
|
||
double mpg = 0;
|
||
calculateConsumptionFuel() {
|
||
mpg = Get.find<HomeCaptainController>().fuelPrice /
|
||
12; //todo in register car add mpg in box
|
||
update();
|
||
}
|
||
|
||
argumentLoading() async {
|
||
// 🔥 [Fix Double-Init] منع التنفيذ المتزامن (onInit + postFrameCallback)
|
||
if (_argumentLoadingInProgress) {
|
||
Log.print(
|
||
"⏳ argumentLoading: Already in progress, skipping concurrent call.");
|
||
return;
|
||
}
|
||
|
||
// 🔥 Debounce: منع التنفيذ المتكرر السريع من build() callbacks
|
||
// نسمح بإعادة التنفيذ فقط بعد مرور 5 ثوانٍ من آخر تنفيذ ناجح
|
||
// (هذا يمنع الـ rapid rebuilds لكن يسمح بالعودة للرحلة من الهوم)
|
||
if (_lastArgumentLoadingTime != null) {
|
||
final elapsed = DateTime.now().difference(_lastArgumentLoadingTime!);
|
||
if (elapsed.inSeconds < 5) {
|
||
Log.print(
|
||
"⏭️ argumentLoading: Debounced (${elapsed.inMilliseconds}ms < 5000ms). Skipping.");
|
||
return;
|
||
}
|
||
}
|
||
|
||
_argumentLoadingInProgress = true;
|
||
|
||
// 🔥 إعادة تعيين flag المسار عند كل استدعاء لاحق
|
||
// (ضروري للعودة للرحلة من صفحة الهوم)
|
||
_isRouteRequested = false;
|
||
|
||
// 🛑 حماية: إذا لم تكن هناك arguments، لا تكمل
|
||
if (Get.arguments == null || Get.arguments is! Map) {
|
||
Log.print("❌ argumentLoading: No valid arguments found. Aborting.");
|
||
_argumentLoadingInProgress = false;
|
||
return;
|
||
}
|
||
try {
|
||
passengerLocation = Get.arguments['passengerLocation']?.toString() ?? '';
|
||
passengerDestination =
|
||
Get.arguments['passengerDestination']?.toString() ?? '';
|
||
duration = Get.arguments['Duration']?.toString() ?? '';
|
||
totalCost = Get.arguments['totalCost']?.toString() ?? '';
|
||
passengerId = Get.arguments['passengerId']?.toString() ?? '';
|
||
driverId = Get.arguments['driverId']?.toString() ?? '';
|
||
distance = Get.arguments['Distance']?.toString() ?? '0';
|
||
passengerName = Get.arguments['name']?.toString();
|
||
passengerEmail = Get.arguments['email']?.toString() ?? '';
|
||
totalPricePassenger = Get.arguments['totalPassenger']?.toString() ?? '';
|
||
passengerPhone = Get.arguments['phone']?.toString() ?? '';
|
||
walletChecked = Get.arguments['WalletChecked']?.toString() ?? '';
|
||
tokenPassenger = Get.arguments['tokenPassenger']?.toString() ?? '';
|
||
direction = Get.arguments['direction']?.toString() ?? '';
|
||
durationToPassenger =
|
||
Get.arguments['DurationToPassenger']?.toString() ?? '100';
|
||
rideId = Get.arguments['rideId']?.toString() ?? '';
|
||
durationOfRideValue =
|
||
Get.arguments['durationOfRideValue']?.toString() ?? '';
|
||
paymentAmount = Get.arguments['paymentAmount']?.toString() ?? '0';
|
||
paymentMethod = Get.arguments['paymentMethod']?.toString() ?? '';
|
||
|
||
// 🔥 حفظ البيانات في الذاكرة المحلية فوراً (لفصل السوكيت عن الكنترولر)
|
||
box.write(BoxName.passengerID, passengerId.toString());
|
||
box.write(BoxName.rideId, rideId.toString());
|
||
// Also save full args for return-to-ride scenarios
|
||
box.write(BoxName.rideArguments, Get.arguments);
|
||
isHaveSteps = Get.arguments['isHaveSteps']?.toString() ?? 'false';
|
||
// [Fix N-4] ملء القائمة بدلاً من 5 متغيرات منفصلة
|
||
steps = List.generate(5, (i) {
|
||
return Get.arguments['step$i']?.toString() ?? '';
|
||
});
|
||
passengerWalletBurc =
|
||
Get.arguments['passengerWalletBurc']?.toString() ?? '';
|
||
timeOfOrder = Get.arguments['timeOfOrder']?.toString() ?? '';
|
||
carType = Get.arguments['carType']?.toString() ?? '';
|
||
kazan = Get.arguments['kazan']?.toString() ?? '';
|
||
startNameLocation = Get.arguments['startNameLocation']?.toString() ?? '';
|
||
endNameLocation = Get.arguments['endNameLocation']?.toString() ?? '';
|
||
|
||
// Parse to double
|
||
latlng(passengerLocation, passengerDestination);
|
||
|
||
String lat =
|
||
Get.find<LocationController>().myLocation.latitude.toString();
|
||
String lng =
|
||
Get.find<LocationController>().myLocation.longitude.toString();
|
||
|
||
// Check which route to draw based on current ride status
|
||
String currentStatus = box.read(BoxName.rideStatus) ?? '';
|
||
|
||
// [Fix 5] إضافة await لضمان أن التأخير يعمل فعلاً قبل رسم المسار.
|
||
await Future.delayed(const Duration(seconds: 1));
|
||
|
||
// 🔥 [Fix Return-to-Ride] تعيين isRideStarted قبل getRoute لمنع onMapCreated
|
||
// من رسم المسار الأصفر (للراكب) بدلاً من الأزرق (للوجهة)
|
||
if (currentStatus == 'Begin') {
|
||
isRideStarted = true;
|
||
isRideBegin = true;
|
||
isPassengerInfoWindow = false;
|
||
}
|
||
|
||
if (currentStatus == 'Begin' &&
|
||
latLngPassengerDestination.latitude != 0 &&
|
||
latLngPassengerDestination.longitude != 0) {
|
||
// Ride already started — draw blue route to destination
|
||
await getRoute(
|
||
origin: Get.find<LocationController>().myLocation,
|
||
destination: latLngPassengerDestination,
|
||
routeColor: Colors.blue,
|
||
);
|
||
|
||
// 🔥 [Fix Return-to-Ride] إعادة تشغيل الخدمات المفقودة عند العودة للرحلة
|
||
// بدون هذا الاستدعاء، يُرسم المسار لكن:
|
||
// - عداد السعر (_rideTimer) لا يعمل
|
||
// - الملاحة (_navigationTimer) لا تعمل
|
||
// - الماركر لا يتحرك
|
||
if (_rideTimer == null || !_rideTimer!.isActive) {
|
||
rideIsBeginPassengerTimer();
|
||
}
|
||
await startListeningStepNavigation();
|
||
} else {
|
||
// Ride not yet started — draw yellow route to passenger
|
||
await getRoute(
|
||
origin: Get.find<LocationController>().myLocation,
|
||
destination: latLngPassengerLocation,
|
||
routeColor: Colors.yellow,
|
||
);
|
||
// 🔥 بدء الملاحة بعد تحميل المسار (تتبع حركة السائق نحو الراكب)
|
||
if (_navigationTimer == null || !_navigationTimer!.isActive) {
|
||
startListeningStepNavigation();
|
||
}
|
||
}
|
||
_isRouteRequested = true;
|
||
update();
|
||
} catch (e) {
|
||
Log.print("Error parsing arguments: $e");
|
||
} finally {
|
||
// 🔥 [Fix Double-Init] دائماً إعادة تعيين الـ flag عند الانتهاء
|
||
_argumentLoadingInProgress = false;
|
||
// سجّل وقت الانتهاء للـ debounce
|
||
_lastArgumentLoadingTime = DateTime.now();
|
||
}
|
||
}
|
||
|
||
void latlng(String passengerLocation, String passengerDestination) {
|
||
try {
|
||
// ── مكان الراكب ──────────────────────────────────────────────────
|
||
final List<String> locParts = passengerLocation.split(',');
|
||
double latPassengerLocation = locParts.length >= 1
|
||
? (double.tryParse(locParts[0].trim()) ?? 0.0)
|
||
: 0.0;
|
||
double lngPassengerLocation = locParts.length >= 2
|
||
? (double.tryParse(locParts[1].trim()) ?? 0.0)
|
||
: 0.0;
|
||
|
||
// ── وجهة الراكب ─────────────────────────────────────────────────
|
||
final List<String> destParts = passengerDestination.split(',');
|
||
double latPassengerDestination = destParts.length >= 1
|
||
? (double.tryParse(destParts[0].trim()) ?? 0.0)
|
||
: 0.0;
|
||
double lngPassengerDestination = destParts.length >= 2
|
||
? (double.tryParse(destParts[1].trim()) ?? 0.0)
|
||
: 0.0;
|
||
|
||
latLngPassengerLocation = LatLng(
|
||
latPassengerLocation,
|
||
lngPassengerLocation,
|
||
);
|
||
latLngPassengerDestination = LatLng(
|
||
latPassengerDestination,
|
||
lngPassengerDestination,
|
||
);
|
||
|
||
Log.print(
|
||
"📍 latlng() parsed => Pax: ($latPassengerLocation, $lngPassengerLocation) | Dest: ($latPassengerDestination, $lngPassengerDestination)");
|
||
} catch (e, stack) {
|
||
Log.print("❌ latlng() FormatException prevented: $e");
|
||
Log.print("Stack: $stack");
|
||
// قيم افتراضية آمنة لمنع الكراش
|
||
latLngPassengerLocation = LatLng(0, 0);
|
||
latLngPassengerDestination = LatLng(0, 0);
|
||
}
|
||
}
|
||
|
||
Duration durationToAdd = Duration.zero;
|
||
int hours = 0;
|
||
int minutes = 0;
|
||
String carType = '';
|
||
String kazan = '';
|
||
String startNameLocation = '';
|
||
String endNameLocation = '';
|
||
|
||
Future<void> runGoogleMapDirectly() async {
|
||
if (box.read(BoxName.googlaMapApp) == true) {
|
||
if (Platform.isAndroid) {
|
||
Bubble().startBubbleHead(sendAppToBackground: true);
|
||
}
|
||
await openGoogleMapFromDriverToPassenger();
|
||
}
|
||
}
|
||
|
||
@override
|
||
void onInit() async {
|
||
mapAPIKEY = await storage.read(key: BoxName.mapAPIKEY);
|
||
await argumentLoading();
|
||
Get.put(FirebaseMessagesController());
|
||
runGoogleMapDirectly();
|
||
addCustomCarIcon();
|
||
addCustomPassengerIcon();
|
||
addCustomStartIcon();
|
||
addCustomEndIcon();
|
||
MarkerGenerator.createWalkMarker().then((icon) {
|
||
walkIcon = icon;
|
||
update();
|
||
});
|
||
|
||
if (!Get.isRegistered<TextToSpeechController>()) {
|
||
Get.put(TextToSpeechController(), permanent: true);
|
||
}
|
||
|
||
_animController = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 1000),
|
||
);
|
||
_animController!.addListener(() {
|
||
if (_oldLoc != null && _targetLoc != null) {
|
||
final t = _animController!.value;
|
||
final lat = lerpDouble(_oldLoc!.latitude, _targetLoc!.latitude, t)!;
|
||
final lng = lerpDouble(_oldLoc!.longitude, _targetLoc!.longitude, t)!;
|
||
smoothedLocation = LatLng(lat, lng);
|
||
smoothedHeading = _lerpAngle(_oldHeading, _targetHeading, t);
|
||
update();
|
||
}
|
||
});
|
||
|
||
_startLocationListening();
|
||
|
||
startTimerToShowPassengerInfoWindowFromDriver();
|
||
durationToAdd = Duration(seconds: parseDurationToInt(duration));
|
||
hours = durationToAdd.inHours;
|
||
minutes = (durationToAdd.inMinutes % 60).round();
|
||
calculateConsumptionFuel();
|
||
super.onInit();
|
||
}
|
||
|
||
void _startLocationListening() {
|
||
// Location stream is now centralized in LocationController to prevent device hanging.
|
||
// LocationController will call handleLocationUpdateFromCentral directly.
|
||
}
|
||
|
||
/// [Fix C-4] تحديث myLocation في المستمع الأساسي
|
||
void handleLocationUpdateFromCentral(
|
||
LatLng newLoc, double posSpeed, double posHeading) {
|
||
myLocation = newLoc; // ← [Fix C-4] تحديث الموقع الفوري
|
||
_oldLoc = smoothedLocation ?? newLoc;
|
||
_targetLoc = newLoc;
|
||
|
||
_oldHeading = smoothedHeading;
|
||
if (posSpeed > 0.5) {
|
||
_targetHeading = posHeading;
|
||
} else {
|
||
_targetHeading = _oldHeading;
|
||
}
|
||
|
||
_animController?.forward(from: 0.0);
|
||
|
||
if (routeSteps.isNotEmpty) {
|
||
_checkNavigationStep(newLoc);
|
||
}
|
||
}
|
||
|
||
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 _checkNavigationStep(LatLng pos) {
|
||
if (routeSteps.isEmpty || currentStepIndex >= routeSteps.length) return;
|
||
|
||
final step = routeSteps[currentStepIndex];
|
||
final stepLoc = step['end_location'];
|
||
if (stepLoc == null) return;
|
||
|
||
final double stepLat = stepLoc['lat'];
|
||
final double stepLng = stepLoc['lng'];
|
||
|
||
final distance = geo.Geolocator.distanceBetween(
|
||
pos.latitude,
|
||
pos.longitude,
|
||
stepLat,
|
||
stepLng,
|
||
);
|
||
|
||
distanceToNextStep = distance > 1000
|
||
? "${(distance / 1000).toStringAsFixed(1)} km"
|
||
: "${distance.toStringAsFixed(0)} m";
|
||
|
||
if (distance < 50 &&
|
||
!_nextInstructionSpoken &&
|
||
(currentStepIndex + 1) < routeSteps.length) {
|
||
final nextText =
|
||
routeSteps[currentStepIndex + 1]['html_instructions'] ?? "";
|
||
if (isTtsEnabled) {
|
||
Get.find<TextToSpeechController>().speakText(nextText);
|
||
}
|
||
_nextInstructionSpoken = true;
|
||
}
|
||
|
||
if (distance < 25) {
|
||
_advanceStep();
|
||
}
|
||
update();
|
||
}
|
||
|
||
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 _advanceStep() {
|
||
currentStepIndex++;
|
||
if (currentStepIndex < routeSteps.length) {
|
||
currentInstruction =
|
||
routeSteps[currentStepIndex]['html_instructions'] ?? "";
|
||
currentManeuverModifier = routeSteps[currentStepIndex]['sign'] ?? 0;
|
||
_nextInstructionSpoken = false;
|
||
}
|
||
}
|
||
|
||
int parseDurationToInt(dynamic value) {
|
||
if (value == null) return 0;
|
||
String text = value.toString();
|
||
// حذف كل شيء ما عدا الأرقام (الإنجليزية)
|
||
String digits = text.replaceAll(RegExp(r'[^0-9]'), '');
|
||
if (digits.isEmpty) return 0;
|
||
return int.tryParse(digits) ?? 0;
|
||
}
|
||
|
||
Timer? _navigationTimer;
|
||
// أضف هذا المتغير في الكلاس
|
||
LatLng? _lastRecordedLocation;
|
||
|
||
// [Fix M-4] تم إزالة الكود المعلّق القديم لـ startListeningStepNavigation
|
||
|
||
void _animateCameraToNavigationMode(LatLng target, double bearing) {
|
||
mapController?.animateCamera(
|
||
CameraUpdate.newCameraPosition(
|
||
CameraPosition(
|
||
target: target,
|
||
bearing: bearing,
|
||
tilt: 45.0, // منظور ثلاثي الأبعاد (3D Perspective)
|
||
zoom: 18.0, // تقريب للملاحة
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// متغير لتتبع آخر نقطة وصلنا لها
|
||
int _lastTraveledIndex = 0;
|
||
|
||
// تحديث المسار الذكي
|
||
void _updateTraveledPolylineSmart(LatLng currentPos) {
|
||
if (upcomingPathPoints.isEmpty) return;
|
||
|
||
// Bidirectional Sliding Window: نبحث 30 نقطة للخلف و30 نقطة للأمام لدعم الرجوع أو اتخاذ مسارات بديلة
|
||
int searchWindow = 60;
|
||
int halfWindow = searchWindow ~/ 2;
|
||
int startIndex = max(0, _lastTraveledIndex - halfWindow);
|
||
int endIndex =
|
||
min(_lastTraveledIndex + halfWindow, upcomingPathPoints.length);
|
||
|
||
double minDistance = double.infinity;
|
||
int closestIndex = _lastTraveledIndex;
|
||
bool foundCloser = false;
|
||
|
||
for (int i = startIndex; i < endIndex; i++) {
|
||
final point = upcomingPathPoints[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 != _lastTraveledIndex) {
|
||
_lastTraveledIndex = closestIndex;
|
||
|
||
final remaining = upcomingPathPoints.sublist(_lastTraveledIndex);
|
||
final traveled = upcomingPathPoints.sublist(0, _lastTraveledIndex + 1);
|
||
|
||
polyLines.removeWhere((p) => p.polylineId.value == 'upcoming_route');
|
||
polyLines.removeWhere((p) => p.polylineId.value == 'traveled_route');
|
||
|
||
// المسار المتبقي
|
||
polyLines.add(
|
||
Polyline(
|
||
polylineId: const PolylineId("upcoming_route"),
|
||
points: remaining,
|
||
width: 8,
|
||
color: isRideStarted ? Colors.blue : Colors.yellow,
|
||
),
|
||
);
|
||
|
||
// المسار المقطوع (رمادي)
|
||
polyLines.add(
|
||
Polyline(
|
||
polylineId: const PolylineId('traveled_route'),
|
||
points: traveled,
|
||
color: Colors.grey.withValues(alpha: 0.8),
|
||
width: 7,
|
||
zIndex: 1,
|
||
),
|
||
);
|
||
|
||
_updatePassengerWalkLine();
|
||
update();
|
||
}
|
||
}
|
||
|
||
void stopListeningStepNavigation() {
|
||
_posSub?.cancel();
|
||
_posSub = null;
|
||
// [Fix 1] إعادة تشغيل المستمع الأساسي للحركة السلسة بعد إيقاف الملاحة.
|
||
_startLocationListening();
|
||
}
|
||
|
||
// دالة لبناء الخط المنقط
|
||
List<Polyline> _buildDashedLine(LatLng start, LatLng end,
|
||
{required Color color, required String prefixId}) {
|
||
List<Polyline> segments = [];
|
||
double dist = Geolocator.distanceBetween(
|
||
start.latitude, start.longitude, end.latitude, end.longitude);
|
||
if (dist < 2) return []; // قريبة جداً، لا نرسم خطاً
|
||
|
||
const double dashLengthMeters = 8.0;
|
||
const double gapLengthMeters = 6.0;
|
||
double totalLength = 0.0;
|
||
|
||
int segmentCount = 0;
|
||
while (totalLength < dist) {
|
||
double startFraction = totalLength / dist;
|
||
double startLat =
|
||
start.latitude + (end.latitude - start.latitude) * startFraction;
|
||
double startLng =
|
||
start.longitude + (end.longitude - start.longitude) * startFraction;
|
||
|
||
totalLength += dashLengthMeters;
|
||
if (totalLength > dist) totalLength = dist;
|
||
|
||
double endFraction = totalLength / dist;
|
||
double endLat =
|
||
start.latitude + (end.latitude - start.latitude) * endFraction;
|
||
double endLng =
|
||
start.longitude + (end.longitude - start.longitude) * endFraction;
|
||
|
||
segments.add(
|
||
Polyline(
|
||
polylineId: PolylineId('${prefixId}_dash_$segmentCount'),
|
||
points: [LatLng(startLat, startLng), LatLng(endLat, endLng)],
|
||
color: color,
|
||
width: 4.0,
|
||
),
|
||
);
|
||
segmentCount++;
|
||
totalLength += gapLengthMeters;
|
||
}
|
||
return segments;
|
||
}
|
||
|
||
// تحديث ورسم الخط المنقط من نهاية الطريق إلى موقع الراكب الفعلي
|
||
void _updatePassengerWalkLine() {
|
||
// مسح خطوط المشي السابقة
|
||
polyLines.removeWhere(
|
||
(p) => p.polylineId.value.startsWith('passenger_walk_line'));
|
||
|
||
if (!isRideStarted &&
|
||
upcomingPathPoints.isNotEmpty &&
|
||
latLngPassengerLocation.latitude != 0) {
|
||
final LatLng lastRoadPt = upcomingPathPoints.last;
|
||
|
||
final walkDashes = _buildDashedLine(
|
||
lastRoadPt,
|
||
latLngPassengerLocation,
|
||
color: Colors.blueGrey, // لون أزرق رمادي مميز
|
||
prefixId: 'passenger_walk_line',
|
||
);
|
||
polyLines.addAll(walkDashes);
|
||
}
|
||
}
|
||
}
|
||
|
||
double safeParseDouble(dynamic value, {double defaultValue = 0.0}) {
|
||
if (value == null) return defaultValue;
|
||
if (value is double) return value;
|
||
if (value is int) return value.toDouble();
|
||
return double.tryParse(value.toString()) ?? defaultValue;
|
||
}
|
||
|
||
int safeParseInt(dynamic value, {int defaultValue = 0}) {
|
||
if (value == null) return defaultValue;
|
||
if (value is int) return value;
|
||
return int.tryParse(value.toString()) ?? defaultValue;
|
||
}
|