Update codebase
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user