389 lines
15 KiB
Dart
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();
|
|
}
|
|
}
|