Food delivery driver module + masked calls + TURN
This commit is contained in:
@@ -1,37 +1,87 @@
|
||||
// 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 '../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<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();
|
||||
_refreshAll();
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 4), (_) => _refreshAll());
|
||||
FoodNotificationService.instance.ensureChannels();
|
||||
_bindSocketHandlers();
|
||||
_bootstrap();
|
||||
|
||||
// نبضة خفيفة: المهام دائماً، والعروض فقط إذا كان السوكيت مقطوعاً
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 8), (_) => _pollTick());
|
||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) => _tickOffers());
|
||||
}
|
||||
|
||||
Future<void> _refreshAll() async {
|
||||
if (isDeliveryModeEnabled) {
|
||||
await _fetchPendingOffers();
|
||||
}
|
||||
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;
|
||||
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();
|
||||
@@ -41,23 +91,165 @@ class FoodDeliveryController extends GetxController {
|
||||
|
||||
if (res.success) {
|
||||
isDeliveryModeEnabled = enable;
|
||||
if (!enable) pendingOffers = [];
|
||||
if (enable) {
|
||||
await FoodSocketService.instance.connect();
|
||||
} else {
|
||||
FoodSocketService.instance.disconnect();
|
||||
pendingOffers.clear();
|
||||
FoodNotificationService.instance.cancelOfferNotification();
|
||||
}
|
||||
} else {
|
||||
mySnackbarWarning(res.message);
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
Future<void> _fetchPendingOffers() async {
|
||||
final res = await FoodDeliveryService.getPendingOffers();
|
||||
if (res.success) {
|
||||
pendingOffers = res.data ?? [];
|
||||
// ── السوكيت ────────────────────────────────────────────────────────────
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchActiveTasks() async {
|
||||
isLoadingTasks = true;
|
||||
// ── العروض ─────────────────────────────────────────────────────────────
|
||||
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<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 ?? [];
|
||||
@@ -76,6 +268,7 @@ class FoodDeliveryController extends GetxController {
|
||||
|
||||
_respondingOfferIds.remove(orderId);
|
||||
pendingOffers.removeWhere((o) => o.orderId == orderId);
|
||||
FoodNotificationService.instance.cancelOfferNotification();
|
||||
|
||||
if (!res.success) {
|
||||
mySnackbarWarning(res.message);
|
||||
@@ -83,10 +276,15 @@ class FoodDeliveryController extends GetxController {
|
||||
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);
|
||||
@@ -115,6 +313,7 @@ class FoodDeliveryController extends GetxController {
|
||||
if (res.success) {
|
||||
mySnackbarSuccess('تم تسليم الطلب بنجاح');
|
||||
await fetchActiveTasks();
|
||||
await refreshStatus(); // تحديث عدّاد أرباح اليوم فوراً بعد التسليم
|
||||
} else {
|
||||
mySnackbarWarning(res.message);
|
||||
}
|
||||
@@ -124,6 +323,10 @@ class FoodDeliveryController extends GetxController {
|
||||
@override
|
||||
void onClose() {
|
||||
_pollTimer?.cancel();
|
||||
_countdownTimer?.cancel();
|
||||
_audioPlayer.dispose();
|
||||
// لا نقطع السوكيت هنا: وضع التوصيل قد يبقى مفعّلاً بعد إغلاق الشاشة،
|
||||
// والقطع يتم فقط عند إطفاء الوضع صراحةً من التبديل.
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user