From 9830e5f649cedbf36d4b95acb592e5d2b80d5030 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 7 Aug 2026 01:53:09 +0300 Subject: [PATCH] Update: 2026-08-07 01:53:09 --- backend/functions.php | 26 +++++- backend/ride/dispatch/dispatch_helper.php | 24 ++++++ backend/ride/rides/add_ride.php | 22 +++++ .../functions/background_service.dart | 55 ++++++++++++ .../home/captin/order_request_controller.dart | 23 +++++ siro_driver/lib/models/model/order_data.dart | 83 +++++++++++++++++++ .../Captin/orderCaptin/order_over_lay.dart | 82 ++++++++++++------ 7 files changed, 289 insertions(+), 26 deletions(-) diff --git a/backend/functions.php b/backend/functions.php index 99e365f7..76fcd385 100644 --- a/backend/functions.php +++ b/backend/functions.php @@ -365,7 +365,12 @@ function notifyPassengerOnRideServer($passenger_id, $payload) { // ============================================================================ // دالة توزيع الطلب (Dispatch Function) - النسخة المصححة // ============================================================================ -function dispatchRideToDrivers($driversData, $rideId, $payloadTemplate, $startNameLoc, $encryptionHelper, $extraFcmData = []) { +/** + * ‏$offerInfo — وسم العرض في الإسناد بالدفعات: ['exclusive', 5]. + * ‏يُمرَّر فقط لصاحب السبق الحصري، ويُترك null في البثّ الحر فلا يتغيّر شيء. + * ‏الفهرسان 37 و38 يقرأهما OrderData.fromList في تطبيق السائق. + */ +function dispatchRideToDrivers($driversData, $rideId, $payloadTemplate, $startNameLoc, $encryptionHelper, $extraFcmData = [], $offerInfo = null) { $countDrivers = count($driversData); error_log("🚀 [DISPATCH_START] RideID: $rideId | Drivers Count: $countDrivers"); @@ -386,7 +391,19 @@ function dispatchRideToDrivers($driversData, $rideId, $payloadTemplate, $startNa // تجهيز البيانات الخاصة بالسائق $payloadForDriver = $payloadTemplate; $payloadForDriver[6] = (string)$driverId; - $payloadForDriver[18] = (string)$driverId; + $payloadForDriver[18] = (string)$driverId; + + // ‏وسم العرض للفهرسَين اللذين يقرأهما تطبيق السائق. الفهارس بينهما + // ‏قد تكون غير معرَّفة، فنملؤها بفراغ وإلا صارت القائمة متفرقة + // ‏(sparse) وخرجت من json_encode كخريطة لا كمصفوفة. + if ($offerInfo !== null) { + for ($i = count($payloadForDriver); $i < 37; $i++) { + $payloadForDriver[$i] = ''; + } + $payloadForDriver[37] = (string)$offerInfo[0]; + $payloadForDriver[38] = (string)$offerInfo[1]; + ksort($payloadForDriver); + } // 2. إرسال السوكيت sendSocketNotification($driverId, $rideId, $payloadForDriver, $socketUrl, $internalKey); @@ -400,6 +417,11 @@ function dispatchRideToDrivers($driversData, $rideId, $payloadTemplate, $startNa // 🆕 Destination Match Indicator $title = "طلب جديد 🔔"; + if ($offerInfo !== null && $offerInfo[0] === 'exclusive') { + $title = "⭐ طلب خاص لك"; + $fcmData['offer_type'] = 'exclusive'; + $fcmData['offer_expires_in'] = (string)$offerInfo[1]; + } if (!empty($driver['is_destination_match'])) { $title = "في طريقك! 📍"; $fcmData['is_destination_match'] = "1"; diff --git a/backend/ride/dispatch/dispatch_helper.php b/backend/ride/dispatch/dispatch_helper.php index 6dc8128d..8a1055a3 100644 --- a/backend/ride/dispatch/dispatch_helper.php +++ b/backend/ride/dispatch/dispatch_helper.php @@ -155,6 +155,30 @@ function dispatchReadSignals($redisLocation, array $driverIds): array return $signals; } +/** + * ‏أول مرشح في الطابور — أي صاحب السبق المتوقّع. + * + * ‏يُقرأ بلا إخراج (lIndex لا lPop): السوكِت هو من يسحب من الطابور، وسحبنا + * ‏منه هنا كان سيحرم الأول من عرضه. + * + * ⚠️ ‏قد يتخطّاه السوكِت إن كان غير متصل، فيصير السبق لغيره بينما وصل هذا + * ‏إشعار FCM. لا ضرر: تطبيقه سيجد الرحلة مأخوذة أو عامة، والسبق ينتهي + * ‏بعد ثوانٍ على أي حال. + */ +function dispatchHeadStartDriver($redisLocation, $rideId): ?string +{ + if (!$redisLocation) { + return null; + } + try { + $first = $redisLocation->lIndex("ride:$rideId:dispatch_queue", 0); + return ($first === false || $first === null) ? null : (string) $first; + } catch (Throwable $e) { + error_log("[dispatch] تعذّرت قراءة صاحب السبق RideID=$rideId: " . $e->getMessage()); + return null; + } +} + /** * ‏يرتّب المرشحين ويسلّمهم للسوكِت ليمنح الأفضلَ سبقاً حصرياً قصيراً. * diff --git a/backend/ride/rides/add_ride.php b/backend/ride/rides/add_ride.php index f2e4a1f5..5aee2fa8 100644 --- a/backend/ride/rides/add_ride.php +++ b/backend/ride/rides/add_ride.php @@ -492,6 +492,28 @@ try { if ($batchedStarted) { notifySocketStartBatchedDispatch($insertedId); + + // ‏السوكِت يوصل العرض لمن تطبيقه مفتوح فقط. بلا FCM، السائق + // ‏الأفضل ترتيباً وتطبيقه مغلق لا يعلم بسبقه إطلاقاً — فيضيع + // ‏السبق كله ويذهب للبثّ. نرسل له إشعاراً موازياً. + $headStartDriver = dispatchHeadStartDriver($redisLocation ?? null, $insertedId); + if ($headStartDriver !== null) { + $headDriverRow = null; + foreach ($driversData as $d) { + if ((string)($d['driver_id'] ?? '') === $headStartDriver) { + $headDriverRow = $d; + break; + } + } + if ($headDriverRow) { + dispatchRideToDrivers( + [$headDriverRow], $insertedId, $payload, $start_name_loc, + $encryptionHelper, $extraDispatchData, + ['exclusive', dispatchHeadStartSeconds()] + ); + } + } + error_log("[add_ride] RideID=$insertedId أُسند بالدفعات."); } else { error_log("[add_ride] تعذّر الإسناد بالدفعات RideID=$insertedId" diff --git a/siro_driver/lib/controller/functions/background_service.dart b/siro_driver/lib/controller/functions/background_service.dart index 47060414..6052f472 100644 --- a/siro_driver/lib/controller/functions/background_service.dart +++ b/siro_driver/lib/controller/functions/background_service.dart @@ -137,6 +137,61 @@ Future onStart(ServiceInstance service) async { payload: jsonEncode(data)); } }); + + // ══════════════════════════════════════════════════════════════════ + // 🚫 الرحلة أخذها سائق آخر + // + // ‏نافذة الأوفرلي تعيش في عزلة منفصلة بلا سوكِت خاص بها، فلا تعلم + // ‏بشيء من نفسها. وهذه العزلة الخلفية هي الوحيدة التي تسمع الحدث + // ‏والتطبيق مغلق. بدون هذا المستمع تبقى النافذة معروضة وعدّادها + // ‏يعمل على رحلة لم تعد موجودة، ويضغط السائق "قبول" فيجدها مأخوذة. + // + // ‏نغلق النافذتين معاً: نافذة فلاتر (مسار السوكِت) والنافذة الأصلية + // ‏(مسار FCM) — لا نعرف أيّهما معروضة الآن. + // ══════════════════════════════════════════════════════════════════ + socket.on('ride_taken', (data) async { + try { + await GetStorage.init(); + final box = GetStorage(); + final String myDriverId = box.read(BoxName.driverID).toString(); + final String whoTookIt = data?['taken_by_driver_id']?.toString() ?? ''; + + // ‏لا نغلق على من فاز بها — تطبيقه في طريقه لفتح شاشة الرحلة. + if (whoTookIt == myDriverId) return; + + bool closedAny = false; + + if (Platform.isAndroid) { + if (await Overlay.FlutterOverlayWindow.isActive()) { + await Overlay.FlutterOverlayWindow.closeOverlay(); + closedAny = true; + } + if (await TripOverlayPlugin.isOverlayActive()) { + await TripOverlayPlugin.hideOverlay(); + closedAny = true; + } + } + + // ‏الإشعار فقط إن كان هناك ما يُغلق فعلاً — وإلا أزعجنا سائقاً + // ‏لم يُعرض عليه شيء أصلاً (الحدث يُبثّ للجميع). + if (closedAny) { + flutterLocalNotificationsPlugin.show( + id: 1003, + title: "الطلب لم يعد متاحاً", + body: "قام سائق آخر بقبول هذه الرحلة", + notificationDetails: const NotificationDetails( + iOS: DarwinNotificationDetails( + presentAlert: true, + presentBadge: false, + presentSound: false, + ), + ), + payload: ''); + } + } catch (e) { + print("⚠️ ride_taken handler error: $e"); + } + }); } diff --git a/siro_driver/lib/controller/home/captin/order_request_controller.dart b/siro_driver/lib/controller/home/captin/order_request_controller.dart index 077be904..55fc5c9c 100755 --- a/siro_driver/lib/controller/home/captin/order_request_controller.dart +++ b/siro_driver/lib/controller/home/captin/order_request_controller.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_overlay_window/flutter_overlay_window.dart'; +import 'package:trip_overlay_plugin/trip_overlay_plugin.dart'; import 'package:get/get.dart'; import 'package:intaleq_maps/intaleq_maps.dart'; import 'package:geolocator/geolocator.dart'; @@ -656,6 +657,12 @@ class OrderRequestController extends GetxController if (takenRideId == myCurrentRideId && whoTookIt != myDriverId) { _isRideTakenHandled = true; endTimer(); + + // ‏إغلاق نافذة الأوفرلي إن كانت معروضة فوق تطبيق آخر. كانت + // ‏الصفحة تُغلق وحدها وتبقى النافذة معلّقة بعدّاد يعمل على رحلة + // ‏لم تعد موجودة. نغلق النافذتين: فلاتر (السوكِت) والأصلية (FCM). + _closeAnyOverlay(); + // 1. حذف الإشعار من شريط التنبيهات فوراً NotificationController().cancelOrderNotification(); if (Get.isSnackbarOpen) Get.closeCurrentSnackbar(); @@ -684,6 +691,22 @@ class OrderRequestController extends GetxController } } + /// ‏إغلاق أي نافذة عرض معروضة، أياً كان نظامها. لا يرمي أبداً: فشل + /// ‏الإغلاق يجب ألا يمنع بقية معالجة "أخذها سائق آخر". + void _closeAnyOverlay() async { + if (!Platform.isAndroid) return; + try { + if (await FlutterOverlayWindow.isActive()) { + await FlutterOverlayWindow.closeOverlay(); + } + if (await TripOverlayPlugin.isOverlayActive()) { + await TripOverlayPlugin.hideOverlay(); + } + } catch (e) { + Log.print("⚠️ closeAnyOverlay: $e"); + } + } + void _checkOverlay() async { if (Platform.isAndroid && await FlutterOverlayWindow.isActive()) { await FlutterOverlayWindow.closeOverlay(); diff --git a/siro_driver/lib/models/model/order_data.dart b/siro_driver/lib/models/model/order_data.dart index 4f887897..5530fe1a 100755 --- a/siro_driver/lib/models/model/order_data.dart +++ b/siro_driver/lib/models/model/order_data.dart @@ -28,7 +28,16 @@ class OrderData { final double? driverEarningsExtra; final String? driverEarningsCurrency; + /// نوع الطلب: 'exclusive' خلال السبق الحصري، 'public' بعده، و null في + /// وضع البثّ الحر (عندها لا تُعرض شارة ويعمل المؤقّت بمدته العادية). + final String? offerType; + + /// ثواني السبق الحصري كما حددها الخادم (DISPATCH_HEAD_START_SECONDS). + final int offerExpiresIn; + OrderData({ + this.offerType, + this.offerExpiresIn = 0, required this.customerName, required this.customerToken, required this.tripDistanceKm, @@ -135,9 +144,83 @@ class OrderData { list.length > 36 && list[36].toString().isNotEmpty ? list[36].toString() : null, + + // ‏مسار FCM لا يحمل هذين اليوم — يبقيان محايدَين فلا تظهر شارة. + offerType: list.length > 37 && list[37].toString().isNotEmpty + ? list[37].toString() + : null, + offerExpiresIn: + list.length > 38 ? (int.tryParse(list[38].toString()) ?? 0) : 0, ); } + /// بناء من حمولة السوكِت (خريطة). أسماء الحقول تطابق buildMarketPayload() + /// في backend/ride/rides/add_ride.php — أي تغيير هناك يجب أن ينعكس هنا. + /// + /// ملاحظة: حمولة السوق لا تحمل اسم الراكب ولا توكنه عمداً (تصل سائقين لن + /// يقودوا الرحلة)، فتبقى القيم الافتراضية حتى يُقبل الطلب. + factory OrderData.fromSocketMap(Map map) { + String str(String key, [String fallback = '']) => + map[key]?.toString().isNotEmpty == true ? map[key].toString() : fallback; + + final distanceToPassengerMeters = + double.tryParse(str('distanceToPassenger', '0')) ?? 0.0; + + return OrderData( + customerName: str('passengerName', 'Unknown Customer'), + customerToken: str('passengerToken', 'Unknown token'), + tripDistanceKm: double.tryParse(str('distance', '0')) ?? 0.0, + price: str('price', '0').split('.')[0], + startLocationAddress: str('startName', 'Unknown Address'), + endLocationAddress: str('endName', 'Unknown Address'), + distanceToPassengerKm: distanceToPassengerMeters / 1000.0, + tripDurationMinutes: _secondsToRoundedUpMinutes(str('duration', '0')), + durationToPassengerMinutes: + _secondsToRoundedUpMinutes(str('durationToPassenger', '0')), + rideType: _getRideType(str('carType', 'Unknown')), + orderId: str('id', 'N/A'), + passengerId: str('passengerId', 'N/A'), + passengerRate: str('passengerRate', 'N/A'), + rawStartCoordinates: str('start_location').isNotEmpty + ? str('start_location') + : (str('start_lat').isNotEmpty + ? '${str('start_lat')},${str('start_lng')}' + : null), + rawEndCoordinates: str('end_location').isNotEmpty + ? str('end_location') + : (str('end_lat').isNotEmpty + ? '${str('end_lat')},${str('end_lng')}' + : null), + driverEarningsExtra: double.tryParse(str('driver_earnings_extra')), + driverEarningsCurrency: str('driver_earnings_currency').isNotEmpty + ? str('driver_earnings_currency') + : null, + offerType: str('offer_type').isNotEmpty ? str('offer_type') : null, + offerExpiresIn: int.tryParse(str('offer_expires_in', '0')) ?? 0, + ); + } + + /// الموحِّد: FCM يصل قائمةً مفهرسة، والسوكِت يصل خريطة JSON. النافذة + /// الواحدة تخدم المسارين، فالتمييز يتم هنا لا في كل مستدعٍ. + /// يرجع null على أي شكل غير معروف بدل أن يرمي — النافذة تبقى صامتة + /// بدل أن تنهار على حمولة غريبة. + static OrderData? fromAny(dynamic event) { + if (event is List) return OrderData.fromList(event); + + if (event is Map) { + // ‏خريطتان مختلفتان تصلان هنا: حمولة السوكِت (أسماء الخادم: id، + // ‏carType، startName) وخريطة toMap() الداخلية (orderId، rideType). + // ‏نميّز بالمفتاح لا بالنوع، وإلا قرأنا إحداهما بقواعد الأخرى فخرجت + // ‏كل الحقول فارغة بصمت. + if (event.containsKey('orderId') || event.containsKey('rideType')) { + return OrderData.fromMap(Map.from(event)); + } + return OrderData.fromSocketMap(event); + } + + return null; + } + static String _getRideType(String type) { switch (type) { case 'Comfort': diff --git a/siro_driver/lib/views/home/Captin/orderCaptin/order_over_lay.dart b/siro_driver/lib/views/home/Captin/orderCaptin/order_over_lay.dart index 4a32ba96..c6f9f027 100755 --- a/siro_driver/lib/views/home/Captin/orderCaptin/order_over_lay.dart +++ b/siro_driver/lib/views/home/Captin/orderCaptin/order_over_lay.dart @@ -41,9 +41,16 @@ class OrderOverlay extends StatefulWidget { class _OrderOverlayState extends State with WidgetsBindingObserver { // === State Variables === + /// مدة مرحلة "الطلب العام" بعد انتهاء السبق الحصري. + static const int kPublicWindowSeconds = 15; + OrderData? orderData; Timer? timer; - int remainingSeconds = 10; + int remainingSeconds = kPublicWindowSeconds; + + /// هل ما زلنا داخل ثواني السبق الحصري؟ تنقلب إلى false تلقائياً حين + /// يتبقّى من العدّاد ما يساوي مدة المرحلة العامة. + bool isExclusive = false; final AudioPlayer audioPlayer = AudioPlayer(); bool buttonsEnabled = true; final String mapApiKey = AK.mapAPIKEY; @@ -113,20 +120,29 @@ class _OrderOverlayState extends State void _processEventData(dynamic event) { _log("Received event: $event"); - if (event is List) { - try { - myList = event; - final newOrder = OrderData.fromList(event); - _log("Parsed OrderData: ${newOrder.toMap()}"); - setState(() { - orderData = newOrder; - }); - _resetAndStartTimer(); - } catch (e, s) { - _log("Error parsing OrderData: $e\nStackTrace: $s"); + + // ‏مساران يغذّيان هذه النافذة بشكلين مختلفين: + // • FCM → قائمة مفهرسة + // • السوكِت → خريطة JSON + // ‏كان الشرط يقبل القائمة وحدها، فحمولة السوكِت تُرفض بصمت ولا تظهر + // ‏نافذة إطلاقاً. OrderData.fromAny يوحّد الشكلين. + try { + final newOrder = OrderData.fromAny(event); + if (newOrder == null) { + _log("Unexpected data format: $event"); + return; } - } else { - _log("Unexpected data format: $event"); + + if (event is List) myList = event; + + _log("Parsed OrderData: ${newOrder.toMap()}"); + setState(() { + orderData = newOrder; + isExclusive = newOrder.offerType == 'exclusive'; + }); + _resetAndStartTimer(); + } catch (e, s) { + _log("Error parsing OrderData: $e\nStackTrace: $s"); } } @@ -151,13 +167,16 @@ class _OrderOverlayState extends State _startTimer(); } + /// المدة الكلية للنافذة = ثواني السبق الحصري + مدة الطلب العام. + /// + /// في وضع الإسناد بالدفعات: ٥ ثوانٍ الطلب خاص بهذا السائق، ثم ١٥ ثانية + /// طلب عام يتنافس فيها مع غيره — أي نافذة ٢٠ ثانية إجمالاً. + /// في البثّ الحر (بلا offerType): ١٥ ثانية عامة فقط، بلا شارة. int _calculateTimerDuration() { - if (orderData?.durationToPassengerMinutes != null && - orderData!.durationToPassengerMinutes > 0) { - int duration = orderData!.durationToPassengerMinutes * 60; - return duration > 10 ? 10 : duration; - } - return 10; + final int head = orderData?.offerType == 'exclusive' + ? (orderData!.offerExpiresIn > 0 ? orderData!.offerExpiresIn : 5) + : 0; + return head + kPublicWindowSeconds; } void _startTimer() { @@ -171,6 +190,11 @@ class _OrderOverlayState extends State setState(() { if (remainingSeconds > 0) { remainingSeconds--; + // ‏انتهى السبق: الطلب صار عاماً. النافذة لا تُغلق — السائق يظل + // ‏قادراً على القبول لكنه صار يتنافس. + if (isExclusive && remainingSeconds <= kPublicWindowSeconds) { + isExclusive = false; + } } else { timer.cancel(); _stopAudio(); @@ -450,12 +474,22 @@ class _OrderOverlayState extends State children: [ Row( children: [ - Icon(Icons.drive_eta_rounded, color: AppColors.white, size: 24), + Icon( + isExclusive ? Icons.star_rounded : Icons.drive_eta_rounded, + color: isExclusive ? const Color(0xFFFFD54F) : AppColors.white, + size: 24, + ), const SizedBox(width: 8), + // ‏العنوان يحمل حالة العرض: خاص خلال السبق، عام بعده، و"طلب + // ‏جديد" في البثّ الحر حيث لا معنى للتمييز. Text( - "طلب جديد".tr, - style: const TextStyle( - color: AppColors.white, + orderData?.offerType == null + ? "طلب جديد".tr + : (isExclusive ? "⭐ طلب خاص لك".tr : "🌐 طلب عام".tr), + style: TextStyle( + color: isExclusive + ? const Color(0xFFFFD54F) + : AppColors.white, fontSize: 18, fontWeight: FontWeight.w600), ),