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

389 lines
15 KiB
Dart

// food_delivery_controller.dart — حالة تبويب التوصيل (وضع التوصيل، العروض، المهام النشطة)
//
// مصادر الأحداث ثلاثة، مرتّبة بالأولوية:
// 1) سوكيت الطعام المستقل (FoodSocketService) — المسار اللحظي الأساسي.
// 2) polling لـ pending_offers.php — يعمل فقط حين ينقطع السوكيت (لا نُحمّل
// الخادم نداءً كل 4 ثوانٍ بينما القناة الحية شغّالة).
// 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';
import '../../constant/box_name.dart';
import '../../constant/links.dart';
import '../../main.dart';
import '../../print.dart';
import '../../views/food_delivery/food_offer_page.dart';
import '../../views/widgets/error_snakbar.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 {
bool isDeliveryModeEnabled = false;
bool isTogglingMode = false;
bool isSocketConnected = false;
List<FoodDeliveryOffer> pendingOffers = [];
List<FoodDeliveryTask> activeTasks = [];
bool isLoadingTasks = false;
int todayOrdersCount = 0;
int todayEarnings = 0;
final Set<int> _respondingOfferIds = {};
final Set<int> _busyTaskIds = {};
// معرّفات عروض عُرضت أو انتهت — يمنع تكرار فتح الشاشة حين يصل نفس العرض
// من السوكيت ومن polling معاً.
final Set<int> _handledOfferIds = {};
Timer? _pollTimer;
Timer? _countdownTimer;
final AudioPlayer _audioPlayer = AudioPlayer();
bool _isOfferScreenOpen = false;
@override
void onInit() {
super.onInit();
FoodNotificationService.instance.ensureChannels();
_bindSocketHandlers();
_bootstrap();
// نبضة خفيفة: المهام دائماً، والعروض فقط إذا كان السوكيت مقطوعاً
_pollTimer = Timer.periodic(const Duration(seconds: 8), (_) => _pollTick());
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) => _tickOffers());
}
Future<void> _bootstrap() async {
await refreshStatus();
await fetchActiveTasks();
}
// ── وضع التوصيل ────────────────────────────────────────────────────────
Future<void> refreshStatus() async {
final res = await FoodDeliveryService.getStatus();
if (!res.success || res.data == null) return;
final s = res.data!;
isDeliveryModeEnabled = s.deliveryModeEnabled;
box.write(BoxName.foodDeliveryModeEnabled, s.deliveryModeEnabled);
todayOrdersCount = s.todayOrdersCount;
todayEarnings = s.todayEarnings;
// الخادم هو مرجع الوضع — نوصل/نقطع السوكيت تبعاً له لا تبعاً لحالة الشاشة
if (isDeliveryModeEnabled) {
await FoodSocketService.instance.connect();
} else {
FoodSocketService.instance.disconnect();
pendingOffers.clear();
}
update();
}
Future<void> toggleDeliveryMode(bool enable) async {
isTogglingMode = true;
update();
final res = await FoodDeliveryService.toggleAvailability(enable);
isTogglingMode = false;
if (res.success) {
isDeliveryModeEnabled = enable;
// العزلة الخلفية تقرأ هذا المفتاح لتقرر فتح سوكيت الطعام أو إغلاقه،
// فيصل العرض حتى والتطبيق مغلق
box.write(BoxName.foodDeliveryModeEnabled, enable);
FlutterBackgroundService().invoke('foodModeChanged', {'enabled': enable});
if (enable) {
await FoodSocketService.instance.connect();
} else {
FoodSocketService.instance.disconnect();
pendingOffers.clear();
FoodNotificationService.instance.cancelOfferNotification();
}
} else {
mySnackbarWarning(res.message);
}
update();
}
// ── السوكيت ────────────────────────────────────────────────────────────
void _bindSocketHandlers() {
final socket = FoodSocketService.instance;
socket.onOffer = (payload) => _handleIncomingOffer(FoodDeliveryOffer.fromJson(payload));
socket.onOrderUpdate = _handleOrderUpdate;
socket.onIncomingCall = _handleIncomingCall;
socket.onConnectionChanged = (connected) {
isSocketConnected = connected;
// بعد أي إعادة اتصال نسحب الحالة من الخادم — السوكيت ناقل لا مخزن حالة
if (connected) {
fetchActiveTasks();
_fetchPendingOffers();
}
update();
};
}
void _handleOrderUpdate(Map<String, dynamic> payload) {
final status = payload['status']?.toString() ?? '';
final orderId = int.tryParse(payload['order_id']?.toString() ?? '') ?? 0;
if (status.startsWith('cancelled') && orderId != 0) {
FoodNotificationService.instance.showStatusNotification(
orderId: orderId,
title: 'أُلغي طلب التوصيل',
body: 'الطلب #$orderId أُلغي — لا حاجة لمتابعته',
);
mySnackbarWarning('أُلغي طلب التوصيل #$orderId');
}
fetchActiveTasks();
}
// ── المكالمة المقنّعة ───────────────────────────────────────────────────
// الزبون بادر بالاتصال: الحمولة لا تحمل رقماً ولا اسماً حقيقياً، فقط
// session_id ننضم به لنفس خادم الإشارات المستعمل في مكالمات الرحلات.
void _handleIncomingCall(Map<String, dynamic> payload) {
final sessionId = payload['session_id']?.toString() ?? '';
final orderId = payload['order_id']?.toString() ?? '';
if (sessionId.isEmpty || orderId.isEmpty) return;
// مسجَّل عالمياً بـ lazyPut(fenix) في main — والـ put احتياط لو أُزيل
final callController = _voiceCall();
callController.receiveCall(
sessionIdVal: sessionId,
remoteNameVal: payload['caller_name']?.toString() ?? 'زبون الطلب',
rideIdVal: 'food_$orderId',
);
}
/// السائق يتصل بالزبون — لا يرى رقمه ولا يُخزَّن عنده
Future<void> callCustomer(int orderId) async {
// مسجَّل عالمياً بـ lazyPut(fenix) في main — والـ put احتياط لو أُزيل
final callController = _voiceCall();
await callController.startCall(
rideIdVal: 'food_$orderId',
driverId: box.read(BoxName.driverID).toString(),
passengerId: '', // هوية الزبون لا تصل التطبيق — الخادم يحلّها من الطلب
remoteNameVal: 'زبون الطلب',
sessionEndpoint: '${AppLink.server}/food/courier/call_customer.php',
sessionPayload: {'order_id': orderId.toString()},
);
}
VoiceCallController _voiceCall() {
try {
return Get.find<VoiceCallController>();
} catch (_) {
return Get.put(VoiceCallController());
}
}
// ── العروض ─────────────────────────────────────────────────────────────
void _handleIncomingOffer(FoodDeliveryOffer offer) {
if (offer.orderId == 0 || offer.isExpired) return;
if (_handledOfferIds.contains(offer.orderId)) return;
if (pendingOffers.any((o) => o.orderId == offer.orderId)) return;
_handledOfferIds.add(offer.orderId);
pendingOffers.add(offer);
update();
_playOfferSound();
final isForeground = box.read(BoxName.isAppInForeground) ?? false;
if (isForeground == true) {
_openOfferScreen();
return;
}
// التطبيق في الخلفية: النافذة العائمة أولاً (أندرويد)، وإن تعذّرت —
// صلاحية ناقصة أو iOS — يبقى الإشعار ملء الشاشة هو المسار البديل.
_showBackgroundOffer(offer);
}
Future<void> _showBackgroundOffer(FoodDeliveryOffer offer) async {
final shown = await FoodOverlayBridge.show(offer);
if (!shown) {
FoodNotificationService.instance.showOfferNotification(offer);
}
}
void _openOfferScreen() {
if (_isOfferScreenOpen) return;
_isOfferScreenOpen = true;
Get.to(() => const FoodOfferPage(), fullscreenDialog: true)?.whenComplete(() {
_isOfferScreenOpen = false;
});
}
Future<void> _playOfferSound() async {
try {
await _audioPlayer.setAsset('assets/order1.wav');
await _audioPlayer.play();
} catch (e) {
Log.print('🍔 [FoodDelivery] تعذّر تشغيل نغمة العرض: $e');
}
}
// انتهاء المهلة يُدار محلياً للعرض فقط — الخادم يرفض أي قبول متأخر أصلاً
void _tickOffers() {
if (pendingOffers.isEmpty) return;
final before = pendingOffers.length;
pendingOffers.removeWhere((o) => o.isExpired);
if (pendingOffers.isEmpty && before > 0) {
FoodNotificationService.instance.cancelOfferNotification();
if (_isOfferScreenOpen && Get.currentRoute.contains('FoodOfferPage')) {
Get.back();
}
}
update();
}
Future<void> _pollTick() async {
// العروض عبر polling فقط عند انقطاع السوكيت — احتياط لا مسار أساسي
if (isDeliveryModeEnabled && !FoodSocketService.instance.isConnected) {
await _fetchPendingOffers();
}
await fetchActiveTasks(silent: true);
}
Future<void> _fetchPendingOffers() async {
final res = await FoodDeliveryService.getPendingOffers();
if (!res.success) return;
for (final offer in res.data ?? <FoodDeliveryOffer>[]) {
_handleIncomingOffer(offer);
}
}
Future<void> fetchActiveTasks({bool silent = false}) async {
if (!silent) {
isLoadingTasks = true;
update();
}
final res = await FoodDeliveryService.getActiveTasks();
isLoadingTasks = false;
if (res.success) activeTasks = res.data ?? [];
// البثّ نفسه يجري في LocationController (المصدر الوحيد للموقع) — نكتفي
// هنا بتسجيل المهام النشطة كي يستمر البثّ بعد إغلاق هذه الشاشة.
FoodLocationBroadcaster.setActiveOrders(activeTasks.map((t) => t.id).toList());
_syncRideAvailability();
update();
}
// ── مهمة واحدة في الوقت الواحد ──────────────────────────────────────────
// السائق أثناء التوصيل «مشغول» في نظام الرحلات تماماً كما لو كان في رحلة:
// نكتب statusDriverLocation='on' فينقله driver_socket من geo:drivers:available
// إلى geo:drivers:busy، فيتوقف توزيع الرحلات عليه — ويتوقف معه توزيع طلبات
// طعام إضافية، لأن مطابقة الطعام تتقاطع مع نفس المجموعة المتاحة.
// نعيده متاحاً بعد التسليم، وبشرطين: أن نكون نحن من شغَله، وألا تكون هناك
// رحلة جارية فعلاً (وإلا أفرغنا انشغالاً ليس لنا).
bool _markedBusyForDelivery = false;
void _syncRideAvailability() {
final hasDelivery = activeTasks.isNotEmpty;
if (hasDelivery && !_markedBusyForDelivery) {
_markedBusyForDelivery = true;
box.write(BoxName.statusDriverLocation, 'on');
Log.print('🍔 [FoodDelivery] السائق مشغول بتوصيل — أُوقف استقبال الرحلات');
return;
}
if (!hasDelivery && _markedBusyForDelivery) {
_markedBusyForDelivery = false;
final rideStatus = (box.read(BoxName.rideStatus) ?? '').toString();
final onRide = rideStatus == 'Begin' || rideStatus == 'Apply' || rideStatus == 'Arrived';
final blocked = box.read(BoxName.statusDriverLocation) == 'blocked';
if (!onRide && !blocked) {
box.write(BoxName.statusDriverLocation, 'off');
Log.print('🍔 [FoodDelivery] انتهى التوصيل — عاد استقبال الرحلات');
}
}
}
bool isRespondingToOffer(int orderId) => _respondingOfferIds.contains(orderId);
bool isTaskBusy(int orderId) => _busyTaskIds.contains(orderId);
Future<void> respondToOffer(int orderId, bool accept) async {
if (_respondingOfferIds.contains(orderId)) return;
_respondingOfferIds.add(orderId);
update();
final res = await FoodDeliveryService.respondToOffer(orderId, accept);
_respondingOfferIds.remove(orderId);
pendingOffers.removeWhere((o) => o.orderId == orderId);
FoodNotificationService.instance.cancelOfferNotification();
FoodOverlayBridge.hide();
if (!res.success) {
mySnackbarWarning(res.message);
} else if (accept) {
mySnackbarSuccess('تم قبول طلب التوصيل');
}
if (pendingOffers.isEmpty && _isOfferScreenOpen && Get.currentRoute.contains('FoodOfferPage')) {
Get.back();
}
update();
await fetchActiveTasks();
}
// ── خطوات المهمة ───────────────────────────────────────────────────────
Future<void> markPickedUp(int orderId) async {
if (_busyTaskIds.contains(orderId)) return;
_busyTaskIds.add(orderId);
update();
final res = await FoodDeliveryService.markPickedUp(orderId);
_busyTaskIds.remove(orderId);
if (res.success) {
mySnackbarSuccess('تم استلام الطلب من المطعم');
await fetchActiveTasks();
} else {
mySnackbarWarning(res.message);
}
update();
}
Future<void> markDelivered(int orderId) async {
if (_busyTaskIds.contains(orderId)) return;
_busyTaskIds.add(orderId);
update();
final res = await FoodDeliveryService.markDelivered(orderId);
_busyTaskIds.remove(orderId);
if (res.success) {
mySnackbarSuccess('تم تسليم الطلب بنجاح');
await fetchActiveTasks();
await refreshStatus(); // تحديث عدّاد أرباح اليوم فوراً بعد التسليم
} else {
mySnackbarWarning(res.message);
}
update();
}
@override
void onClose() {
_pollTimer?.cancel();
_countdownTimer?.cancel();
_audioPlayer.dispose();
// لا نقطع السوكيت هنا: وضع التوصيل قد يبقى مفعّلاً بعد إغلاق الشاشة،
// والقطع يتم فقط عند إطفاء الوضع صراحةً من التبديل.
super.onClose();
}
}