Files
tripz-llc/apps/driver/lib/controller/functions/background_service.dart
T
2026-08-09 16:56:13 +03:00

392 lines
16 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:ui';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_overlay_window/flutter_overlay_window.dart';
import 'package:socket_io_client/socket_io_client.dart' as IO;
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';
const int notificationId = 888;
const String notificationIcon = '@mipmap/launcher_icon';
@pragma('vm:entry-point')
Future<bool> onStart(ServiceInstance service) async {
DartPluginRegistrant.ensureInitialized();
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
await GetStorage.init();
final box = GetStorage();
IO.Socket? socket;
String driverId = box.read(BoxName.driverID) ?? '';
String token = box.read(BoxName.tokenDriver) ?? '';
if (driverId.isNotEmpty) {
socket = IO.io(
AppLink.locationSocketUrl,
IO.OptionBuilder()
.setTransports(['websocket'])
.disableAutoConnect()
.setQuery({
'driver_id': driverId,
'token': token,
// ‏نفس سبب location_controller: بلا platform يعتبره السيرفر
// ‏أندرويد فلا يرسل إشعار FCM الاحتياطي لكباتن الآيفون.
'platform': Platform.isIOS ? 'ios' : 'android',
'EIO': '3', // توافقية مع Workerman
})
.setReconnectionAttempts(double.infinity)
.build());
socket.connect();
socket.onConnect((_) {
print("✅ Background Service: Socket Connected! ID: ${socket?.id}");
// 🆕 طلب أي رحلات انتظار فاتتنا أثناء انقطاع الخدمة
socket!.emit('get_pending_orders');
if (service is AndroidServiceInstance) {
flutterLocalNotificationsPlugin.show(
id: notificationId,
title: 'أنت متصل الآن',
body: 'بانتظار الطلبات...',
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
notificationChannelId,
'خدمة السائق',
icon: notificationIcon,
ongoing: true,
importance: Importance.low,
priority: Priority.low,
),
),
);
}
});
socket.on('new_ride_request', (data) async {
print("🔔 Background Service: Received new_ride_request");
// 🔥 قراءة حالة التطبيق مباشرة قبل العرض
await GetStorage.init(); // تأكد من تحديث البيانات
final box = GetStorage();
// 🚌 في وضع الباص لا نعرض طلبات الرحلات — السائق في مهمة باص نشطة
final bool inBusMode = box.read(BoxName.isBusMode) ?? false;
if (inBusMode) {
print("🚌 Bus mode active — ignoring new_ride_request in background.");
return;
}
bool isAppInForeground = box.read(BoxName.isAppInForeground) ?? false;
// 🔥 Check إضافي: هل الـ Overlay مفتوح بالفعل؟ (للأندرويد فقط)
bool overlayActive = false;
if (Platform.isAndroid) {
overlayActive = await Overlay.FlutterOverlayWindow.isActive();
}
if (isAppInForeground || overlayActive) {
print("🛑 App is FOREGROUND or Overlay already shown. Skipping.");
return;
}
// عرض الـ Overlay (للأندرويد فقط)
if (Platform.isAndroid) {
print("🚀 App is BACKGROUND. Showing Overlay...");
try {
await Overlay.FlutterOverlayWindow.showOverlay(
enableDrag: true,
overlayTitle: "طلب جديد",
overlayContent: "لديك طلب جديد وصل للتو!",
flag: OverlayFlag.focusPointer,
positionGravity: PositionGravity.auto,
height: WindowSize.matchParent,
width: WindowSize.matchParent,
startPosition: const OverlayPosition(0, -30),
);
await Overlay.FlutterOverlayWindow.shareData(data);
} catch (e) {
print("Overlay Error: $e");
}
} else if (Platform.isIOS) {
// على iOS، نظهر إشعاراً عادياً لأن الـ Overlay غير موجود
flutterLocalNotificationsPlugin.show(
id: 1002,
title: "طلب رحلة جديد 🚖",
body: "لديك طلب رحلة جديد، افتح التطبيق للموافقة عليه",
notificationDetails: const NotificationDetails(
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
payload: jsonEncode(data));
}
});
// ══════════════════════════════════════════════════════════════════
// 🚫 الرحلة أخذها سائق آخر
//
// ‏نافذة الأوفرلي تعيش في عزلة منفصلة بلا سوكِت خاص بها، فلا تعلم
// ‏بشيء من نفسها. وهذه العزلة الخلفية هي الوحيدة التي تسمع الحدث
// ‏والتطبيق مغلق. بدون هذا المستمع تبقى النافذة معروضة وعدّادها
// ‏يعمل على رحلة لم تعد موجودة، ويضغط السائق "قبول" فيجدها مأخوذة.
//
// ‏نغلق النافذتين معاً: نافذة فلاتر (مسار السوكِت) والنافذة الأصلية
// ‏(مسار FCM) — لا نعرف أيّهما معروضة الآن.
// ══════════════════════════════════════════════════════════════════
socket.on('ride_taken', (data) async {
try {
await GetStorage.init();
final box = GetStorage();
final String myDriverId = box.read(BoxName.driverID).toString();
final String whoTookIt = data?['taken_by_driver_id']?.toString() ?? '';
// ‏لا نغلق على من فاز بها — تطبيقه في طريقه لفتح شاشة الرحلة.
if (whoTookIt == myDriverId) return;
bool closedAny = false;
if (Platform.isAndroid) {
if (await Overlay.FlutterOverlayWindow.isActive()) {
await Overlay.FlutterOverlayWindow.closeOverlay();
closedAny = true;
}
if (await TripOverlayPlugin.isOverlayActive()) {
await TripOverlayPlugin.hideOverlay();
closedAny = true;
}
}
// ‏الإشعار فقط إن كان هناك ما يُغلق فعلاً — وإلا أزعجنا سائقاً
// ‏لم يُعرض عليه شيء أصلاً (الحدث يُبثّ للجميع).
if (closedAny) {
flutterLocalNotificationsPlugin.show(
id: 1003,
title: "الطلب لم يعد متاحاً",
body: "قام سائق آخر بقبول هذه الرحلة",
notificationDetails: const NotificationDetails(
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: false,
),
),
payload: '');
}
} catch (e) {
print("⚠️ ride_taken handler error: $e");
}
});
}
// ══════════════════════════════════════════════════════════════════════
// سوكيت الطعام في العزلة الخلفية — مستقل تماماً عن سوكيت الرحلات أعلاه
// (خادم وبورت وغرفة مختلفة). بدونه لا يصل عرض توصيل والتطبيق مغلق، لأن
// 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();
});
// 🚫 [Architecture Rule] NO redundant GPS stream in background service!
// LocationController is the SINGLE SOURCE OF TRUTH for all location GPS updates.
// It already uses location.enableBackgroundMode(enable: true) to keep the GPS
// stream alive even when the app is in the background. The main socket in
// LocationController handles all emitLocationToSocket() calls including heartbeat.
//
// The background service is ONLY responsible for:
// 1. Keeping the socket connection alive for receiving 'new_ride_request'
// and 'cancel_ride' events while the main isolate is paused on Android.
// 2. Showing the Android Overlay UI for incoming ride requests.
// 3. Notifications for iOS background state.
//
// Location data is not sent from the background isolate — it would conflict
// with LocationController's stream and cause duplicate GPS listeners,
// battery drain, and device freeze (as documented in driver_lifecycle.md).
Timer.periodic(const Duration(seconds: 30), (timer) async {
if (service is AndroidServiceInstance) {
if (await service.isForegroundService()) {
flutterLocalNotificationsPlugin.show(
id: notificationId,
title: 'خدمة السائق نشطة',
body: 'بانتظار الطلبات...',
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
notificationChannelId,
'خدمة السائق',
icon: notificationIcon,
ongoing: true,
importance: Importance.low,
priority: Priority.low,
),
),
);
}
}
});
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();
await service.configure(
androidConfiguration: AndroidConfiguration(
onStart: onStart,
autoStart: false,
isForegroundMode: true,
notificationChannelId: notificationChannelId,
initialNotificationTitle: 'تطبيق السائق',
initialNotificationContent: 'تجهيز الخدمة...',
foregroundServiceNotificationId: notificationId,
),
iosConfiguration: IosConfiguration(
autoStart: false,
onForeground: onStart,
onBackground: onStart,
),
);
}
static Future<void> startService() async {
final service = FlutterBackgroundService();
if (!await service.isRunning()) {
await service.startService();
}
}
static Future<void> stopService() async {
final service = FlutterBackgroundService();
service.invoke("stopService");
}
}