Update: 2026-08-07 01:53:09
This commit is contained in:
+24
-2
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* يرتّب المرشحين ويسلّمهم للسوكِت ليمنح الأفضلَ سبقاً حصرياً قصيراً.
|
||||
*
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -137,6 +137,61 @@ Future<bool> 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<dynamic, dynamic> 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<String, dynamic>.from(event));
|
||||
}
|
||||
return OrderData.fromSocketMap(event);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static String _getRideType(String type) {
|
||||
switch (type) {
|
||||
case 'Comfort':
|
||||
|
||||
@@ -41,9 +41,16 @@ class OrderOverlay extends StatefulWidget {
|
||||
class _OrderOverlayState extends State<OrderOverlay>
|
||||
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<OrderOverlay>
|
||||
|
||||
void _processEventData(dynamic event) {
|
||||
_log("Received event: $event");
|
||||
if (event is List<dynamic>) {
|
||||
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<OrderOverlay>
|
||||
_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<OrderOverlay>
|
||||
setState(() {
|
||||
if (remainingSeconds > 0) {
|
||||
remainingSeconds--;
|
||||
// انتهى السبق: الطلب صار عاماً. النافذة لا تُغلق — السائق يظل
|
||||
// قادراً على القبول لكنه صار يتنافس.
|
||||
if (isExclusive && remainingSeconds <= kPublicWindowSeconds) {
|
||||
isExclusive = false;
|
||||
}
|
||||
} else {
|
||||
timer.cancel();
|
||||
_stopAudio();
|
||||
@@ -450,12 +474,22 @@ class _OrderOverlayState extends State<OrderOverlay>
|
||||
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),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user