diff --git a/siro_driver/lib/constant/box_name.dart b/siro_driver/lib/constant/box_name.dart index ef2a01a7..6e999dcc 100755 --- a/siro_driver/lib/constant/box_name.dart +++ b/siro_driver/lib/constant/box_name.dart @@ -134,6 +134,13 @@ class BoxName { // (بلا GMS يسقط الباكيج إلى LocationManager الخام: بطارية أعلى، دقة أقل) static const String isGmsAvailable = 'isGmsAvailable'; + // وضع التوصيل مفعّل — تقرأه العزلة الخلفية لتقرر فتح سوكيت الطعام + static const String foodDeliveryModeEnabled = 'foodDeliveryModeEnabled'; + + // معرّفات مهام التوصيل النشطة — يقرأها بثّ الموقع في LocationController + // فيستمر البثّ بعد إغلاق شاشة التوصيل + static const String foodActiveOrders = 'foodActiveOrders'; + // بيانات اعتماد TURN المؤقتة (مكالمات الصوت) — مخزّنة حتى قرب انتهائها static const String turnIceCache = 'turnIceCache'; } diff --git a/siro_driver/lib/controller/food_delivery/food_delivery_controller.dart b/siro_driver/lib/controller/food_delivery/food_delivery_controller.dart index f019a868..7d138504 100644 --- a/siro_driver/lib/controller/food_delivery/food_delivery_controller.dart +++ b/siro_driver/lib/controller/food_delivery/food_delivery_controller.dart @@ -7,6 +7,7 @@ // 3) active.php — مصدر الحقيقة لحالة المهام، يُصحّح أي فارق بعد أي انقطاع. import 'dart:async'; +import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:get/get.dart'; import 'package:just_audio/just_audio.dart'; @@ -16,11 +17,12 @@ import '../../main.dart'; import '../../print.dart'; import '../../views/food_delivery/food_offer_page.dart'; import '../../views/widgets/error_snakbar.dart'; -import '../functions/location_controller.dart'; import '../voice_call_controller.dart'; import 'food_delivery_models.dart'; import 'food_delivery_service.dart'; +import 'food_location_broadcaster.dart'; import 'food_notification_service.dart'; +import 'food_overlay_bridge.dart'; import 'food_socket_service.dart'; class FoodDeliveryController extends GetxController { @@ -70,6 +72,7 @@ class FoodDeliveryController extends GetxController { final s = res.data!; isDeliveryModeEnabled = s.deliveryModeEnabled; + box.write(BoxName.foodDeliveryModeEnabled, s.deliveryModeEnabled); todayOrdersCount = s.todayOrdersCount; todayEarnings = s.todayEarnings; @@ -92,6 +95,11 @@ class FoodDeliveryController extends GetxController { if (res.success) { isDeliveryModeEnabled = enable; + // العزلة الخلفية تقرأ هذا المفتاح لتقرر فتح سوكيت الطعام أو إغلاقه، + // فيصل العرض حتى والتطبيق مغلق + box.write(BoxName.foodDeliveryModeEnabled, enable); + FlutterBackgroundService().invoke('foodModeChanged', {'enabled': enable}); + if (enable) { await FoodSocketService.instance.connect(); } else { @@ -193,8 +201,17 @@ class FoodDeliveryController extends GetxController { final isForeground = box.read(BoxName.isAppInForeground) ?? false; if (isForeground == true) { _openOfferScreen(); - } else { - // التطبيق في الخلفية — إشعار ملء شاشة بقناة التوصيل الخاصة + return; + } + + // التطبيق في الخلفية: النافذة العائمة أولاً (أندرويد)، وإن تعذّرت — + // صلاحية ناقصة أو iOS — يبقى الإشعار ملء الشاشة هو المسار البديل. + _showBackgroundOffer(offer); + } + + Future _showBackgroundOffer(FoodDeliveryOffer offer) async { + final shown = await FoodOverlayBridge.show(offer); + if (!shown) { FoodNotificationService.instance.showOfferNotification(offer); } } @@ -236,26 +253,6 @@ class FoodDeliveryController extends GetxController { await _fetchPendingOffers(); } await fetchActiveTasks(silent: true); - await _broadcastLocation(); - } - - // موقع السائق يُبثّ فقط ما دامت هناك مهمة نشطة — لا شيء يُرسل قبل الإسناد - // ولا بعد التسليم، والخادم يرفضه في الحالتين أصلاً كطبقة ثانية. - Future _broadcastLocation() async { - if (activeTasks.isEmpty) return; - if (!Get.isRegistered()) return; - - final loc = Get.find(); - if (loc.myLocation.latitude == 0 && loc.myLocation.longitude == 0) return; - - for (final task in activeTasks) { - await FoodDeliveryService.sendLocation( - orderId: task.id, - lat: loc.myLocation.latitude, - lng: loc.myLocation.longitude, - heading: loc.heading, - ); - } } Future _fetchPendingOffers() async { @@ -274,6 +271,10 @@ class FoodDeliveryController extends GetxController { final res = await FoodDeliveryService.getActiveTasks(); isLoadingTasks = false; if (res.success) activeTasks = res.data ?? []; + + // البثّ نفسه يجري في LocationController (المصدر الوحيد للموقع) — نكتفي + // هنا بتسجيل المهام النشطة كي يستمر البثّ بعد إغلاق هذه الشاشة. + FoodLocationBroadcaster.setActiveOrders(activeTasks.map((t) => t.id).toList()); _syncRideAvailability(); update(); } @@ -323,6 +324,7 @@ class FoodDeliveryController extends GetxController { _respondingOfferIds.remove(orderId); pendingOffers.removeWhere((o) => o.orderId == orderId); FoodNotificationService.instance.cancelOfferNotification(); + FoodOverlayBridge.hide(); if (!res.success) { mySnackbarWarning(res.message); diff --git a/siro_driver/lib/controller/food_delivery/food_location_broadcaster.dart b/siro_driver/lib/controller/food_delivery/food_location_broadcaster.dart new file mode 100644 index 00000000..8bc18891 --- /dev/null +++ b/siro_driver/lib/controller/food_delivery/food_location_broadcaster.dart @@ -0,0 +1,79 @@ +// food_location_broadcaster.dart — بثّ موقع السائق للزبون أثناء التوصيل +// +// لماذا هنا ولا في الخدمة الخلفية (background_service.dart): +// قاعدة معمارية مثبّتة في المشروع تمنع أي تدفّق GPS ثانٍ في العزلة الخلفية — +// `LocationController` هو المصدر الوحيد، وهو يبقى حياً في الخلفية أصلاً عبر +// enableBackgroundMode. فتح تدفّق ثانٍ هناك يعني مستمعَي GPS متوازيين واستنزاف +// بطارية وتجميد أجهزة (موثّق في driver_lifecycle.md). فالبثّ يركب على النبضة +// القائمة في LocationController بدل إنشاء مصدر جديد — وهو ما يجعله يستمر بعد +// إغلاق شاشة التوصيل، وهو المطلوب فعلاً. +// +// المهام النشطة تُحفظ في التخزين المحلي، فلا يحتاج هذا الملف كنترولر التوصيل +// ولا يموت بموته. +import 'dart:convert'; + +import '../../constant/box_name.dart'; +import '../../main.dart'; +import '../../print.dart'; +import 'food_delivery_service.dart'; + +class FoodLocationBroadcaster { + FoodLocationBroadcaster._(); + + static const Duration _minInterval = Duration(seconds: 7); + static DateTime? _lastSentAt; + static bool _inFlight = false; + + /// تُخزَّن من كنترولر التوصيل عند كل تحديث للمهام + static void setActiveOrders(List orderIds) { + box.write(BoxName.foodActiveOrders, jsonEncode(orderIds)); + } + + static List get activeOrders { + try { + final raw = box.read(BoxName.foodActiveOrders); + if (raw == null) return const []; + return (jsonDecode(raw.toString()) as List) + .map((e) => int.tryParse(e.toString()) ?? 0) + .where((e) => e != 0) + .toList(); + } catch (_) { + return const []; + } + } + + static void clear() => box.remove(BoxName.foodActiveOrders); + + /// يُنادى من LocationController على كل تحديث موقع — يخرج فوراً إن لم تكن + /// هناك مهمة توصيل، فلا يكلّف شيئاً في الحالة الغالبة. + static Future maybeSend({ + required double lat, + required double lng, + double? heading, + }) async { + final orders = activeOrders; + if (orders.isEmpty) return; + if (lat == 0 && lng == 0) return; + if (_inFlight) return; + + final now = DateTime.now(); + if (_lastSentAt != null && now.difference(_lastSentAt!) < _minInterval) return; + _lastSentAt = now; + + _inFlight = true; + try { + for (final orderId in orders) { + await FoodDeliveryService.sendLocation( + orderId: orderId, + lat: lat, + lng: lng, + heading: heading, + ); + } + } catch (e) { + Log.print('🍔 [FoodLocation] تعذّر بثّ الموقع: $e'); + } finally { + _inFlight = false; + } + } +} diff --git a/siro_driver/lib/controller/food_delivery/food_overlay_bridge.dart b/siro_driver/lib/controller/food_delivery/food_overlay_bridge.dart new file mode 100644 index 00000000..ad81fff6 --- /dev/null +++ b/siro_driver/lib/controller/food_delivery/food_overlay_bridge.dart @@ -0,0 +1,92 @@ +// food_overlay_bridge.dart — نافذة عرض التوصيل العائمة (أندرويد) ونتيجتها +// +// لماذا جسر مستقل عن كنترولر التوصيل: +// العرض قد يصل والتطبيق في الخلفية وشاشة التوصيل مغلقة — بل وقد يصل من العزلة +// الخلفية حيث لا وجود لأي كنترولر. فالاشتراك في نتيجة النافذة يجب أن يكون +// عالمياً ويعيش طوال عمر التطبيق، ويردّ على الخادم بنفسه بلا انتظار شاشة. +import 'dart:async'; + +import 'package:get/get.dart'; +import 'package:trip_overlay_plugin/trip_overlay_plugin.dart'; + +import '../../print.dart'; +import '../../views/food_delivery/food_task_details_page.dart'; +import '../../views/widgets/error_snakbar.dart'; +import 'food_delivery_models.dart'; +import 'food_delivery_service.dart'; + +class FoodOverlayBridge { + FoodOverlayBridge._(); + + static StreamSubscription? _acceptSub; + static StreamSubscription? _rejectSub; + + /// تُنادى مرة واحدة عند إقلاع التطبيق + static void attach() { + if (!TripOverlayPlugin.isSupported) return; // أندرويد فقط + if (_acceptSub != null) return; + + TripOverlayPlugin.initialize(); + + _acceptSub = TripOverlayPlugin.onFoodOrderAccepted.listen(_onAccepted); + _rejectSub = TripOverlayPlugin.onFoodOrderRejected.listen(_onRejected); + Log.print('🍔 [FoodOverlay] جاهز لاستقبال نتائج النافذة العائمة'); + } + + static Future _onAccepted(String orderIdRaw) async { + final orderId = int.tryParse(orderIdRaw) ?? 0; + if (orderId == 0) return; + + Log.print('🍔 [FoodOverlay] قبول من النافذة — طلب #$orderId'); + + // القبول يتم هنا لا في الشاشة: النافذة تُغلق فوراً والخادم لا ينتظر + // فتح واجهة. لو تأخّرنا حتى تُبنى الشاشة قد تنتهي مهلة العرض. + final res = await FoodDeliveryService.respondToOffer(orderId, true); + + if (res.success) { + mySnackbarSuccess('تم قبول طلب التوصيل'); + Get.to(() => FoodTaskDetailsPage(orderId: orderId)); + } else { + mySnackbarWarning(res.message); + } + } + + static Future _onRejected(String orderIdRaw) async { + final orderId = int.tryParse(orderIdRaw) ?? 0; + if (orderId == 0) return; + + // الرفض الصريح يُبلَّغ الخادم ليعرض الطلب على سائق آخر فوراً بدل انتظار + // انتهاء المهلة. انتهاء المهلة يصل بنفس الحدث، والخادم يتعامل مع الحالتين + // بأمان (يرفض إن لم يعد العرض قائماً). + Log.print('🍔 [FoodOverlay] رفض/انتهاء مهلة — طلب #$orderId'); + await FoodDeliveryService.respondToOffer(orderId, false); + } + + /// عرض النافذة لعرض توصيل وارد — تُنادى حين يكون التطبيق في الخلفية + static Future show(FoodDeliveryOffer offer, {String? distanceText}) { + return TripOverlayPlugin.showFoodOverlay( + FoodOrderOverlayData( + orderId: offer.orderId.toString(), + merchantName: offer.merchantNameAr, + merchantAddress: offer.merchantAddress ?? '', + // التنسيق يتم هنا لا في Kotlin — العملة والوحدة الصغرى منطق تطبيق + deliveryFeeText: foodFormatPrice(offer.deliveryFee), + cashToCollectText: + offer.cashToCollect > 0 ? foodFormatPrice(offer.cashToCollect) : '', + distanceText: distanceText ?? '', + itemsCount: offer.itemsCount, + isCash: offer.isCash, + ), + autoCloseSeconds: offer.secondsRemaining.clamp(5, 20), + ); + } + + static Future hide() => TripOverlayPlugin.hideOverlay(); + + static void detach() { + _acceptSub?.cancel(); + _rejectSub?.cancel(); + _acceptSub = null; + _rejectSub = null; + } +} diff --git a/siro_driver/lib/controller/functions/background_service.dart b/siro_driver/lib/controller/functions/background_service.dart index 47417ce3..47060414 100644 --- a/siro_driver/lib/controller/functions/background_service.dart +++ b/siro_driver/lib/controller/functions/background_service.dart @@ -11,6 +11,7 @@ import 'package:flutter_overlay_window/flutter_overlay_window.dart' as Overlay; import 'package:get_storage/get_storage.dart'; import '../../constant/box_name.dart'; import '../../constant/links.dart'; +import 'package:trip_overlay_plugin/trip_overlay_plugin.dart'; import '../firebase/local_notification.dart'; const String notificationChannelId = 'driver_service_channel'; @@ -138,9 +139,115 @@ Future onStart(ServiceInstance service) async { }); } + + // ══════════════════════════════════════════════════════════════════════ + // سوكيت الطعام في العزلة الخلفية — مستقل تماماً عن سوكيت الرحلات أعلاه + // (خادم وبورت وغرفة مختلفة). بدونه لا يصل عرض توصيل والتطبيق مغلق، لأن + // FoodSocketService يعيش في العزلة الرئيسية التي يوقفها أندرويد. + // نفتحه فقط إن كان وضع التوصيل مفعّلاً — لا اتصال دائم بلا داعٍ. + // ══════════════════════════════════════════════════════════════════════ + IO.Socket? foodSocket; + + void connectFoodSocket() { + if (foodSocket != null || driverId.isEmpty) return; + + foodSocket = IO.io( + AppLink.foodSocketUrl, + IO.OptionBuilder() + .setTransports(['websocket']) + .setQuery({ + 'role': 'driver', + 'id': driverId, + 'jwt': token, + 'platform': Platform.isIOS ? 'ios' : 'android', + }) + .enableReconnection() + .setReconnectionDelay(3000) + .build()); + + foodSocket!.onConnect((_) => print('🍔 Background: food socket connected')); + + foodSocket!.on('food_delivery_offer', (data) async { + print('🍔 Background: food delivery offer received'); + await GetStorage.init(); + final box = GetStorage(); + + // الواجهة أمام السائق تتكفّل بالعرض إن كان التطبيق مفتوحاً + final bool isAppInForeground = box.read(BoxName.isAppInForeground) ?? false; + if (isAppInForeground) return; + + Map? payload; + if (data is Map) { + payload = Map.from(data); + } else if (data is List && data.isNotEmpty && data.first is Map) { + payload = Map.from(data.first as Map); + } + if (payload == null) return; + + if (Platform.isAndroid) { + final shown = await TripOverlayPlugin.showFoodOverlay( + FoodOrderOverlayData( + orderId: payload['order_id'].toString(), + merchantName: payload['merchant_name_ar']?.toString() ?? 'مطعم', + merchantAddress: payload['merchant_address']?.toString() ?? '', + deliveryFeeText: _formatFoodPrice(payload['delivery_fee']), + cashToCollectText: _formatFoodPrice(payload['cash_to_collect']), + distanceText: '', + itemsCount: int.tryParse(payload['items_count']?.toString() ?? '') ?? 0, + isCash: payload['payment_method']?.toString() == 'cash', + ), + autoCloseSeconds: 20, + ); + if (shown) return; + } + + // iOS أو تعذّرت النافذة — إشعار على قناة التوصيل الخاصة + flutterLocalNotificationsPlugin.show( + id: 7001, + title: '🍔 عرض توصيل جديد', + body: '${payload['merchant_name_ar'] ?? 'مطعم'} — ' + '${_formatFoodPrice(payload['delivery_fee'])}', + notificationDetails: const NotificationDetails( + android: AndroidNotificationDetails( + 'food_delivery_offer_channel', + 'عروض التوصيل', + importance: Importance.max, + priority: Priority.max, + fullScreenIntent: true, + category: AndroidNotificationCategory.call, + ), + iOS: DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ), + ), + payload: jsonEncode({'food_event': 'offer', 'order_id': payload['order_id']}), + ); + }); + } + + if (box.read(BoxName.foodDeliveryModeEnabled) == true) { + connectFoodSocket(); + } + + // تفعيل/إطفاء وضع التوصيل من الواجهة ينعكس هنا بلا إعادة تشغيل الخدمة + service.on('foodModeChanged').listen((event) { + final enabled = event?['enabled'] == true; + if (enabled) { + connectFoodSocket(); + } else { + foodSocket?.clearListeners(); + foodSocket?.dispose(); + foodSocket = null; + } + }); + service.on('stopService').listen((event) { socket?.clearListeners(); socket?.dispose(); + foodSocket?.clearListeners(); + foodSocket?.dispose(); service.stopSelf(); }); @@ -185,6 +292,14 @@ Future onStart(ServiceInstance service) async { return true; } +// نفس قاعدة foodFormatPrice في نماذج الطعام — مكرّرة هنا عمداً لأن العزلة +// الخلفية يجب أن تبقى خفيفة الاعتماديات ولا تسحب طبقة النماذج كاملة. +String _formatFoodPrice(dynamic smallestUnit) { + final v = int.tryParse(smallestUnit?.toString() ?? '') ?? 0; + if (v == 0) return ''; + return '${(v / 1000).toStringAsFixed(3)} د.أ'; +} + class BackgroundServiceHelper { static Future initialize() async { final service = FlutterBackgroundService(); diff --git a/siro_driver/lib/controller/functions/location_controller.dart b/siro_driver/lib/controller/functions/location_controller.dart index 5fcb221b..96a2c6da 100755 --- a/siro_driver/lib/controller/functions/location_controller.dart +++ b/siro_driver/lib/controller/functions/location_controller.dart @@ -25,6 +25,7 @@ import '../home/navigation/navigation_controller.dart'; import 'background_service.dart'; import 'crud.dart'; import '../transit/transit_driver_controller.dart'; +import '../food_delivery/food_location_broadcaster.dart'; /// إعدادات تتبّع الموقع لحالة معيّنة من حالات السائق. /// الهدف: عدم تشغيل الـ GPS بأقصى طاقته إلا أثناء الرحلة الفعلية. @@ -697,6 +698,15 @@ class LocationController extends GetxController with WidgetsBindingObserver { navCtrl.handleLocationUpdateFromCentral(pos, speed, heading); } + // بثّ الموقع للزبون أثناء مهمة توصيل — يركب على هذه النبضة بدل تدفّق + // GPS ثانٍ، فيستمر بعد إغلاق شاشة التوصيل ما دامت هذه الخدمة حية. + // يخرج فوراً إن لم تكن هناك مهمة نشطة. + FoodLocationBroadcaster.maybeSend( + lat: pos.latitude, + lng: pos.longitude, + heading: heading, + ); + await _saveBehaviorIfMoved(pos, now, currentSpeed: speed); }, onError: (e) => Log.print('❌ Location Stream Error: $e')); } diff --git a/siro_driver/lib/main.dart b/siro_driver/lib/main.dart index b62832fe..8a4038b9 100755 --- a/siro_driver/lib/main.dart +++ b/siro_driver/lib/main.dart @@ -24,6 +24,7 @@ import 'constant/links.dart'; import 'controller/firebase/firbase_messge.dart'; import 'controller/firebase/local_notification.dart'; import 'controller/functions/background_service.dart'; +import 'controller/food_delivery/food_overlay_bridge.dart'; import 'controller/functions/crud.dart'; import 'controller/home/captin/home_captain_controller.dart'; import 'controller/local/local_controller.dart'; @@ -276,6 +277,9 @@ void main() { await requestNotificationPermission(); await TripOverlayPlugin.initialize(); + // نتيجة نافذة عرض التوصيل العائمة تصل عالمياً — قد تُقبل والتطبيق مغلق + // وشاشة التوصيل غير مبنية أصلاً، فالاشتراك لا يجوز أن يتبع شاشة. + FoodOverlayBridge.attach(); await createAllNotificationChannels(); await BackgroundServiceHelper.initialize(); diff --git a/siro_driver/trip_overlay_plugin/android/src/main/kotlin/com/siro_driver/trip_overlay_plugin/TripOverlayPlugin.kt b/siro_driver/trip_overlay_plugin/android/src/main/kotlin/com/siro_driver/trip_overlay_plugin/TripOverlayPlugin.kt index c23cb46c..5e4856aa 100644 --- a/siro_driver/trip_overlay_plugin/android/src/main/kotlin/com/siro_driver/trip_overlay_plugin/TripOverlayPlugin.kt +++ b/siro_driver/trip_overlay_plugin/android/src/main/kotlin/com/siro_driver/trip_overlay_plugin/TripOverlayPlugin.kt @@ -46,6 +46,17 @@ class TripOverlayPlugin : methodChannel?.invokeMethod("onTripRejected", mapOf("tripId" to tripId)) Log.d(TAG, "notifyTripRejected: $tripId") } + + /** طلبات توصيل الطعام — قناة منفصلة عن الرحلات في جهة Dart أيضاً */ + fun notifyFoodOrderAccepted(orderId: String) { + methodChannel?.invokeMethod("onFoodOrderAccepted", mapOf("orderId" to orderId)) + Log.d(TAG, "notifyFoodOrderAccepted: $orderId") + } + + fun notifyFoodOrderRejected(orderId: String) { + methodChannel?.invokeMethod("onFoodOrderRejected", mapOf("orderId" to orderId)) + Log.d(TAG, "notifyFoodOrderRejected: $orderId") + } } // ─── FlutterPlugin ─────────────────────────────────────────────────────── @@ -85,6 +96,16 @@ class TripOverlayPlugin : val success = showOverlay(tripDataJson, autoCloseSeconds) result.success(success) } + "showFoodOverlay" -> { + val foodDataJson = + call.argument("foodData") + ?: run { + result.error("INVALID_ARGS", "foodData is required", null) + return + } + val autoCloseSeconds = call.argument("autoCloseSeconds") ?: 20 + result.success(showFoodOverlay(foodDataJson, autoCloseSeconds)) + } "hideOverlay" -> { hideOverlay() result.success(null) @@ -141,6 +162,26 @@ class TripOverlayPlugin : return true } + private fun showFoodOverlay(foodDataJson: String, autoCloseSeconds: Int): Boolean { + return try { + val intent = + Intent(context, TripOverlayService::class.java).apply { + action = TripOverlayService.ACTION_SHOW_FOOD + putExtra(TripOverlayService.EXTRA_FOOD_DATA, foodDataJson) + putExtra(TripOverlayService.EXTRA_AUTO_CLOSE_SECONDS, autoCloseSeconds) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + true + } catch (e: Exception) { + Log.e(TAG, "showFoodOverlay failed: ${e.message}") + false + } + } + private fun hideOverlay() { val intent = Intent(context, TripOverlayService::class.java).apply { diff --git a/siro_driver/trip_overlay_plugin/android/src/main/kotlin/com/siro_driver/trip_overlay_plugin/TripzOverlayService.kt b/siro_driver/trip_overlay_plugin/android/src/main/kotlin/com/siro_driver/trip_overlay_plugin/TripzOverlayService.kt index 50b71606..f9d35fdb 100644 --- a/siro_driver/trip_overlay_plugin/android/src/main/kotlin/com/siro_driver/trip_overlay_plugin/TripzOverlayService.kt +++ b/siro_driver/trip_overlay_plugin/android/src/main/kotlin/com/siro_driver/trip_overlay_plugin/TripzOverlayService.kt @@ -28,8 +28,10 @@ class TripOverlayService : Service() { companion object { const val TAG = "TripOverlayService" const val ACTION_SHOW = "SHOW" + const val ACTION_SHOW_FOOD = "SHOW_FOOD" const val ACTION_HIDE = "HIDE" const val EXTRA_TRIP_DATA = "trip_data" + const val EXTRA_FOOD_DATA = "food_data" const val EXTRA_AUTO_CLOSE_SECONDS = "auto_close_seconds" private const val NOTIFICATION_ID = 9900 private const val CHANNEL_ID = "trip_overlay_service_channel" @@ -43,6 +45,9 @@ class TripOverlayService : Service() { private var overlayView: View? = null private var countDownTimer: CountDownTimer? = null private var currentTripId: String = "" + private var currentFoodOrderId: String = "" + // نوع النافذة المعروضة الآن — يحدّد لمن نُرسل نتيجة القبول/الرفض + private var isFoodOverlay: Boolean = false private var mediaPlayer: MediaPlayer? = null // 🔴 مشغل الصوت override fun onBind(intent: Intent?): IBinder? = null @@ -61,6 +66,11 @@ class TripOverlayService : Service() { startForegroundWithNotification() showTripOverlay(tripDataJson, autoClose) } + ACTION_SHOW_FOOD -> { + val foodDataJson = intent.getStringExtra(EXTRA_FOOD_DATA) ?: return START_NOT_STICKY + val autoClose = intent.getIntExtra(EXTRA_AUTO_CLOSE_SECONDS, 20) + showFoodOverlay(foodDataJson, autoClose) + } ACTION_HIDE -> dismissOverlay(reason = "programmatic") } return START_NOT_STICKY @@ -171,6 +181,291 @@ class TripOverlayService : Service() { } } + + // ═══════════════════════════════════════════════════════════════════ + // نافذة طلبات التوصيل — تصميم مستقل عن نافذة الرحلة، ويشترك معها في + // البنية المثبتة فقط (الصوت، العدّاد، صلاحية النافذة، الخدمة الأمامية). + // ═══════════════════════════════════════════════════════════════════ + private fun showFoodOverlay(foodDataJson: String, autoCloseSeconds: Int) { + removeOverlayView() + + val food = + parseFoodData(foodDataJson) + ?: run { + stopSelf() + return + } + currentFoodOrderId = food.orderId + isFoodOverlay = true + + val view = buildFoodOverlayView(food, autoCloseSeconds) + overlayView = view + + val type = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + else @Suppress("DEPRECATION") WindowManager.LayoutParams.TYPE_PHONE + + val params = + WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT, + type, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, + PixelFormat.TRANSLUCENT + ) + .apply { + gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL + val metrics = resources.displayMetrics + y = (metrics.heightPixels * 0.15).toInt() + } + + try { + windowManager?.addView(view, params) + isRunning = true + playSound() + startCountdown(autoCloseSeconds) + } catch (e: Exception) { + Log.e(TAG, "Food overlay failed: ${e.message}") + stopSelf() + } + } + + private fun buildFoodOverlayView(food: FoodInfo, autoCloseSeconds: Int): View { + val ctx = this + val orange = Color.parseColor("#FF6B35") + + val card = + FrameLayout(ctx).apply { + background = + GradientDrawable().apply { + cornerRadius = 60f + setColor(Color.WHITE) + setStroke(3, orange) + } + elevation = 40f + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ) + .apply { setMargins(40, 0, 40, 0) } + } + + val root = LinearLayout(ctx).apply { orientation = LinearLayout.VERTICAL } + card.addView(root) + + // ── ترويسة برتقالية: هوية بصرية تفصل التوصيل عن الرحلة من النظرة الأولى ── + root.addView( + LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + background = + GradientDrawable().apply { + cornerRadii = + floatArrayOf(60f, 60f, 60f, 60f, 0f, 0f, 0f, 0f) + setColor(orange) + } + setPadding(40, 34, 40, 34) + addView( + TextView(ctx).apply { + text = "\uD83C\uDF54 طلب توصيل جديد" + textSize = 17f + setTextColor(Color.WHITE) + gravity = Gravity.CENTER + setTypeface(typeface, android.graphics.Typeface.BOLD) + } + ) + addView( + TextView(ctx).apply { + text = food.deliveryFeeText + textSize = 30f + setTextColor(Color.WHITE) + gravity = Gravity.CENTER + setTypeface(typeface, android.graphics.Typeface.BOLD) + } + ) + addView( + TextView(ctx).apply { + text = "أجرة التوصيل" + textSize = 12f + setTextColor(Color.parseColor("#FFE0D2")) + gravity = Gravity.CENTER + } + ) + } + ) + + val body = + LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + setPadding(40, 30, 40, 30) + } + root.addView(body) + + // ── المطعم ووجهة التسليم ── + body.addView(labeledRow(ctx, "\uD83C\uDFEA المطعم", food.merchantName)) + if (food.merchantAddress.isNotEmpty()) { + body.addView(labeledRow(ctx, "\uD83D\uDCCD العنوان", food.merchantAddress)) + } + if (food.distanceText.isNotEmpty()) { + body.addView(labeledRow(ctx, "\uD83D\uDEE3\uFE0F المسافة", food.distanceText)) + } + if (food.itemsCount > 0) { + body.addView(labeledRow(ctx, "\uD83E\uDDFE الأصناف", "${food.itemsCount}")) + } + body.addView( + labeledRow(ctx, "\uD83D\uDCB3 الدفع", if (food.isCash) "نقداً" else "محفظة") + ) + + // ── تنبيه التحصيل النقدي: قرار مالي يحتاجه السائق قبل القبول لا بعده ── + if (food.isCash && food.cashToCollectText.isNotEmpty()) { + body.addView( + TextView(ctx).apply { + text = "\uD83D\uDCB5 تحصّل من الزبون: ${food.cashToCollectText}" + textSize = 13f + setTextColor(Color.parseColor("#8A6100")) + background = + GradientDrawable().apply { + cornerRadius = 24f + setColor(Color.parseColor("#FFF4DC")) + } + setPadding(24, 18, 24, 18) + layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + .apply { topMargin = 16 } + } + ) + } + + // ── العدّاد ── + val countdownLabel = + TextView(ctx).apply { + text = "ينتهي خلال $autoCloseSeconds ثانية" + textSize = 13f + setTextColor(Color.parseColor("#7F8C8D")) + gravity = Gravity.CENTER + layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + .apply { topMargin = 24 } + } + val progressBar = + ProgressBar(ctx, null, android.R.attr.progressBarStyleHorizontal).apply { + max = autoCloseSeconds + progress = autoCloseSeconds + progressDrawable?.colorFilter = + android.graphics.PorterDuffColorFilter( + orange, + android.graphics.PorterDuff.Mode.SRC_IN + ) + layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 15 + ) + .apply { topMargin = 10 } + } + body.addView(countdownLabel) + body.addView(progressBar) + + // ── الأزرار ── + val buttonsRow = + LinearLayout(ctx).apply { + orientation = LinearLayout.HORIZONTAL + layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + .apply { topMargin = 26 } + } + buttonsRow.addView( + Button(ctx).apply { + text = "رفض" + textSize = 16f + setTextColor(Color.parseColor("#E74C3C")) + background = + GradientDrawable().apply { + cornerRadius = 100f + setColor(Color.parseColor("#FDEDEC")) + } + layoutParams = + LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + .apply { rightMargin = 20 } + setOnClickListener { dismissOverlay(reason = "rejected") } + } + ) + buttonsRow.addView( + Button(ctx).apply { + text = "قبول التوصيل" + textSize = 16f + setTextColor(Color.WHITE) + background = + GradientDrawable().apply { + cornerRadius = 100f + setColor(Color.parseColor("#27AE60")) + } + layoutParams = + LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1.5f + ) + setOnClickListener { onFoodOrderAccepted() } + } + ) + body.addView(buttonsRow) + + card.tag = mapOf("countdownLabel" to countdownLabel, "progressBar" to progressBar) + return card + } + + private fun onFoodOrderAccepted() { + stopSound() + countDownTimer?.cancel() + TripOverlayPlugin.notifyFoodOrderAccepted(currentFoodOrderId) + bringAppToForeground() + removeOverlayView() + stopSelf() + } + + private fun parseFoodData(json: String): FoodInfo? { + return try { + val obj = JSONObject(json) + FoodInfo( + orderId = obj.getString("orderId"), + merchantName = obj.optString("merchantName", ""), + merchantAddress = obj.optString("merchantAddress", ""), + deliveryFeeText = obj.optString("deliveryFeeText", ""), + cashToCollectText = obj.optString("cashToCollectText", ""), + distanceText = obj.optString("distanceText", ""), + itemsCount = obj.optInt("itemsCount", 0), + isCash = obj.optBoolean("isCash", false) + ) + } catch (e: Exception) { + Log.e(TAG, "parseFoodData failed: ${e.message}") + null + } + } + + data class FoodInfo( + val orderId: String, + val merchantName: String, + val merchantAddress: String, + val deliveryFeeText: String, + val cashToCollectText: String, + val distanceText: String, + val itemsCount: Int, + val isCash: Boolean + ) + private fun buildOverlayView(trip: TripInfo, autoCloseSeconds: Int): View { val ctx = this @@ -502,7 +797,12 @@ class TripOverlayService : Service() { stopSound() // 🔴 countDownTimer?.cancel() if (reason == "rejected" || reason == "timeout") { - TripOverlayPlugin.notifyTripRejected(currentTripId) + // النتيجة تذهب لقناة النوع المعروض — لا نخلط عرض توصيل بعرض رحلة + if (isFoodOverlay) { + TripOverlayPlugin.notifyFoodOrderRejected(currentFoodOrderId) + } else { + TripOverlayPlugin.notifyTripRejected(currentTripId) + } } removeOverlayView() stopSelf() @@ -516,7 +816,11 @@ class TripOverlayService : Service() { Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP ) - putExtra("acceptedTripId", currentTripId) + if (isFoodOverlay) { + putExtra("acceptedFoodOrderId", currentFoodOrderId) + } else { + putExtra("acceptedTripId", currentTripId) + } } startActivity(launchIntent) } diff --git a/siro_driver/trip_overlay_plugin/example/pubspec.lock b/siro_driver/trip_overlay_plugin/example/pubspec.lock index 22a7d80c..d621858d 100644 --- a/siro_driver/trip_overlay_plugin/example/pubspec.lock +++ b/siro_driver/trip_overlay_plugin/example/pubspec.lock @@ -134,10 +134,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -150,10 +150,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" path: dependency: transitive description: @@ -243,10 +243,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.9" trip_overlay_plugin: dependency: "direct main" description: diff --git a/siro_driver/trip_overlay_plugin/lib/trip_overlay_plugin.dart b/siro_driver/trip_overlay_plugin/lib/trip_overlay_plugin.dart index 83be6d47..fcfe44e2 100644 --- a/siro_driver/trip_overlay_plugin/lib/trip_overlay_plugin.dart +++ b/siro_driver/trip_overlay_plugin/lib/trip_overlay_plugin.dart @@ -62,6 +62,45 @@ class TripData { String toJson() => jsonEncode(toMap()); } + +/// بيانات طلب توصيل الطعام المعروضة في النافذة العائمة. +/// المبالغ تصل **منسّقة نصّاً** عمداً: التنسيق والعملة يخصّان التطبيق لا +/// الطبقة الأصلية، فلا نكرّر منطق قسمة الوحدة الصغرى داخل Kotlin. +class FoodOrderOverlayData { + final String orderId; + final String merchantName; + final String merchantAddress; + final String deliveryFeeText; + final String cashToCollectText; + final String distanceText; + final int itemsCount; + final bool isCash; + + FoodOrderOverlayData({ + required this.orderId, + required this.merchantName, + required this.deliveryFeeText, + this.merchantAddress = '', + this.cashToCollectText = '', + this.distanceText = '', + this.itemsCount = 0, + this.isCash = false, + }); + + Map toMap() => { + 'orderId': orderId, + 'merchantName': merchantName, + 'merchantAddress': merchantAddress, + 'deliveryFeeText': deliveryFeeText, + 'cashToCollectText': cashToCollectText, + 'distanceText': distanceText, + 'itemsCount': itemsCount, + 'isCash': isCash, + }; + + String toJson() => jsonEncode(toMap()); +} + /// Result returned when the driver accepts a trip class TripAcceptedResult { final String tripId; @@ -82,6 +121,18 @@ class TripOverlayPlugin { static final StreamController _tripRejectedController = StreamController.broadcast(); + // قناة طلبات التوصيل منفصلة عن قناة الرحلات — لا يخلط المستمع بين النوعين + static final StreamController _foodAcceptedController = + StreamController.broadcast(); + static final StreamController _foodRejectedController = + StreamController.broadcast(); + + /// معرّف طلب التوصيل الذي قبله السائق من النافذة العائمة + static Stream get onFoodOrderAccepted => _foodAcceptedController.stream; + + /// معرّف طلب التوصيل الذي رفضه السائق أو انتهت مهلته + static Stream get onFoodOrderRejected => _foodRejectedController.stream; + static bool _isInitialized = false; /// Stream that fires when the driver taps "Accept" in the overlay @@ -113,6 +164,14 @@ class TripOverlayPlugin { _tripRejectedController.add(tripId); break; + case 'onFoodOrderAccepted': + _foodAcceptedController.add(call.arguments['orderId'].toString()); + break; + + case 'onFoodOrderRejected': + _foodRejectedController.add(call.arguments['orderId'].toString()); + break; + default: throw PlatformException( code: 'UNKNOWN_METHOD', @@ -187,6 +246,34 @@ class TripOverlayPlugin { } } + /// نافذة عرض توصيل طعام. تعيد false ولا ترمي أبداً — المُنادي يكمل مساره + /// (إشعار محلي، حفظ العرض) حتى لو تعذّرت النافذة أو نقصت الصلاحية. + static Future showFoodOverlay( + FoodOrderOverlayData data, { + int autoCloseSeconds = 20, + }) async { + if (!isSupported) return false; + + final granted = await isPermissionGranted(); + if (!granted) { + await requestPermission(); + return false; + } + + try { + final result = await _channel.invokeMethod('showFoodOverlay', { + 'foodData': data.toJson(), + 'autoCloseSeconds': autoCloseSeconds, + }); + return result ?? false; + } on PlatformException catch (e) { + debugPrint('[TripOverlay] showFoodOverlay failed: ${e.message}'); + return false; + } on MissingPluginException { + return false; + } + } + /// Programmatically close the overlay (e.g. if trip was cancelled) static Future hideOverlay() async { if (!isSupported) return;