Update codebase

This commit is contained in:
Hamza-Ayed
2026-08-09 16:56:13 +03:00
parent 95e2e4f35d
commit b64debaa88
1058 changed files with 164327 additions and 113928 deletions
@@ -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();
}
}
@@ -0,0 +1,323 @@
// food_delivery_models.dart — نماذج بيانات وحدة التوصيل (جهة السائق)
int _int(dynamic v) => int.tryParse(v?.toString() ?? '') ?? 0;
double? _doubleOrNull(dynamic v) => double.tryParse(v?.toString() ?? '');
double _double(dynamic v) => double.tryParse(v?.toString() ?? '') ?? 0;
DateTime? _date(dynamic v) => DateTime.tryParse(v?.toString() ?? '');
class FoodDeliveryTask {
final int id;
final String status; // courier_assigned | picked_up
final int merchantId;
final String merchantNameAr;
final double? merchantLat;
final double? merchantLng;
final String? merchantAddress;
final int deliveryFee;
final int itemsTotal;
final int serviceFee;
final int discount;
final int grandTotal;
final String paymentMethod; // wallet | cash
final int cashToCollect;
final int courierOwes;
final int itemsCount;
final String? customerNote;
final String? deliveryAddress;
final double deliveryLat;
final double deliveryLng;
final DateTime? createdAt;
final DateTime? readyAt;
final DateTime? courierAssignedAt;
final DateTime? pickedUpAt;
FoodDeliveryTask({
required this.id,
required this.status,
required this.merchantId,
required this.merchantNameAr,
required this.deliveryFee,
required this.deliveryLat,
required this.deliveryLng,
this.itemsTotal = 0,
this.serviceFee = 0,
this.discount = 0,
this.grandTotal = 0,
this.paymentMethod = 'wallet',
this.cashToCollect = 0,
this.courierOwes = 0,
this.itemsCount = 0,
this.customerNote,
this.merchantLat,
this.merchantLng,
this.merchantAddress,
this.deliveryAddress,
this.createdAt,
this.readyAt,
this.courierAssignedAt,
this.pickedUpAt,
});
bool get isPickedUp => status == 'picked_up';
bool get isCash => paymentMethod == 'cash';
factory FoodDeliveryTask.fromJson(Map<String, dynamic> j) => FoodDeliveryTask(
id: _int(j['id']),
status: j['status']?.toString() ?? '',
merchantId: _int(j['merchant_id']),
merchantNameAr: j['merchant_name_ar']?.toString() ?? '',
merchantLat: _doubleOrNull(j['merchant_lat']),
merchantLng: _doubleOrNull(j['merchant_lng']),
merchantAddress: j['merchant_address']?.toString(),
deliveryFee: _int(j['delivery_fee']),
itemsTotal: _int(j['items_total']),
serviceFee: _int(j['service_fee']),
discount: _int(j['discount']),
grandTotal: _int(j['grand_total']),
paymentMethod: j['payment_method']?.toString() ?? 'wallet',
cashToCollect: _int(j['cash_to_collect']),
courierOwes: _int(j['courier_owes']),
itemsCount: _int(j['items_count']),
customerNote: j['customer_note']?.toString(),
deliveryAddress: j['delivery_address']?.toString(),
deliveryLat: _double(j['delivery_lat']),
deliveryLng: _double(j['delivery_lng']),
createdAt: _date(j['created_at']),
readyAt: _date(j['ready_at']),
courierAssignedAt: _date(j['courier_assigned_at']),
pickedUpAt: _date(j['picked_up_at']),
);
}
// عرض توصيل وارد — يصل عبر سوكيت الطعام ('food_delivery_offer') وبنفس الحقول
// من courier/pending_offers.php عند انقطاع السوكيت (foodBuildCourierOfferPayload).
class FoodDeliveryOffer {
final int orderId;
final int merchantId;
final String merchantNameAr;
final String? merchantAddress;
final double? merchantLat;
final double? merchantLng;
final double deliveryLat;
final double deliveryLng;
final int deliveryFee;
final int itemsCount;
final String paymentMethod;
final int cashToCollect;
final int ttlSeconds;
final DateTime receivedAt;
final DateTime? offeredAt;
FoodDeliveryOffer({
required this.orderId,
required this.merchantId,
required this.merchantNameAr,
required this.deliveryFee,
this.merchantAddress,
this.merchantLat,
this.merchantLng,
this.deliveryLat = 0,
this.deliveryLng = 0,
this.itemsCount = 0,
this.paymentMethod = 'wallet',
this.cashToCollect = 0,
this.ttlSeconds = 20,
DateTime? receivedAt,
this.offeredAt,
}) : receivedAt = receivedAt ?? DateTime.now();
bool get isCash => paymentMethod == 'cash';
factory FoodDeliveryOffer.fromJson(Map<String, dynamic> j) => FoodDeliveryOffer(
orderId: _int(j['order_id']),
merchantId: _int(j['merchant_id']),
merchantNameAr: j['merchant_name_ar']?.toString() ?? '',
merchantAddress: j['merchant_address']?.toString(),
merchantLat: _doubleOrNull(j['merchant_lat']),
merchantLng: _doubleOrNull(j['merchant_lng']),
deliveryLat: _double(j['delivery_lat']),
deliveryLng: _double(j['delivery_lng']),
deliveryFee: _int(j['delivery_fee']),
itemsCount: _int(j['items_count']),
paymentMethod: j['payment_method']?.toString() ?? 'wallet',
cashToCollect: _int(j['cash_to_collect']),
ttlSeconds: _int(j['offer_ttl_seconds']) == 0 ? 20 : _int(j['offer_ttl_seconds']),
offeredAt: _date(j['offered_at']),
);
// العدّاد للعرض فقط — الخادم هو الحكم الفعلي ويرفض offer_respond بعد المهلة.
// نبدأ من offered_at إن جاءت (مسار polling)، وإلا من لحظة الاستلام (مسار السوكيت).
int get secondsRemaining {
final start = offeredAt ?? receivedAt;
final elapsed = DateTime.now().difference(start).inSeconds;
return (ttlSeconds - elapsed).clamp(0, ttlSeconds);
}
bool get isExpired => secondsRemaining <= 0;
}
// صنف داخل الطلب — يراه السائق ليتأكد من محتوى الكيس عند الاستلام من المطعم
class FoodOrderItem {
final String nameAr;
final int quantity;
final int unitPrice;
final int lineTotal;
FoodOrderItem({
required this.nameAr,
required this.quantity,
required this.unitPrice,
required this.lineTotal,
});
factory FoodOrderItem.fromJson(Map<String, dynamic> j) => FoodOrderItem(
nameAr: j['name_ar']?.toString() ?? '',
quantity: _int(j['quantity']),
unitPrice: _int(j['unit_price']),
lineTotal: _int(j['line_total']),
);
}
class FoodOrderDetails {
final FoodDeliveryTask task;
final List<FoodOrderItem> items;
final String? merchantPhone;
FoodOrderDetails({required this.task, required this.items, this.merchantPhone});
factory FoodOrderDetails.fromJson(Map<String, dynamic> j) => FoodOrderDetails(
task: FoodDeliveryTask.fromJson(j),
items: (j['items'] is List)
? (j['items'] as List)
.map((i) => FoodOrderItem.fromJson(Map<String, dynamic>.from(i)))
.toList()
: <FoodOrderItem>[],
merchantPhone: j['merchant_phone']?.toString(),
);
}
class FoodCourierStatus {
final bool deliveryModeEnabled;
final int activeOrdersCount;
final int todayOrdersCount;
final int todayEarnings;
FoodCourierStatus({
required this.deliveryModeEnabled,
this.activeOrdersCount = 0,
this.todayOrdersCount = 0,
this.todayEarnings = 0,
});
factory FoodCourierStatus.fromJson(Map<String, dynamic> j) => FoodCourierStatus(
deliveryModeEnabled: j['delivery_mode_enabled'] == true ||
j['delivery_mode_enabled']?.toString() == '1' ||
j['delivery_mode_enabled']?.toString() == 'true',
activeOrdersCount: _int(j['active_orders_count']),
todayOrdersCount: _int(j['today_orders_count']),
todayEarnings: _int(j['today_earnings']),
);
}
class FoodEarningsDay {
final String day;
final int ordersCount;
final int earnings;
FoodEarningsDay({required this.day, required this.ordersCount, required this.earnings});
factory FoodEarningsDay.fromJson(Map<String, dynamic> j) => FoodEarningsDay(
day: j['day']?.toString() ?? '',
ordersCount: _int(j['orders_count']),
earnings: _int(j['earnings']),
);
}
class FoodEarnings {
final int todayEarnings;
final int todayOrders;
final int weekEarnings;
final int weekOrders;
final int monthEarnings;
final int monthOrders;
final int totalEarnings;
final int totalOrders;
final int pendingCashOwed;
final List<FoodEarningsDay> daily;
FoodEarnings({
required this.todayEarnings,
required this.todayOrders,
required this.weekEarnings,
required this.weekOrders,
required this.monthEarnings,
required this.monthOrders,
required this.totalEarnings,
required this.totalOrders,
required this.pendingCashOwed,
required this.daily,
});
factory FoodEarnings.fromJson(Map<String, dynamic> j) => FoodEarnings(
todayEarnings: _int(j['today_earnings']),
todayOrders: _int(j['today_orders']),
weekEarnings: _int(j['week_earnings']),
weekOrders: _int(j['week_orders']),
monthEarnings: _int(j['month_earnings']),
monthOrders: _int(j['month_orders']),
totalEarnings: _int(j['total_earnings']),
totalOrders: _int(j['total_orders']),
pendingCashOwed: _int(j['pending_cash_owed']),
daily: (j['daily'] is List)
? (j['daily'] as List)
.map((d) => FoodEarningsDay.fromJson(Map<String, dynamic>.from(d)))
.toList()
: <FoodEarningsDay>[],
);
}
class FoodHistoryEntry {
final int id;
final String merchantNameAr;
final String? merchantAddress;
final int deliveryFee;
final int grandTotal;
final String paymentMethod;
final int itemsCount;
final int? rating;
final int? durationMinutes;
final DateTime? deliveredAt;
FoodHistoryEntry({
required this.id,
required this.merchantNameAr,
required this.deliveryFee,
this.merchantAddress,
this.grandTotal = 0,
this.paymentMethod = 'wallet',
this.itemsCount = 0,
this.rating,
this.durationMinutes,
this.deliveredAt,
});
factory FoodHistoryEntry.fromJson(Map<String, dynamic> j) => FoodHistoryEntry(
id: _int(j['id']),
merchantNameAr: j['merchant_name_ar']?.toString() ?? '',
merchantAddress: j['merchant_address']?.toString(),
deliveryFee: _int(j['delivery_fee']),
grandTotal: _int(j['grand_total']),
paymentMethod: j['payment_method']?.toString() ?? 'wallet',
itemsCount: _int(j['items_count']),
rating: j['rating'] == null ? null : _int(j['rating']),
durationMinutes: j['duration_minutes'] == null ? null : _int(j['duration_minutes']),
deliveredAt: _date(j['delivered_at']),
);
}
const int foodCurrencyDivisor = 1000;
String foodFormatPrice(int smallestUnit, {String currencySymbol = 'د.أ'}) {
final decimal = smallestUnit / foodCurrencyDivisor;
return '${decimal.toStringAsFixed(3)} $currencySymbol';
}
@@ -0,0 +1,157 @@
// food_delivery_service.dart — طبقة الاتصال بـ backend/food (جهة السائق/الموصّل)
import '../functions/crud.dart';
import '../../constant/links.dart';
import 'food_delivery_models.dart';
class FoodDeliveryApiResult<T> {
final bool success;
final T? data;
final String message;
FoodDeliveryApiResult(this.success, this.data, this.message);
}
class FoodDeliveryService {
static String get _base => '${AppLink.server}/food';
static Future<FoodDeliveryApiResult<bool>> toggleAvailability(bool enabled) async {
final res = await CRUD().post(
link: '$_base/courier/toggle_availability.php',
payload: {'enabled': enabled.toString()},
);
if (res is Map && res['status'] == 'success') return FoodDeliveryApiResult(true, enabled, 'ok');
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
/// حالة السائق عند فتح التبويب — وضع التوصيل مخزَّن في Redis على الخادم،
/// فلا يجوز افتراض "مغلق" محلياً وإلا ظهر التبديل مطفأً والعروض تصل فعلاً.
static Future<FoodDeliveryApiResult<FoodCourierStatus>> getStatus() async {
final res = await CRUD().post(link: '$_base/courier/status.php');
final msg = _payload(res);
if (msg != null) {
return FoodDeliveryApiResult(true, FoodCourierStatus.fromJson(msg), 'ok');
}
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
static Future<FoodDeliveryApiResult<List<FoodDeliveryTask>>> getActiveTasks() async {
final res = await CRUD().post(link: '$_base/courier/active.php');
final msg = _payload(res);
if (msg != null) {
return FoodDeliveryApiResult(true, _list(msg['orders'], FoodDeliveryTask.fromJson), 'ok');
}
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
static Future<FoodDeliveryApiResult<List<FoodDeliveryOffer>>> getPendingOffers() async {
final res = await CRUD().post(link: '$_base/courier/pending_offers.php');
final msg = _payload(res);
if (msg != null) {
return FoodDeliveryApiResult(true, _list(msg['offers'], FoodDeliveryOffer.fromJson), 'ok');
}
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
static Future<FoodDeliveryApiResult<FoodOrderDetails>> getOrderDetails(int orderId) async {
final res = await CRUD().post(
link: '$_base/courier/order_details.php',
payload: {'order_id': orderId.toString()},
);
final msg = _payload(res);
if (msg != null && msg['order'] is Map) {
return FoodDeliveryApiResult(
true, FoodOrderDetails.fromJson(Map<String, dynamic>.from(msg['order'])), 'ok');
}
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
static Future<FoodDeliveryApiResult<FoodEarnings>> getEarnings() async {
final res = await CRUD().post(link: '$_base/courier/earnings.php');
final msg = _payload(res);
if (msg != null) return FoodDeliveryApiResult(true, FoodEarnings.fromJson(msg), 'ok');
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
static Future<FoodDeliveryApiResult<List<FoodHistoryEntry>>> getHistory({
int limit = 20,
int offset = 0,
}) async {
final res = await CRUD().post(
link: '$_base/courier/history.php',
payload: {'limit': limit.toString(), 'offset': offset.toString()},
);
final msg = _payload(res);
if (msg != null) {
return FoodDeliveryApiResult(true, _list(msg['orders'], FoodHistoryEntry.fromJson), 'ok');
}
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
static Future<FoodDeliveryApiResult<String>> respondToOffer(int orderId, bool accept) async {
final res = await CRUD().post(
link: '$_base/courier/offer_respond.php',
payload: {'order_id': orderId.toString(), 'response': accept ? 'accept' : 'reject'},
);
final msg = _payload(res);
if (msg != null) {
return FoodDeliveryApiResult(true, msg['response']?.toString() ?? '', 'ok');
}
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
/// بثّ موقع السائق للزبون أثناء المهمة. الخادم يرفضه بعد التسليم،
/// وينتهي آخر موقع من Redis تلقائياً — لا تتبّع خارج نافذة العمل.
static Future<void> sendLocation({
required int orderId,
required double lat,
required double lng,
double? heading,
}) async {
await CRUD().post(
link: '$_base/courier/location.php',
payload: {
'order_id': orderId.toString(),
'lat': lat.toString(),
'lng': lng.toString(),
if (heading != null) 'heading': heading.toStringAsFixed(0),
},
);
}
static Future<FoodDeliveryApiResult<void>> markPickedUp(int orderId) async {
final res = await CRUD().post(
link: '$_base/courier/picked_up.php',
payload: {'order_id': orderId.toString()},
);
if (res is Map && res['status'] == 'success') return FoodDeliveryApiResult(true, null, 'ok');
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
static Future<FoodDeliveryApiResult<void>> markDelivered(int orderId) async {
final res = await CRUD().post(
link: '$_base/courier/delivered.php',
payload: {'order_id': orderId.toString()},
);
if (res is Map && res['status'] == 'success') return FoodDeliveryApiResult(true, null, 'ok');
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
// كل ردود backend/food تأتي بغلاف {status, message:{...}} — نستخرج المحتوى مرة واحدة
static Map<String, dynamic>? _payload(dynamic res) {
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
return Map<String, dynamic>.from(res['message']);
}
return null;
}
static List<T> _list<T>(dynamic raw, T Function(Map<String, dynamic>) fromJson) {
if (raw is! List) return <T>[];
return raw.map((e) => fromJson(Map<String, dynamic>.from(e))).toList();
}
static String _errMsg(dynamic res) {
if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت';
if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً';
if (res is Map && res['message'] is String) return res['message'];
return 'حدث خطأ، حاول مجدداً';
}
}
@@ -0,0 +1,79 @@
// food_location_broadcaster.dart — بثّ موقع السائق للزبون أثناء التوصيل
//
// لماذا هنا ولا في الخدمة الخلفية (background_service.dart):
// قاعدة معمارية مثبّتة في المشروع تمنع أي تدفّق GPS ثانٍ في العزلة الخلفية —
// `LocationController` هو المصدر الوحيد، وهو يبقى حياً في الخلفية أصلاً عبر
// enableBackgroundMode. فتح تدفّق ثانٍ هناك يعني مستمعَي GPS متوازيين واستنزاف
// بطارية وتجميد أجهزة (موثّق في driver_lifecycle.md). فالبثّ يركب على النبضة
// القائمة في LocationController بدل إنشاء مصدر جديد — وهو ما يجعله يستمر بعد
// إغلاق شاشة التوصيل، وهو المطلوب فعلاً.
//
// المهام النشطة تُحفظ في التخزين المحلي، فلا يحتاج هذا الملف كنترولر التوصيل
// ولا يموت بموته.
import 'dart:convert';
import '../../constant/box_name.dart';
import '../../main.dart';
import '../../print.dart';
import 'food_delivery_service.dart';
class FoodLocationBroadcaster {
FoodLocationBroadcaster._();
static const Duration _minInterval = Duration(seconds: 7);
static DateTime? _lastSentAt;
static bool _inFlight = false;
/// تُخزَّن من كنترولر التوصيل عند كل تحديث للمهام
static void setActiveOrders(List<int> orderIds) {
box.write(BoxName.foodActiveOrders, jsonEncode(orderIds));
}
static List<int> get activeOrders {
try {
final raw = box.read(BoxName.foodActiveOrders);
if (raw == null) return const [];
return (jsonDecode(raw.toString()) as List)
.map((e) => int.tryParse(e.toString()) ?? 0)
.where((e) => e != 0)
.toList();
} catch (_) {
return const [];
}
}
static void clear() => box.remove(BoxName.foodActiveOrders);
/// يُنادى من LocationController على كل تحديث موقع — يخرج فوراً إن لم تكن
/// هناك مهمة توصيل، فلا يكلّف شيئاً في الحالة الغالبة.
static Future<void> maybeSend({
required double lat,
required double lng,
double? heading,
}) async {
final orders = activeOrders;
if (orders.isEmpty) return;
if (lat == 0 && lng == 0) return;
if (_inFlight) return;
final now = DateTime.now();
if (_lastSentAt != null && now.difference(_lastSentAt!) < _minInterval) return;
_lastSentAt = now;
_inFlight = true;
try {
for (final orderId in orders) {
await FoodDeliveryService.sendLocation(
orderId: orderId,
lat: lat,
lng: lng,
heading: heading,
);
}
} catch (e) {
Log.print('🍔 [FoodLocation] تعذّر بثّ الموقع: $e');
} finally {
_inFlight = false;
}
}
}
@@ -0,0 +1,62 @@
// food_navigation.dart — تشغيل ملاحة سيرو الداخلية لمهام التوصيل
//
// لماذا النسخة نفسها (بلا tag) لا نسخة مستقلة للتوصيل:
// خطّ تحديث الموقع المركزي في LocationController يُغذّي نسخة واحدة فقط
// (Get.find<NavigationController> بلا وسم). نسخة موسومة للتوصيل ستُحرم من
// تحديثات الموقع فلا تعمل الملاحة أصلاً، وتغذيتها يستلزم تعديل مسار الموقع
// وهو المسار الأخطر في التطبيق. فنستعمل النسخة المشتركة، ونعالج التضارب
// الوحيد الممكن (سائق في ملاحة رحلة ثم يفتح مهمة توصيل) بسؤاله صراحةً
// بدل استبدال مساره صامتاً.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intaleq_maps/intaleq_maps.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../views/widgets/error_snakbar.dart';
import '../home/navigation/navigation_controller.dart';
import '../home/navigation/navigation_view.dart';
class FoodNavigation {
/// يفتح ملاحة سيرو إلى [lat]/[lng]. يعيد false إذا ألغى السائق الاستبدال.
static Future<bool> startInApp({
required double lat,
required double lng,
required String title,
}) async {
final controller = Get.isRegistered<NavigationController>()
? Get.find<NavigationController>()
: Get.put(NavigationController());
if (controller.isNavigating) {
final replace = await Get.dialog<bool>(
AlertDialog(
title: const Text('ملاحة نشطة'),
content: Text('لديك ملاحة جارية. هل تستبدلها بالوجهة الجديدة؟\n$title'),
actions: [
TextButton(onPressed: () => Get.back(result: false), child: const Text('إبقاء الحالية')),
TextButton(onPressed: () => Get.back(result: true), child: const Text('استبدال')),
],
),
);
if (replace != true) return false;
}
Get.to(() => const NavigationView());
await controller.startNavigationTo(LatLng(lat, lng), infoWindowTitle: title);
return true;
}
/// خيار ثانوي لمن يفضّل جوجل — نفس ما يتيحه تدفّق الرحلات.
/// لا نمرّر في الرابط سوى الإحداثيات: لا اسم زبون ولا رقم طلب.
static Future<void> startExternal(double lat, double lng) async {
final nav = Uri.parse('google.navigation:q=$lat,$lng');
if (await canLaunchUrl(nav)) {
await launchUrl(nav);
return;
}
final web = Uri.parse('https://www.google.com/maps/dir/?api=1&destination=$lat,$lng');
if (!await launchUrl(web, mode: LaunchMode.externalApplication)) {
mySnackbarWarning('تعذّر فتح تطبيق الخرائط');
}
}
}
@@ -0,0 +1,156 @@
// food_notification_service.dart — إشعارات وحدة التوصيل، منفصلة كلياً عن إشعارات الرحلات
//
// الفصل مقصود على ثلاثة مستويات:
// 1) قناتان أندرويد خاصتان (food_delivery_offer_channel / food_delivery_status_channel)
// غير 'high_importance_channel' الخاص بالرحلات — فيقدر السائق يكتم أو يغيّر
// نغمة التوصيل من إعدادات النظام دون أن يمسّ تنبيه الرحلات إطلاقاً.
// 2) معرّفات إشعارات مستقلة (7001/7002) فلا يستبدل إشعار توصيل إشعارَ رحلة.
// 3) أزرار وحمولة خاصة ('FOOD_ACCEPT'/'FOOD_REJECT' + {"food_event": ...})
// يلتقطها فرع مستقل في handleNotificationResponse.
import 'dart:convert';
import 'dart:ui';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'food_delivery_models.dart';
class FoodNotificationService {
FoodNotificationService._();
static final FoodNotificationService instance = FoodNotificationService._();
// نفس نسخة الإضافة المستخدمة في NotificationController (singleton داخل الحزمة)،
// لكن بقنوات ومعرّفات خاصة بنا — لا نستدعي initialize هنا إطلاقاً حتى لا
// نستبدل معالِج النقر العام الذي هيّأه تطبيق الرحلات عند الإقلاع.
final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin();
static const String offerChannelId = 'food_delivery_offer_channel';
static const String statusChannelId = 'food_delivery_status_channel';
static const int offerNotificationId = 7001;
static const int statusNotificationId = 7002;
bool _channelsReady = false;
Future<void> ensureChannels() async {
if (_channelsReady) return;
final android = _plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (android == null) {
_channelsReady = true; // iOS — لا قنوات
return;
}
const offerChannel = AndroidNotificationChannel(
offerChannelId,
'عروض التوصيل',
description: 'تنبيه وصول عرض توصيل طلب طعام جديد',
importance: Importance.max,
playSound: true,
sound: RawResourceAndroidNotificationSound('order1'),
enableVibration: true,
);
const statusChannel = AndroidNotificationChannel(
statusChannelId,
'تحديثات طلبات التوصيل',
description: 'تغيّر حالة طلب توصيل قائم (جاهز، ملغى، إلخ)',
importance: Importance.high,
playSound: true,
);
await android.createNotificationChannel(offerChannel);
await android.createNotificationChannel(statusChannel);
_channelsReady = true;
}
/// إشعار عرض توصيل — ملء الشاشة (fullScreenIntent) كإشعار المكالمة، لأن مهلة
/// العرض 20 ثانية ولا تحتمل أن يمرّ السائق على شريط الإشعارات لاحقاً.
Future<void> showOfferNotification(FoodDeliveryOffer offer) async {
await ensureChannels();
final body = '${offer.merchantNameAr}\n'
'💰 أجرة التوصيل: ${foodFormatPrice(offer.deliveryFee)}'
'${offer.itemsCount > 0 ? ' | 🧾 ${offer.itemsCount} صنف' : ''}'
'${offer.cashToCollect > 0 ? '\n💵 تحصيل نقدي: ${foodFormatPrice(offer.cashToCollect)}' : ''}';
final androidDetails = AndroidNotificationDetails(
offerChannelId,
'عروض التوصيل',
importance: Importance.max,
priority: Priority.max,
fullScreenIntent: true,
category: AndroidNotificationCategory.call,
visibility: NotificationVisibility.public,
timeoutAfter: 20000, // يختفي مع انتهاء مهلة العرض نفسها
sound: const RawResourceAndroidNotificationSound('order1'),
audioAttributesUsage: AudioAttributesUsage.alarm,
color: const Color(0xFFFF6B35),
styleInformation: BigTextStyleInformation(
body,
contentTitle: '🍔 عرض توصيل جديد',
summaryText: foodFormatPrice(offer.deliveryFee),
),
actions: const <AndroidNotificationAction>[
AndroidNotificationAction('FOOD_ACCEPT', '✅ قبول',
showsUserInterface: true, titleColor: Color(0xFF4CAF50)),
AndroidNotificationAction('FOOD_REJECT', '❌ رفض',
cancelNotification: true, titleColor: Color(0xFFE53935)),
],
);
const iosDetails = DarwinNotificationDetails(
sound: 'order1.wav',
presentAlert: true,
presentBadge: true,
presentSound: true,
categoryIdentifier: 'FOOD_OFFER_CATEGORY',
interruptionLevel: InterruptionLevel.timeSensitive,
);
await _plugin.show(
id: offerNotificationId,
title: '🍔 عرض توصيل جديد',
body: '${offer.merchantNameAr} — ${foodFormatPrice(offer.deliveryFee)}',
notificationDetails:
NotificationDetails(android: androidDetails, iOS: iosDetails),
payload: jsonEncode({
'food_event': 'offer',
'order_id': offer.orderId,
}),
);
}
Future<void> cancelOfferNotification() async {
await _plugin.cancel(id: offerNotificationId);
}
/// تحديث حالة مهمة قائمة (مثلاً إلغاء من النظام) — قناة أهدأ، بلا ملء شاشة
Future<void> showStatusNotification({
required int orderId,
required String title,
required String body,
}) async {
await ensureChannels();
const androidDetails = AndroidNotificationDetails(
statusChannelId,
'تحديثات طلبات التوصيل',
importance: Importance.high,
priority: Priority.high,
color: Color(0xFFFF6B35),
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
);
await _plugin.show(
id: statusNotificationId,
title: title,
body: body,
notificationDetails:
const NotificationDetails(android: androidDetails, iOS: iosDetails),
payload: jsonEncode({'food_event': 'status', 'order_id': orderId}),
);
}
}
@@ -0,0 +1,92 @@
// food_overlay_bridge.dart — نافذة عرض التوصيل العائمة (أندرويد) ونتيجتها
//
// لماذا جسر مستقل عن كنترولر التوصيل:
// العرض قد يصل والتطبيق في الخلفية وشاشة التوصيل مغلقة — بل وقد يصل من العزلة
// الخلفية حيث لا وجود لأي كنترولر. فالاشتراك في نتيجة النافذة يجب أن يكون
// عالمياً ويعيش طوال عمر التطبيق، ويردّ على الخادم بنفسه بلا انتظار شاشة.
import 'dart:async';
import 'package:get/get.dart';
import 'package:trip_overlay_plugin/trip_overlay_plugin.dart';
import '../../print.dart';
import '../../views/food_delivery/food_task_details_page.dart';
import '../../views/widgets/error_snakbar.dart';
import 'food_delivery_models.dart';
import 'food_delivery_service.dart';
class FoodOverlayBridge {
FoodOverlayBridge._();
static StreamSubscription<String>? _acceptSub;
static StreamSubscription<String>? _rejectSub;
/// تُنادى مرة واحدة عند إقلاع التطبيق
static void attach() {
if (!TripOverlayPlugin.isSupported) return; // أندرويد فقط
if (_acceptSub != null) return;
TripOverlayPlugin.initialize();
_acceptSub = TripOverlayPlugin.onFoodOrderAccepted.listen(_onAccepted);
_rejectSub = TripOverlayPlugin.onFoodOrderRejected.listen(_onRejected);
Log.print('🍔 [FoodOverlay] جاهز لاستقبال نتائج النافذة العائمة');
}
static Future<void> _onAccepted(String orderIdRaw) async {
final orderId = int.tryParse(orderIdRaw) ?? 0;
if (orderId == 0) return;
Log.print('🍔 [FoodOverlay] قبول من النافذة — طلب #$orderId');
// القبول يتم هنا لا في الشاشة: النافذة تُغلق فوراً والخادم لا ينتظر
// فتح واجهة. لو تأخّرنا حتى تُبنى الشاشة قد تنتهي مهلة العرض.
final res = await FoodDeliveryService.respondToOffer(orderId, true);
if (res.success) {
mySnackbarSuccess('تم قبول طلب التوصيل');
Get.to(() => FoodTaskDetailsPage(orderId: orderId));
} else {
mySnackbarWarning(res.message);
}
}
static Future<void> _onRejected(String orderIdRaw) async {
final orderId = int.tryParse(orderIdRaw) ?? 0;
if (orderId == 0) return;
// الرفض الصريح يُبلَّغ الخادم ليعرض الطلب على سائق آخر فوراً بدل انتظار
// انتهاء المهلة. انتهاء المهلة يصل بنفس الحدث، والخادم يتعامل مع الحالتين
// بأمان (يرفض إن لم يعد العرض قائماً).
Log.print('🍔 [FoodOverlay] رفض/انتهاء مهلة — طلب #$orderId');
await FoodDeliveryService.respondToOffer(orderId, false);
}
/// عرض النافذة لعرض توصيل وارد — تُنادى حين يكون التطبيق في الخلفية
static Future<bool> show(FoodDeliveryOffer offer, {String? distanceText}) {
return TripOverlayPlugin.showFoodOverlay(
FoodOrderOverlayData(
orderId: offer.orderId.toString(),
merchantName: offer.merchantNameAr,
merchantAddress: offer.merchantAddress ?? '',
// التنسيق يتم هنا لا في Kotlin — العملة والوحدة الصغرى منطق تطبيق
deliveryFeeText: foodFormatPrice(offer.deliveryFee),
cashToCollectText:
offer.cashToCollect > 0 ? foodFormatPrice(offer.cashToCollect) : '',
distanceText: distanceText ?? '',
itemsCount: offer.itemsCount,
isCash: offer.isCash,
),
autoCloseSeconds: offer.secondsRemaining.clamp(5, 20),
);
}
static Future<void> hide() => TripOverlayPlugin.hideOverlay();
static void detach() {
_acceptSub?.cancel();
_rejectSub?.cancel();
_acceptSub = null;
_rejectSub = null;
}
}
@@ -0,0 +1,169 @@
// food_socket_service.dart — قناة سوكيت مستقلة تماماً لوحدة التوصيل
//
// لماذا سوكيت منفصل ولا نركب على سوكيت الرحلات (LocationController.socket)؟
// • خادمان منفصلان فعلاً: food_socket.php (4040) مقابل driver_socket (2020)،
// وغرفة السائق هناك courier_food_{id} بمصادقة JWT مختلفة الاستعلام.
// • عزل الأعطال: سقوط سوكيت الطعام لا يقطع توزيع الرحلات، وهو المسار الحرج.
// • دورة حياة مختلفة: نتصل فقط أثناء «وضع التوصيل» وننفصل عند إطفائه،
// بينما سوكيت الرحلات يبقى حياً طوال مناوبة السائق.
import 'dart:async';
import 'dart:io';
import 'package:socket_io_client/socket_io_client.dart' as IO;
import '../../constant/box_name.dart';
import '../../constant/links.dart';
import '../../main.dart';
import '../../print.dart';
typedef FoodOfferHandler = void Function(Map<String, dynamic> payload);
typedef FoodOrderUpdateHandler = void Function(Map<String, dynamic> payload);
class FoodSocketService {
FoodSocketService._();
static final FoodSocketService instance = FoodSocketService._();
IO.Socket? _socket;
bool _isConnecting = false;
Timer? _heartbeat;
FoodOfferHandler? onOffer;
FoodOrderUpdateHandler? onOrderUpdate;
FoodOrderUpdateHandler? onIncomingCall;
void Function(bool connected)? onConnectionChanged;
bool get isConnected => _socket?.connected == true;
Future<void> connect() async {
if (isConnected || _isConnecting) return;
final driverId = box.read(BoxName.driverID)?.toString() ?? '';
final token = box.read(BoxName.tokenDriver)?.toString() ?? '';
if (driverId.isEmpty || token.isEmpty) {
Log.print('🍔 [FoodSocket] لا يوجد driverID/token — تخطّي الاتصال');
return;
}
_isConnecting = true;
_disposeSocket();
try {
_socket = IO.io(
AppLink.foodSocketUrl,
IO.OptionBuilder()
.setTransports(['websocket'])
// food_socket.php يتحقق: role=driver ويطابق user_id داخل الـ JWT مع id
.setQuery({
'role': 'driver',
'id': driverId,
'jwt': token,
'platform': Platform.isIOS ? 'ios' : 'android',
})
.enableForceNew()
.enableReconnection()
.setReconnectionDelay(2000)
.build(),
);
_bindListeners();
_socket!.connect();
} catch (e) {
_isConnecting = false;
Log.print('❌ [FoodSocket] فشل التهيئة: $e');
}
}
void _bindListeners() {
final s = _socket;
if (s == null) return;
s.onConnect((_) {
_isConnecting = false;
Log.print('✅ [FoodSocket] متصل — غرفة courier_food');
onConnectionChanged?.call(true);
_startHeartbeat();
});
s.onDisconnect((_) {
Log.print('⚠️ [FoodSocket] انقطع الاتصال');
onConnectionChanged?.call(false);
_stopHeartbeat();
});
s.onConnectError((e) {
_isConnecting = false;
Log.print('❌ [FoodSocket] خطأ اتصال: $e');
onConnectionChanged?.call(false);
});
s.onError((e) => Log.print('❌ [FoodSocket] خطأ: $e'));
// عرض توصيل جديد — الحمولة كاملة من foodBuildCourierOfferPayload،
// فشاشة العرض تُبنى فوراً بلا نداء HTTP إضافي (المهلة 20 ثانية فقط).
s.on('food_delivery_offer', (data) {
final map = _asMap(data);
if (map == null) return;
Log.print('🍔 [FoodSocket] عرض توصيل #${map['order_id']}');
onOffer?.call(map);
});
// مكالمة واردة من الزبون عبر القناة المقنّعة — لا رقم هاتف في الحمولة،
// فقط session_id ينضم به الطرفان لخادم الإشارات.
s.on('food_incoming_call', (data) {
final map = _asMap(data);
if (map == null) return;
Log.print('🍔 [FoodSocket] مكالمة واردة على الطلب #${map['order_id']}');
onIncomingCall?.call(map);
});
// تحديث حالة طلب قائم (المطعم ألغى، النظام ألغى، إلخ)
s.on('food_order_update', (data) {
final map = _asMap(data);
if (map == null) return;
Log.print('🍔 [FoodSocket] تحديث طلب #${map['order_id']} → ${map['status']}');
onOrderUpdate?.call(map);
});
}
// السوكيت ناقل إشعار لا مخزن حالة — أي حمولة غير مفهومة تُهمَل بصمت
// ويصحّح التطبيق نفسه من courier/active.php.
Map<String, dynamic>? _asMap(dynamic data) {
try {
if (data is Map) return Map<String, dynamic>.from(data);
if (data is List && data.isNotEmpty && data.first is Map) {
return Map<String, dynamic>.from(data.first as Map);
}
} catch (e) {
Log.print('❌ [FoodSocket] حمولة غير صالحة: $e');
}
return null;
}
void _startHeartbeat() {
_stopHeartbeat();
_heartbeat = Timer.periodic(const Duration(seconds: 25), (_) {
if (isConnected) _socket!.emit('heartbeat', {});
});
}
void _stopHeartbeat() {
_heartbeat?.cancel();
_heartbeat = null;
}
void _disposeSocket() {
if (_socket == null) return;
_socket!.clearListeners();
_socket!.dispose();
_socket = null;
}
void disconnect() {
_stopHeartbeat();
_isConnecting = false;
_socket?.disconnect();
_disposeSocket();
onConnectionChanged?.call(false);
Log.print('🍔 [FoodSocket] تم قطع الاتصال (وضع التوصيل مطفأ)');
}
}