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
@@ -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<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) {
socket?.clearListeners();
socket?.dispose();
foodSocket?.clearListeners();
foodSocket?.dispose();
service.stopSelf();
});
@@ -185,6 +292,14 @@ Future<bool> 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<void> initialize() async {
final service = FlutterBackgroundService();
@@ -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'));
}