Food: background location + native delivery overlay
This commit is contained in:
@@ -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<void> _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<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 {
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user