Food: background location + native delivery overlay
This commit is contained in:
@@ -134,6 +134,13 @@ class BoxName {
|
|||||||
// (بلا GMS يسقط الباكيج إلى LocationManager الخام: بطارية أعلى، دقة أقل)
|
// (بلا GMS يسقط الباكيج إلى LocationManager الخام: بطارية أعلى، دقة أقل)
|
||||||
static const String isGmsAvailable = 'isGmsAvailable';
|
static const String isGmsAvailable = 'isGmsAvailable';
|
||||||
|
|
||||||
|
// وضع التوصيل مفعّل — تقرأه العزلة الخلفية لتقرر فتح سوكيت الطعام
|
||||||
|
static const String foodDeliveryModeEnabled = 'foodDeliveryModeEnabled';
|
||||||
|
|
||||||
|
// معرّفات مهام التوصيل النشطة — يقرأها بثّ الموقع في LocationController
|
||||||
|
// فيستمر البثّ بعد إغلاق شاشة التوصيل
|
||||||
|
static const String foodActiveOrders = 'foodActiveOrders';
|
||||||
|
|
||||||
// بيانات اعتماد TURN المؤقتة (مكالمات الصوت) — مخزّنة حتى قرب انتهائها
|
// بيانات اعتماد TURN المؤقتة (مكالمات الصوت) — مخزّنة حتى قرب انتهائها
|
||||||
static const String turnIceCache = 'turnIceCache';
|
static const String turnIceCache = 'turnIceCache';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
// 3) active.php — مصدر الحقيقة لحالة المهام، يُصحّح أي فارق بعد أي انقطاع.
|
// 3) active.php — مصدر الحقيقة لحالة المهام، يُصحّح أي فارق بعد أي انقطاع.
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_background_service/flutter_background_service.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
|
||||||
@@ -16,11 +17,12 @@ import '../../main.dart';
|
|||||||
import '../../print.dart';
|
import '../../print.dart';
|
||||||
import '../../views/food_delivery/food_offer_page.dart';
|
import '../../views/food_delivery/food_offer_page.dart';
|
||||||
import '../../views/widgets/error_snakbar.dart';
|
import '../../views/widgets/error_snakbar.dart';
|
||||||
import '../functions/location_controller.dart';
|
|
||||||
import '../voice_call_controller.dart';
|
import '../voice_call_controller.dart';
|
||||||
import 'food_delivery_models.dart';
|
import 'food_delivery_models.dart';
|
||||||
import 'food_delivery_service.dart';
|
import 'food_delivery_service.dart';
|
||||||
|
import 'food_location_broadcaster.dart';
|
||||||
import 'food_notification_service.dart';
|
import 'food_notification_service.dart';
|
||||||
|
import 'food_overlay_bridge.dart';
|
||||||
import 'food_socket_service.dart';
|
import 'food_socket_service.dart';
|
||||||
|
|
||||||
class FoodDeliveryController extends GetxController {
|
class FoodDeliveryController extends GetxController {
|
||||||
@@ -70,6 +72,7 @@ class FoodDeliveryController extends GetxController {
|
|||||||
|
|
||||||
final s = res.data!;
|
final s = res.data!;
|
||||||
isDeliveryModeEnabled = s.deliveryModeEnabled;
|
isDeliveryModeEnabled = s.deliveryModeEnabled;
|
||||||
|
box.write(BoxName.foodDeliveryModeEnabled, s.deliveryModeEnabled);
|
||||||
todayOrdersCount = s.todayOrdersCount;
|
todayOrdersCount = s.todayOrdersCount;
|
||||||
todayEarnings = s.todayEarnings;
|
todayEarnings = s.todayEarnings;
|
||||||
|
|
||||||
@@ -92,6 +95,11 @@ class FoodDeliveryController extends GetxController {
|
|||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
isDeliveryModeEnabled = enable;
|
isDeliveryModeEnabled = enable;
|
||||||
|
// العزلة الخلفية تقرأ هذا المفتاح لتقرر فتح سوكيت الطعام أو إغلاقه،
|
||||||
|
// فيصل العرض حتى والتطبيق مغلق
|
||||||
|
box.write(BoxName.foodDeliveryModeEnabled, enable);
|
||||||
|
FlutterBackgroundService().invoke('foodModeChanged', {'enabled': enable});
|
||||||
|
|
||||||
if (enable) {
|
if (enable) {
|
||||||
await FoodSocketService.instance.connect();
|
await FoodSocketService.instance.connect();
|
||||||
} else {
|
} else {
|
||||||
@@ -193,8 +201,17 @@ class FoodDeliveryController extends GetxController {
|
|||||||
final isForeground = box.read(BoxName.isAppInForeground) ?? false;
|
final isForeground = box.read(BoxName.isAppInForeground) ?? false;
|
||||||
if (isForeground == true) {
|
if (isForeground == true) {
|
||||||
_openOfferScreen();
|
_openOfferScreen();
|
||||||
} else {
|
return;
|
||||||
// التطبيق في الخلفية — إشعار ملء شاشة بقناة التوصيل الخاصة
|
}
|
||||||
|
|
||||||
|
// التطبيق في الخلفية: النافذة العائمة أولاً (أندرويد)، وإن تعذّرت —
|
||||||
|
// صلاحية ناقصة أو iOS — يبقى الإشعار ملء الشاشة هو المسار البديل.
|
||||||
|
_showBackgroundOffer(offer);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showBackgroundOffer(FoodDeliveryOffer offer) async {
|
||||||
|
final shown = await FoodOverlayBridge.show(offer);
|
||||||
|
if (!shown) {
|
||||||
FoodNotificationService.instance.showOfferNotification(offer);
|
FoodNotificationService.instance.showOfferNotification(offer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,26 +253,6 @@ class FoodDeliveryController extends GetxController {
|
|||||||
await _fetchPendingOffers();
|
await _fetchPendingOffers();
|
||||||
}
|
}
|
||||||
await fetchActiveTasks(silent: true);
|
await fetchActiveTasks(silent: true);
|
||||||
await _broadcastLocation();
|
|
||||||
}
|
|
||||||
|
|
||||||
// موقع السائق يُبثّ فقط ما دامت هناك مهمة نشطة — لا شيء يُرسل قبل الإسناد
|
|
||||||
// ولا بعد التسليم، والخادم يرفضه في الحالتين أصلاً كطبقة ثانية.
|
|
||||||
Future<void> _broadcastLocation() async {
|
|
||||||
if (activeTasks.isEmpty) return;
|
|
||||||
if (!Get.isRegistered<LocationController>()) return;
|
|
||||||
|
|
||||||
final loc = Get.find<LocationController>();
|
|
||||||
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<void> _fetchPendingOffers() async {
|
Future<void> _fetchPendingOffers() async {
|
||||||
@@ -274,6 +271,10 @@ class FoodDeliveryController extends GetxController {
|
|||||||
final res = await FoodDeliveryService.getActiveTasks();
|
final res = await FoodDeliveryService.getActiveTasks();
|
||||||
isLoadingTasks = false;
|
isLoadingTasks = false;
|
||||||
if (res.success) activeTasks = res.data ?? [];
|
if (res.success) activeTasks = res.data ?? [];
|
||||||
|
|
||||||
|
// البثّ نفسه يجري في LocationController (المصدر الوحيد للموقع) — نكتفي
|
||||||
|
// هنا بتسجيل المهام النشطة كي يستمر البثّ بعد إغلاق هذه الشاشة.
|
||||||
|
FoodLocationBroadcaster.setActiveOrders(activeTasks.map((t) => t.id).toList());
|
||||||
_syncRideAvailability();
|
_syncRideAvailability();
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
@@ -323,6 +324,7 @@ class FoodDeliveryController extends GetxController {
|
|||||||
_respondingOfferIds.remove(orderId);
|
_respondingOfferIds.remove(orderId);
|
||||||
pendingOffers.removeWhere((o) => o.orderId == orderId);
|
pendingOffers.removeWhere((o) => o.orderId == orderId);
|
||||||
FoodNotificationService.instance.cancelOfferNotification();
|
FoodNotificationService.instance.cancelOfferNotification();
|
||||||
|
FoodOverlayBridge.hide();
|
||||||
|
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
mySnackbarWarning(res.message);
|
mySnackbarWarning(res.message);
|
||||||
|
|||||||
@@ -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<int> orderIds) {
|
||||||
|
box.write(BoxName.foodActiveOrders, jsonEncode(orderIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<int> 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<void> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>? _acceptSub;
|
||||||
|
static StreamSubscription<String>? _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<void> _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<void> _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<bool> 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<void> hide() => TripOverlayPlugin.hideOverlay();
|
||||||
|
|
||||||
|
static void detach() {
|
||||||
|
_acceptSub?.cancel();
|
||||||
|
_rejectSub?.cancel();
|
||||||
|
_acceptSub = null;
|
||||||
|
_rejectSub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import 'package:flutter_overlay_window/flutter_overlay_window.dart' as Overlay;
|
|||||||
import 'package:get_storage/get_storage.dart';
|
import 'package:get_storage/get_storage.dart';
|
||||||
import '../../constant/box_name.dart';
|
import '../../constant/box_name.dart';
|
||||||
import '../../constant/links.dart';
|
import '../../constant/links.dart';
|
||||||
|
import 'package:trip_overlay_plugin/trip_overlay_plugin.dart';
|
||||||
import '../firebase/local_notification.dart';
|
import '../firebase/local_notification.dart';
|
||||||
|
|
||||||
const String notificationChannelId = 'driver_service_channel';
|
const String notificationChannelId = 'driver_service_channel';
|
||||||
@@ -138,9 +139,115 @@ Future<bool> 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<String, dynamic>? payload;
|
||||||
|
if (data is Map) {
|
||||||
|
payload = Map<String, dynamic>.from(data);
|
||||||
|
} else if (data is List && data.isNotEmpty && data.first is Map) {
|
||||||
|
payload = Map<String, dynamic>.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) {
|
service.on('stopService').listen((event) {
|
||||||
socket?.clearListeners();
|
socket?.clearListeners();
|
||||||
socket?.dispose();
|
socket?.dispose();
|
||||||
|
foodSocket?.clearListeners();
|
||||||
|
foodSocket?.dispose();
|
||||||
service.stopSelf();
|
service.stopSelf();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,6 +292,14 @@ Future<bool> onStart(ServiceInstance service) async {
|
|||||||
return true;
|
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 {
|
class BackgroundServiceHelper {
|
||||||
static Future<void> initialize() async {
|
static Future<void> initialize() async {
|
||||||
final service = FlutterBackgroundService();
|
final service = FlutterBackgroundService();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import '../home/navigation/navigation_controller.dart';
|
|||||||
import 'background_service.dart';
|
import 'background_service.dart';
|
||||||
import 'crud.dart';
|
import 'crud.dart';
|
||||||
import '../transit/transit_driver_controller.dart';
|
import '../transit/transit_driver_controller.dart';
|
||||||
|
import '../food_delivery/food_location_broadcaster.dart';
|
||||||
|
|
||||||
/// إعدادات تتبّع الموقع لحالة معيّنة من حالات السائق.
|
/// إعدادات تتبّع الموقع لحالة معيّنة من حالات السائق.
|
||||||
/// الهدف: عدم تشغيل الـ GPS بأقصى طاقته إلا أثناء الرحلة الفعلية.
|
/// الهدف: عدم تشغيل الـ GPS بأقصى طاقته إلا أثناء الرحلة الفعلية.
|
||||||
@@ -697,6 +698,15 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
|||||||
navCtrl.handleLocationUpdateFromCentral(pos, speed, heading);
|
navCtrl.handleLocationUpdateFromCentral(pos, speed, heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// بثّ الموقع للزبون أثناء مهمة توصيل — يركب على هذه النبضة بدل تدفّق
|
||||||
|
// GPS ثانٍ، فيستمر بعد إغلاق شاشة التوصيل ما دامت هذه الخدمة حية.
|
||||||
|
// يخرج فوراً إن لم تكن هناك مهمة نشطة.
|
||||||
|
FoodLocationBroadcaster.maybeSend(
|
||||||
|
lat: pos.latitude,
|
||||||
|
lng: pos.longitude,
|
||||||
|
heading: heading,
|
||||||
|
);
|
||||||
|
|
||||||
await _saveBehaviorIfMoved(pos, now, currentSpeed: speed);
|
await _saveBehaviorIfMoved(pos, now, currentSpeed: speed);
|
||||||
}, onError: (e) => Log.print('❌ Location Stream Error: $e'));
|
}, onError: (e) => Log.print('❌ Location Stream Error: $e'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import 'constant/links.dart';
|
|||||||
import 'controller/firebase/firbase_messge.dart';
|
import 'controller/firebase/firbase_messge.dart';
|
||||||
import 'controller/firebase/local_notification.dart';
|
import 'controller/firebase/local_notification.dart';
|
||||||
import 'controller/functions/background_service.dart';
|
import 'controller/functions/background_service.dart';
|
||||||
|
import 'controller/food_delivery/food_overlay_bridge.dart';
|
||||||
import 'controller/functions/crud.dart';
|
import 'controller/functions/crud.dart';
|
||||||
import 'controller/home/captin/home_captain_controller.dart';
|
import 'controller/home/captin/home_captain_controller.dart';
|
||||||
import 'controller/local/local_controller.dart';
|
import 'controller/local/local_controller.dart';
|
||||||
@@ -276,6 +277,9 @@ void main() {
|
|||||||
|
|
||||||
await requestNotificationPermission();
|
await requestNotificationPermission();
|
||||||
await TripOverlayPlugin.initialize();
|
await TripOverlayPlugin.initialize();
|
||||||
|
// نتيجة نافذة عرض التوصيل العائمة تصل عالمياً — قد تُقبل والتطبيق مغلق
|
||||||
|
// وشاشة التوصيل غير مبنية أصلاً، فالاشتراك لا يجوز أن يتبع شاشة.
|
||||||
|
FoodOverlayBridge.attach();
|
||||||
await createAllNotificationChannels();
|
await createAllNotificationChannels();
|
||||||
await BackgroundServiceHelper.initialize();
|
await BackgroundServiceHelper.initialize();
|
||||||
|
|
||||||
|
|||||||
+41
@@ -46,6 +46,17 @@ class TripOverlayPlugin :
|
|||||||
methodChannel?.invokeMethod("onTripRejected", mapOf("tripId" to tripId))
|
methodChannel?.invokeMethod("onTripRejected", mapOf("tripId" to tripId))
|
||||||
Log.d(TAG, "notifyTripRejected: $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 ───────────────────────────────────────────────────────
|
// ─── FlutterPlugin ───────────────────────────────────────────────────────
|
||||||
@@ -85,6 +96,16 @@ class TripOverlayPlugin :
|
|||||||
val success = showOverlay(tripDataJson, autoCloseSeconds)
|
val success = showOverlay(tripDataJson, autoCloseSeconds)
|
||||||
result.success(success)
|
result.success(success)
|
||||||
}
|
}
|
||||||
|
"showFoodOverlay" -> {
|
||||||
|
val foodDataJson =
|
||||||
|
call.argument<String>("foodData")
|
||||||
|
?: run {
|
||||||
|
result.error("INVALID_ARGS", "foodData is required", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val autoCloseSeconds = call.argument<Int>("autoCloseSeconds") ?: 20
|
||||||
|
result.success(showFoodOverlay(foodDataJson, autoCloseSeconds))
|
||||||
|
}
|
||||||
"hideOverlay" -> {
|
"hideOverlay" -> {
|
||||||
hideOverlay()
|
hideOverlay()
|
||||||
result.success(null)
|
result.success(null)
|
||||||
@@ -141,6 +162,26 @@ class TripOverlayPlugin :
|
|||||||
return true
|
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() {
|
private fun hideOverlay() {
|
||||||
val intent =
|
val intent =
|
||||||
Intent(context, TripOverlayService::class.java).apply {
|
Intent(context, TripOverlayService::class.java).apply {
|
||||||
|
|||||||
+306
-2
@@ -28,8 +28,10 @@ class TripOverlayService : Service() {
|
|||||||
companion object {
|
companion object {
|
||||||
const val TAG = "TripOverlayService"
|
const val TAG = "TripOverlayService"
|
||||||
const val ACTION_SHOW = "SHOW"
|
const val ACTION_SHOW = "SHOW"
|
||||||
|
const val ACTION_SHOW_FOOD = "SHOW_FOOD"
|
||||||
const val ACTION_HIDE = "HIDE"
|
const val ACTION_HIDE = "HIDE"
|
||||||
const val EXTRA_TRIP_DATA = "trip_data"
|
const val EXTRA_TRIP_DATA = "trip_data"
|
||||||
|
const val EXTRA_FOOD_DATA = "food_data"
|
||||||
const val EXTRA_AUTO_CLOSE_SECONDS = "auto_close_seconds"
|
const val EXTRA_AUTO_CLOSE_SECONDS = "auto_close_seconds"
|
||||||
private const val NOTIFICATION_ID = 9900
|
private const val NOTIFICATION_ID = 9900
|
||||||
private const val CHANNEL_ID = "trip_overlay_service_channel"
|
private const val CHANNEL_ID = "trip_overlay_service_channel"
|
||||||
@@ -43,6 +45,9 @@ class TripOverlayService : Service() {
|
|||||||
private var overlayView: View? = null
|
private var overlayView: View? = null
|
||||||
private var countDownTimer: CountDownTimer? = null
|
private var countDownTimer: CountDownTimer? = null
|
||||||
private var currentTripId: String = ""
|
private var currentTripId: String = ""
|
||||||
|
private var currentFoodOrderId: String = ""
|
||||||
|
// نوع النافذة المعروضة الآن — يحدّد لمن نُرسل نتيجة القبول/الرفض
|
||||||
|
private var isFoodOverlay: Boolean = false
|
||||||
private var mediaPlayer: MediaPlayer? = null // 🔴 مشغل الصوت
|
private var mediaPlayer: MediaPlayer? = null // 🔴 مشغل الصوت
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
@@ -61,6 +66,11 @@ class TripOverlayService : Service() {
|
|||||||
startForegroundWithNotification()
|
startForegroundWithNotification()
|
||||||
showTripOverlay(tripDataJson, autoClose)
|
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")
|
ACTION_HIDE -> dismissOverlay(reason = "programmatic")
|
||||||
}
|
}
|
||||||
return START_NOT_STICKY
|
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 {
|
private fun buildOverlayView(trip: TripInfo, autoCloseSeconds: Int): View {
|
||||||
val ctx = this
|
val ctx = this
|
||||||
|
|
||||||
@@ -502,7 +797,12 @@ class TripOverlayService : Service() {
|
|||||||
stopSound() // 🔴
|
stopSound() // 🔴
|
||||||
countDownTimer?.cancel()
|
countDownTimer?.cancel()
|
||||||
if (reason == "rejected" || reason == "timeout") {
|
if (reason == "rejected" || reason == "timeout") {
|
||||||
TripOverlayPlugin.notifyTripRejected(currentTripId)
|
// النتيجة تذهب لقناة النوع المعروض — لا نخلط عرض توصيل بعرض رحلة
|
||||||
|
if (isFoodOverlay) {
|
||||||
|
TripOverlayPlugin.notifyFoodOrderRejected(currentFoodOrderId)
|
||||||
|
} else {
|
||||||
|
TripOverlayPlugin.notifyTripRejected(currentTripId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
removeOverlayView()
|
removeOverlayView()
|
||||||
stopSelf()
|
stopSelf()
|
||||||
@@ -516,7 +816,11 @@ class TripOverlayService : Service() {
|
|||||||
Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or
|
Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or
|
||||||
Intent.FLAG_ACTIVITY_SINGLE_TOP
|
Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
)
|
)
|
||||||
putExtra("acceptedTripId", currentTripId)
|
if (isFoodOverlay) {
|
||||||
|
putExtra("acceptedFoodOrderId", currentFoodOrderId)
|
||||||
|
} else {
|
||||||
|
putExtra("acceptedTripId", currentTripId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
startActivity(launchIntent)
|
startActivity(launchIntent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,10 +134,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.18"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -150,10 +150,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.17.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -243,10 +243,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.9"
|
||||||
trip_overlay_plugin:
|
trip_overlay_plugin:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -62,6 +62,45 @@ class TripData {
|
|||||||
String toJson() => jsonEncode(toMap());
|
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<String, dynamic> 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
|
/// Result returned when the driver accepts a trip
|
||||||
class TripAcceptedResult {
|
class TripAcceptedResult {
|
||||||
final String tripId;
|
final String tripId;
|
||||||
@@ -82,6 +121,18 @@ class TripOverlayPlugin {
|
|||||||
static final StreamController<String> _tripRejectedController =
|
static final StreamController<String> _tripRejectedController =
|
||||||
StreamController<String>.broadcast();
|
StreamController<String>.broadcast();
|
||||||
|
|
||||||
|
// قناة طلبات التوصيل منفصلة عن قناة الرحلات — لا يخلط المستمع بين النوعين
|
||||||
|
static final StreamController<String> _foodAcceptedController =
|
||||||
|
StreamController<String>.broadcast();
|
||||||
|
static final StreamController<String> _foodRejectedController =
|
||||||
|
StreamController<String>.broadcast();
|
||||||
|
|
||||||
|
/// معرّف طلب التوصيل الذي قبله السائق من النافذة العائمة
|
||||||
|
static Stream<String> get onFoodOrderAccepted => _foodAcceptedController.stream;
|
||||||
|
|
||||||
|
/// معرّف طلب التوصيل الذي رفضه السائق أو انتهت مهلته
|
||||||
|
static Stream<String> get onFoodOrderRejected => _foodRejectedController.stream;
|
||||||
|
|
||||||
static bool _isInitialized = false;
|
static bool _isInitialized = false;
|
||||||
|
|
||||||
/// Stream that fires when the driver taps "Accept" in the overlay
|
/// Stream that fires when the driver taps "Accept" in the overlay
|
||||||
@@ -113,6 +164,14 @@ class TripOverlayPlugin {
|
|||||||
_tripRejectedController.add(tripId);
|
_tripRejectedController.add(tripId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'onFoodOrderAccepted':
|
||||||
|
_foodAcceptedController.add(call.arguments['orderId'].toString());
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'onFoodOrderRejected':
|
||||||
|
_foodRejectedController.add(call.arguments['orderId'].toString());
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw PlatformException(
|
throw PlatformException(
|
||||||
code: 'UNKNOWN_METHOD',
|
code: 'UNKNOWN_METHOD',
|
||||||
@@ -187,6 +246,34 @@ class TripOverlayPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// نافذة عرض توصيل طعام. تعيد false ولا ترمي أبداً — المُنادي يكمل مساره
|
||||||
|
/// (إشعار محلي، حفظ العرض) حتى لو تعذّرت النافذة أو نقصت الصلاحية.
|
||||||
|
static Future<bool> 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<bool>('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)
|
/// Programmatically close the overlay (e.g. if trip was cancelled)
|
||||||
static Future<void> hideOverlay() async {
|
static Future<void> hideOverlay() async {
|
||||||
if (!isSupported) return;
|
if (!isSupported) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user