Food: background location + native delivery overlay

This commit is contained in:
Hamza-Ayed
2026-08-04 03:02:04 +03:00
parent d695834d19
commit ca55aaebe8
11 changed files with 772 additions and 31 deletions
@@ -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;
}
}