// food_delivery_controller.dart — حالة تبويب التوصيل (وضع التوصيل، العروض، المهام النشطة) // // مصادر الأحداث ثلاثة، مرتّبة بالأولوية: // 1) سوكيت الطعام المستقل (FoodSocketService) — المسار اللحظي الأساسي. // 2) polling لـ pending_offers.php — يعمل فقط حين ينقطع السوكيت (لا نُحمّل // الخادم نداءً كل 4 ثوانٍ بينما القناة الحية شغّالة). // 3) active.php — مصدر الحقيقة لحالة المهام، يُصحّح أي فارق بعد أي انقطاع. import 'dart:async'; 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 '../functions/location_controller.dart'; import '../voice_call_controller.dart'; import 'food_delivery_models.dart'; import 'food_delivery_service.dart'; import 'food_notification_service.dart'; import 'food_socket_service.dart'; class FoodDeliveryController extends GetxController { bool isDeliveryModeEnabled = false; bool isTogglingMode = false; bool isSocketConnected = false; List pendingOffers = []; List activeTasks = []; bool isLoadingTasks = false; int todayOrdersCount = 0; int todayEarnings = 0; final Set _respondingOfferIds = {}; final Set _busyTaskIds = {}; // معرّفات عروض عُرضت أو انتهت — يمنع تكرار فتح الشاشة حين يصل نفس العرض // من السوكيت ومن polling معاً. final Set _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 _bootstrap() async { await refreshStatus(); await fetchActiveTasks(); } // ── وضع التوصيل ──────────────────────────────────────────────────────── Future refreshStatus() async { final res = await FoodDeliveryService.getStatus(); if (!res.success || res.data == null) return; final s = res.data!; isDeliveryModeEnabled = s.deliveryModeEnabled; todayOrdersCount = s.todayOrdersCount; todayEarnings = s.todayEarnings; // الخادم هو مرجع الوضع — نوصل/نقطع السوكيت تبعاً له لا تبعاً لحالة الشاشة if (isDeliveryModeEnabled) { await FoodSocketService.instance.connect(); } else { FoodSocketService.instance.disconnect(); pendingOffers.clear(); } update(); } Future toggleDeliveryMode(bool enable) async { isTogglingMode = true; update(); final res = await FoodDeliveryService.toggleAvailability(enable); isTogglingMode = false; if (res.success) { isDeliveryModeEnabled = 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 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 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 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(); } 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(); } else { // التطبيق في الخلفية — إشعار ملء شاشة بقناة التوصيل الخاصة FoodNotificationService.instance.showOfferNotification(offer); } } void _openOfferScreen() { if (_isOfferScreenOpen) return; _isOfferScreenOpen = true; Get.to(() => const FoodOfferPage(), fullscreenDialog: true)?.whenComplete(() { _isOfferScreenOpen = false; }); } Future _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 _pollTick() async { // العروض عبر polling فقط عند انقطاع السوكيت — احتياط لا مسار أساسي if (isDeliveryModeEnabled && !FoodSocketService.instance.isConnected) { await _fetchPendingOffers(); } await fetchActiveTasks(silent: true); await _broadcastLocation(); } // موقع السائق يُبثّ فقط ما دامت هناك مهمة نشطة — لا شيء يُرسل قبل الإسناد // ولا بعد التسليم، والخادم يرفضه في الحالتين أصلاً كطبقة ثانية. Future _broadcastLocation() async { if (activeTasks.isEmpty) return; if (!Get.isRegistered()) return; final loc = Get.find(); 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 _fetchPendingOffers() async { final res = await FoodDeliveryService.getPendingOffers(); if (!res.success) return; for (final offer in res.data ?? []) { _handleIncomingOffer(offer); } } Future fetchActiveTasks({bool silent = false}) async { if (!silent) { isLoadingTasks = true; update(); } final res = await FoodDeliveryService.getActiveTasks(); isLoadingTasks = false; if (res.success) activeTasks = res.data ?? []; _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 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(); if (!res.success) { mySnackbarWarning(res.message); } else if (accept) { mySnackbarSuccess('تم قبول طلب التوصيل'); } if (pendingOffers.isEmpty && _isOfferScreenOpen && Get.currentRoute.contains('FoodOfferPage')) { Get.back(); } update(); await fetchActiveTasks(); } // ── خطوات المهمة ─────────────────────────────────────────────────────── Future 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 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(); } }