2790 lines
100 KiB
Dart
Executable File
2790 lines
100 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 '../../../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;
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
|
|
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();
|
|
}
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
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.IntaleqSyriaServer); // مثال: اختر سيرفر سوريا للبيانات
|
|
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,
|
|
),
|
|
);
|
|
|
|
// د) معالجة الخطوات (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,
|
|
);
|
|
|
|
// 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();
|
|
|
|
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,
|
|
),
|
|
);
|
|
|
|
update();
|
|
}
|
|
}
|
|
|
|
void stopListeningStepNavigation() {
|
|
_posSub?.cancel();
|
|
_posSub = null;
|
|
// [Fix 1] إعادة تشغيل المستمع الأساسي للحركة السلسة بعد إيقاف الملاحة.
|
|
_startLocationListening();
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|