From f01e408ba66dbb23476546a54fccc4746abda2cc Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Tue, 4 Aug 2026 01:50:50 +0300 Subject: [PATCH] Food delivery driver module + masked calls + TURN --- backend/food/courier/active.php | 16 +- backend/food/courier/call_customer.php | 48 ++ backend/food/courier/earnings.php | 57 +++ backend/food/courier/history.php | 34 ++ backend/food/courier/order_details.php | 64 +++ backend/food/courier/pending_offers.php | 20 +- backend/food/courier/status.php | 32 ++ backend/food/functions.php | 134 +++++- backend/food/order/call_courier.php | 39 ++ backend/ride/call/turn_credentials.php | 108 +++++ docker/.env.example | 16 + docker/coturn/turnserver.conf | 64 +++ docker/docker-compose.yml | 17 + food_server/food_socket.php | 12 + siro_admin/pubspec.lock | 14 +- siro_driver/lib/constant/box_name.dart | 3 + siro_driver/lib/constant/links.dart | 17 + .../firebase/local_notification.dart | 45 ++ .../food_delivery_controller.dart | 229 ++++++++- .../food_delivery/food_delivery_models.dart | 273 ++++++++++- .../food_delivery/food_delivery_service.dart | 87 +++- .../food_notification_service.dart | 156 +++++++ .../food_delivery/food_socket_service.dart | 169 +++++++ .../lib/controller/voice_call_controller.dart | 35 +- .../services/turn_credentials_service.dart | 79 ++++ .../food_delivery_earnings_page.dart | 200 ++++++++ .../food_delivery_home_page.dart | 283 ++++++----- .../views/food_delivery/food_offer_page.dart | 439 ++++++++++++++++++ .../food_delivery/food_task_details_page.dart | 368 +++++++++++++++ siro_driver/pubspec.lock | 12 +- siro_rider/lib/constant/box_name.dart | 3 + .../lib/controller/voice_call_controller.dart | 32 +- .../services/turn_credentials_service.dart | 79 ++++ .../views/food/food_order_tracking_page.dart | 45 +- siro_rider/pubspec.lock | 12 +- 35 files changed, 3029 insertions(+), 212 deletions(-) create mode 100644 backend/food/courier/call_customer.php create mode 100644 backend/food/courier/earnings.php create mode 100644 backend/food/courier/history.php create mode 100644 backend/food/courier/order_details.php create mode 100644 backend/food/courier/status.php create mode 100644 backend/food/order/call_courier.php create mode 100644 backend/ride/call/turn_credentials.php create mode 100644 docker/coturn/turnserver.conf create mode 100644 siro_driver/lib/controller/food_delivery/food_notification_service.dart create mode 100644 siro_driver/lib/controller/food_delivery/food_socket_service.dart create mode 100644 siro_driver/lib/services/turn_credentials_service.dart create mode 100644 siro_driver/lib/views/food_delivery/food_delivery_earnings_page.dart create mode 100644 siro_driver/lib/views/food_delivery/food_offer_page.dart create mode 100644 siro_driver/lib/views/food_delivery/food_task_details_page.dart create mode 100644 siro_rider/lib/services/turn_credentials_service.dart diff --git a/backend/food/courier/active.php b/backend/food/courier/active.php index bbf91f1b..2152ff2b 100644 --- a/backend/food/courier/active.php +++ b/backend/food/courier/active.php @@ -4,8 +4,11 @@ require_once __DIR__ . '/../connect_courier.php'; $st = $food_con->prepare( "SELECT o.id, o.status, o.merchant_id, m.name_ar AS merchant_name_ar, m.latitude AS merchant_lat, - m.longitude AS merchant_lng, m.address AS merchant_address, o.delivery_fee, - o.delivery_address, o.delivery_lat, o.delivery_lng, o.created_at, + m.longitude AS merchant_lng, m.address AS merchant_address, m.avg_prep_minutes, + o.delivery_fee, o.items_total, o.service_fee, o.discount, o.grand_total, o.payment_method, + o.customer_note, o.delivery_address, o.delivery_lat, o.delivery_lng, + o.created_at, o.ready_at, o.courier_assigned_at, o.picked_up_at, + (SELECT COALESCE(SUM(quantity),0) FROM food_order_items WHERE order_id=o.id) AS items_count, CASE WHEN o.status IN ('courier_assigned','picked_up') THEN o.delivery_address ELSE NULL END AS visible_address FROM food_orders o JOIN food_merchants m ON m.id = o.merchant_id @@ -20,6 +23,15 @@ foreach ($orders as &$o) { unset($o['delivery_address']); $o['delivery_address'] = $o['visible_address']; unset($o['visible_address']); + + $o['items_count'] = (int)$o['items_count']; + // نقداً: السائق يحصّل grand_total من الزبون ويحتفظ بأجرته (delivery_fee)، + // والباقي يبقى ديناً عليه حتى التسوية — نُظهر الرقمين صراحةً في التطبيق + // كي لا يجتهد السائق في الحساب على باب الزبون. + $o['cash_to_collect'] = $o['payment_method'] === 'cash' ? (int)$o['grand_total'] : 0; + $o['courier_owes'] = $o['payment_method'] === 'cash' + ? (int)$o['grand_total'] - (int)$o['delivery_fee'] + : 0; } unset($o); diff --git a/backend/food/courier/call_customer.php b/backend/food/courier/call_customer.php new file mode 100644 index 00000000..b07ee002 --- /dev/null +++ b/backend/food/courier/call_customer.php @@ -0,0 +1,48 @@ + 'incoming_call', + 'type' => 'incoming_call', + 'session_id' => $session['session_id'], + 'caller_name' => 'سائق التوصيل', + 'caller_avatar' => '', + 'ride_id' => 'food_' . $orderId, + 'food_order_id' => (string)$orderId, + ] +); + +appLog("[FOOD][CALL] courier {$food_courier_id} → passenger (order {$orderId})", 'INFO'); + +jsonSuccess([ + 'session_id' => $session['session_id'], + 'expires_in' => $session['expires_in'], +]); diff --git a/backend/food/courier/earnings.php b/backend/food/courier/earnings.php new file mode 100644 index 00000000..aa8f6466 --- /dev/null +++ b/backend/food/courier/earnings.php @@ -0,0 +1,57 @@ +prepare( + "SELECT + COUNT(*) AS total_orders, + COALESCE(SUM(delivery_fee),0) AS total_earnings, + COALESCE(SUM(CASE WHEN DATE(delivered_at)=CURDATE() THEN delivery_fee END),0) AS today_earnings, + COUNT(CASE WHEN DATE(delivered_at)=CURDATE() THEN 1 END) AS today_orders, + COALESCE(SUM(CASE WHEN delivered_at >= (NOW() - INTERVAL 7 DAY) THEN delivery_fee END),0) AS week_earnings, + COUNT(CASE WHEN delivered_at >= (NOW() - INTERVAL 7 DAY) THEN 1 END) AS week_orders, + COALESCE(SUM(CASE WHEN delivered_at >= (NOW() - INTERVAL 30 DAY) THEN delivery_fee END),0) AS month_earnings, + COUNT(CASE WHEN delivered_at >= (NOW() - INTERVAL 30 DAY) THEN 1 END) AS month_orders + FROM food_orders + WHERE courier_id=? AND status='delivered'" +); +$sums->execute([$food_courier_id]); +$row = $sums->fetch() ?: []; + +// ديون التحصيل النقدي غير المسوّاة — السائق يحتاج يعرف كم عليه قبل ما يفاجأ بخصم +$owedSt = $food_con->prepare( + "SELECT COALESCE(SUM(p.amount),0) AS owed + FROM food_order_payments p + JOIN food_orders o ON o.id = p.order_id + WHERE o.courier_id=? AND p.type='cash_settlement' AND p.status='pending'" +); +$owedSt->execute([$food_courier_id]); + +// آخر 7 أيام مفصّلة — للرسم البياني في التطبيق +$dailySt = $food_con->prepare( + "SELECT DATE(delivered_at) AS day, COUNT(*) AS orders_count, COALESCE(SUM(delivery_fee),0) AS earnings + FROM food_orders + WHERE courier_id=? AND status='delivered' AND delivered_at >= (NOW() - INTERVAL 7 DAY) + GROUP BY DATE(delivered_at) + ORDER BY day DESC" +); +$dailySt->execute([$food_courier_id]); + +jsonSuccess([ + 'today_earnings' => (int)($row['today_earnings'] ?? 0), + 'today_orders' => (int)($row['today_orders'] ?? 0), + 'week_earnings' => (int)($row['week_earnings'] ?? 0), + 'week_orders' => (int)($row['week_orders'] ?? 0), + 'month_earnings' => (int)($row['month_earnings'] ?? 0), + 'month_orders' => (int)($row['month_orders'] ?? 0), + 'total_earnings' => (int)($row['total_earnings'] ?? 0), + 'total_orders' => (int)($row['total_orders'] ?? 0), + 'pending_cash_owed' => (int)($owedSt->fetch()['owed'] ?? 0), + 'daily' => array_map(static fn($d) => [ + 'day' => $d['day'], + 'orders_count' => (int)$d['orders_count'], + 'earnings' => (int)$d['earnings'], + ], $dailySt->fetchAll()), +]); diff --git a/backend/food/courier/history.php b/backend/food/courier/history.php new file mode 100644 index 00000000..7018aa08 --- /dev/null +++ b/backend/food/courier/history.php @@ -0,0 +1,34 @@ +prepare( + "SELECT o.id, o.status, o.merchant_id, m.name_ar AS merchant_name_ar, m.address AS merchant_address, + o.delivery_fee, o.grand_total, o.payment_method, o.rating, + o.courier_assigned_at, o.picked_up_at, o.delivered_at, o.created_at, + (SELECT COALESCE(SUM(quantity),0) FROM food_order_items WHERE order_id=o.id) AS items_count, + TIMESTAMPDIFF(MINUTE, o.courier_assigned_at, o.delivered_at) AS duration_minutes + FROM food_orders o + JOIN food_merchants m ON m.id = o.merchant_id + WHERE o.courier_id = ? AND o.status = 'delivered' + ORDER BY o.delivered_at DESC + LIMIT $limit OFFSET $offset" +); +$st->execute([$food_courier_id]); + +$orders = array_map(static function (array $o): array { + $o['items_count'] = (int)$o['items_count']; + $o['delivery_fee'] = (int)$o['delivery_fee']; + $o['grand_total'] = (int)$o['grand_total']; + $o['duration_minutes'] = $o['duration_minutes'] === null ? null : (int)$o['duration_minutes']; + return $o; +}, $st->fetchAll()); + +jsonSuccess(['orders' => $orders, 'limit' => $limit, 'offset' => $offset]); diff --git a/backend/food/courier/order_details.php b/backend/food/courier/order_details.php new file mode 100644 index 00000000..cec901e8 --- /dev/null +++ b/backend/food/courier/order_details.php @@ -0,0 +1,64 @@ +prepare( + "SELECT o.id, o.status, o.merchant_id, o.items_total, o.delivery_fee, o.service_fee, o.discount, + o.grand_total, o.payment_method, o.customer_note, o.delivery_lat, o.delivery_lng, + o.created_at, o.ready_at, o.courier_assigned_at, o.picked_up_at, o.delivered_at, + CASE WHEN o.status IN ('courier_assigned','picked_up') THEN o.delivery_address ELSE NULL END AS delivery_address, + m.name_ar AS merchant_name_ar, m.address AS merchant_address, m.latitude AS merchant_lat, + m.longitude AS merchant_lng, m.avg_prep_minutes + FROM food_orders o + JOIN food_merchants m ON m.id = o.merchant_id + WHERE o.id = ? LIMIT 1" +); +$st->execute([$orderId]); +$order = $st->fetch(); +if (!$order) jsonError('Order not found', 404); + +$itemsSt = $food_con->prepare( + "SELECT name_ar_snapshot, quantity, unit_price, line_total, option_price_json + FROM food_order_items WHERE order_id=? ORDER BY id ASC" +); +$itemsSt->execute([$orderId]); +$items = array_map(static function (array $i): array { + return [ + 'name_ar' => $i['name_ar_snapshot'], + 'quantity' => (int)$i['quantity'], + 'unit_price' => (int)$i['unit_price'], + 'line_total' => (int)$i['line_total'], + 'options' => $i['option_price_json'] ? json_decode($i['option_price_json'], true) : null, + ]; +}, $itemsSt->fetchAll()); + +// هاتف المطعم — يُعاد فقط أثناء المهمة النشطة (قبل التسليم)، لا في السجل +$merchantPhone = null; +if (in_array($order['status'], ['courier_assigned', 'picked_up'], true)) { + $phoneSt = $food_con->prepare( + "SELECT phone FROM food_merchant_users + WHERE merchant_id=? AND is_active=1 ORDER BY role='owner' DESC, id ASC LIMIT 1" + ); + $phoneSt->execute([$order['merchant_id']]); + $merchantPhone = $phoneSt->fetch()['phone'] ?? null; +} + +// ملاحظة: لا هاتف للزبون هنا — قاعدة siro_food معزولة ولا تحمل بيانات الراكب، +// وممنوع Database::get('main') داخل backend/food/. التواصل مع الزبون يبقى عبر +// عنوان التسليم والملاحظة، إلى أن تُضاف قناة اتصال مقنّعة كما في الرحلات. +$order['items'] = $items; +$order['items_count'] = array_sum(array_column($items, 'quantity')); +$order['merchant_phone'] = $merchantPhone; +$order['cash_to_collect'] = $order['payment_method'] === 'cash' ? (int)$order['grand_total'] : 0; +$order['courier_owes'] = $order['payment_method'] === 'cash' + ? (int)$order['grand_total'] - (int)$order['delivery_fee'] + : 0; + +jsonSuccess(['order' => $order]); diff --git a/backend/food/courier/pending_offers.php b/backend/food/courier/pending_offers.php index 33d74568..16e207dd 100644 --- a/backend/food/courier/pending_offers.php +++ b/backend/food/courier/pending_offers.php @@ -5,15 +5,25 @@ require_once __DIR__ . '/../connect_courier.php'; $st = $food_con->prepare( - "SELECT a.order_id, a.offered_at, o.merchant_id, m.name_ar AS merchant_name_ar, - m.latitude AS merchant_lat, m.longitude AS merchant_lng, o.delivery_fee, - o.delivery_lat, o.delivery_lng + "SELECT a.order_id, a.offered_at FROM food_courier_assignments a JOIN food_orders o ON o.id = a.order_id - JOIN food_merchants m ON m.id = o.merchant_id WHERE a.courier_id = ? AND a.status = 'offered' AND a.offered_at > (NOW() - INTERVAL 20 SECOND) + AND o.status = 'ready' AND o.courier_id IS NULL ORDER BY a.offered_at ASC" ); $st->execute([$food_courier_id]); -jsonSuccess(['offers' => $st->fetchAll()]); +// نفس حمولة مسار السوكيت حرفياً (foodBuildCourierOfferPayload) — شاشة العرض +// عند السائق واحدة، فلا يجوز أن تختلف الحقول حسب المسار الذي وصل منه العرض. +$offers = []; +foreach ($st->fetchAll() as $row) { + $payload = foodBuildCourierOfferPayload((int)$row['order_id']); + if (!$payload) continue; + $offers[] = array_merge( + ['order_id' => (int)$row['order_id'], 'offered_at' => $row['offered_at'], 'offer_ttl_seconds' => 20], + $payload + ); +} + +jsonSuccess(['offers' => $offers]); diff --git a/backend/food/courier/status.php b/backend/food/courier/status.php new file mode 100644 index 00000000..391fc49f --- /dev/null +++ b/backend/food/courier/status.php @@ -0,0 +1,32 @@ +sIsMember('food:couriers:opted_in', $food_courier_id); +} + +$activeSt = $food_con->prepare( + "SELECT COUNT(*) AS c FROM food_orders + WHERE courier_id=? AND status IN ('courier_assigned','picked_up')" +); +$activeSt->execute([$food_courier_id]); +$activeCount = (int)($activeSt->fetch()['c'] ?? 0); + +$todaySt = $food_con->prepare( + "SELECT COUNT(*) AS orders_count, COALESCE(SUM(delivery_fee),0) AS earnings + FROM food_orders + WHERE courier_id=? AND status='delivered' AND DATE(delivered_at)=CURDATE()" +); +$todaySt->execute([$food_courier_id]); +$today = $todaySt->fetch(); + +jsonSuccess([ + 'delivery_mode_enabled' => $enabled, + 'active_orders_count' => $activeCount, + 'today_orders_count' => (int)$today['orders_count'], + 'today_earnings' => (int)$today['earnings'], +]); diff --git a/backend/food/functions.php b/backend/food/functions.php index 4661a9bf..22ade719 100644 --- a/backend/food/functions.php +++ b/backend/food/functions.php @@ -179,6 +179,91 @@ function foodSendNotificationToPassenger(string $passengerId, string $title, str } } +// ============================================================ +// قناة اتصال مقنّعة بين السائق والزبون (WebRTC — بلا أرقام هواتف) +// ------------------------------------------------------------ +// لا نكشف رقم أي طرف للآخر إطلاقاً: خادم الإشارات (Node) يفتح جلسة +// بمعرّف عشوائي قصير العمر، والطرفان ينضمّان إليها بـ session_id فقط. +// نستعمل نفس بنية مكالمات الرحلات (VOICE_CALL_SERVER_URL) لكن بمرجع +// جلسة مُسمّى food_{order_id} حتى تبقى سجلات الطعام مميّزة عن الرحلات. +// ============================================================ + +// حد إساءة الاستخدام: عدد محاولات فتح جلسة لكل طلب/طرف خلال ساعتين +const FOOD_CALL_MAX_PER_ORDER = 10; + +function foodCallQuotaExceeded(int $orderId, string $callerRole): bool +{ + global $redis; + if (!$redis) return false; + + $key = "food:call_quota:{$orderId}:{$callerRole}"; + $count = (int)$redis->incr($key); + if ($count === 1) $redis->expire($key, 7200); + + if ($count > FOOD_CALL_MAX_PER_ORDER) { + appLog("[FOOD][CALL] quota exceeded order=$orderId role=$callerRole", 'WARNING'); + return true; + } + return false; +} + +/** + * يفتح جلسة مكالمة على خادم الإشارات ويعيد session_id. + * يعيد null عند أي فشل — المُنادي هو من يقرر رسالة الخطأ للمستخدم. + */ +function foodCreateCallSession(int $orderId, string $courierId, string $passengerId): ?array +{ + $url = (getenv('VOICE_CALL_SERVER_URL') ?: 'https://calls.intaleqapp.com') . '/sessions'; + $apiKey = getenv('VOICE_CALL_API_KEY') ?: ''; + if (!$apiKey) { + appLog('[FOOD][CALL] VOICE_CALL_API_KEY missing — cannot create session', 'ERROR'); + return null; + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POSTFIELDS => json_encode([ + // خادم الإشارات يعامل ride_id كمعرّف نصّي مبهم — نُميّز طلبات الطعام + 'ride_id' => 'food_' . $orderId, + 'driver_id' => $courierId, + 'passenger_id' => $passengerId, + ]), + CURLOPT_HTTPHEADER => ["x-api-key: $apiKey", 'Content-Type: application/json'], + CURLOPT_TIMEOUT => 5, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]); + + $result = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + appLog("[FOOD][CALL] signaling server failed (HTTP $httpCode): $result", 'ERROR'); + return null; + } + + $data = json_decode((string)$result, true); + if (!isset($data['session_id'])) { + appLog('[FOOD][CALL] invalid response schema from signaling server', 'ERROR'); + return null; + } + + return [ + 'session_id' => (string)$data['session_id'], + 'expires_in' => (int)($data['expires_in'] ?? 60), + ]; +} + +// المكالمة مسموحة فقط داخل النافذة التشغيلية للطلب — بعد التسليم تُقفل القناة +function foodOrderAllowsCall(array $order): bool +{ + return in_array($order['status'], ['courier_assigned', 'picked_up'], true) + && !empty($order['courier_id']); +} + // ── Session مطعم (هاتف+كلمة مرور — مستقلة عن JWT، مثل transit) ── function foodCreateMerchantSession(int $merchantUserId, int $merchantId): string @@ -433,11 +518,56 @@ function foodOfferOrderToCourier(int $orderId, string $courierId): void "INSERT INTO food_courier_assignments (order_id, courier_id, status) VALUES (?,?,'offered')" )->execute([$orderId, $courierId]); + // العرض يُدفع كاملاً عبر السوكيت: شاشة العرض عند السائق تُبنى من هذه الحمولة + // مباشرة بلا نداء HTTP إضافي (مهلة العرض 20 ثانية لا تحتمل round-trip زائد). + $offer = foodBuildCourierOfferPayload($orderId); + // ملاحظة: لا FCM هنا عمداً — توكن جهاز السائق في جدول driverToken على main DB، // وممنوع Database::get('main') داخل backend/food/ (نفس قاعدة transit). الإشعار // اللحظي يمر فقط عبر socket_food (السائق متصل بسوكيته أثناء وضع التوصيل)، - // والتطبيق يعتمد أيضاً على courier/active.php كـ polling fallback عند الانقطاع. - foodPushToSocket('courier_offer', ['order_id' => $orderId, 'courier_id' => $courierId]); + // والتطبيق يعتمد أيضاً على courier/pending_offers.php كـ polling fallback عند الانقطاع. + foodPushToSocket('courier_offer', array_merge( + ['order_id' => $orderId, 'courier_id' => $courierId, 'offer_ttl_seconds' => 20], + $offer + )); +} + +// حمولة عرض التوصيل — مصدر واحد يستخدمه السوكيت و pending_offers.php معاً +// حتى لا تختلف الحقول بين المسار اللحظي ومسار الاحتياط. +function foodBuildCourierOfferPayload(int $orderId): array +{ + $con = Database::get('food'); + $st = $con->prepare( + "SELECT o.id, o.merchant_id, o.delivery_fee, o.grand_total, o.payment_method, + o.delivery_lat, o.delivery_lng, o.items_total, o.created_at, + m.name_ar AS merchant_name_ar, m.address AS merchant_address, + m.latitude AS merchant_lat, m.longitude AS merchant_lng, + (SELECT COALESCE(SUM(quantity),0) FROM food_order_items WHERE order_id=o.id) AS items_count + FROM food_orders o + JOIN food_merchants m ON m.id = o.merchant_id + WHERE o.id = ? LIMIT 1" + ); + $st->execute([$orderId]); + $row = $st->fetch(); + if (!$row) return []; + + // العنوان النصّي للزبون لا يُرسل في العرض — يظهر فقط بعد القبول (active.php). + // نرسل الإحداثيات فقط لحساب المسافة/الاتجاه في شاشة العرض. + return [ + 'merchant_id' => (int)$row['merchant_id'], + 'merchant_name_ar' => $row['merchant_name_ar'], + 'merchant_address' => $row['merchant_address'], + 'merchant_lat' => (float)$row['merchant_lat'], + 'merchant_lng' => (float)$row['merchant_lng'], + 'delivery_lat' => (float)$row['delivery_lat'], + 'delivery_lng' => (float)$row['delivery_lng'], + 'delivery_fee' => (int)$row['delivery_fee'], + 'items_count' => (int)$row['items_count'], + 'payment_method' => $row['payment_method'], + // نقداً: السائق سيحصّل هذا المبلغ من الزبون — معلومة قرار أساسية قبل القبول + 'cash_to_collect' => $row['payment_method'] === 'cash' ? (int)$row['grand_total'] : 0, + 'order_created_at' => $row['created_at'], + ]; } // SET NX EX 20 — القابل الأول فقط يفوز، ذرّياً (يمنع سباق القبول) diff --git a/backend/food/order/call_courier.php b/backend/food/order/call_courier.php new file mode 100644 index 00000000..d8e24577 --- /dev/null +++ b/backend/food/order/call_courier.php @@ -0,0 +1,39 @@ + $orderId, + 'courier_id' => $courierId, + 'session_id' => $session['session_id'], + 'caller_name' => 'زبون الطلب #' . $orderId, + 'expires_in' => $session['expires_in'], +]); + +appLog("[FOOD][CALL] passenger {$food_passenger_id} → courier (order {$orderId})", 'INFO'); + +jsonSuccess([ + 'session_id' => $session['session_id'], + 'expires_in' => $session['expires_in'], +]); diff --git a/backend/ride/call/turn_credentials.php b/backend/ride/call/turn_credentials.php new file mode 100644 index 00000000..d0780004 --- /dev/null +++ b/backend/ride/call/turn_credentials.php @@ -0,0 +1,108 @@ +enforce(RateLimiter::identifier(), 'api'); + +$jwtService = new JwtService($redis); +$decoded = $jwtService->authenticate(); + +$role = (string)($decoded->role ?? ''); +$userId = (string)($decoded->user_id ?? ''); +if (!in_array($role, ['driver', 'passenger'], true) || $userId === '') { + jsonError('Forbidden — driver or passenger token required', 403); +} + +// السر نفسه المُمرَّر إلى coturn — من /keys أولاً كبقية أسرار المشروع +$secret = ''; +$secretPath = getenv('TURN_SECRET_PATH') ?: '/keys/turn_secret'; +if (is_readable($secretPath)) { + $secret = trim((string)file_get_contents($secretPath)); +} +if ($secret === '') { + $secret = (string)(getenv('TURN_STATIC_AUTH_SECRET') ?: ''); +} + +$turnHost = getenv('TURN_HOST') ?: ''; + +// بلا إعداد TURN لا نُفشل المكالمة: نُعيد STUN فقط ويبقى السلوك كما كان +// قبل إضافة TURN (اتصال مباشر ينجح في أغلب الشبكات المنزلية والـ WiFi). +if ($secret === '' || $turnHost === '') { + appLog('[TURN] not configured (missing secret or TURN_HOST) — returning STUN only', 'WARNING'); + jsonSuccess([ + 'ice_servers' => turnDefaultStunServers(), + 'ttl' => 0, + 'turn_enabled' => false, + ]); +} + +$ttl = (int)(getenv('TURN_CREDENTIAL_TTL') ?: 43200); // 12 ساعة +$expiry = time() + $ttl; +$username = $expiry . ':' . $role . '_' . $userId; +$password = base64_encode(hash_hmac('sha1', $username, $secret, true)); + +$turnPort = (int)(getenv('TURN_PORT') ?: 3478); +$turnTlsPort = (int)(getenv('TURN_TLS_PORT') ?: 5349); + +// الترتيب مقصود: STUN أولاً (اتصال مباشر أرخص وأقل تأخيراً)، ثم UDP TURN، +// ثم TCP/TLS على 5349 للشبكات التي تحجب UDP كلياً (بعض شبكات الشركات). +$iceServers = turnDefaultStunServers(); +$iceServers[] = [ + 'urls' => "turn:{$turnHost}:{$turnPort}?transport=udp", + 'username' => $username, + 'credential' => $password, +]; +$iceServers[] = [ + 'urls' => "turn:{$turnHost}:{$turnPort}?transport=tcp", + 'username' => $username, + 'credential' => $password, +]; +// turns: يُعلَن فقط إذا كانت شهادة TLS مركّبة فعلاً على coturn — وإلا صار +// مساراً ميتاً في قائمة ICE يُبطئ التفاوض بلا أن يعمل أبداً. +if (getenv('TURN_TLS_ENABLED') === 'true') { + $iceServers[] = [ + 'urls' => "turns:{$turnHost}:{$turnTlsPort}?transport=tcp", + 'username' => $username, + 'credential' => $password, + ]; +} + +jsonSuccess([ + 'ice_servers' => $iceServers, + 'ttl' => $ttl, + 'expires_at' => $expiry, + 'turn_enabled' => true, +]); + +function turnDefaultStunServers(): array +{ + $host = getenv('TURN_HOST') ?: ''; + $port = (int)(getenv('TURN_PORT') ?: 3478); + + // خادمنا يعمل STUN أيضاً — نضعه أولاً، ونُبقي خوادم Google احتياطاً + // إن سقط خادمنا (اكتشاف العنوان العام فقط، لا يمرّ عبره أي صوت). + $servers = []; + if ($host !== '') { + $servers[] = ['urls' => "stun:{$host}:{$port}"]; + } + $servers[] = ['urls' => 'stun:stun.l.google.com:19302']; + $servers[] = ['urls' => 'stun:stun1.l.google.com:19302']; + return $servers; +} diff --git a/docker/.env.example b/docker/.env.example index 627b3f63..0d0db0db 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -113,6 +113,22 @@ WALLET_SECRET_KEY_PATH=/keys/.secret_key_pay ENCRYPTION_KEY_PATH=/keys/.enckey PAYMENT_INTERNAL_KEY_PATH=/keys/.internal_socket_key +# --- 6.b خادم TURN (مكالمات الصوت: الرحلات + توصيل الطعام) --- +# TURN_HOST يجب أن يكون اسم نطاق أو IP عاماً يصل إليه التطبيق مباشرة. +# TURN_EXTERNAL_IP هو IP الخادم العام كما يراه العالم (coturn يُعلنه في ICE). +# السرّ يُقرأ من /keys/turn_secret أولاً؛ وقيمة .env احتياط للتطوير فقط. +# توليده: openssl rand -hex 32 > docker/keys/turn_secret +TURN_HOST=turn.siromove.com +TURN_EXTERNAL_IP= +TURN_REALM=siromove.com +TURN_PORT=3478 +# لا تجعلها true إلا بعد تركيب شهادة TLS فعلياً على coturn (انظر turnserver.conf) +TURN_TLS_ENABLED=false +TURN_TLS_PORT=5349 +TURN_SECRET_PATH=/keys/turn_secret +TURN_STATIC_AUTH_SECRET= +TURN_CREDENTIAL_TTL=43200 + # --- 7. Third-Party APIs --- GEMINI_API_KEY=AIzaSyDHp0yXCGWqnd4ynlCCWzDz9Un1EJOKeW8 SMS_API_ENDPOINT=https://sms.kazumi.me/api/sms/send-sms diff --git a/docker/coturn/turnserver.conf b/docker/coturn/turnserver.conf new file mode 100644 index 00000000..fd01f240 --- /dev/null +++ b/docker/coturn/turnserver.conf @@ -0,0 +1,64 @@ +# ============================================================ +# turnserver.conf — خادم TURN (coturn) لمكالمات سيرو الصوتية +# ------------------------------------------------------------ +# لماذا TURN أصلاً: STUN وحده يكفي فقط حين يستطيع الطرفان رؤية بعضهما +# مباشرة. سائق وزبون كلاهما على بيانات الجوال يقعان غالباً خلف CGNAT +# (Symmetric NAT)، فتفشل المسارات المباشرة وتبقى المكالمة صامتة رغم +# نجاح الإشارات. TURN يمرّر الصوت عبر الخادم كملاذ أخير. +# +# المصادقة: use-auth-secret (بيانات مؤقتة عبر HMAC) — لا كلمات مرور +# ثابتة في التطبيق. المفتاح السرّي يُمرَّر من docker-compose، ونفسه +# يستعمله backend/ride/call/turn_credentials.php لتوليد البيانات. +# ============================================================ + +listening-port=3478 + +# TLS (turns:) معطّل افتراضياً عمداً: بلا شهادة صالحة يرفض coturn تشغيل +# مستمع TLS ويكتفي بتسجيل خطأ، فيصير عنوان turns: في قائمة ICE مساراً +# ميتاً يُبطئ التفاوض بلا فائدة. لتفعيله: ضع شهادة النطاق وامنح الحاوية +# قراءتها، ثم أزل التعليق عن الأسطر الثلاثة، واضبط TURN_TLS_ENABLED=true +# في .env كي يُعلنه الباك إند للتطبيق. +# tls-listening-port=5349 +# cert=/keys/turn/fullchain.pem +# pkey=/keys/turn/privkey.pem + +fingerprint +use-auth-secret +# static-auth-secret و realm و external-ip تُمرَّر كوسائط من docker-compose +# (من .env) حتى لا يُخزَّن السر داخل ملف متعقَّب في Git. + +# نطاق منافذ التمرير — كل مكالمة تحجز منفذاً أو اثنين. +# يجب فتح هذا النطاق UDP على جدار الحماية وإلا فشل التمرير بصمت. +min-port=49160 +max-port=49300 + +# ── تقييد الوجهات ────────────────────────────────────────── +# بلا هذا يصير TURN بوابة تصل للشبكة الداخلية: أي مستخدم يملك بيانات +# اعتماد صالحة يطلب من الخادم فتح مسار إلى 127.0.0.1 أو 172.x فيصل +# إلى mysql/redis/الحاويات. نمنع كل النطاقات الخاصة صراحةً. +no-multicast-peers +denied-peer-ip=0.0.0.0-0.255.255.255 +denied-peer-ip=10.0.0.0-10.255.255.255 +denied-peer-ip=127.0.0.0-127.255.255.255 +denied-peer-ip=169.254.0.0-169.254.255.255 +denied-peer-ip=172.16.0.0-172.31.255.255 +denied-peer-ip=192.168.0.0-192.168.255.255 +denied-peer-ip=100.64.0.0-100.127.255.255 +denied-peer-ip=::1 +denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff +denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff + +# ── حدود الاستهلاك ───────────────────────────────────────── +# TURN يمرّر كل بايت من الصوت، فبلا سقف تصير الفاتورة والنطاق مفتوحين. +# مكالمة صوتية Opus ≈ 40 kbps لكل اتجاه — 500 kbps سقف سخيّ جداً للصوت +# ويقطع أي محاولة لاستعمال الخادم كبروكسي فيديو أو تنزيل. +user-quota=12 +total-quota=1200 +max-bps=64000 +bps-capacity=0 + +# ── تشغيل ────────────────────────────────────────────────── +no-cli +stale-nonce=600 +# لا نُسجّل كل تخصيص مسار (يمتلئ القرص) — الأخطاء فقط +simple-log diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 303bac91..a910d69d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -230,6 +230,23 @@ services: mem_limit: 512m restart: unless-stopped + # خادم TURN لمكالمات الصوت (رحلات + توصيل الطعام). + # network_mode: host إجباري هنا لا تفضيلاً: coturn يضع عنوان الخادم داخل + # حمولة ICE، ولو مرّ عبر جسر دوكر لأعلن عنواناً داخلياً (172.x) فيفشل + # التمرير. ومع host يسري جدار الحماية عادةً بدل أن يلتفّ عليه docker-proxy. + coturn: + image: coturn/coturn:4.6-alpine + network_mode: host + volumes: + - ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro + command: > + -c /etc/coturn/turnserver.conf + --realm=${TURN_REALM:-siromove.com} + --static-auth-secret=${TURN_STATIC_AUTH_SECRET:?TURN_STATIC_AUTH_SECRET is required} + --external-ip=${TURN_EXTERNAL_IP:?TURN_EXTERNAL_IP is required} + mem_limit: 256m + restart: unless-stopped + volumes: mysql-data: redis-data: diff --git a/food_server/food_socket.php b/food_server/food_socket.php index 11753d3e..981e843c 100644 --- a/food_server/food_socket.php +++ b/food_server/food_socket.php @@ -144,6 +144,18 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY, $INTERNAL_PORT) { $io->to('courier_food_' . $courierId)->emit('food_delivery_offer', $payload); socket_log("[HTTP_SUCCESS] courier_offer pushed to courier #$courierId", $payload); $connection->send('OK'); + } elseif ($action === 'courier_call') { + // مكالمة مقنّعة من الزبون إلى السائق — نمرّر session_id فقط، + // لا رقم هاتف ولا هوية حقيقية لأي طرف. + $courierId = $payload['courier_id'] ?? null; + $sessionId = $payload['session_id'] ?? null; + if (!$courierId || !$sessionId) { + $connection->send('Error: Missing courier_id/session_id'); + return; + } + $io->to('courier_food_' . $courierId)->emit('food_incoming_call', $payload); + socket_log("[HTTP_SUCCESS] courier_call pushed to courier #$courierId (order #" . ($payload['order_id'] ?? '?') . ')'); + $connection->send('OK'); } else { socket_log("[HTTP_WARNING] Unknown action received: $action", $post); $connection->send('Unknown action: ' . $action); diff --git a/siro_admin/pubspec.lock b/siro_admin/pubspec.lock index fa70eb66..dc0c512f 100644 --- a/siro_admin/pubspec.lock +++ b/siro_admin/pubspec.lock @@ -892,10 +892,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -908,10 +908,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -1232,10 +1232,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.11" typed_data: dependency: transitive description: @@ -1413,5 +1413,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0-0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.32.0" diff --git a/siro_driver/lib/constant/box_name.dart b/siro_driver/lib/constant/box_name.dart index e4ca8039..ef2a01a7 100755 --- a/siro_driver/lib/constant/box_name.dart +++ b/siro_driver/lib/constant/box_name.dart @@ -133,4 +133,7 @@ class BoxName { // توفّر Google Play Services — يحدّد شراسة ملف تتبّع الموقع // (بلا GMS يسقط الباكيج إلى LocationManager الخام: بطارية أعلى، دقة أقل) static const String isGmsAvailable = 'isGmsAvailable'; + + // بيانات اعتماد TURN المؤقتة (مكالمات الصوت) — مخزّنة حتى قرب انتهائها + static const String turnIceCache = 'turnIceCache'; } diff --git a/siro_driver/lib/constant/links.dart b/siro_driver/lib/constant/links.dart index 4b152432..13810956 100755 --- a/siro_driver/lib/constant/links.dart +++ b/siro_driver/lib/constant/links.dart @@ -82,6 +82,23 @@ class AppLink { } } + /// سوكيت الطعام — قناة مستقلة تماماً عن سوكيت الرحلات (locationSocketUrl). + /// عملية منفصلة (food_socket.php) وبورت منفصل 4040، فانقطاع أحدهما لا يُسقط + /// الآخر: السائق يظل يستقبل الرحلات وإن سقط سوكيت الطعام والعكس. + /// nginx على المضيف يستمع 4040 بالشهادة ويمرّر إلى 14040 داخل دوكر. + static String get foodSocketUrl { + switch (currentCountry) { + case 'Syria': + return 'https://food-syria.siromove.com:4040'; + case 'Egypt': + return 'https://food-egypt.siromove.com:4040'; + case 'Jordan': + return 'https://jordan-siro.intaleqapp.com:4040'; + default: + return 'https://jordan-siro.intaleqapp.com:4040'; + } + } + static String get mapSaasRoute { switch (currentCountry) { case 'Syria': diff --git a/siro_driver/lib/controller/firebase/local_notification.dart b/siro_driver/lib/controller/firebase/local_notification.dart index 7ad6a180..97db65c8 100755 --- a/siro_driver/lib/controller/firebase/local_notification.dart +++ b/siro_driver/lib/controller/firebase/local_notification.dart @@ -15,8 +15,13 @@ import '../../constant/box_name.dart'; import '../../constant/links.dart'; import '../../main.dart'; // للوصول لـ box import '../../print.dart'; +import '../../views/food_delivery/food_delivery_home_page.dart'; +import '../../views/food_delivery/food_offer_page.dart'; +import '../../views/food_delivery/food_task_details_page.dart'; import '../../views/home/Captin/driver_map_page.dart'; import '../../views/home/Captin/orderCaptin/order_request_page.dart'; +import '../food_delivery/food_delivery_controller.dart'; +import '../food_delivery/food_notification_service.dart'; import '../functions/crud.dart'; import '../home/captin/home_captain_controller.dart'; @@ -213,6 +218,14 @@ class NotificationController extends GetxController { if (payload == null) return; final payloadData = jsonDecode(payload) as Map; + + // إشعارات وحدة التوصيل لها حمولة وأزرار خاصة بها — نفرزها قبل أي شيء + // لأن باقي هذه الدالة يفترض حمولة رحلة (مصفوفة add_ride.php). + if (payloadData.containsKey('food_event')) { + await _handleFoodNotificationResponse(response, payloadData); + return; + } + final rawData = payloadData['data']; List listData = []; @@ -248,6 +261,38 @@ class NotificationController extends GetxController { } } + // ── فرع وحدة التوصيل — مسار مستقل تماماً عن مسار الرحلات أعلاه ────────── + Future _handleFoodNotificationResponse( + NotificationResponse response, Map payloadData) async { + final orderId = int.tryParse(payloadData['order_id']?.toString() ?? '') ?? 0; + if (orderId == 0) return; + + await FoodNotificationService.instance.cancelOfferNotification(); + + // الكنترولر قد لا يكون مسجَّلاً (الضغط على الإشعار والتطبيق مغلق)، فنسجّله + // ليعيد بناء حالته من الخادم قبل عرض أي شاشة. + final controller = Get.isRegistered() + ? Get.find() + : Get.put(FoodDeliveryController()); + + if (response.actionId == 'FOOD_ACCEPT') { + await controller.respondToOffer(orderId, true); + Get.to(() => FoodTaskDetailsPage(orderId: orderId)); + return; + } + + if (response.actionId == 'FOOD_REJECT') { + await controller.respondToOffer(orderId, false); + return; + } + + if (payloadData['food_event'] == 'offer') { + Get.to(() => const FoodOfferPage(), fullscreenDialog: true); + } else { + Get.to(() => const FoodDeliveryHomePage()); + } + } + // ============================================================================== // 4. منطق القبول الآمن (Safe Accept Logic) // ============================================================================== diff --git a/siro_driver/lib/controller/food_delivery/food_delivery_controller.dart b/siro_driver/lib/controller/food_delivery/food_delivery_controller.dart index 47126830..8fe92ed4 100644 --- a/siro_driver/lib/controller/food_delivery/food_delivery_controller.dart +++ b/siro_driver/lib/controller/food_delivery/food_delivery_controller.dart @@ -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 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(); - _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 _refreshAll() async { - if (isDeliveryModeEnabled) { - await _fetchPendingOffers(); - } + 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(); @@ -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 _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 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()); } } - Future 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 _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); + } + + 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 ?? []; @@ -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 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(); } } diff --git a/siro_driver/lib/controller/food_delivery/food_delivery_models.dart b/siro_driver/lib/controller/food_delivery/food_delivery_models.dart index 12e6b81b..2b80d7ff 100644 --- a/siro_driver/lib/controller/food_delivery/food_delivery_models.dart +++ b/siro_driver/lib/controller/food_delivery/food_delivery_models.dart @@ -1,5 +1,10 @@ // 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 @@ -9,10 +14,22 @@ class FoodDeliveryTask { 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, @@ -22,37 +39,73 @@ class FoodDeliveryTask { 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 j) => FoodDeliveryTask( - id: int.tryParse(j['id'].toString()) ?? 0, + id: _int(j['id']), status: j['status']?.toString() ?? '', - merchantId: int.tryParse(j['merchant_id'].toString()) ?? 0, + merchantId: _int(j['merchant_id']), merchantNameAr: j['merchant_name_ar']?.toString() ?? '', - merchantLat: double.tryParse(j['merchant_lat']?.toString() ?? ''), - merchantLng: double.tryParse(j['merchant_lng']?.toString() ?? ''), + merchantLat: _doubleOrNull(j['merchant_lat']), + merchantLng: _doubleOrNull(j['merchant_lng']), merchantAddress: j['merchant_address']?.toString(), - deliveryFee: int.tryParse(j['delivery_fee']?.toString() ?? '0') ?? 0, + 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.tryParse(j['delivery_lat']?.toString() ?? '0') ?? 0, - deliveryLng: double.tryParse(j['delivery_lng']?.toString() ?? '0') ?? 0, - createdAt: DateTime.tryParse(j['created_at']?.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']), ); } -// عرض توصيل وارد — من polling على food/courier/pending_offers.php +// عرض توصيل وارد — يصل عبر سوكيت الطعام ('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({ @@ -60,28 +113,206 @@ class FoodDeliveryOffer { 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 j) => FoodDeliveryOffer( - orderId: int.tryParse(j['order_id'].toString()) ?? 0, - merchantId: int.tryParse(j['merchant_id'].toString()) ?? 0, + orderId: _int(j['order_id']), + merchantId: _int(j['merchant_id']), merchantNameAr: j['merchant_name_ar']?.toString() ?? '', - merchantLat: double.tryParse(j['merchant_lat']?.toString() ?? ''), - merchantLng: double.tryParse(j['merchant_lng']?.toString() ?? ''), - deliveryFee: int.tryParse(j['delivery_fee']?.toString() ?? '0') ?? 0, - offeredAt: DateTime.tryParse(j['offered_at']?.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']), ); - // مهلة العرض 20 ثانية من الخادم (SET NX EX 20) — عدّاد تقريبي للعرض فقط، - // الخادم هو الحكم الفعلي (رفض offer_respond إن انتهت المهلة فعلاً). + // العدّاد للعرض فقط — الخادم هو الحكم الفعلي ويرفض offer_respond بعد المهلة. + // نبدأ من offered_at إن جاءت (مسار polling)، وإلا من لحظة الاستلام (مسار السوكيت). int get secondsRemaining { - if (offeredAt == null) return 20; - final elapsed = DateTime.now().difference(offeredAt!).inSeconds; - return (20 - elapsed).clamp(0, 20); + 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 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 items; + final String? merchantPhone; + + FoodOrderDetails({required this.task, required this.items, this.merchantPhone}); + + factory FoodOrderDetails.fromJson(Map j) => FoodOrderDetails( + task: FoodDeliveryTask.fromJson(j), + items: (j['items'] is List) + ? (j['items'] as List) + .map((i) => FoodOrderItem.fromJson(Map.from(i))) + .toList() + : [], + 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 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 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 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 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.from(d))) + .toList() + : [], + ); +} + +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 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; diff --git a/siro_driver/lib/controller/food_delivery/food_delivery_service.dart b/siro_driver/lib/controller/food_delivery/food_delivery_service.dart index ca96ba0f..ee4e1c6f 100644 --- a/siro_driver/lib/controller/food_delivery/food_delivery_service.dart +++ b/siro_driver/lib/controller/food_delivery/food_delivery_service.dart @@ -22,30 +22,66 @@ class FoodDeliveryService { return FoodDeliveryApiResult(false, null, _errMsg(res)); } + /// حالة السائق عند فتح التبويب — وضع التوصيل مخزَّن في Redis على الخادم، + /// فلا يجوز افتراض "مغلق" محلياً وإلا ظهر التبديل مطفأً والعروض تصل فعلاً. + static Future> 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>> getActiveTasks() async { final res = await CRUD().post(link: '$_base/courier/active.php'); - if (res is Map && res['status'] == 'success') { - final msg = res['message']; - final list = (msg is Map && msg['orders'] is List) - ? (msg['orders'] as List) - .map((o) => FoodDeliveryTask.fromJson(Map.from(o))) - .toList() - : []; - return FoodDeliveryApiResult(true, list, 'ok'); + final msg = _payload(res); + if (msg != null) { + return FoodDeliveryApiResult(true, _list(msg['orders'], FoodDeliveryTask.fromJson), 'ok'); } return FoodDeliveryApiResult(false, null, _errMsg(res)); } static Future>> getPendingOffers() async { final res = await CRUD().post(link: '$_base/courier/pending_offers.php'); - if (res is Map && res['status'] == 'success') { - final msg = res['message']; - final list = (msg is Map && msg['offers'] is List) - ? (msg['offers'] as List) - .map((o) => FoodDeliveryOffer.fromJson(Map.from(o))) - .toList() - : []; - return FoodDeliveryApiResult(true, list, 'ok'); + final msg = _payload(res); + if (msg != null) { + return FoodDeliveryApiResult(true, _list(msg['offers'], FoodDeliveryOffer.fromJson), 'ok'); + } + return FoodDeliveryApiResult(false, null, _errMsg(res)); + } + + static Future> 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.from(msg['order'])), 'ok'); + } + return FoodDeliveryApiResult(false, null, _errMsg(res)); + } + + static Future> 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>> 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)); } @@ -55,9 +91,9 @@ class FoodDeliveryService { link: '$_base/courier/offer_respond.php', payload: {'order_id': orderId.toString(), 'response': accept ? 'accept' : 'reject'}, ); - if (res is Map && res['status'] == 'success') { - final msg = res['message']; - return FoodDeliveryApiResult(true, (msg is Map ? msg['response']?.toString() : null) ?? '', 'ok'); + final msg = _payload(res); + if (msg != null) { + return FoodDeliveryApiResult(true, msg['response']?.toString() ?? '', 'ok'); } return FoodDeliveryApiResult(false, null, _errMsg(res)); } @@ -80,6 +116,19 @@ class FoodDeliveryService { return FoodDeliveryApiResult(false, null, _errMsg(res)); } + // كل ردود backend/food تأتي بغلاف {status, message:{...}} — نستخرج المحتوى مرة واحدة + static Map? _payload(dynamic res) { + if (res is Map && res['status'] == 'success' && res['message'] is Map) { + return Map.from(res['message']); + } + return null; + } + + static List _list(dynamic raw, T Function(Map) fromJson) { + if (raw is! List) return []; + return raw.map((e) => fromJson(Map.from(e))).toList(); + } + static String _errMsg(dynamic res) { if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت'; if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً'; diff --git a/siro_driver/lib/controller/food_delivery/food_notification_service.dart b/siro_driver/lib/controller/food_delivery/food_notification_service.dart new file mode 100644 index 00000000..50b7659a --- /dev/null +++ b/siro_driver/lib/controller/food_delivery/food_notification_service.dart @@ -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 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 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('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 cancelOfferNotification() async { + await _plugin.cancel(id: offerNotificationId); + } + + /// تحديث حالة مهمة قائمة (مثلاً إلغاء من النظام) — قناة أهدأ، بلا ملء شاشة + Future 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}), + ); + } +} diff --git a/siro_driver/lib/controller/food_delivery/food_socket_service.dart b/siro_driver/lib/controller/food_delivery/food_socket_service.dart new file mode 100644 index 00000000..a8d4e2a1 --- /dev/null +++ b/siro_driver/lib/controller/food_delivery/food_socket_service.dart @@ -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 payload); +typedef FoodOrderUpdateHandler = void Function(Map 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 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? _asMap(dynamic data) { + try { + if (data is Map) return Map.from(data); + if (data is List && data.isNotEmpty && data.first is Map) { + return Map.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] تم قطع الاتصال (وضع التوصيل مطفأ)'); + } +} diff --git a/siro_driver/lib/controller/voice_call_controller.dart b/siro_driver/lib/controller/voice_call_controller.dart index a2464112..ef255221 100644 --- a/siro_driver/lib/controller/voice_call_controller.dart +++ b/siro_driver/lib/controller/voice_call_controller.dart @@ -11,6 +11,7 @@ import '../../constant/links.dart'; import '../../main.dart'; import '../../print.dart'; import '../../services/signaling_service.dart'; +import '../../services/turn_credentials_service.dart'; import '../../views/widgets/voice_call_bottom_sheet.dart'; import 'functions/crud.dart'; @@ -229,11 +230,16 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver { // EN: Initiates an outgoing call. // AR: يبدأ مكالمة صادرة. + /// [sessionEndpoint] و [sessionPayload] يسمحان لوحدات أخرى (توصيل الطعام) + /// بإعادة استخدام نفس قناة WebRTC المقنّعة بواجهة إنشاء جلسة خاصة بها، + /// بدل تكرار منطق الإشارات والصوت. القيمة الافتراضية هي مكالمة الرحلة. Future startCall({ required String rideIdVal, required String driverId, required String passengerId, required String remoteNameVal, + String? sessionEndpoint, + Map? sessionPayload, }) async { if (state.value != VoiceCallState.idle) return; @@ -268,8 +274,9 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver { // 2. EN: Call PHP Backend to create Node.js session & notify Passenger via FCM. // AR: استدعاء واجهة PHP لإنشاء الجلسة على Node.js وإشعار الراكب عبر FCM. final response = await CRUD().post( - link: "${AppLink.server}/ride/call/driver/create_call_session.php", - payload: {'ride_id': rideIdVal}, + link: sessionEndpoint ?? + "${AppLink.server}/ride/call/driver/create_call_session.php", + payload: sessionPayload ?? {'ride_id': rideIdVal}, ); if (response == null || @@ -281,8 +288,16 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver { return; } - final data = response['data']; - sessionId.value = data['session_id']; + // واجهات الرحلات تُعيد الجلسة في 'data'، وواجهات وحدة الطعام تُعيدها في + // 'message' (غلاف jsonSuccess الموحّد) — نقبل الشكلين. + final data = response['data'] ?? response['message']; + if (data is! Map || data['session_id'] == null) { + errorMessage.value = + "Failed to initiate call session. Please try again.".tr; + _endCallInternal("session_creation_failed"); + return; + } + sessionId.value = data['session_id'].toString(); // 3. EN: Connect to WebRTC signaling server / AR: الاتصال بخادم الإشارات await _signaling.connect(sessionId.value, currentUserId); @@ -492,8 +507,16 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver { }); } } - } else { - // EN: Fallback STUN servers / AR: خوام STUN الاحتياطية + } + + // خوادم TURN من الباك إند — بيانات اعتماد مؤقتة. بلا TURN تفشل المكالمة + // صامتةً حين يكون الطرفان خلف CGNAT (شبكة الجوال)، وهي الحالة الغالبة. + // نُضيفها قبل خوادم الإشارات: STUN يبقى أولاً في الترتيب داخل القائمة. + final turnServers = await TurnCredentialsService.getIceServers(); + iceServers.addAll(turnServers); + + if (iceServers.isEmpty) { + // EN: Fallback STUN servers / AR: خوادم STUN الاحتياطية iceServers.addAll([ {"urls": "stun:stun.l.google.com:19302"}, {"urls": "stun:stun1.l.google.com:19302"}, diff --git a/siro_driver/lib/services/turn_credentials_service.dart b/siro_driver/lib/services/turn_credentials_service.dart new file mode 100644 index 00000000..5c98afb3 --- /dev/null +++ b/siro_driver/lib/services/turn_credentials_service.dart @@ -0,0 +1,79 @@ +// turn_credentials_service.dart — جلب بيانات اعتماد TURN المؤقتة وتخزينها +// +// خادم الإشارات (Node) يُعيد STUN فقط، وSTUN وحده لا يكفي حين يكون الطرفان +// خلف CGNAT — وهي الحالة الغالبة لسائق وزبون على بيانات الجوال. نجلب TURN +// من الباك إند ونضمّه إلى قائمة ICE قبل إنشاء الاتصال. +// +// البيانات مؤقتة (HMAC بمهلة) لا كلمة مرور ثابتة داخل التطبيق، ونُخزّنها +// محلياً حتى قرب انتهائها فلا نُثقل بنداء شبكة قبل كل مكالمة. +import 'dart:convert'; + +import '../constant/box_name.dart'; +import '../constant/links.dart'; +import '../controller/functions/crud.dart'; +import '../main.dart'; +import '../print.dart'; + +class TurnCredentialsService { + static const String _cacheKey = BoxName.turnIceCache; + + /// خوادم ICE جاهزة لتمريرها إلى createPeerConnection. + /// تعيد قائمة فارغة عند أي فشل — والمُنادي يكمل بـ STUN كما كان سابقاً. + static Future>> getIceServers() async { + final cached = _readCache(); + if (cached != null) return cached; + + try { + final res = await CRUD().post( + link: '${AppLink.server}/ride/call/turn_credentials.php', + ); + + if (res is! Map || res['status'] != 'success') return []; + + // غلاف jsonSuccess يضع الحمولة في message + final payload = res['message']; + if (payload is! Map || payload['ice_servers'] is! List) return []; + + final servers = (payload['ice_servers'] as List) + .whereType() + .map((s) => Map.from(s)) + .toList(); + + final ttl = int.tryParse(payload['ttl']?.toString() ?? '0') ?? 0; + if (ttl > 0 && servers.isNotEmpty) { + // ننتهي قبل الخادم بخمس دقائق تفادياً لسباق انتهاء الصلاحية أثناء مكالمة + final expiresAt = DateTime.now().add(Duration(seconds: ttl - 300)); + box.write(_cacheKey, jsonEncode({ + 'expires_at': expiresAt.toIso8601String(), + 'servers': servers, + })); + } + + return servers; + } catch (e) { + Log.print('⚠️ [TURN] تعذّر جلب بيانات الاعتماد: $e'); + return []; + } + } + + static List>? _readCache() { + try { + final raw = box.read(_cacheKey); + if (raw == null) return null; + + final data = jsonDecode(raw.toString()); + final expiresAt = DateTime.tryParse(data['expires_at']?.toString() ?? ''); + if (expiresAt == null || DateTime.now().isAfter(expiresAt)) return null; + + final servers = (data['servers'] as List) + .whereType() + .map((s) => Map.from(s)) + .toList(); + return servers.isEmpty ? null : servers; + } catch (_) { + return null; + } + } + + static void clearCache() => box.remove(_cacheKey); +} diff --git a/siro_driver/lib/views/food_delivery/food_delivery_earnings_page.dart b/siro_driver/lib/views/food_delivery/food_delivery_earnings_page.dart new file mode 100644 index 00000000..e77dce22 --- /dev/null +++ b/siro_driver/lib/views/food_delivery/food_delivery_earnings_page.dart @@ -0,0 +1,200 @@ +// food_delivery_earnings_page.dart — أرباح التوصيل وسجل الطلبات المسلَّمة +// (منفصلة عن أرباح الرحلات: المصدر food_orders.delivery_fee لا محفظة الرحلات) +import 'package:flutter/material.dart'; + +import '../../constant/colors.dart'; +import '../../constant/style.dart'; +import '../../controller/food_delivery/food_delivery_models.dart'; +import '../../controller/food_delivery/food_delivery_service.dart'; +import '../widgets/my_scafold.dart'; + +class FoodDeliveryEarningsPage extends StatefulWidget { + const FoodDeliveryEarningsPage({super.key}); + + @override + State createState() => _FoodDeliveryEarningsPageState(); +} + +class _FoodDeliveryEarningsPageState extends State { + FoodEarnings? earnings; + List history = []; + bool isLoading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final results = await Future.wait([ + FoodDeliveryService.getEarnings(), + FoodDeliveryService.getHistory(limit: 30), + ]); + if (!mounted) return; + setState(() { + isLoading = false; + earnings = (results[0] as FoodDeliveryApiResult).data; + history = (results[1] as FoodDeliveryApiResult>).data ?? []; + }); + } + + @override + Widget build(BuildContext context) { + return MyScafolld( + title: 'أرباح التوصيل', + isleading: true, + body: [ + if (isLoading) + const Center(child: CircularProgressIndicator()) + else + RefreshIndicator( + onRefresh: _load, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (earnings != null) ..._summary(earnings!), + const SizedBox(height: 20), + Text('سجل التوصيلات', + style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + if (history.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Text('لم تنفّذ أي طلب توصيل بعد', style: AppStyle.subtitle)), + ) + else + ...history.map(_historyTile), + ], + ), + ), + ], + ); + } + + List _summary(FoodEarnings e) { + return [ + Row( + children: [ + Expanded(child: _statTile('اليوم', e.todayEarnings, e.todayOrders, AppColor.greenColor)), + const SizedBox(width: 10), + Expanded(child: _statTile('آخر 7 أيام', e.weekEarnings, e.weekOrders, AppColor.accentColor)), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded(child: _statTile('آخر 30 يوم', e.monthEarnings, e.monthOrders, AppColor.primaryColor)), + const SizedBox(width: 10), + Expanded(child: _statTile('الإجمالي', e.totalEarnings, e.totalOrders, AppColor.grayColor)), + ], + ), + if (e.pendingCashOwed > 0) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF29900).withOpacity(0.12), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFF29900)), + ), + child: Row( + children: [ + const Icon(Icons.account_balance_rounded, color: Color(0xFFF29900)), + const SizedBox(width: 10), + Expanded( + child: Text( + 'مبالغ نقدية بذمّتك للتسوية: ${foodFormatPrice(e.pendingCashOwed)}', + style: AppStyle.title, + ), + ), + ], + ), + ), + ], + if (e.daily.isNotEmpty) ...[ + const SizedBox(height: 20), + Text('تفصيل آخر 7 أيام', style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + ...e.daily.map( + (d) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(d.day, style: AppStyle.subtitle), + Text('${d.ordersCount} طلب', style: AppStyle.subtitle), + Text(foodFormatPrice(d.earnings), + style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)), + ], + ), + ), + ), + ], + ]; + } + + Widget _statTile(String label, int amount, int orders, Color color) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColor.cardColor, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColor.borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: AppStyle.subtitle), + const SizedBox(height: 6), + Text(foodFormatPrice(amount), + style: AppStyle.title.copyWith(fontWeight: FontWeight.bold, color: color)), + Text('$orders طلب', style: AppStyle.subtitle), + ], + ), + ); + } + + Widget _historyTile(FoodHistoryEntry h) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColor.cardColor, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColor.borderColor), + ), + child: Row( + children: [ + Icon(Icons.moped_rounded, color: AppColor.accentColor), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(h.merchantNameAr, + style: AppStyle.title.copyWith(fontWeight: FontWeight.bold), + maxLines: 1, + overflow: TextOverflow.ellipsis), + Text( + [ + if (h.deliveredAt != null) + '${h.deliveredAt!.year}/${h.deliveredAt!.month}/${h.deliveredAt!.day}', + if (h.durationMinutes != null) '${h.durationMinutes} دقيقة', + '${h.itemsCount} صنف', + h.paymentMethod == 'cash' ? 'نقداً' : 'محفظة', + ].join(' • '), + style: AppStyle.subtitle, + ), + ], + ), + ), + Text(foodFormatPrice(h.deliveryFee), + style: AppStyle.title.copyWith( + fontWeight: FontWeight.bold, color: AppColor.greenColor)), + ], + ), + ); + } +} diff --git a/siro_driver/lib/views/food_delivery/food_delivery_home_page.dart b/siro_driver/lib/views/food_delivery/food_delivery_home_page.dart index c90f5405..10a49f67 100644 --- a/siro_driver/lib/views/food_delivery/food_delivery_home_page.dart +++ b/siro_driver/lib/views/food_delivery/food_delivery_home_page.dart @@ -1,4 +1,6 @@ -// food_delivery_home_page.dart — تبويب التوصيل: تفعيل وضع التوصيل، عروض واردة، مهام نشطة +// food_delivery_home_page.dart — تبويب التوصيل: وضع التوصيل، أرباح اليوم، مهام نشطة +// شاشة العرض الوارد مستقلة (FoodOfferPage) وتُفتح تلقائياً من الكنترولر عند وصول +// عرض من سوكيت الطعام — هذه الشاشة للإدارة والمتابعة فقط. import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -8,6 +10,9 @@ import '../../controller/food_delivery/food_delivery_controller.dart'; import '../../controller/food_delivery/food_delivery_models.dart'; import '../widgets/elevated_btn.dart'; import '../widgets/my_scafold.dart'; +import 'food_delivery_earnings_page.dart'; +import 'food_offer_page.dart'; +import 'food_task_details_page.dart'; class FoodDeliveryHomePage extends StatelessWidget { const FoodDeliveryHomePage({super.key}); @@ -18,16 +23,25 @@ class FoodDeliveryHomePage extends StatelessWidget { return GetBuilder( builder: (c) => MyScafolld( - title: 'Delivery'.tr, + title: 'التوصيل', isleading: true, + action: IconButton( + icon: Icon(Icons.account_balance_wallet_rounded, color: AppColor.accentColor), + tooltip: 'أرباح التوصيل', + onPressed: () => Get.to(() => const FoodDeliveryEarningsPage()), + ), body: [ Column( children: [ _modeToggleBar(c), - if (c.pendingOffers.isNotEmpty) _offersSection(c), + _todayStrip(c), + if (c.pendingOffers.isNotEmpty) _offersBanner(c), Expanded( child: RefreshIndicator( - onRefresh: c.fetchActiveTasks, + onRefresh: () async { + await c.refreshStatus(); + await c.fetchActiveTasks(); + }, child: c.isLoadingTasks && c.activeTasks.isEmpty ? const Center(child: CircularProgressIndicator()) : c.activeTasks.isEmpty @@ -48,12 +62,14 @@ class FoodDeliveryHomePage extends StatelessWidget { Widget _modeToggleBar(FoodDeliveryController c) { return Container( - margin: const EdgeInsets.all(16), + margin: const EdgeInsets.fromLTRB(16, 16, 16, 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: AppColor.cardColor, borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColor.borderColor), + border: Border.all( + color: c.isDeliveryModeEnabled ? AppColor.greenColor : AppColor.borderColor, + ), ), child: Row( children: [ @@ -66,11 +82,27 @@ class FoodDeliveryHomePage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Delivery Mode'.tr, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)), + Row( + children: [ + Text('وضع التوصيل', + style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)), + if (c.isDeliveryModeEnabled) ...[ + const SizedBox(width: 8), + // مؤشّر قناة الطعام وحدها — انقطاعها لا يعني انقطاع الرحلات + Icon( + c.isSocketConnected ? Icons.wifi_rounded : Icons.wifi_off_rounded, + size: 14, + color: c.isSocketConnected ? AppColor.greenColor : AppColor.grayColor, + ), + ], + ], + ), Text( c.isDeliveryModeEnabled - ? 'You will receive delivery offers'.tr - : 'Turn on to receive delivery offers'.tr, + ? (c.isSocketConnected + ? 'متصل — ستصلك عروض التوصيل فوراً' + : 'إعادة الاتصال… العروض تصل بالتحديث الدوري') + : 'فعّله لاستقبال عروض توصيل الطعام', style: AppStyle.subtitle, ), ], @@ -88,80 +120,62 @@ class FoodDeliveryHomePage extends StatelessWidget { ); } - Widget _offersSection(FoodDeliveryController c) { - return SizedBox( - height: 150, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - itemCount: c.pendingOffers.length, - itemBuilder: (_, i) => _offerCard(c, c.pendingOffers[i]), + Widget _todayStrip(FoodDeliveryController c) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: AppColor.cardColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColor.borderColor), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _miniStat('توصيلات اليوم', '${c.todayOrdersCount}'), + Container(width: 1, height: 28, color: AppColor.borderColor), + _miniStat('أرباح اليوم', foodFormatPrice(c.todayEarnings)), + ], ), ); } - Widget _offerCard(FoodDeliveryController c, FoodDeliveryOffer offer) { - final isResponding = c.isRespondingToOffer(offer.orderId); - return Container( - width: 260, - margin: const EdgeInsets.symmetric(horizontal: 4), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppColor.primaryColor, - borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.15), blurRadius: 12, offset: const Offset(0, 4))], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.notifications_active_rounded, color: Colors.white, size: 18), - const SizedBox(width: 6), - Expanded( - child: Text('New Delivery Offer'.tr, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), + Widget _miniStat(String label, String value) { + return Column( + children: [ + Text(label, style: AppStyle.subtitle), + Text(value, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)), + ], + ); + } + + // شريط تنبيه فقط — القرار الفعلي يتم في شاشة العرض المستقلة + Widget _offersBanner(FoodDeliveryController c) { + final count = c.pendingOffers.length; + return GestureDetector( + onTap: () => Get.to(() => const FoodOfferPage(), fullscreenDialog: true), + child: Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [Color(0xFFFF6B35), Color(0xFFF7931E)]), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + const Icon(Icons.notifications_active_rounded, color: Colors.white), + const SizedBox(width: 10), + Expanded( + child: Text( + count > 1 ? 'لديك $count عروض توصيل بانتظار ردّك' : 'لديك عرض توصيل جديد', + style: const TextStyle( + color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15), ), - Text('${offer.secondsRemaining}s', style: const TextStyle(color: Colors.amber, fontWeight: FontWeight.bold)), - ], - ), - const SizedBox(height: 6), - Text(offer.merchantNameAr, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15), - maxLines: 1, overflow: TextOverflow.ellipsis), - Text(foodFormatPrice(offer.deliveryFee), - style: const TextStyle(color: Colors.white70, fontSize: 13)), - const Spacer(), - Row( - children: [ - Expanded( - child: SizedBox( - height: 36, - child: isResponding - ? const Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))) - : OutlinedButton( - style: OutlinedButton.styleFrom(side: const BorderSide(color: Colors.white54)), - onPressed: () => c.respondToOffer(offer.orderId, false), - child: Text('Reject'.tr, style: const TextStyle(color: Colors.white70, fontSize: 12)), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: SizedBox( - height: 36, - child: isResponding - ? const SizedBox.shrink() - : MyElevatedButton( - title: 'Accept'.tr, - kolor: AppColor.greenColor, - onPressed: () => c.respondToOffer(offer.orderId, true), - ), - ), - ), - ], - ), - ], + ), + Text('${c.pendingOffers.first.secondsRemaining}s', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ], + ), ), ); } @@ -172,13 +186,20 @@ class FoodDeliveryHomePage extends StatelessWidget { const SizedBox(height: 100), Icon(Icons.moped_outlined, size: 72, color: AppColor.grayColor), const SizedBox(height: 16), - Center(child: Text('No delivery tasks right now'.tr, style: AppStyle.title)), + Center( + child: Text( + c.isDeliveryModeEnabled + ? 'لا توجد مهام توصيل حالياً — بانتظار العروض' + : 'فعّل وضع التوصيل لاستقبال الطلبات', + style: AppStyle.title, + textAlign: TextAlign.center, + ), + ), ], ); } Widget _taskCard(FoodDeliveryController c, FoodDeliveryTask task) { - final isPickedUp = task.status == 'picked_up'; final isBusy = c.isTaskBusy(task.id); return Card( @@ -187,47 +208,71 @@ class FoodDeliveryHomePage extends StatelessWidget { elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), - side: BorderSide(color: isPickedUp ? AppColor.greenColor : AppColor.borderColor, width: isPickedUp ? 1.5 : 1), + side: BorderSide( + color: task.isPickedUp ? AppColor.greenColor : AppColor.borderColor, + width: task.isPickedUp ? 1.5 : 1, + ), ), - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(isPickedUp ? Icons.location_on_rounded : Icons.storefront_rounded, - color: AppColor.accentColor), - const SizedBox(width: 8), - Expanded( - child: Text( - isPickedUp ? (task.deliveryAddress ?? '') : task.merchantNameAr, - style: AppStyle.title.copyWith(fontWeight: FontWeight.bold), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Text(foodFormatPrice(task.deliveryFee), style: AppStyle.subtitle), - ], - ), - const SizedBox(height: 4), - Text( - isPickedUp ? 'Deliver to customer'.tr : (task.merchantAddress ?? ''), - style: AppStyle.subtitle, - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - height: 44, - child: isBusy - ? const Center(child: CircularProgressIndicator()) - : MyElevatedButton( - title: isPickedUp ? 'Mark Delivered'.tr : 'Picked Up from Restaurant'.tr, - kolor: isPickedUp ? AppColor.greenColor : AppColor.accentColor, - onPressed: () => isPickedUp ? c.markDelivered(task.id) : c.markPickedUp(task.id), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () => Get.to(() => FoodTaskDetailsPage(orderId: task.id)), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(task.isPickedUp ? Icons.location_on_rounded : Icons.storefront_rounded, + color: AppColor.accentColor), + const SizedBox(width: 8), + Expanded( + child: Text( + task.isPickedUp + ? (task.deliveryAddress ?? 'عنوان الزبون') + : task.merchantNameAr, + style: AppStyle.title.copyWith(fontWeight: FontWeight.bold), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ), - ], + ), + Text(foodFormatPrice(task.deliveryFee), style: AppStyle.subtitle), + ], + ), + const SizedBox(height: 4), + Text( + task.isPickedUp + ? 'في الطريق للزبون — ${task.itemsCount} صنف' + : (task.merchantAddress ?? 'استلم الطلب من المطعم'), + style: AppStyle.subtitle, + ), + if (task.isCash) ...[ + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.payments_rounded, size: 16, color: Color(0xFFF29900)), + const SizedBox(width: 6), + Text('تحصيل نقدي: ${foodFormatPrice(task.cashToCollect)}', + style: AppStyle.subtitle.copyWith(color: const Color(0xFFF29900))), + ], + ), + ], + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 44, + child: isBusy + ? const Center(child: CircularProgressIndicator()) + : MyElevatedButton( + title: task.isPickedUp ? 'تأكيد التسليم' : 'استلمت الطلب من المطعم', + kolor: task.isPickedUp ? AppColor.greenColor : AppColor.accentColor, + onPressed: () => task.isPickedUp + ? c.markDelivered(task.id) + : c.markPickedUp(task.id), + ), + ), + ], + ), ), ), ); diff --git a/siro_driver/lib/views/food_delivery/food_offer_page.dart b/siro_driver/lib/views/food_delivery/food_offer_page.dart new file mode 100644 index 00000000..cec4438b --- /dev/null +++ b/siro_driver/lib/views/food_delivery/food_offer_page.dart @@ -0,0 +1,439 @@ +// food_offer_page.dart — شاشة استقبال عرض التوصيل (مستقلة عن شاشة عرض الرحلة) +// +// شاشة كاملة مقصودة لا بطاقة صغيرة: مهلة العرض 20 ثانية، والسائق يحتاج يقرأ +// المطعم والأجرة وهل التحصيل نقدي قبل أن يقرر. تفتح تلقائياً عند وصول العرض +// من سوكيت الطعام، وتُغلق نفسها إذا انتهت مهلة كل العروض. +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:get/get.dart'; + +import '../../constant/colors.dart'; +import '../../controller/food_delivery/food_delivery_controller.dart'; +import '../../controller/food_delivery/food_delivery_models.dart'; +import '../../controller/functions/location_controller.dart'; + +class FoodOfferPage extends StatelessWidget { + const FoodOfferPage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + builder: (c) { + if (c.pendingOffers.isEmpty) return const _NoOfferView(); + + final offer = c.pendingOffers.first; + final isResponding = c.isRespondingToOffer(offer.orderId); + + return Scaffold( + backgroundColor: const Color(0xFF12171F), + body: SafeArea( + child: Column( + children: [ + _Header(offer: offer, queued: c.pendingOffers.length), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + const SizedBox(height: 8), + _FeeBadge(offer: offer), + const SizedBox(height: 20), + _RouteCard(offer: offer), + const SizedBox(height: 14), + _MetaRow(offer: offer), + if (offer.isCash) ...[ + const SizedBox(height: 14), + _CashWarning(amount: offer.cashToCollect), + ], + const SizedBox(height: 24), + ], + ), + ), + ), + _Actions( + isResponding: isResponding, + onAccept: () => c.respondToOffer(offer.orderId, true), + onReject: () => c.respondToOffer(offer.orderId, false), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _NoOfferView extends StatelessWidget { + const _NoOfferView(); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF12171F), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.timer_off_rounded, size: 64, color: Colors.white38), + const SizedBox(height: 12), + const Text('انتهت مهلة العرض', + style: TextStyle(color: Colors.white70, fontSize: 16)), + const SizedBox(height: 20), + TextButton(onPressed: Get.back, child: const Text('رجوع')), + ], + ), + ), + ); + } +} + +class _Header extends StatelessWidget { + final FoodDeliveryOffer offer; + final int queued; + const _Header({required this.offer, required this.queued}); + + @override + Widget build(BuildContext context) { + final remaining = offer.secondsRemaining; + final progress = offer.ttlSeconds == 0 ? 0.0 : remaining / offer.ttlSeconds; + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Row( + children: [ + SizedBox( + width: 62, + height: 62, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 62, + height: 62, + child: CircularProgressIndicator( + value: progress, + strokeWidth: 5, + backgroundColor: Colors.white12, + valueColor: AlwaysStoppedAnimation( + remaining <= 5 ? const Color(0xFFE53935) : const Color(0xFFFF6B35), + ), + ), + ), + Text('$remaining', + style: const TextStyle( + color: Colors.white, fontWeight: FontWeight.bold, fontSize: 20)), + ], + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('🍔 عرض توصيل طلب طعام', + style: TextStyle( + color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), + Text( + queued > 1 ? 'لديك $queued عروض بالانتظار' : 'وافق قبل انتهاء المهلة', + style: const TextStyle(color: Colors.white54, fontSize: 13), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _FeeBadge extends StatelessWidget { + final FoodDeliveryOffer offer; + const _FeeBadge({required this.offer}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 18), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFFF6B35), Color(0xFFF7931E)], + ), + borderRadius: BorderRadius.circular(18), + ), + child: Column( + children: [ + const Text('أجرة التوصيل', + style: TextStyle(color: Colors.white70, fontSize: 13)), + const SizedBox(height: 4), + Text(foodFormatPrice(offer.deliveryFee), + style: const TextStyle( + color: Colors.white, fontSize: 30, fontWeight: FontWeight.bold)), + ], + ), + ); + } +} + +class _RouteCard extends StatelessWidget { + final FoodDeliveryOffer offer; + const _RouteCard({required this.offer}); + + // مسافات تقريبية بخط مستقيم — كافية لقرار القبول خلال ثوانٍ، ولا نستدعي + // خدمة مسارات هنا حتى لا نضيف زمناً على مهلة عرض قصيرة أصلاً. + double? _distanceKm(double? aLat, double? aLng, double bLat, double bLng) { + if (aLat == null || aLng == null || bLat == 0 || bLng == 0) return null; + return Geolocator.distanceBetween(aLat, aLng, bLat, bLng) / 1000; + } + + @override + Widget build(BuildContext context) { + double? driverLat; + double? driverLng; + if (Get.isRegistered()) { + final loc = Get.find().myLocation; + if (loc.latitude != 0) { + driverLat = loc.latitude; + driverLng = loc.longitude; + } + } + + final toMerchant = + _distanceKm(driverLat, driverLng, offer.merchantLat ?? 0, offer.merchantLng ?? 0); + final merchantToCustomer = + _distanceKm(offer.merchantLat, offer.merchantLng, offer.deliveryLat, offer.deliveryLng); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.06), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white12), + ), + child: Column( + children: [ + _RouteRow( + icon: Icons.storefront_rounded, + iconColor: const Color(0xFFFF6B35), + title: offer.merchantNameAr, + subtitle: offer.merchantAddress ?? 'الاستلام من المطعم', + trailing: toMerchant == null ? null : '${toMerchant.toStringAsFixed(1)} كم', + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + const SizedBox(width: 18), + Container(width: 2, height: 22, color: Colors.white24), + ], + ), + ), + _RouteRow( + icon: Icons.location_on_rounded, + iconColor: const Color(0xFF4CAF50), + title: 'التسليم للزبون', + // العنوان النصّي لا يصل في العرض — يظهر بعد القبول فقط (حماية بيانات الزبون) + subtitle: 'يظهر العنوان الكامل بعد قبول الطلب', + trailing: merchantToCustomer == null + ? null + : '${merchantToCustomer.toStringAsFixed(1)} كم', + ), + ], + ), + ); + } +} + +class _RouteRow extends StatelessWidget { + final IconData icon; + final Color iconColor; + final String title; + final String subtitle; + final String? trailing; + + const _RouteRow({ + required this.icon, + required this.iconColor, + required this.title, + required this.subtitle, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: iconColor, size: 24), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold), + maxLines: 1, + overflow: TextOverflow.ellipsis), + Text(subtitle, + style: const TextStyle(color: Colors.white54, fontSize: 12), + maxLines: 2, + overflow: TextOverflow.ellipsis), + ], + ), + ), + if (trailing != null) + Text(trailing!, + style: const TextStyle( + color: Colors.white70, fontSize: 13, fontWeight: FontWeight.bold)), + ], + ); + } +} + +class _MetaRow extends StatelessWidget { + final FoodDeliveryOffer offer; + const _MetaRow({required this.offer}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _MetaTile( + icon: Icons.shopping_bag_rounded, + label: 'عدد الأصناف', + value: offer.itemsCount > 0 ? '${offer.itemsCount}' : '—', + ), + ), + const SizedBox(width: 12), + Expanded( + child: _MetaTile( + icon: offer.isCash ? Icons.payments_rounded : Icons.account_balance_wallet_rounded, + label: 'طريقة الدفع', + value: offer.isCash ? 'نقداً' : 'محفظة', + ), + ), + ], + ); + } +} + +class _MetaTile extends StatelessWidget { + final IconData icon; + final String label; + final String value; + const _MetaTile({required this.icon, required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.06), + borderRadius: BorderRadius.circular(14), + ), + child: Column( + children: [ + Icon(icon, color: Colors.white70, size: 20), + const SizedBox(height: 6), + Text(label, style: const TextStyle(color: Colors.white54, fontSize: 11)), + Text(value, + style: const TextStyle( + color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold)), + ], + ), + ); + } +} + +class _CashWarning extends StatelessWidget { + final int amount; + const _CashWarning({required this.amount}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF29900).withOpacity(0.15), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFF29900).withOpacity(0.5)), + ), + child: Row( + children: [ + const Icon(Icons.info_rounded, color: Color(0xFFF29900)), + const SizedBox(width: 10), + Expanded( + child: Text( + 'طلب نقدي — ستحصّل ${foodFormatPrice(amount)} من الزبون عند التسليم', + style: const TextStyle(color: Color(0xFFFFD08A), fontSize: 13), + ), + ), + ], + ), + ); + } +} + +class _Actions extends StatelessWidget { + final bool isResponding; + final VoidCallback onAccept; + final VoidCallback onReject; + + const _Actions({ + required this.isResponding, + required this.onAccept, + required this.onReject, + }); + + @override + Widget build(BuildContext context) { + if (isResponding) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator(color: Color(0xFFFF6B35))), + ); + } + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 56, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.white24), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + onPressed: onReject, + child: const Text('رفض', + style: TextStyle(color: Colors.white70, fontSize: 16)), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: SizedBox( + height: 56, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColor.greenColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + onPressed: onAccept, + child: const Text('قبول الطلب', + style: TextStyle( + color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold)), + ), + ), + ), + ], + ), + ); + } +} diff --git a/siro_driver/lib/views/food_delivery/food_task_details_page.dart b/siro_driver/lib/views/food_delivery/food_task_details_page.dart new file mode 100644 index 00000000..4e1eea2c --- /dev/null +++ b/siro_driver/lib/views/food_delivery/food_task_details_page.dart @@ -0,0 +1,368 @@ +// food_task_details_page.dart — شاشة مهمة التوصيل: الأصناف، المبالغ، الملاحة، +// الاتصال بالمطعم، ومبلغ التحصيل النقدي، وزر الخطوة التالية (استلمت/سلّمت). +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../constant/colors.dart'; +import '../../constant/style.dart'; +import '../../controller/food_delivery/food_delivery_controller.dart'; +import '../../controller/food_delivery/food_delivery_models.dart'; +import '../../controller/food_delivery/food_delivery_service.dart'; +import '../widgets/elevated_btn.dart'; +import '../widgets/error_snakbar.dart'; +import '../widgets/my_scafold.dart'; + +class FoodTaskDetailsPage extends StatefulWidget { + final int orderId; + const FoodTaskDetailsPage({super.key, required this.orderId}); + + @override + State createState() => _FoodTaskDetailsPageState(); +} + +class _FoodTaskDetailsPageState extends State { + FoodOrderDetails? details; + bool isLoading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final res = await FoodDeliveryService.getOrderDetails(widget.orderId); + if (!mounted) return; + setState(() { + isLoading = false; + details = res.data; + }); + if (!res.success) mySnackbarWarning(res.message); + } + + Future _openMaps(double lat, double lng) async { + // نفتح تطبيق الخرائط الافتراضي على الجهاز — لا نضمّن مفاتيح ولا نمرّر + // بيانات الزبون في الرابط سوى الإحداثيات اللازمة للملاحة. + final uri = Uri.parse('https://www.google.com/maps/dir/?api=1&destination=$lat,$lng'); + if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { + mySnackbarWarning('تعذّر فتح تطبيق الخرائط'); + } + } + + Future _call(String phone) async { + final uri = Uri(scheme: 'tel', path: phone); + if (!await launchUrl(uri)) mySnackbarWarning('تعذّر إجراء الاتصال'); + } + + @override + Widget build(BuildContext context) { + return MyScafolld( + title: 'مهمة التوصيل', + isleading: true, + body: [ + if (isLoading) + const Center(child: CircularProgressIndicator()) + else if (details == null) + Center(child: Text('تعذّر تحميل تفاصيل الطلب', style: AppStyle.title)) + else + RefreshIndicator( + onRefresh: _load, + child: ListView( + padding: const EdgeInsets.all(16), + children: _content(details!), + ), + ), + ], + ); + } + + List _content(FoodOrderDetails d) { + final t = d.task; + return [ + _statusStepper(t), + const SizedBox(height: 16), + if (t.isCash) ...[_cashBanner(t), const SizedBox(height: 16)], + _locationCard( + icon: Icons.storefront_rounded, + color: AppColor.accentColor, + title: t.merchantNameAr, + subtitle: t.merchantAddress ?? 'مطعم', + onNavigate: (t.merchantLat != null && t.merchantLng != null) + ? () => _openMaps(t.merchantLat!, t.merchantLng!) + : null, + onCall: d.merchantPhone == null ? null : () => _call(d.merchantPhone!), + ), + const SizedBox(height: 12), + _locationCard( + icon: Icons.location_on_rounded, + color: AppColor.greenColor, + title: 'عنوان الزبون', + subtitle: t.deliveryAddress ?? 'غير متاح', + onNavigate: t.deliveryLat == 0 ? null : () => _openMaps(t.deliveryLat, t.deliveryLng), + // مكالمة مقنّعة: لا رقم هاتف للزبون يظهر أو يُخزَّن — اتصال صوتي + // عبر جلسة مؤقتة، ومتاح فقط ما دامت المهمة نشطة. + onCall: () => Get.find().callCustomer(t.id), + callIcon: Icons.phone_in_talk_rounded, + callTooltip: 'اتصال مقنّع بالزبون', + ), + if ((t.customerNote ?? '').isNotEmpty) ...[ + const SizedBox(height: 12), + _noteCard(t.customerNote!), + ], + const SizedBox(height: 16), + _itemsCard(d.items), + const SizedBox(height: 16), + _totalsCard(t), + const SizedBox(height: 20), + _actionButton(t), + const SizedBox(height: 24), + ]; + } + + Widget _statusStepper(FoodDeliveryTask t) { + final steps = [ + ('تم الإسناد', true), + ('استلام من المطعم', t.isPickedUp), + ('تسليم للزبون', false), + ]; + return Row( + children: List.generate(steps.length * 2 - 1, (i) { + if (i.isOdd) { + final done = steps[(i - 1) ~/ 2].$2; + return Expanded( + child: Container(height: 2, color: done ? AppColor.greenColor : AppColor.borderColor), + ); + } + final step = steps[i ~/ 2]; + return Column( + children: [ + CircleAvatar( + radius: 14, + backgroundColor: step.$2 ? AppColor.greenColor : AppColor.borderColor, + child: Icon(step.$2 ? Icons.check : Icons.circle, + size: 14, color: Colors.white), + ), + const SizedBox(height: 4), + SizedBox( + width: 80, + child: Text(step.$1, + textAlign: TextAlign.center, + style: AppStyle.subtitle.copyWith(fontSize: 11)), + ), + ], + ); + }), + ); + } + + Widget _cashBanner(FoodDeliveryTask t) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF29900).withOpacity(0.12), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFF29900)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.payments_rounded, color: Color(0xFFF29900)), + const SizedBox(width: 8), + Text('تحصيل نقدي', + style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)), + ], + ), + const SizedBox(height: 8), + Text('المبلغ المطلوب من الزبون: ${foodFormatPrice(t.cashToCollect)}', + style: AppStyle.title), + const SizedBox(height: 4), + // نفصّل الرقمين صراحةً كي لا يحسب السائق على باب الزبون: + // أجرته تبقى معه، والباقي يُسوَّى إدارياً لاحقاً. + Text( + 'تحتفظ بأجرتك ${foodFormatPrice(t.deliveryFee)} — ويبقى ${foodFormatPrice(t.courierOwes)} للتسوية', + style: AppStyle.subtitle, + ), + ], + ), + ); + } + + Widget _locationCard({ + required IconData icon, + required Color color, + required String title, + required String subtitle, + VoidCallback? onNavigate, + VoidCallback? onCall, + IconData callIcon = Icons.call_rounded, + String callTooltip = 'اتصال', + }) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColor.cardColor, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColor.borderColor), + ), + child: Row( + children: [ + Icon(icon, color: color), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: AppStyle.title.copyWith(fontWeight: FontWeight.bold), + maxLines: 1, + overflow: TextOverflow.ellipsis), + Text(subtitle, style: AppStyle.subtitle, maxLines: 2), + ], + ), + ), + if (onCall != null) + IconButton( + onPressed: onCall, + icon: Icon(callIcon, color: AppColor.greenColor), + tooltip: callTooltip, + ), + if (onNavigate != null) + IconButton( + onPressed: onNavigate, + icon: Icon(Icons.navigation_rounded, color: AppColor.accentColor), + tooltip: 'الملاحة', + ), + ], + ), + ); + } + + Widget _noteCard(String note) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColor.cardColor, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColor.borderColor), + ), + child: Row( + children: [ + Icon(Icons.sticky_note_2_rounded, color: AppColor.grayColor), + const SizedBox(width: 10), + Expanded(child: Text('ملاحظة الزبون: $note', style: AppStyle.subtitle)), + ], + ), + ); + } + + Widget _itemsCard(List items) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColor.cardColor, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColor.borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('محتوى الطلب', style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + if (items.isEmpty) + Text('لا توجد تفاصيل أصناف', style: AppStyle.subtitle) + else + ...items.map( + (i) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppColor.accentColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Text('×${i.quantity}', + style: AppStyle.subtitle.copyWith(fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 10), + Expanded(child: Text(i.nameAr, style: AppStyle.title)), + Text(foodFormatPrice(i.lineTotal), style: AppStyle.subtitle), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _totalsCard(FoodDeliveryTask t) { + Widget row(String label, int amount, {bool bold = false}) => Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, + style: bold + ? AppStyle.title.copyWith(fontWeight: FontWeight.bold) + : AppStyle.subtitle), + Text(foodFormatPrice(amount), + style: bold + ? AppStyle.title.copyWith(fontWeight: FontWeight.bold) + : AppStyle.subtitle), + ], + ), + ); + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColor.cardColor, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColor.borderColor), + ), + child: Column( + children: [ + row('قيمة الأصناف', t.itemsTotal), + row('أجرة التوصيل (لك)', t.deliveryFee), + if (t.serviceFee > 0) row('رسوم الخدمة', t.serviceFee), + if (t.discount > 0) row('الخصم', t.discount), + const Divider(), + row('إجمالي الطلب', t.grandTotal, bold: true), + ], + ), + ); + } + + Widget _actionButton(FoodDeliveryTask t) { + final c = Get.find(); + return GetBuilder( + builder: (_) { + if (c.isTaskBusy(t.id)) { + return const Center(child: CircularProgressIndicator()); + } + return SizedBox( + width: double.infinity, + height: 52, + child: MyElevatedButton( + title: t.isPickedUp ? 'تأكيد التسليم للزبون' : 'استلمت الطلب من المطعم', + kolor: t.isPickedUp ? AppColor.greenColor : AppColor.accentColor, + onPressed: () async { + if (t.isPickedUp) { + await c.markDelivered(t.id); + if (mounted) Get.back(); + } else { + await c.markPickedUp(t.id); + await _load(); + } + }, + ), + ); + }, + ); + } +} diff --git a/siro_driver/pubspec.lock b/siro_driver/pubspec.lock index 8acfc053..1bfc6081 100644 --- a/siro_driver/pubspec.lock +++ b/siro_driver/pubspec.lock @@ -1521,10 +1521,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -1537,10 +1537,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -2125,10 +2125,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.9" timezone: dependency: transitive description: diff --git a/siro_rider/lib/constant/box_name.dart b/siro_rider/lib/constant/box_name.dart index 1458230f..a88b4e4e 100644 --- a/siro_rider/lib/constant/box_name.dart +++ b/siro_rider/lib/constant/box_name.dart @@ -114,4 +114,7 @@ class BoxName { static const String isDestinationMatch = 'isDestinationMatch'; // 🆕 AI Destination Matching static const String isPrime = 'isPrime'; // 👑 Siro Prime subscription status + + // بيانات اعتماد TURN المؤقتة (مكالمات الصوت) — مخزّنة حتى قرب انتهائها + static const String turnIceCache = 'turnIceCache'; } diff --git a/siro_rider/lib/controller/voice_call_controller.dart b/siro_rider/lib/controller/voice_call_controller.dart index 149dd3e0..051fe679 100644 --- a/siro_rider/lib/controller/voice_call_controller.dart +++ b/siro_rider/lib/controller/voice_call_controller.dart @@ -12,6 +12,7 @@ import '../../constant/links.dart'; import '../../main.dart'; import '../../print.dart'; import '../../services/signaling_service.dart'; +import '../../services/turn_credentials_service.dart'; import '../../views/widgets/voice_call_bottom_sheet.dart'; import 'functions/crud.dart'; @@ -231,11 +232,16 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver { // EN: Initiates an outgoing call. // AR: يبدأ مكالمة صادرة. + /// [sessionEndpoint] و [sessionPayload] يسمحان لوحدات أخرى (طلبات الطعام) + /// بإعادة استخدام نفس قناة WebRTC المقنّعة بواجهة إنشاء جلسة خاصة بها، + /// بدل تكرار منطق الإشارات والصوت. القيمة الافتراضية هي مكالمة الرحلة. Future startCall({ required String rideIdVal, required String driverId, required String passengerId, required String remoteNameVal, + String? sessionEndpoint, + Map? sessionPayload, }) async { if (state.value != VoiceCallState.idle) return; @@ -266,8 +272,9 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver { // 2. EN: Call PHP Backend to create Node.js session & notify Driver via FCM. // AR: استدعاء واجهة PHP لإنشاء الجلسة على Node.js وإشعار السائق عبر FCM. final response = await CRUD().post( - link: "${AppLink.server}/ride/call/passenger/create_call_session.php", - payload: {'ride_id': rideIdVal}, + link: sessionEndpoint ?? + "${AppLink.server}/ride/call/passenger/create_call_session.php", + payload: sessionPayload ?? {'ride_id': rideIdVal}, ); if (response == null || @@ -278,8 +285,15 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver { return; } - final data = response['data']; - sessionId.value = data['session_id']; + // واجهات الرحلات تُعيد الجلسة في 'data'، وواجهات وحدة الطعام تُعيدها في + // 'message' (غلاف jsonSuccess الموحّد) — نقبل الشكلين. + final data = response['data'] ?? response['message']; + if (data is! Map || data['session_id'] == null) { + _endCallInternal("session_creation_failed"); + mySnackbarError("Error starting voice call".tr); + return; + } + sessionId.value = data['session_id'].toString(); // 3. EN: Connect to WebRTC signaling server / AR: الاتصال بخادم الإشارات await _signaling.connect(sessionId.value, currentUserId); @@ -473,7 +487,15 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver { }); } } - } else { + } + + // خوادم TURN من الباك إند — بيانات اعتماد مؤقتة. بلا TURN تفشل المكالمة + // صامتةً حين يكون الطرفان خلف CGNAT (شبكة الجوال)، وهي الحالة الغالبة. + // نُضيفها قبل خوادم الإشارات: STUN يبقى أولاً في الترتيب داخل القائمة. + final turnServers = await TurnCredentialsService.getIceServers(); + iceServers.addAll(turnServers); + + if (iceServers.isEmpty) { // EN: Fallback STUN servers / AR: خوادم STUN الاحتياطية iceServers.addAll([ {"urls": "stun:stun.l.google.com:19302"}, diff --git a/siro_rider/lib/services/turn_credentials_service.dart b/siro_rider/lib/services/turn_credentials_service.dart new file mode 100644 index 00000000..5c98afb3 --- /dev/null +++ b/siro_rider/lib/services/turn_credentials_service.dart @@ -0,0 +1,79 @@ +// turn_credentials_service.dart — جلب بيانات اعتماد TURN المؤقتة وتخزينها +// +// خادم الإشارات (Node) يُعيد STUN فقط، وSTUN وحده لا يكفي حين يكون الطرفان +// خلف CGNAT — وهي الحالة الغالبة لسائق وزبون على بيانات الجوال. نجلب TURN +// من الباك إند ونضمّه إلى قائمة ICE قبل إنشاء الاتصال. +// +// البيانات مؤقتة (HMAC بمهلة) لا كلمة مرور ثابتة داخل التطبيق، ونُخزّنها +// محلياً حتى قرب انتهائها فلا نُثقل بنداء شبكة قبل كل مكالمة. +import 'dart:convert'; + +import '../constant/box_name.dart'; +import '../constant/links.dart'; +import '../controller/functions/crud.dart'; +import '../main.dart'; +import '../print.dart'; + +class TurnCredentialsService { + static const String _cacheKey = BoxName.turnIceCache; + + /// خوادم ICE جاهزة لتمريرها إلى createPeerConnection. + /// تعيد قائمة فارغة عند أي فشل — والمُنادي يكمل بـ STUN كما كان سابقاً. + static Future>> getIceServers() async { + final cached = _readCache(); + if (cached != null) return cached; + + try { + final res = await CRUD().post( + link: '${AppLink.server}/ride/call/turn_credentials.php', + ); + + if (res is! Map || res['status'] != 'success') return []; + + // غلاف jsonSuccess يضع الحمولة في message + final payload = res['message']; + if (payload is! Map || payload['ice_servers'] is! List) return []; + + final servers = (payload['ice_servers'] as List) + .whereType() + .map((s) => Map.from(s)) + .toList(); + + final ttl = int.tryParse(payload['ttl']?.toString() ?? '0') ?? 0; + if (ttl > 0 && servers.isNotEmpty) { + // ننتهي قبل الخادم بخمس دقائق تفادياً لسباق انتهاء الصلاحية أثناء مكالمة + final expiresAt = DateTime.now().add(Duration(seconds: ttl - 300)); + box.write(_cacheKey, jsonEncode({ + 'expires_at': expiresAt.toIso8601String(), + 'servers': servers, + })); + } + + return servers; + } catch (e) { + Log.print('⚠️ [TURN] تعذّر جلب بيانات الاعتماد: $e'); + return []; + } + } + + static List>? _readCache() { + try { + final raw = box.read(_cacheKey); + if (raw == null) return null; + + final data = jsonDecode(raw.toString()); + final expiresAt = DateTime.tryParse(data['expires_at']?.toString() ?? ''); + if (expiresAt == null || DateTime.now().isAfter(expiresAt)) return null; + + final servers = (data['servers'] as List) + .whereType() + .map((s) => Map.from(s)) + .toList(); + return servers.isEmpty ? null : servers; + } catch (_) { + return null; + } + } + + static void clearCache() => box.remove(_cacheKey); +} diff --git a/siro_rider/lib/views/food/food_order_tracking_page.dart b/siro_rider/lib/views/food/food_order_tracking_page.dart index 9964e093..3353ae84 100644 --- a/siro_rider/lib/views/food/food_order_tracking_page.dart +++ b/siro_rider/lib/views/food/food_order_tracking_page.dart @@ -4,9 +4,11 @@ import 'package:get/get.dart'; import '../../constant/box_name.dart'; import '../../constant/colors.dart'; +import '../../constant/links.dart'; import '../../constant/style.dart'; import '../../main.dart'; import '../../controller/food/food_controller.dart'; +import '../../controller/voice_call_controller.dart'; import '../../controller/food/food_models.dart'; import '../widgets/my_scafold.dart'; import 'food_home_page.dart'; @@ -127,7 +129,27 @@ class _FoodOrderTrackingPageState extends State { Text(foodFormatPrice(order.grandTotal), style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)), ], ), - const SizedBox(height: 24), + const SizedBox(height: 16), + // الاتصال بالسائق متاح فقط أثناء التوصيل، وعبر قناة مقنّعة: + // لا رقم هاتف يُعرض لأي طرف — جلسة صوتية مؤقتة تُقفل بانتهاء الطلب. + if (order.status == 'courier_assigned' || order.status == 'picked_up') + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: BorderSide(color: AppColor.primaryColor), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + onPressed: () => _callCourier(order.id), + icon: Icon(Icons.phone_in_talk_rounded, color: AppColor.primaryColor), + label: Text( + _isAr ? 'الاتصال بالسائق' : 'Call the courier', + style: TextStyle(color: AppColor.primaryColor), + ), + ), + ), + const SizedBox(height: 12), if (order.status == 'pending') SizedBox( width: double.infinity, @@ -172,6 +194,27 @@ class _FoodOrderTrackingPageState extends State { ); } + // القناة نفسها المستعملة في مكالمات الرحلات (WebRTC عبر خادم الإشارات)، + // لكن الجلسة تُنشأ من واجهة الطعام التي تتحقق أن الطلب لي وأنه قيد التوصيل. + Future _callCourier(int orderId) async { + // مسجَّل عالمياً بـ lazyPut(fenix) في app_bindings — والـ put احتياط + VoiceCallController voiceCtrl; + try { + voiceCtrl = Get.find(); + } catch (_) { + voiceCtrl = Get.put(VoiceCallController()); + } + + await voiceCtrl.startCall( + rideIdVal: 'food_$orderId', + driverId: '', // هوية السائق لا تصل التطبيق — الخادم يحلّها من الطلب + passengerId: box.read(BoxName.passengerID).toString(), + remoteNameVal: _isAr ? 'سائق التوصيل' : 'Delivery courier', + sessionEndpoint: '${AppLink.server}/food/order/call_courier.php', + sessionPayload: {'order_id': orderId.toString()}, + ); + } + Widget _statusTimeline(String currentStatus) { final currentIndex = foodOrderStatusFlow.indexOf(currentStatus); return Column( diff --git a/siro_rider/pubspec.lock b/siro_rider/pubspec.lock index def65d08..1b8d51ce 100644 --- a/siro_rider/pubspec.lock +++ b/siro_rider/pubspec.lock @@ -1297,10 +1297,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -1313,10 +1313,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: "direct main" description: @@ -1901,10 +1901,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.9" timezone: dependency: transitive description: