إصلاح مسار الإشعار→الطلب عند الكابتن + توحيد إعادة اتصال سوكيت الراكب

سوكيت الراكب:
- setReconnectionAttempts: من 20 إلى لا نهائي، توحيداً مع تطبيق السائق. بعد
  20 محاولة كان يستسلم **نهائياً** فيصمت للأبد بعد دقائق من شبكة سيئة ويبقى
  الراكب على الـ polling بلا أن يدري — وخطره أكبر الآن لأن الاتصال صار يعيش
  من فتح الخريطة لا من إنشاء الرحلة.
- الـ heartbeat من 15ث إلى 30ث: Socket.IO يعمل ping/pong على مستوى
  البروتوكول ومستمع 'heartbeat' في passenger_socket.php فارغ أصلاً.

البدء البارد على iOS (سبب "التطبيق يغلق ونرجع من أول"):
- didChangeAppLifecycleState لا يُنفَّذ عند إطلاق التطبيق (يبدأ في resumed
  بلا انتقال حالة)، وهو المُنادي الوحيد لـ _checkPendingTrip ⇒ الضغط على
  الإشعار والتطبيق مقتول لا يوجّه لصفحة الطلب إطلاقاً.
- وحتى لو نُفِّذ، كان يستسلم على '/' ويحوّل المسؤولية لـ
  HomeCaptainController الذي لا يملك أي منطق كهذا ⇒ الطلب يبقى في التخزين
  للأبد. صار addPostFrameCallback + مُجدوِل يعيد المحاولة حتى خروج التطبيق
  من شاشة البداية (سقف ~30ث)، ولا يحذف الطلب قبل التوجيه.
- حرس إضافي: لا اقتحام لرحلة نشطة بطلب قديم، لا توجيه مكرر إذا كنا على
  OrderRequestPage، وتنظيف الحمولة التالفة.

TripOverlayPlugin أندرويد فقط بلا أي حرس منصّة:
- كل دالة كانت تنادي invokeMethod مباشرة ⇒ MissingPluginException على iOS
  (لا يوجد ios/ ولا مدخل في pubspec.plugin.platforms).
- الأخطر: backgroundMessageHandler كان ينادي showOverlay **قبل** حفظ
  pending_driver_list، فيسقط الاستثناء ويُلغي الحفظ كلياً على iOS. عُكس
  الترتيب — الحفظ أولاً دائماً، وهو الحدّ الأدنى الذي لا يتوقف على منصّة أو
  صلاحية.
- أُضيف isSupported وحُصِّنت كل الدوال (ترجع false/no-op ولا ترمي أبداً)،
  فلا يُقطع أيضاً _initApp قبل _listenToOverlayEvents.

أخطاء index في حمولة add_ride.php (أربع نقاط قبول):
- 'Duration' كان index 4 وهو end_lng لا المدة ⇒ صار 15 (duration_text).
  أحد المواضع كان فيه تعليق "انتبه: تأكد من الإندكس الصحيح للوقت".
- 'passengerWalletBurc' كان 26 وهو price_for_driver (أرباح السائق) لا رصيد
  الراكب ⇒ صار 27 (passenger_wallet).
- الموضعان مكرران في main.dart و local_notification.dart و order_over_lay.dart
  و order_request_controller.dart — وُحِّدت كلها.
- حرس الطول في backgroundMessageHandler: كان length > 29 مع قراءة index 30
  ⇒ RangeError على قائمة طولها 30 بالضبط.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-27 00:07:27 +03:00
co-authored by Claude Opus 5
parent ab3be7e2e3
commit a26472ed9f
6 changed files with 176 additions and 40 deletions
@@ -359,7 +359,7 @@ class NotificationController extends GetxController {
return { return {
'passengerLocation': '${_getVal(data, 0)},${_getVal(data, 1)}', 'passengerLocation': '${_getVal(data, 0)},${_getVal(data, 1)}',
'passengerDestination': '${_getVal(data, 3)},${_getVal(data, 4)}', 'passengerDestination': '${_getVal(data, 3)},${_getVal(data, 4)}',
'Duration': _getVal(data, 4), // انتبه: تأكد من الإندكس الصحيح للوقت 'Duration': _getVal(data, 15), // 15 = duration_text في حمولة add_ride.php (كان 4 = end_lng)
'totalCost': _getVal(data, 26), 'totalCost': _getVal(data, 26),
'Distance': _getVal(data, 5), 'Distance': _getVal(data, 5),
'name': _getVal(data, 8), 'name': _getVal(data, 8),
@@ -382,7 +382,7 @@ class NotificationController extends GetxController {
'step2': _getVal(data, 23), 'step2': _getVal(data, 23),
'step3': _getVal(data, 24), 'step3': _getVal(data, 24),
'step4': _getVal(data, 25), 'step4': _getVal(data, 25),
'passengerWalletBurc': _getVal(data, 26), 'passengerWalletBurc': _getVal(data, 27), // 27 = passenger_wallet (كان 26 = price_for_driver)
'timeOfOrder': DateTime.now().toString(), 'timeOfOrder': DateTime.now().toString(),
'totalPassenger': _getVal(data, 2), 'totalPassenger': _getVal(data, 2),
'carType': _getVal(data, 31), 'carType': _getVal(data, 31),
@@ -755,7 +755,7 @@ class OrderRequestController extends GetxController
'step2': _safeGet(23), 'step2': _safeGet(23),
'step3': _safeGet(24), 'step3': _safeGet(24),
'step4': _safeGet(25), 'step4': _safeGet(25),
'passengerWalletBurc': _safeGet(26), 'passengerWalletBurc': _safeGet(27), // 27 = passenger_wallet (كان 26 = price_for_driver)
'timeOfOrder': DateTime.now().toString(), 'timeOfOrder': DateTime.now().toString(),
'totalPassenger': _safeGet(2), 'totalPassenger': _safeGet(2),
'carType': _safeGet(31), 'carType': _safeGet(31),
+89 -15
View File
@@ -207,7 +207,9 @@ Future<void> backgroundMessageHandler(RemoteMessage message) async {
double pLat = 0.0; double pLat = 0.0;
double pLng = 0.0; double pLng = 0.0;
if (myList.isNotEmpty && myList.length > 29) { // نقرأ حتى index 30، فالطول المطلوب 31 لا 30 (كان > 29 ⇒ RangeError
// على قائمة طولها 30 بالضبط).
if (myList.length > 30) {
fare = double.tryParse(myList[26].toString()) ?? 0.0; fare = double.tryParse(myList[26].toString()) ?? 0.0;
distance = double.tryParse(myList[11].toString()) ?? 0.0; distance = double.tryParse(myList[11].toString()) ?? 0.0;
passengerName = myList[8].toString(); passengerName = myList[8].toString();
@@ -238,11 +240,15 @@ Future<void> backgroundMessageHandler(RemoteMessage message) async {
currentRideStatus == 'Arrived'); currentRideStatus == 'Arrived');
if (!hasActiveRide) { if (!hasActiveRide) {
if (!isAppInForeground) { // 🔴 الحفظ **أولاً** ثم النافذة. كان العكس: showOverlay ترمي
// MissingPluginException على iOS (الإضافة أندرويد فقط) فيُلغى الحفظ
// تماماً ⇒ يضغط الكابتن الإشعار فلا يجد طلباً ويبدأ التطبيق من الصفر.
// الحفظ هو الحدّ الأدنى الذي لا يجوز أن يتوقف على أي منصّة أو صلاحية.
await storage.write(key: 'pending_driver_list', value: myListString);
if (!isAppInForeground && TripOverlayPlugin.isSupported) {
await TripOverlayPlugin.showOverlay(tripData, autoCloseSeconds: 15); await TripOverlayPlugin.showOverlay(tripData, autoCloseSeconds: 15);
} }
// 🔴 الكتابة على القرص مباشرة بدلاً من الرام لضمان انتقالها للتطبيق 🔴
await storage.write(key: 'pending_driver_list', value: myListString);
} else { } else {
print("⛔ [Background FCM] Ignoring new order overlay - driver is in active ride ($currentRideStatus)"); print("⛔ [Background FCM] Ignoring new order overlay - driver is in active ride ($currentRideStatus)");
} }
@@ -299,6 +305,15 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_initApp(); _initApp();
// 🔴 البدء البارد: didChangeAppLifecycleState **لا يُنفَّذ** عند إطلاق
// التطبيق (يبدأ في resumed بلا انتقال حالة)، فكان _checkPendingTrip لا
// يُنادى إطلاقاً حين يفتح الكابتن التطبيق من الإشعار وهو مقتول. والتعليق
// القديم كان يُحوّل المسؤولية لـ HomeCaptainController وهو لا يملك أي
// منطق كهذا ⇒ يبقى الطلب في التخزين للأبد ويهبط الكابتن على الرئيسية.
WidgetsBinding.instance.addPostFrameCallback((_) {
_schedulePendingTripCheck();
});
// 🔥 التقاط رسالة القبول من الأندرويد بشكل مباشر 100% 🔥 // 🔥 التقاط رسالة القبول من الأندرويد بشكل مباشر 100% 🔥
const platformAppControl = MethodChannel('com.siro_driver/app_control'); const platformAppControl = MethodChannel('com.siro_driver/app_control');
platformAppControl.setMethodCallHandler((call) async { platformAppControl.setMethodCallHandler((call) async {
@@ -405,18 +420,74 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
} }
} }
void _checkPendingTrip() async { /// ينتظر خروج التطبيق من شاشة البداية ثم يفحص الطلب المعلّق.
// 🔴 القراءة من القرص مباشرة 🔴 /// كان الفحص يستسلم فوراً إذا كان على '/' فيُهدر الطلب — الآن يعيد المحاولة.
String? savedTrip = await storage.read(key: 'pending_driver_list'); bool _pendingCheckScheduled = false;
if (savedTrip != null && savedTrip.isNotEmpty) { Future<void> _schedulePendingTripCheck() async {
if (Get.currentRoute == '/') { if (_pendingCheckScheduled) return;
print( _pendingCheckScheduled = true;
'⏳ App is still on Splash screen. Postponing notification trip navigation to HomeCaptainController.'); try {
// سقف ~30 ثانية: يكفي لأبطأ إقلاع بلا أن يدور للأبد
for (int attempt = 0; attempt < 60; attempt++) {
final String? savedTrip = await storage.read(key: 'pending_driver_list');
if (savedTrip == null || savedTrip.isEmpty) {
// لا شيء معلّق بعد — الإشعار قد يكون ما زال يُحفَظ من
// getInitialMessage، فنكمل الانتظار قليلاً.
if (attempt > 10) return;
await Future.delayed(const Duration(milliseconds: 500));
continue;
}
if (Get.currentRoute == '/' || Get.currentRoute.isEmpty) {
await Future.delayed(const Duration(milliseconds: 500));
continue;
}
await _checkPendingTrip();
return; return;
} }
print('⚠️ Pending trip check timed out waiting for app to leave splash.');
} finally {
_pendingCheckScheduled = false;
}
}
Future<void> _checkPendingTrip() async {
// 🔴 القراءة من القرص مباشرة 🔴
String? savedTrip = await storage.read(key: 'pending_driver_list');
if (savedTrip == null || savedTrip.isEmpty) return;
if (Get.currentRoute == '/' || Get.currentRoute.isEmpty) {
// ما زلنا على شاشة البداية — لا نحذف الطلب، ندع المُجدوِل يعيد المحاولة
print('⏳ Still on splash — deferring pending trip navigation (kept).');
_schedulePendingTripCheck();
return;
}
// لا نقتحم رحلة نشطة بطلب قديم
final String currentRideStatus = box.read(BoxName.rideStatus)?.toString() ?? '';
if (currentRideStatus == 'Begin' ||
currentRideStatus == 'Apply' ||
currentRideStatus == 'Arrived') {
print('⛔ Active ride ($currentRideStatus) — discarding stale pending order.');
await storage.delete(key: 'pending_driver_list');
return;
}
if (Get.currentRoute == '/OrderRequestPage') {
print('⚠️ Already on OrderRequestPage — skipping duplicate navigation.');
return;
}
List<dynamic> driverList;
try {
driverList = jsonDecode(savedTrip) as List<dynamic>;
} catch (e) {
print('❌ Corrupt pending_driver_list, discarding: $e');
await storage.delete(key: 'pending_driver_list');
return;
}
await storage.delete(key: 'pending_driver_list'); await storage.delete(key: 'pending_driver_list');
List<dynamic> driverList = jsonDecode(savedTrip);
print('👀 التطبيق فتح من الإشعار أو يدوياً، توجيه لصفحة الطلب...'); print('👀 التطبيق فتح من الإشعار أو يدوياً، توجيه لصفحة الطلب...');
Get.toNamed('/OrderRequestPage', arguments: { Get.toNamed('/OrderRequestPage', arguments: {
@@ -425,7 +496,6 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
'body': 'طلب جديد' 'body': 'طلب جديد'
}); });
} }
}
// ============================================================================== // ==============================================================================
// 🟢 دوال معالجة قبول الطلب وتجهيز المتغيرات 🟢 // 🟢 دوال معالجة قبول الطلب وتجهيز المتغيرات 🟢
@@ -521,7 +591,9 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
return { return {
'passengerLocation': '${_getVal(data, 0)},${_getVal(data, 1)}', 'passengerLocation': '${_getVal(data, 0)},${_getVal(data, 1)}',
'passengerDestination': '${_getVal(data, 3)},${_getVal(data, 4)}', 'passengerDestination': '${_getVal(data, 3)},${_getVal(data, 4)}',
'Duration': _getVal(data, 4), // كان index 4 وهو end_lng لا المدة! ترتيب الحمولة في add_ride.php:
// 15 = duration_text. (راجع $payload هناك قبل تعديل أي index)
'Duration': _getVal(data, 15),
'totalCost': _getVal(data, 26), 'totalCost': _getVal(data, 26),
'Distance': _getVal(data, 5), 'Distance': _getVal(data, 5),
'name': _getVal(data, 8), 'name': _getVal(data, 8),
@@ -544,7 +616,9 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
'step2': _getVal(data, 23), 'step2': _getVal(data, 23),
'step3': _getVal(data, 24), 'step3': _getVal(data, 24),
'step4': _getVal(data, 25), 'step4': _getVal(data, 25),
'passengerWalletBurc': _getVal(data, 26), // كان index 26 وهو price_for_driver (أرباح السائق) لا رصيد الراكب.
// 27 = passenger_wallet في حمولة add_ride.php.
'passengerWalletBurc': _getVal(data, 27),
'timeOfOrder': DateTime.now().toString(), 'timeOfOrder': DateTime.now().toString(),
'totalPassenger': _getVal(data, 2), 'totalPassenger': _getVal(data, 2),
'carType': _getVal(data, 31), 'carType': _getVal(data, 31),
@@ -230,7 +230,7 @@ class _OrderOverlayState extends State<OrderOverlay>
'status': 'Apply', 'status': 'Apply',
'passengerLocation': '${_getData(0)},${_getData(1)}', 'passengerLocation': '${_getData(0)},${_getData(1)}',
'passengerDestination': '${_getData(3)},${_getData(4)}', 'passengerDestination': '${_getData(3)},${_getData(4)}',
'Duration': _getData(4), 'Duration': _getData(15), // 15 = duration_text (كان 4 = end_lng)
'totalCost': _getData(26), 'totalCost': _getData(26),
'Distance': _getData(5), 'Distance': _getData(5),
'name': _getData(8), 'name': _getData(8),
@@ -251,7 +251,7 @@ class _OrderOverlayState extends State<OrderOverlay>
'step2': myList[23].toString(), 'step2': myList[23].toString(),
'step3': myList[24].toString(), 'step3': myList[24].toString(),
'step4': myList[25].toString(), 'step4': myList[25].toString(),
'passengerWalletBurc': myList[26].toString(), 'passengerWalletBurc': myList[27].toString(), // 27 = passenger_wallet (كان 26 = price_for_driver)
'carType': myList[31].toString(), 'carType': myList[31].toString(),
'kazan': myList[32].toString(), 'kazan': myList[32].toString(),
'startNameLocation': myList[29].toString(), 'startNameLocation': myList[29].toString(),
@@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
/// Model for trip data passed to the overlay /// Model for trip data passed to the overlay
@@ -119,45 +121,96 @@ class TripOverlayPlugin {
} }
} }
/// هذه الإضافة **أندرويد فقط** — لا يوجد تنفيذ iOS ولا مجلد ios/ ولا مدخل
/// في pubspec.plugin.platforms. iOS لا يسمح بنوافذ فوق التطبيقات أصلاً.
///
/// بلا هذا الحرس كان أي نداء يرمي MissingPluginException على iOS، وأخطر
/// موضع: backgroundMessageHandler ينادي showOverlay **قبل** حفظ
/// pending_driver_list، فيسقط الاستثناء ويُلغي الحفظ ⇒ يفتح الكابتن الإشعار
/// فلا يجد طلباً وتبدأ الشاشة من الصفر.
static bool get isSupported => Platform.isAndroid;
/// Check if SYSTEM_ALERT_WINDOW permission is granted /// Check if SYSTEM_ALERT_WINDOW permission is granted
static Future<bool> isPermissionGranted() async { static Future<bool> isPermissionGranted() async {
if (!isSupported) return false;
try {
final result = await _channel.invokeMethod<bool>('isPermissionGranted'); final result = await _channel.invokeMethod<bool>('isPermissionGranted');
return result ?? false; return result ?? false;
} on PlatformException catch (e) {
debugPrint('[TripOverlay] isPermissionGranted failed: ${e.message}');
return false;
} on MissingPluginException {
return false;
}
} }
/// Open system settings to grant SYSTEM_ALERT_WINDOW permission /// Open system settings to grant SYSTEM_ALERT_WINDOW permission
static Future<void> requestPermission() async { static Future<void> requestPermission() async {
if (!isSupported) return;
try {
await _channel.invokeMethod('requestPermission'); await _channel.invokeMethod('requestPermission');
} on PlatformException catch (e) {
debugPrint('[TripOverlay] requestPermission failed: ${e.message}');
} on MissingPluginException {
// لا شيء — غير مدعوم على هذه المنصة
}
} }
/// Show the trip overlay with the given [tripData] /// Show the trip overlay with the given [tripData]
/// [autoCloseSeconds] — how long before auto-dismiss (default 30s) /// [autoCloseSeconds] — how long before auto-dismiss (default 30s)
///
/// يرجع false ولا يرمي أبداً: المُنادي يجب أن يكمل مساره (حفظ الطلب المعلّق،
/// الإشعار المحلي) حتى لو تعذّرت النافذة.
static Future<bool> showOverlay( static Future<bool> showOverlay(
TripData tripData, { TripData tripData, {
int autoCloseSeconds = 30, int autoCloseSeconds = 30,
}) async { }) async {
if (!isSupported) return false;
final granted = await isPermissionGranted(); final granted = await isPermissionGranted();
if (!granted) { if (!granted) {
await requestPermission(); await requestPermission();
return false; return false;
} }
try {
final result = await _channel.invokeMethod<bool>('showOverlay', { final result = await _channel.invokeMethod<bool>('showOverlay', {
'tripData': tripData.toJson(), 'tripData': tripData.toJson(),
'autoCloseSeconds': autoCloseSeconds, 'autoCloseSeconds': autoCloseSeconds,
}); });
return result ?? false; return result ?? false;
} on PlatformException catch (e) {
debugPrint('[TripOverlay] showOverlay failed: ${e.message}');
return false;
} on MissingPluginException {
return false;
}
} }
/// Programmatically close the overlay (e.g. if trip was cancelled) /// Programmatically close the overlay (e.g. if trip was cancelled)
static Future<void> hideOverlay() async { static Future<void> hideOverlay() async {
if (!isSupported) return;
try {
await _channel.invokeMethod('hideOverlay'); await _channel.invokeMethod('hideOverlay');
} on PlatformException catch (e) {
debugPrint('[TripOverlay] hideOverlay failed: ${e.message}');
} on MissingPluginException {
// لا شيء
}
} }
/// Check if the overlay is currently visible /// Check if the overlay is currently visible
static Future<bool> isOverlayActive() async { static Future<bool> isOverlayActive() async {
if (!isSupported) return false;
try {
final result = await _channel.invokeMethod<bool>('isOverlayActive'); final result = await _channel.invokeMethod<bool>('isOverlayActive');
return result ?? false; return result ?? false;
} on PlatformException catch (e) {
debugPrint('[TripOverlay] isOverlayActive failed: ${e.message}');
return false;
} on MissingPluginException {
return false;
}
} }
/// Dispose streams — call in app's dispose() /// Dispose streams — call in app's dispose()
@@ -87,7 +87,11 @@ class MapSocketController extends GetxController {
.setTransports(['websocket']) .setTransports(['websocket'])
.disableAutoConnect() .disableAutoConnect()
.setQuery({'id': passengerId, 'jwt': jwt}) .setQuery({'id': passengerId, 'jwt': jwt})
.setReconnectionAttempts(20) // محاولات لا نهائية، توحيداً مع تطبيق السائق (background_service.dart).
// كانت 20: بعدها يستسلم السوكيت **نهائياً** فيصمت للأبد بعد بضع دقائق
// من شبكة سيئة، ويبقى الراكب على الـ polling بلا أن يدري. صار الاتصال
// يعيش من فتح الخريطة لا من إنشاء الرحلة، فالسقف الثابت خطر أكبر الآن.
.setReconnectionAttempts(double.infinity)
.setReconnectionDelay(2000) .setReconnectionDelay(2000)
.setReconnectionDelayMax(10000) .setReconnectionDelayMax(10000)
.enableReconnection() .enableReconnection()
@@ -266,9 +270,14 @@ class MapSocketController extends GetxController {
} }
} }
/// ⚠️ زائدة عن الحاجة عملياً: Socket.IO يعمل ping/pong على مستوى البروتوكول،
/// ومستمع 'heartbeat' في passenger_socket.php فارغ ولا يفعل شيئاً. أبقيناها
/// بفاصل 30ث بدل 15ث لتنصيف حركتها — الاتصال صار يعيش من فتح الخريطة لا من
/// إنشاء الرحلة، فعدد الاتصالات الخاملة أعلى بكثير. حذفها كاملاً ممكن لاحقاً
/// بعد التأكد أن لا شيء على السيرفر يعتمد عليها لقياس الحضور.
void _startHeartbeat() { void _startHeartbeat() {
_heartbeatTimer?.cancel(); _heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(const Duration(seconds: 15), (timer) { _heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
if (isSocketConnected && socket.connected) { if (isSocketConnected && socket.connected) {
socket.emit('heartbeat', socket.emit('heartbeat',
{'passenger_id': box.read(BoxName.passengerID).toString()}); {'passenger_id': box.read(BoxName.passengerID).toString()});