Update: 2026-08-07 01:53:09

This commit is contained in:
Hamza-Ayed
2026-08-07 01:53:09 +03:00
parent 5b864e30df
commit 9830e5f649
7 changed files with 289 additions and 26 deletions
+24 -2
View File
@@ -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";
+24
View File
@@ -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;
}
}
/**
* ‏يرتّب المرشحين ويسلّمهم للسوكِت ليمنح الأفضلَ سبقاً حصرياً قصيراً.
*
+22
View File
@@ -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),
),