Food delivery driver module + masked calls + TURN

This commit is contained in:
Hamza-Ayed
2026-08-04 01:50:50 +03:00
parent 04c214bdf6
commit f01e408ba6
35 changed files with 3029 additions and 212 deletions
+14 -2
View File
@@ -4,8 +4,11 @@ require_once __DIR__ . '/../connect_courier.php';
$st = $food_con->prepare( $st = $food_con->prepare(
"SELECT o.id, o.status, o.merchant_id, m.name_ar AS merchant_name_ar, m.latitude AS merchant_lat, "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, m.longitude AS merchant_lng, m.address AS merchant_address, m.avg_prep_minutes,
o.delivery_address, o.delivery_lat, o.delivery_lng, o.created_at, 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 CASE WHEN o.status IN ('courier_assigned','picked_up') THEN o.delivery_address ELSE NULL END AS visible_address
FROM food_orders o FROM food_orders o
JOIN food_merchants m ON m.id = o.merchant_id JOIN food_merchants m ON m.id = o.merchant_id
@@ -20,6 +23,15 @@ foreach ($orders as &$o) {
unset($o['delivery_address']); unset($o['delivery_address']);
$o['delivery_address'] = $o['visible_address']; $o['delivery_address'] = $o['visible_address'];
unset($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); unset($o);
+48
View File
@@ -0,0 +1,48 @@
<?php
// food/courier/call_customer.php — السائق يتصل بالزبون عبر قناة مقنّعة
// لا رقم هاتف يُعرض لأي طرف: نفتح جلسة WebRTC ونُشعر الزبون بمعرّف الجلسة فقط.
require_once __DIR__ . '/../connect_courier.php';
$orderId = filterRequest('order_id', 'int');
if (!$orderId) jsonError('order_id is required');
$order = foodAssertOrderOwnership($orderId, 'courier', $food_courier_id);
if (!foodOrderAllowsCall($order)) {
// بعد التسليم أو قبل الإسناد لا حاجة تشغيلية للاتصال — والقناة تُقفل
jsonError('Calling is only allowed while the delivery is active', 403);
}
if (foodCallQuotaExceeded($orderId, 'courier')) {
jsonError('Too many call attempts for this order', 429);
}
$session = foodCreateCallSession($orderId, $food_courier_id, (string)$order['passenger_id']);
if (!$session) jsonError('Call service unavailable', 502);
// إشعار صامت للزبون عبر موضوع FCM الخاص به — نفس القناة المستعملة في وحدة
// الطعام أصلاً (ممنوع Database::get('main') هنا لجلب توكن الجهاز).
// الاسم المعروض عام عمداً: هوية السائق الحقيقية لا تُكشف للزبون.
foodSendNotificationToPassenger(
(string)$order['passenger_id'],
'مكالمة واردة',
'سائق التوصيل يتصل بك',
[
// تطبيق الراكب يفرز الإشعارات بـ data['category'] — ونُبقي 'type'
// للتوافق مع بقية حمولات وحدة الطعام.
'category' => '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'],
]);
+57
View File
@@ -0,0 +1,57 @@
<?php
// food/courier/earnings.php — ملخص أرباح التوصيل للسائق (اليوم/الأسبوع/الشهر)
// المبالغ من food_orders.delivery_fee للطلبات المسلَّمة فقط — نفس ما يُقيَّد في
// food_order_payments (courier_payout)، والتسوية الفعلية خارج نطاق هذا الملف.
require_once __DIR__ . '/../connect_courier.php';
$sums = $food_con->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()),
]);
+34
View File
@@ -0,0 +1,34 @@
<?php
// food/courier/history.php — سجل طلبات التوصيل المنتهية لهذا السائق
require_once __DIR__ . '/../connect_courier.php';
$limit = (int)(filterRequest('limit', 'int') ?: 20);
$offset = (int)(filterRequest('offset', 'int') ?: 0);
$limit = max(1, min($limit, 50));
$offset = max(0, $offset);
// العنوان النصّي للزبون لا يُعاد في السجل — انتهت الحاجة التشغيلية إليه بالتسليم،
// ونفس قاعدة الحجب المطبَّقة في active.php.
$st = $food_con->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]);
+64
View File
@@ -0,0 +1,64 @@
<?php
// food/courier/order_details.php — تفاصيل مهمة توصيل واحدة (شاشة المهمة عند السائق)
// أصناف الطلب + تفصيل المبالغ + هاتف المطعم للتواصل أثناء الاستلام
require_once __DIR__ . '/../connect_courier.php';
$orderId = filterRequest('order_id', 'int');
if (!$orderId) jsonError('order_id is required');
// يفشل لو لم يكن الطلب مُسنداً لهذا السائق — لا وصول لطلبات الآخرين
foodAssertOrderOwnership($orderId, 'courier', $food_courier_id);
$st = $food_con->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]);
+15 -5
View File
@@ -5,15 +5,25 @@
require_once __DIR__ . '/../connect_courier.php'; require_once __DIR__ . '/../connect_courier.php';
$st = $food_con->prepare( $st = $food_con->prepare(
"SELECT a.order_id, a.offered_at, o.merchant_id, m.name_ar AS merchant_name_ar, "SELECT a.order_id, a.offered_at
m.latitude AS merchant_lat, m.longitude AS merchant_lng, o.delivery_fee,
o.delivery_lat, o.delivery_lng
FROM food_courier_assignments a FROM food_courier_assignments a
JOIN food_orders o ON o.id = a.order_id 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) 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" ORDER BY a.offered_at ASC"
); );
$st->execute([$food_courier_id]); $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]);
+32
View File
@@ -0,0 +1,32 @@
<?php
// food/courier/status.php — حالة السائق في وحدة التوصيل عند فتح التبويب
// (وضع التوصيل مخزَّن في Redis لا في التطبيق — بدون هذا النداء يفتح التطبيق
// على "مغلق" بينما السائق ما زال فعلياً في food:couriers:opted_in ويستقبل عروضاً)
require_once __DIR__ . '/../connect_courier.php';
$enabled = false;
if ($redis) {
$enabled = (bool)$redis->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'],
]);
+132 -2
View File
@@ -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) ── // ── Session مطعم (هاتف+كلمة مرور — مستقلة عن JWT، مثل transit) ──
function foodCreateMerchantSession(int $merchantUserId, int $merchantId): string 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')" "INSERT INTO food_courier_assignments (order_id, courier_id, status) VALUES (?,?,'offered')"
)->execute([$orderId, $courierId]); )->execute([$orderId, $courierId]);
// العرض يُدفع كاملاً عبر السوكيت: شاشة العرض عند السائق تُبنى من هذه الحمولة
// مباشرة بلا نداء HTTP إضافي (مهلة العرض 20 ثانية لا تحتمل round-trip زائد).
$offer = foodBuildCourierOfferPayload($orderId);
// ملاحظة: لا FCM هنا عمداً — توكن جهاز السائق في جدول driverToken على main DB، // ملاحظة: لا FCM هنا عمداً — توكن جهاز السائق في جدول driverToken على main DB،
// وممنوع Database::get('main') داخل backend/food/ (نفس قاعدة transit). الإشعار // وممنوع Database::get('main') داخل backend/food/ (نفس قاعدة transit). الإشعار
// اللحظي يمر فقط عبر socket_food (السائق متصل بسوكيته أثناء وضع التوصيل)، // اللحظي يمر فقط عبر socket_food (السائق متصل بسوكيته أثناء وضع التوصيل)،
// والتطبيق يعتمد أيضاً على courier/active.php كـ polling fallback عند الانقطاع. // والتطبيق يعتمد أيضاً على courier/pending_offers.php كـ polling fallback عند الانقطاع.
foodPushToSocket('courier_offer', ['order_id' => $orderId, 'courier_id' => $courierId]); 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 — القابل الأول فقط يفوز، ذرّياً (يمنع سباق القبول) // SET NX EX 20 — القابل الأول فقط يفوز، ذرّياً (يمنع سباق القبول)
+39
View File
@@ -0,0 +1,39 @@
<?php
// food/order/call_courier.php — الزبون يتصل بسائق التوصيل عبر قناة مقنّعة
// لا رقم هاتف يُعرض لأي طرف — session_id فقط، والقناة تُقفل بانتهاء الطلب.
require_once __DIR__ . '/../connect_app.php';
$orderId = filterRequest('order_id', 'int');
if (!$orderId) jsonError('order_id is required');
$order = foodAssertOrderOwnership($orderId, 'customer', $food_passenger_id);
if (!foodOrderAllowsCall($order)) {
jsonError('Calling is only allowed while a courier is delivering your order', 403);
}
if (foodCallQuotaExceeded($orderId, 'customer')) {
jsonError('Too many call attempts for this order', 429);
}
$courierId = (string)$order['courier_id'];
$session = foodCreateCallSession($orderId, $courierId, $food_passenger_id);
if (!$session) jsonError('Call service unavailable', 502);
// السائق يصله التنبيه عبر سوكيت الطعام (غرفة courier_food_{id}) — لا موضوع FCM
// خاصاً بكل سائق في النظام، والسوكيت حيّ أصلاً أثناء وضع التوصيل.
// الاسم المعروض عام عمداً: هوية الزبون لا تُكشف للسائق.
foodPushToSocket('courier_call', [
'order_id' => $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'],
]);
+108
View File
@@ -0,0 +1,108 @@
<?php
// ═══════════════════════════════════════════════════════════════
// ride/call/turn_credentials.php
// الغرض : بيانات اعتماد مؤقتة لخادم TURN (coturn use-auth-secret)
// التطبيق: السائق والراكب معاً — نفس القناة تخدم مكالمات الرحلات
// ومكالمات توصيل الطعام المقنّعة.
// المصادقة: JWT (driver أو passenger)
// ───────────────────────────────────────────────────────────────
// لماذا بيانات مؤقتة ولا مستخدم ثابت: كلمة مرور TURN ثابتة داخل تطبيق
// منشور = مفتاح تمرير مجاني للجميع بمجرد فكّ حزمة APK. آلية coturn
// القياسية: username = "{انتهاء الصلاحية}:{المستخدم}"، والكلمة
// = base64(HMAC-SHA1(secret, username)). الخادم يتحقق حسابياً بلا
// قاعدة بيانات ولا مزامنة، والصلاحية تنتهي تلقائياً.
// ═══════════════════════════════════════════════════════════════
declare(strict_types=1);
require_once __DIR__ . '/../../core/bootstrap.php';
require_once __DIR__ . '/../../functions.php';
$limiter = new RateLimiter($redis);
$limiter->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;
}
+16
View File
@@ -113,6 +113,22 @@ WALLET_SECRET_KEY_PATH=/keys/.secret_key_pay
ENCRYPTION_KEY_PATH=/keys/.enckey ENCRYPTION_KEY_PATH=/keys/.enckey
PAYMENT_INTERNAL_KEY_PATH=/keys/.internal_socket_key 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 --- # --- 7. Third-Party APIs ---
GEMINI_API_KEY=AIzaSyDHp0yXCGWqnd4ynlCCWzDz9Un1EJOKeW8 GEMINI_API_KEY=AIzaSyDHp0yXCGWqnd4ynlCCWzDz9Un1EJOKeW8
SMS_API_ENDPOINT=https://sms.kazumi.me/api/sms/send-sms SMS_API_ENDPOINT=https://sms.kazumi.me/api/sms/send-sms
+64
View File
@@ -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
+17
View File
@@ -230,6 +230,23 @@ services:
mem_limit: 512m mem_limit: 512m
restart: unless-stopped 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: volumes:
mysql-data: mysql-data:
redis-data: redis-data:
+12
View File
@@ -144,6 +144,18 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY, $INTERNAL_PORT) {
$io->to('courier_food_' . $courierId)->emit('food_delivery_offer', $payload); $io->to('courier_food_' . $courierId)->emit('food_delivery_offer', $payload);
socket_log("[HTTP_SUCCESS] courier_offer pushed to courier #$courierId", $payload); socket_log("[HTTP_SUCCESS] courier_offer pushed to courier #$courierId", $payload);
$connection->send('OK'); $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 { } else {
socket_log("[HTTP_WARNING] Unknown action received: $action", $post); socket_log("[HTTP_WARNING] Unknown action received: $action", $post);
$connection->send('Unknown action: ' . $action); $connection->send('Unknown action: ' . $action);
+7 -7
View File
@@ -892,10 +892,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.18" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -908,10 +908,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
mgrs_dart: mgrs_dart:
dependency: transitive dependency: transitive
description: description:
@@ -1232,10 +1232,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.9" version: "0.7.11"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -1413,5 +1413,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.9.0-0 <4.0.0" dart: ">=3.10.0-0 <4.0.0"
flutter: ">=3.32.0" flutter: ">=3.32.0"
+3
View File
@@ -133,4 +133,7 @@ class BoxName {
// توفّر Google Play Services — يحدّد شراسة ملف تتبّع الموقع // توفّر Google Play Services — يحدّد شراسة ملف تتبّع الموقع
// (بلا GMS يسقط الباكيج إلى LocationManager الخام: بطارية أعلى، دقة أقل) // (بلا GMS يسقط الباكيج إلى LocationManager الخام: بطارية أعلى، دقة أقل)
static const String isGmsAvailable = 'isGmsAvailable'; static const String isGmsAvailable = 'isGmsAvailable';
// بيانات اعتماد TURN المؤقتة (مكالمات الصوت) — مخزّنة حتى قرب انتهائها
static const String turnIceCache = 'turnIceCache';
} }
+17
View File
@@ -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 { static String get mapSaasRoute {
switch (currentCountry) { switch (currentCountry) {
case 'Syria': case 'Syria':
@@ -15,8 +15,13 @@ import '../../constant/box_name.dart';
import '../../constant/links.dart'; import '../../constant/links.dart';
import '../../main.dart'; // للوصول لـ box import '../../main.dart'; // للوصول لـ box
import '../../print.dart'; 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/driver_map_page.dart';
import '../../views/home/Captin/orderCaptin/order_request_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 '../functions/crud.dart';
import '../home/captin/home_captain_controller.dart'; import '../home/captin/home_captain_controller.dart';
@@ -213,6 +218,14 @@ class NotificationController extends GetxController {
if (payload == null) return; if (payload == null) return;
final payloadData = jsonDecode(payload) as Map<String, dynamic>; final payloadData = jsonDecode(payload) as Map<String, dynamic>;
// إشعارات وحدة التوصيل لها حمولة وأزرار خاصة بها — نفرزها قبل أي شيء
// لأن باقي هذه الدالة يفترض حمولة رحلة (مصفوفة add_ride.php).
if (payloadData.containsKey('food_event')) {
await _handleFoodNotificationResponse(response, payloadData);
return;
}
final rawData = payloadData['data']; final rawData = payloadData['data'];
List<dynamic> listData = []; List<dynamic> listData = [];
@@ -248,6 +261,38 @@ class NotificationController extends GetxController {
} }
} }
// ── فرع وحدة التوصيل — مسار مستقل تماماً عن مسار الرحلات أعلاه ──────────
Future<void> _handleFoodNotificationResponse(
NotificationResponse response, Map<String, dynamic> payloadData) async {
final orderId = int.tryParse(payloadData['order_id']?.toString() ?? '') ?? 0;
if (orderId == 0) return;
await FoodNotificationService.instance.cancelOfferNotification();
// الكنترولر قد لا يكون مسجَّلاً (الضغط على الإشعار والتطبيق مغلق)، فنسجّله
// ليعيد بناء حالته من الخادم قبل عرض أي شاشة.
final controller = Get.isRegistered<FoodDeliveryController>()
? Get.find<FoodDeliveryController>()
: 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) // 4. منطق القبول الآمن (Safe Accept Logic)
// ============================================================================== // ==============================================================================
@@ -1,37 +1,87 @@
// food_delivery_controller.dart — حالة تبويب التوصيل (وضع التوصيل، العروض، المهام النشطة) // food_delivery_controller.dart — حالة تبويب التوصيل (وضع التوصيل، العروض، المهام النشطة)
//
// مصادر الأحداث ثلاثة، مرتّبة بالأولوية:
// 1) سوكيت الطعام المستقل (FoodSocketService) — المسار اللحظي الأساسي.
// 2) polling لـ pending_offers.php — يعمل فقط حين ينقطع السوكيت (لا نُحمّل
// الخادم نداءً كل 4 ثوانٍ بينما القناة الحية شغّالة).
// 3) active.php — مصدر الحقيقة لحالة المهام، يُصحّح أي فارق بعد أي انقطاع.
import 'dart:async'; import 'dart:async';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:just_audio/just_audio.dart';
import '../../constant/box_name.dart';
import '../../constant/links.dart';
import '../../main.dart';
import '../../print.dart';
import '../../views/food_delivery/food_offer_page.dart';
import '../../views/widgets/error_snakbar.dart'; import '../../views/widgets/error_snakbar.dart';
import '../voice_call_controller.dart';
import 'food_delivery_models.dart'; import 'food_delivery_models.dart';
import 'food_delivery_service.dart'; import 'food_delivery_service.dart';
import 'food_notification_service.dart';
import 'food_socket_service.dart';
class FoodDeliveryController extends GetxController { class FoodDeliveryController extends GetxController {
bool isDeliveryModeEnabled = false; bool isDeliveryModeEnabled = false;
bool isTogglingMode = false; bool isTogglingMode = false;
bool isSocketConnected = false;
List<FoodDeliveryOffer> pendingOffers = []; List<FoodDeliveryOffer> pendingOffers = [];
List<FoodDeliveryTask> activeTasks = []; List<FoodDeliveryTask> activeTasks = [];
bool isLoadingTasks = false; bool isLoadingTasks = false;
int todayOrdersCount = 0;
int todayEarnings = 0;
final Set<int> _respondingOfferIds = {}; final Set<int> _respondingOfferIds = {};
final Set<int> _busyTaskIds = {}; final Set<int> _busyTaskIds = {};
// معرّفات عروض عُرضت أو انتهت — يمنع تكرار فتح الشاشة حين يصل نفس العرض
// من السوكيت ومن polling معاً.
final Set<int> _handledOfferIds = {};
Timer? _pollTimer; Timer? _pollTimer;
Timer? _countdownTimer;
final AudioPlayer _audioPlayer = AudioPlayer();
bool _isOfferScreenOpen = false;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
_refreshAll(); FoodNotificationService.instance.ensureChannels();
_pollTimer = Timer.periodic(const Duration(seconds: 4), (_) => _refreshAll()); _bindSocketHandlers();
_bootstrap();
// نبضة خفيفة: المهام دائماً، والعروض فقط إذا كان السوكيت مقطوعاً
_pollTimer = Timer.periodic(const Duration(seconds: 8), (_) => _pollTick());
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) => _tickOffers());
} }
Future<void> _refreshAll() async { Future<void> _bootstrap() async {
if (isDeliveryModeEnabled) { await refreshStatus();
await _fetchPendingOffers();
}
await fetchActiveTasks(); await fetchActiveTasks();
} }
// ── وضع التوصيل ────────────────────────────────────────────────────────
Future<void> refreshStatus() async {
final res = await FoodDeliveryService.getStatus();
if (!res.success || res.data == null) return;
final s = res.data!;
isDeliveryModeEnabled = s.deliveryModeEnabled;
todayOrdersCount = s.todayOrdersCount;
todayEarnings = s.todayEarnings;
// الخادم هو مرجع الوضع — نوصل/نقطع السوكيت تبعاً له لا تبعاً لحالة الشاشة
if (isDeliveryModeEnabled) {
await FoodSocketService.instance.connect();
} else {
FoodSocketService.instance.disconnect();
pendingOffers.clear();
}
update();
}
Future<void> toggleDeliveryMode(bool enable) async { Future<void> toggleDeliveryMode(bool enable) async {
isTogglingMode = true; isTogglingMode = true;
update(); update();
@@ -41,23 +91,165 @@ class FoodDeliveryController extends GetxController {
if (res.success) { if (res.success) {
isDeliveryModeEnabled = enable; isDeliveryModeEnabled = enable;
if (!enable) pendingOffers = []; if (enable) {
await FoodSocketService.instance.connect();
} else {
FoodSocketService.instance.disconnect();
pendingOffers.clear();
FoodNotificationService.instance.cancelOfferNotification();
}
} else { } else {
mySnackbarWarning(res.message); mySnackbarWarning(res.message);
} }
update(); update();
} }
Future<void> _fetchPendingOffers() async { // ── السوكيت ────────────────────────────────────────────────────────────
final res = await FoodDeliveryService.getPendingOffers(); void _bindSocketHandlers() {
if (res.success) { final socket = FoodSocketService.instance;
pendingOffers = res.data ?? []; socket.onOffer = (payload) => _handleIncomingOffer(FoodDeliveryOffer.fromJson(payload));
socket.onOrderUpdate = _handleOrderUpdate;
socket.onIncomingCall = _handleIncomingCall;
socket.onConnectionChanged = (connected) {
isSocketConnected = connected;
// بعد أي إعادة اتصال نسحب الحالة من الخادم — السوكيت ناقل لا مخزن حالة
if (connected) {
fetchActiveTasks();
_fetchPendingOffers();
}
update(); update();
};
}
void _handleOrderUpdate(Map<String, dynamic> payload) {
final status = payload['status']?.toString() ?? '';
final orderId = int.tryParse(payload['order_id']?.toString() ?? '') ?? 0;
if (status.startsWith('cancelled') && orderId != 0) {
FoodNotificationService.instance.showStatusNotification(
orderId: orderId,
title: 'أُلغي طلب التوصيل',
body: 'الطلب #$orderId أُلغي — لا حاجة لمتابعته',
);
mySnackbarWarning('أُلغي طلب التوصيل #$orderId');
}
fetchActiveTasks();
}
// ── المكالمة المقنّعة ───────────────────────────────────────────────────
// الزبون بادر بالاتصال: الحمولة لا تحمل رقماً ولا اسماً حقيقياً، فقط
// session_id ننضم به لنفس خادم الإشارات المستعمل في مكالمات الرحلات.
void _handleIncomingCall(Map<String, dynamic> payload) {
final sessionId = payload['session_id']?.toString() ?? '';
final orderId = payload['order_id']?.toString() ?? '';
if (sessionId.isEmpty || orderId.isEmpty) return;
// مسجَّل عالمياً بـ lazyPut(fenix) في main — والـ put احتياط لو أُزيل
final callController = _voiceCall();
callController.receiveCall(
sessionIdVal: sessionId,
remoteNameVal: payload['caller_name']?.toString() ?? 'زبون الطلب',
rideIdVal: 'food_$orderId',
);
}
/// السائق يتصل بالزبون — لا يرى رقمه ولا يُخزَّن عنده
Future<void> callCustomer(int orderId) async {
// مسجَّل عالمياً بـ lazyPut(fenix) في main — والـ put احتياط لو أُزيل
final callController = _voiceCall();
await callController.startCall(
rideIdVal: 'food_$orderId',
driverId: box.read(BoxName.driverID).toString(),
passengerId: '', // هوية الزبون لا تصل التطبيق — الخادم يحلّها من الطلب
remoteNameVal: 'زبون الطلب',
sessionEndpoint: '${AppLink.server}/food/courier/call_customer.php',
sessionPayload: {'order_id': orderId.toString()},
);
}
VoiceCallController _voiceCall() {
try {
return Get.find<VoiceCallController>();
} catch (_) {
return Get.put(VoiceCallController());
} }
} }
Future<void> fetchActiveTasks() async { // ── العروض ─────────────────────────────────────────────────────────────
void _handleIncomingOffer(FoodDeliveryOffer offer) {
if (offer.orderId == 0 || offer.isExpired) return;
if (_handledOfferIds.contains(offer.orderId)) return;
if (pendingOffers.any((o) => o.orderId == offer.orderId)) return;
_handledOfferIds.add(offer.orderId);
pendingOffers.add(offer);
update();
_playOfferSound();
final isForeground = box.read(BoxName.isAppInForeground) ?? false;
if (isForeground == true) {
_openOfferScreen();
} else {
// التطبيق في الخلفية — إشعار ملء شاشة بقناة التوصيل الخاصة
FoodNotificationService.instance.showOfferNotification(offer);
}
}
void _openOfferScreen() {
if (_isOfferScreenOpen) return;
_isOfferScreenOpen = true;
Get.to(() => const FoodOfferPage(), fullscreenDialog: true)?.whenComplete(() {
_isOfferScreenOpen = false;
});
}
Future<void> _playOfferSound() async {
try {
await _audioPlayer.setAsset('assets/order1.wav');
await _audioPlayer.play();
} catch (e) {
Log.print('🍔 [FoodDelivery] تعذّر تشغيل نغمة العرض: $e');
}
}
// انتهاء المهلة يُدار محلياً للعرض فقط — الخادم يرفض أي قبول متأخر أصلاً
void _tickOffers() {
if (pendingOffers.isEmpty) return;
final before = pendingOffers.length;
pendingOffers.removeWhere((o) => o.isExpired);
if (pendingOffers.isEmpty && before > 0) {
FoodNotificationService.instance.cancelOfferNotification();
if (_isOfferScreenOpen && Get.currentRoute.contains('FoodOfferPage')) {
Get.back();
}
}
update();
}
Future<void> _pollTick() async {
// العروض عبر polling فقط عند انقطاع السوكيت — احتياط لا مسار أساسي
if (isDeliveryModeEnabled && !FoodSocketService.instance.isConnected) {
await _fetchPendingOffers();
}
await fetchActiveTasks(silent: true);
}
Future<void> _fetchPendingOffers() async {
final res = await FoodDeliveryService.getPendingOffers();
if (!res.success) return;
for (final offer in res.data ?? <FoodDeliveryOffer>[]) {
_handleIncomingOffer(offer);
}
}
Future<void> fetchActiveTasks({bool silent = false}) async {
if (!silent) {
isLoadingTasks = true; isLoadingTasks = true;
update();
}
final res = await FoodDeliveryService.getActiveTasks(); final res = await FoodDeliveryService.getActiveTasks();
isLoadingTasks = false; isLoadingTasks = false;
if (res.success) activeTasks = res.data ?? []; if (res.success) activeTasks = res.data ?? [];
@@ -76,6 +268,7 @@ class FoodDeliveryController extends GetxController {
_respondingOfferIds.remove(orderId); _respondingOfferIds.remove(orderId);
pendingOffers.removeWhere((o) => o.orderId == orderId); pendingOffers.removeWhere((o) => o.orderId == orderId);
FoodNotificationService.instance.cancelOfferNotification();
if (!res.success) { if (!res.success) {
mySnackbarWarning(res.message); mySnackbarWarning(res.message);
@@ -83,10 +276,15 @@ class FoodDeliveryController extends GetxController {
mySnackbarSuccess('تم قبول طلب التوصيل'); mySnackbarSuccess('تم قبول طلب التوصيل');
} }
if (pendingOffers.isEmpty && _isOfferScreenOpen && Get.currentRoute.contains('FoodOfferPage')) {
Get.back();
}
update(); update();
await fetchActiveTasks(); await fetchActiveTasks();
} }
// ── خطوات المهمة ───────────────────────────────────────────────────────
Future<void> markPickedUp(int orderId) async { Future<void> markPickedUp(int orderId) async {
if (_busyTaskIds.contains(orderId)) return; if (_busyTaskIds.contains(orderId)) return;
_busyTaskIds.add(orderId); _busyTaskIds.add(orderId);
@@ -115,6 +313,7 @@ class FoodDeliveryController extends GetxController {
if (res.success) { if (res.success) {
mySnackbarSuccess('تم تسليم الطلب بنجاح'); mySnackbarSuccess('تم تسليم الطلب بنجاح');
await fetchActiveTasks(); await fetchActiveTasks();
await refreshStatus(); // تحديث عدّاد أرباح اليوم فوراً بعد التسليم
} else { } else {
mySnackbarWarning(res.message); mySnackbarWarning(res.message);
} }
@@ -124,6 +323,10 @@ class FoodDeliveryController extends GetxController {
@override @override
void onClose() { void onClose() {
_pollTimer?.cancel(); _pollTimer?.cancel();
_countdownTimer?.cancel();
_audioPlayer.dispose();
// لا نقطع السوكيت هنا: وضع التوصيل قد يبقى مفعّلاً بعد إغلاق الشاشة،
// والقطع يتم فقط عند إطفاء الوضع صراحةً من التبديل.
super.onClose(); super.onClose();
} }
} }
@@ -1,5 +1,10 @@
// food_delivery_models.dart — نماذج بيانات وحدة التوصيل (جهة السائق) // 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 { class FoodDeliveryTask {
final int id; final int id;
final String status; // courier_assigned | picked_up final String status; // courier_assigned | picked_up
@@ -9,10 +14,22 @@ class FoodDeliveryTask {
final double? merchantLng; final double? merchantLng;
final String? merchantAddress; final String? merchantAddress;
final int deliveryFee; 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 String? deliveryAddress;
final double deliveryLat; final double deliveryLat;
final double deliveryLng; final double deliveryLng;
final DateTime? createdAt; final DateTime? createdAt;
final DateTime? readyAt;
final DateTime? courierAssignedAt;
final DateTime? pickedUpAt;
FoodDeliveryTask({ FoodDeliveryTask({
required this.id, required this.id,
@@ -22,37 +39,73 @@ class FoodDeliveryTask {
required this.deliveryFee, required this.deliveryFee,
required this.deliveryLat, required this.deliveryLat,
required this.deliveryLng, 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.merchantLat,
this.merchantLng, this.merchantLng,
this.merchantAddress, this.merchantAddress,
this.deliveryAddress, this.deliveryAddress,
this.createdAt, this.createdAt,
this.readyAt,
this.courierAssignedAt,
this.pickedUpAt,
}); });
bool get isPickedUp => status == 'picked_up';
bool get isCash => paymentMethod == 'cash';
factory FoodDeliveryTask.fromJson(Map<String, dynamic> j) => FoodDeliveryTask( factory FoodDeliveryTask.fromJson(Map<String, dynamic> j) => FoodDeliveryTask(
id: int.tryParse(j['id'].toString()) ?? 0, id: _int(j['id']),
status: j['status']?.toString() ?? '', status: j['status']?.toString() ?? '',
merchantId: int.tryParse(j['merchant_id'].toString()) ?? 0, merchantId: _int(j['merchant_id']),
merchantNameAr: j['merchant_name_ar']?.toString() ?? '', merchantNameAr: j['merchant_name_ar']?.toString() ?? '',
merchantLat: double.tryParse(j['merchant_lat']?.toString() ?? ''), merchantLat: _doubleOrNull(j['merchant_lat']),
merchantLng: double.tryParse(j['merchant_lng']?.toString() ?? ''), merchantLng: _doubleOrNull(j['merchant_lng']),
merchantAddress: j['merchant_address']?.toString(), 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(), deliveryAddress: j['delivery_address']?.toString(),
deliveryLat: double.tryParse(j['delivery_lat']?.toString() ?? '0') ?? 0, deliveryLat: _double(j['delivery_lat']),
deliveryLng: double.tryParse(j['delivery_lng']?.toString() ?? '0') ?? 0, deliveryLng: _double(j['delivery_lng']),
createdAt: DateTime.tryParse(j['created_at']?.toString() ?? ''), 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 { class FoodDeliveryOffer {
final int orderId; final int orderId;
final int merchantId; final int merchantId;
final String merchantNameAr; final String merchantNameAr;
final String? merchantAddress;
final double? merchantLat; final double? merchantLat;
final double? merchantLng; final double? merchantLng;
final double deliveryLat;
final double deliveryLng;
final int deliveryFee; final int deliveryFee;
final int itemsCount;
final String paymentMethod;
final int cashToCollect;
final int ttlSeconds;
final DateTime receivedAt;
final DateTime? offeredAt; final DateTime? offeredAt;
FoodDeliveryOffer({ FoodDeliveryOffer({
@@ -60,28 +113,206 @@ class FoodDeliveryOffer {
required this.merchantId, required this.merchantId,
required this.merchantNameAr, required this.merchantNameAr,
required this.deliveryFee, required this.deliveryFee,
this.merchantAddress,
this.merchantLat, this.merchantLat,
this.merchantLng, this.merchantLng,
this.deliveryLat = 0,
this.deliveryLng = 0,
this.itemsCount = 0,
this.paymentMethod = 'wallet',
this.cashToCollect = 0,
this.ttlSeconds = 20,
DateTime? receivedAt,
this.offeredAt, this.offeredAt,
}); }) : receivedAt = receivedAt ?? DateTime.now();
bool get isCash => paymentMethod == 'cash';
factory FoodDeliveryOffer.fromJson(Map<String, dynamic> j) => FoodDeliveryOffer( factory FoodDeliveryOffer.fromJson(Map<String, dynamic> j) => FoodDeliveryOffer(
orderId: int.tryParse(j['order_id'].toString()) ?? 0, orderId: _int(j['order_id']),
merchantId: int.tryParse(j['merchant_id'].toString()) ?? 0, merchantId: _int(j['merchant_id']),
merchantNameAr: j['merchant_name_ar']?.toString() ?? '', merchantNameAr: j['merchant_name_ar']?.toString() ?? '',
merchantLat: double.tryParse(j['merchant_lat']?.toString() ?? ''), merchantAddress: j['merchant_address']?.toString(),
merchantLng: double.tryParse(j['merchant_lng']?.toString() ?? ''), merchantLat: _doubleOrNull(j['merchant_lat']),
deliveryFee: int.tryParse(j['delivery_fee']?.toString() ?? '0') ?? 0, merchantLng: _doubleOrNull(j['merchant_lng']),
offeredAt: DateTime.tryParse(j['offered_at']?.toString() ?? ''), 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 { int get secondsRemaining {
if (offeredAt == null) return 20; final start = offeredAt ?? receivedAt;
final elapsed = DateTime.now().difference(offeredAt!).inSeconds; final elapsed = DateTime.now().difference(start).inSeconds;
return (20 - elapsed).clamp(0, 20); return (ttlSeconds - elapsed).clamp(0, ttlSeconds);
} }
bool get isExpired => secondsRemaining <= 0;
}
// صنف داخل الطلب — يراه السائق ليتأكد من محتوى الكيس عند الاستلام من المطعم
class FoodOrderItem {
final String nameAr;
final int quantity;
final int unitPrice;
final int lineTotal;
FoodOrderItem({
required this.nameAr,
required this.quantity,
required this.unitPrice,
required this.lineTotal,
});
factory FoodOrderItem.fromJson(Map<String, dynamic> j) => FoodOrderItem(
nameAr: j['name_ar']?.toString() ?? '',
quantity: _int(j['quantity']),
unitPrice: _int(j['unit_price']),
lineTotal: _int(j['line_total']),
);
}
class FoodOrderDetails {
final FoodDeliveryTask task;
final List<FoodOrderItem> items;
final String? merchantPhone;
FoodOrderDetails({required this.task, required this.items, this.merchantPhone});
factory FoodOrderDetails.fromJson(Map<String, dynamic> j) => FoodOrderDetails(
task: FoodDeliveryTask.fromJson(j),
items: (j['items'] is List)
? (j['items'] as List)
.map((i) => FoodOrderItem.fromJson(Map<String, dynamic>.from(i)))
.toList()
: <FoodOrderItem>[],
merchantPhone: j['merchant_phone']?.toString(),
);
}
class FoodCourierStatus {
final bool deliveryModeEnabled;
final int activeOrdersCount;
final int todayOrdersCount;
final int todayEarnings;
FoodCourierStatus({
required this.deliveryModeEnabled,
this.activeOrdersCount = 0,
this.todayOrdersCount = 0,
this.todayEarnings = 0,
});
factory FoodCourierStatus.fromJson(Map<String, dynamic> j) => FoodCourierStatus(
deliveryModeEnabled: j['delivery_mode_enabled'] == true ||
j['delivery_mode_enabled']?.toString() == '1' ||
j['delivery_mode_enabled']?.toString() == 'true',
activeOrdersCount: _int(j['active_orders_count']),
todayOrdersCount: _int(j['today_orders_count']),
todayEarnings: _int(j['today_earnings']),
);
}
class FoodEarningsDay {
final String day;
final int ordersCount;
final int earnings;
FoodEarningsDay({required this.day, required this.ordersCount, required this.earnings});
factory FoodEarningsDay.fromJson(Map<String, dynamic> j) => FoodEarningsDay(
day: j['day']?.toString() ?? '',
ordersCount: _int(j['orders_count']),
earnings: _int(j['earnings']),
);
}
class FoodEarnings {
final int todayEarnings;
final int todayOrders;
final int weekEarnings;
final int weekOrders;
final int monthEarnings;
final int monthOrders;
final int totalEarnings;
final int totalOrders;
final int pendingCashOwed;
final List<FoodEarningsDay> daily;
FoodEarnings({
required this.todayEarnings,
required this.todayOrders,
required this.weekEarnings,
required this.weekOrders,
required this.monthEarnings,
required this.monthOrders,
required this.totalEarnings,
required this.totalOrders,
required this.pendingCashOwed,
required this.daily,
});
factory FoodEarnings.fromJson(Map<String, dynamic> j) => FoodEarnings(
todayEarnings: _int(j['today_earnings']),
todayOrders: _int(j['today_orders']),
weekEarnings: _int(j['week_earnings']),
weekOrders: _int(j['week_orders']),
monthEarnings: _int(j['month_earnings']),
monthOrders: _int(j['month_orders']),
totalEarnings: _int(j['total_earnings']),
totalOrders: _int(j['total_orders']),
pendingCashOwed: _int(j['pending_cash_owed']),
daily: (j['daily'] is List)
? (j['daily'] as List)
.map((d) => FoodEarningsDay.fromJson(Map<String, dynamic>.from(d)))
.toList()
: <FoodEarningsDay>[],
);
}
class FoodHistoryEntry {
final int id;
final String merchantNameAr;
final String? merchantAddress;
final int deliveryFee;
final int grandTotal;
final String paymentMethod;
final int itemsCount;
final int? rating;
final int? durationMinutes;
final DateTime? deliveredAt;
FoodHistoryEntry({
required this.id,
required this.merchantNameAr,
required this.deliveryFee,
this.merchantAddress,
this.grandTotal = 0,
this.paymentMethod = 'wallet',
this.itemsCount = 0,
this.rating,
this.durationMinutes,
this.deliveredAt,
});
factory FoodHistoryEntry.fromJson(Map<String, dynamic> j) => FoodHistoryEntry(
id: _int(j['id']),
merchantNameAr: j['merchant_name_ar']?.toString() ?? '',
merchantAddress: j['merchant_address']?.toString(),
deliveryFee: _int(j['delivery_fee']),
grandTotal: _int(j['grand_total']),
paymentMethod: j['payment_method']?.toString() ?? 'wallet',
itemsCount: _int(j['items_count']),
rating: j['rating'] == null ? null : _int(j['rating']),
durationMinutes: j['duration_minutes'] == null ? null : _int(j['duration_minutes']),
deliveredAt: _date(j['delivered_at']),
);
} }
const int foodCurrencyDivisor = 1000; const int foodCurrencyDivisor = 1000;
@@ -22,30 +22,66 @@ class FoodDeliveryService {
return FoodDeliveryApiResult(false, null, _errMsg(res)); return FoodDeliveryApiResult(false, null, _errMsg(res));
} }
/// حالة السائق عند فتح التبويب — وضع التوصيل مخزَّن في Redis على الخادم،
/// فلا يجوز افتراض "مغلق" محلياً وإلا ظهر التبديل مطفأً والعروض تصل فعلاً.
static Future<FoodDeliveryApiResult<FoodCourierStatus>> getStatus() async {
final res = await CRUD().post(link: '$_base/courier/status.php');
final msg = _payload(res);
if (msg != null) {
return FoodDeliveryApiResult(true, FoodCourierStatus.fromJson(msg), 'ok');
}
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
static Future<FoodDeliveryApiResult<List<FoodDeliveryTask>>> getActiveTasks() async { static Future<FoodDeliveryApiResult<List<FoodDeliveryTask>>> getActiveTasks() async {
final res = await CRUD().post(link: '$_base/courier/active.php'); final res = await CRUD().post(link: '$_base/courier/active.php');
if (res is Map && res['status'] == 'success') { final msg = _payload(res);
final msg = res['message']; if (msg != null) {
final list = (msg is Map && msg['orders'] is List) return FoodDeliveryApiResult(true, _list(msg['orders'], FoodDeliveryTask.fromJson), 'ok');
? (msg['orders'] as List)
.map((o) => FoodDeliveryTask.fromJson(Map<String, dynamic>.from(o)))
.toList()
: <FoodDeliveryTask>[];
return FoodDeliveryApiResult(true, list, 'ok');
} }
return FoodDeliveryApiResult(false, null, _errMsg(res)); return FoodDeliveryApiResult(false, null, _errMsg(res));
} }
static Future<FoodDeliveryApiResult<List<FoodDeliveryOffer>>> getPendingOffers() async { static Future<FoodDeliveryApiResult<List<FoodDeliveryOffer>>> getPendingOffers() async {
final res = await CRUD().post(link: '$_base/courier/pending_offers.php'); final res = await CRUD().post(link: '$_base/courier/pending_offers.php');
if (res is Map && res['status'] == 'success') { final msg = _payload(res);
final msg = res['message']; if (msg != null) {
final list = (msg is Map && msg['offers'] is List) return FoodDeliveryApiResult(true, _list(msg['offers'], FoodDeliveryOffer.fromJson), 'ok');
? (msg['offers'] as List) }
.map((o) => FoodDeliveryOffer.fromJson(Map<String, dynamic>.from(o))) return FoodDeliveryApiResult(false, null, _errMsg(res));
.toList() }
: <FoodDeliveryOffer>[];
return FoodDeliveryApiResult(true, list, 'ok'); static Future<FoodDeliveryApiResult<FoodOrderDetails>> getOrderDetails(int orderId) async {
final res = await CRUD().post(
link: '$_base/courier/order_details.php',
payload: {'order_id': orderId.toString()},
);
final msg = _payload(res);
if (msg != null && msg['order'] is Map) {
return FoodDeliveryApiResult(
true, FoodOrderDetails.fromJson(Map<String, dynamic>.from(msg['order'])), 'ok');
}
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
static Future<FoodDeliveryApiResult<FoodEarnings>> getEarnings() async {
final res = await CRUD().post(link: '$_base/courier/earnings.php');
final msg = _payload(res);
if (msg != null) return FoodDeliveryApiResult(true, FoodEarnings.fromJson(msg), 'ok');
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
static Future<FoodDeliveryApiResult<List<FoodHistoryEntry>>> getHistory({
int limit = 20,
int offset = 0,
}) async {
final res = await CRUD().post(
link: '$_base/courier/history.php',
payload: {'limit': limit.toString(), 'offset': offset.toString()},
);
final msg = _payload(res);
if (msg != null) {
return FoodDeliveryApiResult(true, _list(msg['orders'], FoodHistoryEntry.fromJson), 'ok');
} }
return FoodDeliveryApiResult(false, null, _errMsg(res)); return FoodDeliveryApiResult(false, null, _errMsg(res));
} }
@@ -55,9 +91,9 @@ class FoodDeliveryService {
link: '$_base/courier/offer_respond.php', link: '$_base/courier/offer_respond.php',
payload: {'order_id': orderId.toString(), 'response': accept ? 'accept' : 'reject'}, payload: {'order_id': orderId.toString(), 'response': accept ? 'accept' : 'reject'},
); );
if (res is Map && res['status'] == 'success') { final msg = _payload(res);
final msg = res['message']; if (msg != null) {
return FoodDeliveryApiResult(true, (msg is Map ? msg['response']?.toString() : null) ?? '', 'ok'); return FoodDeliveryApiResult(true, msg['response']?.toString() ?? '', 'ok');
} }
return FoodDeliveryApiResult(false, null, _errMsg(res)); return FoodDeliveryApiResult(false, null, _errMsg(res));
} }
@@ -80,6 +116,19 @@ class FoodDeliveryService {
return FoodDeliveryApiResult(false, null, _errMsg(res)); return FoodDeliveryApiResult(false, null, _errMsg(res));
} }
// كل ردود backend/food تأتي بغلاف {status, message:{...}} — نستخرج المحتوى مرة واحدة
static Map<String, dynamic>? _payload(dynamic res) {
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
return Map<String, dynamic>.from(res['message']);
}
return null;
}
static List<T> _list<T>(dynamic raw, T Function(Map<String, dynamic>) fromJson) {
if (raw is! List) return <T>[];
return raw.map((e) => fromJson(Map<String, dynamic>.from(e))).toList();
}
static String _errMsg(dynamic res) { static String _errMsg(dynamic res) {
if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت'; if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت';
if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً'; if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً';
@@ -0,0 +1,156 @@
// food_notification_service.dart — إشعارات وحدة التوصيل، منفصلة كلياً عن إشعارات الرحلات
//
// الفصل مقصود على ثلاثة مستويات:
// 1) قناتان أندرويد خاصتان (food_delivery_offer_channel / food_delivery_status_channel)
// غير 'high_importance_channel' الخاص بالرحلات — فيقدر السائق يكتم أو يغيّر
// نغمة التوصيل من إعدادات النظام دون أن يمسّ تنبيه الرحلات إطلاقاً.
// 2) معرّفات إشعارات مستقلة (7001/7002) فلا يستبدل إشعار توصيل إشعارَ رحلة.
// 3) أزرار وحمولة خاصة ('FOOD_ACCEPT'/'FOOD_REJECT' + {"food_event": ...})
// يلتقطها فرع مستقل في handleNotificationResponse.
import 'dart:convert';
import 'dart:ui';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'food_delivery_models.dart';
class FoodNotificationService {
FoodNotificationService._();
static final FoodNotificationService instance = FoodNotificationService._();
// نفس نسخة الإضافة المستخدمة في NotificationController (singleton داخل الحزمة)،
// لكن بقنوات ومعرّفات خاصة بنا — لا نستدعي initialize هنا إطلاقاً حتى لا
// نستبدل معالِج النقر العام الذي هيّأه تطبيق الرحلات عند الإقلاع.
final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin();
static const String offerChannelId = 'food_delivery_offer_channel';
static const String statusChannelId = 'food_delivery_status_channel';
static const int offerNotificationId = 7001;
static const int statusNotificationId = 7002;
bool _channelsReady = false;
Future<void> ensureChannels() async {
if (_channelsReady) return;
final android = _plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (android == null) {
_channelsReady = true; // iOS — لا قنوات
return;
}
const offerChannel = AndroidNotificationChannel(
offerChannelId,
'عروض التوصيل',
description: 'تنبيه وصول عرض توصيل طلب طعام جديد',
importance: Importance.max,
playSound: true,
sound: RawResourceAndroidNotificationSound('order1'),
enableVibration: true,
);
const statusChannel = AndroidNotificationChannel(
statusChannelId,
'تحديثات طلبات التوصيل',
description: 'تغيّر حالة طلب توصيل قائم (جاهز، ملغى، إلخ)',
importance: Importance.high,
playSound: true,
);
await android.createNotificationChannel(offerChannel);
await android.createNotificationChannel(statusChannel);
_channelsReady = true;
}
/// إشعار عرض توصيل — ملء الشاشة (fullScreenIntent) كإشعار المكالمة، لأن مهلة
/// العرض 20 ثانية ولا تحتمل أن يمرّ السائق على شريط الإشعارات لاحقاً.
Future<void> showOfferNotification(FoodDeliveryOffer offer) async {
await ensureChannels();
final body = '${offer.merchantNameAr}\n'
'💰 أجرة التوصيل: ${foodFormatPrice(offer.deliveryFee)}'
'${offer.itemsCount > 0 ? ' | 🧾 ${offer.itemsCount} صنف' : ''}'
'${offer.cashToCollect > 0 ? '\n💵 تحصيل نقدي: ${foodFormatPrice(offer.cashToCollect)}' : ''}';
final androidDetails = AndroidNotificationDetails(
offerChannelId,
'عروض التوصيل',
importance: Importance.max,
priority: Priority.max,
fullScreenIntent: true,
category: AndroidNotificationCategory.call,
visibility: NotificationVisibility.public,
timeoutAfter: 20000, // يختفي مع انتهاء مهلة العرض نفسها
sound: const RawResourceAndroidNotificationSound('order1'),
audioAttributesUsage: AudioAttributesUsage.alarm,
color: const Color(0xFFFF6B35),
styleInformation: BigTextStyleInformation(
body,
contentTitle: '🍔 عرض توصيل جديد',
summaryText: foodFormatPrice(offer.deliveryFee),
),
actions: const <AndroidNotificationAction>[
AndroidNotificationAction('FOOD_ACCEPT', '✅ قبول',
showsUserInterface: true, titleColor: Color(0xFF4CAF50)),
AndroidNotificationAction('FOOD_REJECT', '❌ رفض',
cancelNotification: true, titleColor: Color(0xFFE53935)),
],
);
const iosDetails = DarwinNotificationDetails(
sound: 'order1.wav',
presentAlert: true,
presentBadge: true,
presentSound: true,
categoryIdentifier: 'FOOD_OFFER_CATEGORY',
interruptionLevel: InterruptionLevel.timeSensitive,
);
await _plugin.show(
id: offerNotificationId,
title: '🍔 عرض توصيل جديد',
body: '${offer.merchantNameAr} — ${foodFormatPrice(offer.deliveryFee)}',
notificationDetails:
NotificationDetails(android: androidDetails, iOS: iosDetails),
payload: jsonEncode({
'food_event': 'offer',
'order_id': offer.orderId,
}),
);
}
Future<void> cancelOfferNotification() async {
await _plugin.cancel(id: offerNotificationId);
}
/// تحديث حالة مهمة قائمة (مثلاً إلغاء من النظام) — قناة أهدأ، بلا ملء شاشة
Future<void> showStatusNotification({
required int orderId,
required String title,
required String body,
}) async {
await ensureChannels();
const androidDetails = AndroidNotificationDetails(
statusChannelId,
'تحديثات طلبات التوصيل',
importance: Importance.high,
priority: Priority.high,
color: Color(0xFFFF6B35),
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
);
await _plugin.show(
id: statusNotificationId,
title: title,
body: body,
notificationDetails:
const NotificationDetails(android: androidDetails, iOS: iosDetails),
payload: jsonEncode({'food_event': 'status', 'order_id': orderId}),
);
}
}
@@ -0,0 +1,169 @@
// food_socket_service.dart — قناة سوكيت مستقلة تماماً لوحدة التوصيل
//
// لماذا سوكيت منفصل ولا نركب على سوكيت الرحلات (LocationController.socket)؟
// • خادمان منفصلان فعلاً: food_socket.php (4040) مقابل driver_socket (2020)،
// وغرفة السائق هناك courier_food_{id} بمصادقة JWT مختلفة الاستعلام.
// • عزل الأعطال: سقوط سوكيت الطعام لا يقطع توزيع الرحلات، وهو المسار الحرج.
// • دورة حياة مختلفة: نتصل فقط أثناء «وضع التوصيل» وننفصل عند إطفائه،
// بينما سوكيت الرحلات يبقى حياً طوال مناوبة السائق.
import 'dart:async';
import 'dart:io';
import 'package:socket_io_client/socket_io_client.dart' as IO;
import '../../constant/box_name.dart';
import '../../constant/links.dart';
import '../../main.dart';
import '../../print.dart';
typedef FoodOfferHandler = void Function(Map<String, dynamic> payload);
typedef FoodOrderUpdateHandler = void Function(Map<String, dynamic> payload);
class FoodSocketService {
FoodSocketService._();
static final FoodSocketService instance = FoodSocketService._();
IO.Socket? _socket;
bool _isConnecting = false;
Timer? _heartbeat;
FoodOfferHandler? onOffer;
FoodOrderUpdateHandler? onOrderUpdate;
FoodOrderUpdateHandler? onIncomingCall;
void Function(bool connected)? onConnectionChanged;
bool get isConnected => _socket?.connected == true;
Future<void> connect() async {
if (isConnected || _isConnecting) return;
final driverId = box.read(BoxName.driverID)?.toString() ?? '';
final token = box.read(BoxName.tokenDriver)?.toString() ?? '';
if (driverId.isEmpty || token.isEmpty) {
Log.print('🍔 [FoodSocket] لا يوجد driverID/token — تخطّي الاتصال');
return;
}
_isConnecting = true;
_disposeSocket();
try {
_socket = IO.io(
AppLink.foodSocketUrl,
IO.OptionBuilder()
.setTransports(['websocket'])
// food_socket.php يتحقق: role=driver ويطابق user_id داخل الـ JWT مع id
.setQuery({
'role': 'driver',
'id': driverId,
'jwt': token,
'platform': Platform.isIOS ? 'ios' : 'android',
})
.enableForceNew()
.enableReconnection()
.setReconnectionDelay(2000)
.build(),
);
_bindListeners();
_socket!.connect();
} catch (e) {
_isConnecting = false;
Log.print('❌ [FoodSocket] فشل التهيئة: $e');
}
}
void _bindListeners() {
final s = _socket;
if (s == null) return;
s.onConnect((_) {
_isConnecting = false;
Log.print('✅ [FoodSocket] متصل — غرفة courier_food');
onConnectionChanged?.call(true);
_startHeartbeat();
});
s.onDisconnect((_) {
Log.print('⚠️ [FoodSocket] انقطع الاتصال');
onConnectionChanged?.call(false);
_stopHeartbeat();
});
s.onConnectError((e) {
_isConnecting = false;
Log.print('❌ [FoodSocket] خطأ اتصال: $e');
onConnectionChanged?.call(false);
});
s.onError((e) => Log.print('❌ [FoodSocket] خطأ: $e'));
// عرض توصيل جديد — الحمولة كاملة من foodBuildCourierOfferPayload،
// فشاشة العرض تُبنى فوراً بلا نداء HTTP إضافي (المهلة 20 ثانية فقط).
s.on('food_delivery_offer', (data) {
final map = _asMap(data);
if (map == null) return;
Log.print('🍔 [FoodSocket] عرض توصيل #${map['order_id']}');
onOffer?.call(map);
});
// مكالمة واردة من الزبون عبر القناة المقنّعة — لا رقم هاتف في الحمولة،
// فقط session_id ينضم به الطرفان لخادم الإشارات.
s.on('food_incoming_call', (data) {
final map = _asMap(data);
if (map == null) return;
Log.print('🍔 [FoodSocket] مكالمة واردة على الطلب #${map['order_id']}');
onIncomingCall?.call(map);
});
// تحديث حالة طلب قائم (المطعم ألغى، النظام ألغى، إلخ)
s.on('food_order_update', (data) {
final map = _asMap(data);
if (map == null) return;
Log.print('🍔 [FoodSocket] تحديث طلب #${map['order_id']} → ${map['status']}');
onOrderUpdate?.call(map);
});
}
// السوكيت ناقل إشعار لا مخزن حالة — أي حمولة غير مفهومة تُهمَل بصمت
// ويصحّح التطبيق نفسه من courier/active.php.
Map<String, dynamic>? _asMap(dynamic data) {
try {
if (data is Map) return Map<String, dynamic>.from(data);
if (data is List && data.isNotEmpty && data.first is Map) {
return Map<String, dynamic>.from(data.first as Map);
}
} catch (e) {
Log.print('❌ [FoodSocket] حمولة غير صالحة: $e');
}
return null;
}
void _startHeartbeat() {
_stopHeartbeat();
_heartbeat = Timer.periodic(const Duration(seconds: 25), (_) {
if (isConnected) _socket!.emit('heartbeat', {});
});
}
void _stopHeartbeat() {
_heartbeat?.cancel();
_heartbeat = null;
}
void _disposeSocket() {
if (_socket == null) return;
_socket!.clearListeners();
_socket!.dispose();
_socket = null;
}
void disconnect() {
_stopHeartbeat();
_isConnecting = false;
_socket?.disconnect();
_disposeSocket();
onConnectionChanged?.call(false);
Log.print('🍔 [FoodSocket] تم قطع الاتصال (وضع التوصيل مطفأ)');
}
}
@@ -11,6 +11,7 @@ import '../../constant/links.dart';
import '../../main.dart'; import '../../main.dart';
import '../../print.dart'; import '../../print.dart';
import '../../services/signaling_service.dart'; import '../../services/signaling_service.dart';
import '../../services/turn_credentials_service.dart';
import '../../views/widgets/voice_call_bottom_sheet.dart'; import '../../views/widgets/voice_call_bottom_sheet.dart';
import 'functions/crud.dart'; import 'functions/crud.dart';
@@ -229,11 +230,16 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver {
// EN: Initiates an outgoing call. // EN: Initiates an outgoing call.
// AR: يبدأ مكالمة صادرة. // AR: يبدأ مكالمة صادرة.
/// [sessionEndpoint] و [sessionPayload] يسمحان لوحدات أخرى (توصيل الطعام)
/// بإعادة استخدام نفس قناة WebRTC المقنّعة بواجهة إنشاء جلسة خاصة بها،
/// بدل تكرار منطق الإشارات والصوت. القيمة الافتراضية هي مكالمة الرحلة.
Future<void> startCall({ Future<void> startCall({
required String rideIdVal, required String rideIdVal,
required String driverId, required String driverId,
required String passengerId, required String passengerId,
required String remoteNameVal, required String remoteNameVal,
String? sessionEndpoint,
Map<String, String>? sessionPayload,
}) async { }) async {
if (state.value != VoiceCallState.idle) return; 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. // 2. EN: Call PHP Backend to create Node.js session & notify Passenger via FCM.
// AR: استدعاء واجهة PHP لإنشاء الجلسة على Node.js وإشعار الراكب عبر FCM. // AR: استدعاء واجهة PHP لإنشاء الجلسة على Node.js وإشعار الراكب عبر FCM.
final response = await CRUD().post( final response = await CRUD().post(
link: "${AppLink.server}/ride/call/driver/create_call_session.php", link: sessionEndpoint ??
payload: {'ride_id': rideIdVal}, "${AppLink.server}/ride/call/driver/create_call_session.php",
payload: sessionPayload ?? {'ride_id': rideIdVal},
); );
if (response == null || if (response == null ||
@@ -281,8 +288,16 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver {
return; return;
} }
final data = response['data']; // واجهات الرحلات تُعيد الجلسة في 'data'، وواجهات وحدة الطعام تُعيدها في
sessionId.value = data['session_id']; // '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: الاتصال بخادم الإشارات // 3. EN: Connect to WebRTC signaling server / AR: الاتصال بخادم الإشارات
await _signaling.connect(sessionId.value, currentUserId); 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([ iceServers.addAll([
{"urls": "stun:stun.l.google.com:19302"}, {"urls": "stun:stun.l.google.com:19302"},
{"urls": "stun:stun1.l.google.com:19302"}, {"urls": "stun:stun1.l.google.com:19302"},
@@ -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<List<Map<String, dynamic>>> 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>()
.map((s) => Map<String, dynamic>.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<Map<String, dynamic>>? _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>()
.map((s) => Map<String, dynamic>.from(s))
.toList();
return servers.isEmpty ? null : servers;
} catch (_) {
return null;
}
}
static void clearCache() => box.remove(_cacheKey);
}
@@ -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<FoodDeliveryEarningsPage> createState() => _FoodDeliveryEarningsPageState();
}
class _FoodDeliveryEarningsPageState extends State<FoodDeliveryEarningsPage> {
FoodEarnings? earnings;
List<FoodHistoryEntry> history = [];
bool isLoading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final results = await Future.wait([
FoodDeliveryService.getEarnings(),
FoodDeliveryService.getHistory(limit: 30),
]);
if (!mounted) return;
setState(() {
isLoading = false;
earnings = (results[0] as FoodDeliveryApiResult<FoodEarnings>).data;
history = (results[1] as FoodDeliveryApiResult<List<FoodHistoryEntry>>).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<Widget> _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)),
],
),
);
}
}
@@ -1,4 +1,6 @@
// food_delivery_home_page.dart — تبويب التوصيل: تفعيل وضع التوصيل، عروض واردة، مهام نشطة // food_delivery_home_page.dart — تبويب التوصيل: وضع التوصيل، أرباح اليوم، مهام نشطة
// شاشة العرض الوارد مستقلة (FoodOfferPage) وتُفتح تلقائياً من الكنترولر عند وصول
// عرض من سوكيت الطعام — هذه الشاشة للإدارة والمتابعة فقط.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.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 '../../controller/food_delivery/food_delivery_models.dart';
import '../widgets/elevated_btn.dart'; import '../widgets/elevated_btn.dart';
import '../widgets/my_scafold.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 { class FoodDeliveryHomePage extends StatelessWidget {
const FoodDeliveryHomePage({super.key}); const FoodDeliveryHomePage({super.key});
@@ -18,16 +23,25 @@ class FoodDeliveryHomePage extends StatelessWidget {
return GetBuilder<FoodDeliveryController>( return GetBuilder<FoodDeliveryController>(
builder: (c) => MyScafolld( builder: (c) => MyScafolld(
title: 'Delivery'.tr, title: 'التوصيل',
isleading: true, isleading: true,
action: IconButton(
icon: Icon(Icons.account_balance_wallet_rounded, color: AppColor.accentColor),
tooltip: 'أرباح التوصيل',
onPressed: () => Get.to(() => const FoodDeliveryEarningsPage()),
),
body: [ body: [
Column( Column(
children: [ children: [
_modeToggleBar(c), _modeToggleBar(c),
if (c.pendingOffers.isNotEmpty) _offersSection(c), _todayStrip(c),
if (c.pendingOffers.isNotEmpty) _offersBanner(c),
Expanded( Expanded(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: c.fetchActiveTasks, onRefresh: () async {
await c.refreshStatus();
await c.fetchActiveTasks();
},
child: c.isLoadingTasks && c.activeTasks.isEmpty child: c.isLoadingTasks && c.activeTasks.isEmpty
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: c.activeTasks.isEmpty : c.activeTasks.isEmpty
@@ -48,12 +62,14 @@ class FoodDeliveryHomePage extends StatelessWidget {
Widget _modeToggleBar(FoodDeliveryController c) { Widget _modeToggleBar(FoodDeliveryController c) {
return Container( return Container(
margin: const EdgeInsets.all(16), margin: const EdgeInsets.fromLTRB(16, 16, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColor.cardColor, color: AppColor.cardColor,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColor.borderColor), border: Border.all(
color: c.isDeliveryModeEnabled ? AppColor.greenColor : AppColor.borderColor,
),
), ),
child: Row( child: Row(
children: [ children: [
@@ -66,11 +82,27 @@ class FoodDeliveryHomePage extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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( Text(
c.isDeliveryModeEnabled c.isDeliveryModeEnabled
? 'You will receive delivery offers'.tr ? (c.isSocketConnected
: 'Turn on to receive delivery offers'.tr, ? 'متصل — ستصلك عروض التوصيل فوراً'
: 'إعادة الاتصال… العروض تصل بالتحديث الدوري')
: 'فعّله لاستقبال عروض توصيل الطعام',
style: AppStyle.subtitle, style: AppStyle.subtitle,
), ),
], ],
@@ -88,80 +120,62 @@ class FoodDeliveryHomePage extends StatelessWidget {
); );
} }
Widget _offersSection(FoodDeliveryController c) { Widget _todayStrip(FoodDeliveryController c) {
return SizedBox( return Container(
height: 150, margin: const EdgeInsets.symmetric(horizontal: 16),
child: ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
scrollDirection: Axis.horizontal, decoration: BoxDecoration(
padding: const EdgeInsets.symmetric(horizontal: 12), color: AppColor.cardColor,
itemCount: c.pendingOffers.length, borderRadius: BorderRadius.circular(12),
itemBuilder: (_, i) => _offerCard(c, c.pendingOffers[i]), 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) { Widget _miniStat(String label, String value) {
final isResponding = c.isRespondingToOffer(offer.orderId); return Column(
return Container( children: [
width: 260, Text(label, style: AppStyle.subtitle),
margin: const EdgeInsets.symmetric(horizontal: 4), 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), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColor.primaryColor, gradient: const LinearGradient(colors: [Color(0xFFFF6B35), Color(0xFFF7931E)]),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.15), blurRadius: 12, offset: const Offset(0, 4))],
), ),
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( const Icon(Icons.notifications_active_rounded, color: Colors.white),
children: [ const SizedBox(width: 10),
const Icon(Icons.notifications_active_rounded, color: Colors.white, size: 18),
const SizedBox(width: 6),
Expanded( Expanded(
child: Text('New Delivery Offer'.tr, child: Text(
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), 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)), ),
Text('${c.pendingOffers.first.secondsRemaining}s',
style: const TextStyle(color: Colors.white, 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),
),
),
),
],
),
],
), ),
); );
} }
@@ -172,13 +186,20 @@ class FoodDeliveryHomePage extends StatelessWidget {
const SizedBox(height: 100), const SizedBox(height: 100),
Icon(Icons.moped_outlined, size: 72, color: AppColor.grayColor), Icon(Icons.moped_outlined, size: 72, color: AppColor.grayColor),
const SizedBox(height: 16), 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) { Widget _taskCard(FoodDeliveryController c, FoodDeliveryTask task) {
final isPickedUp = task.status == 'picked_up';
final isBusy = c.isTaskBusy(task.id); final isBusy = c.isTaskBusy(task.id);
return Card( return Card(
@@ -187,8 +208,14 @@ class FoodDeliveryHomePage extends StatelessWidget {
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14), 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: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => Get.to(() => FoodTaskDetailsPage(orderId: task.id)),
child: Padding( child: Padding(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
child: Column( child: Column(
@@ -196,12 +223,14 @@ class FoodDeliveryHomePage extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
Icon(isPickedUp ? Icons.location_on_rounded : Icons.storefront_rounded, Icon(task.isPickedUp ? Icons.location_on_rounded : Icons.storefront_rounded,
color: AppColor.accentColor), color: AppColor.accentColor),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
isPickedUp ? (task.deliveryAddress ?? '') : task.merchantNameAr, task.isPickedUp
? (task.deliveryAddress ?? 'عنوان الزبون')
: task.merchantNameAr,
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold), style: AppStyle.title.copyWith(fontWeight: FontWeight.bold),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -212,9 +241,22 @@ class FoodDeliveryHomePage extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
isPickedUp ? 'Deliver to customer'.tr : (task.merchantAddress ?? ''), task.isPickedUp
? 'في الطريق للزبون — ${task.itemsCount} صنف'
: (task.merchantAddress ?? 'استلم الطلب من المطعم'),
style: AppStyle.subtitle, 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), const SizedBox(height: 12),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@@ -222,14 +264,17 @@ class FoodDeliveryHomePage extends StatelessWidget {
child: isBusy child: isBusy
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: MyElevatedButton( : MyElevatedButton(
title: isPickedUp ? 'Mark Delivered'.tr : 'Picked Up from Restaurant'.tr, title: task.isPickedUp ? 'تأكيد التسليم' : 'استلمت الطلب من المطعم',
kolor: isPickedUp ? AppColor.greenColor : AppColor.accentColor, kolor: task.isPickedUp ? AppColor.greenColor : AppColor.accentColor,
onPressed: () => isPickedUp ? c.markDelivered(task.id) : c.markPickedUp(task.id), onPressed: () => task.isPickedUp
? c.markDelivered(task.id)
: c.markPickedUp(task.id),
), ),
), ),
], ],
), ),
), ),
),
); );
} }
} }
@@ -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<FoodDeliveryController>(
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<LocationController>()) {
final loc = Get.find<LocationController>().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)),
),
),
),
],
),
);
}
}
@@ -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<FoodTaskDetailsPage> createState() => _FoodTaskDetailsPageState();
}
class _FoodTaskDetailsPageState extends State<FoodTaskDetailsPage> {
FoodOrderDetails? details;
bool isLoading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<void> _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<void> _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<Widget> _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<FoodDeliveryController>().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<FoodOrderItem> 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<FoodDeliveryController>();
return GetBuilder<FoodDeliveryController>(
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();
}
},
),
);
},
);
}
}
+6 -6
View File
@@ -1521,10 +1521,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.19" version: "0.12.18"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -1537,10 +1537,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.17.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@@ -2125,10 +2125,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.9"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
+3
View File
@@ -114,4 +114,7 @@ class BoxName {
static const String isDestinationMatch = static const String isDestinationMatch =
'isDestinationMatch'; // 🆕 AI Destination Matching 'isDestinationMatch'; // 🆕 AI Destination Matching
static const String isPrime = 'isPrime'; // 👑 Siro Prime subscription status static const String isPrime = 'isPrime'; // 👑 Siro Prime subscription status
// بيانات اعتماد TURN المؤقتة (مكالمات الصوت) — مخزّنة حتى قرب انتهائها
static const String turnIceCache = 'turnIceCache';
} }
@@ -12,6 +12,7 @@ import '../../constant/links.dart';
import '../../main.dart'; import '../../main.dart';
import '../../print.dart'; import '../../print.dart';
import '../../services/signaling_service.dart'; import '../../services/signaling_service.dart';
import '../../services/turn_credentials_service.dart';
import '../../views/widgets/voice_call_bottom_sheet.dart'; import '../../views/widgets/voice_call_bottom_sheet.dart';
import 'functions/crud.dart'; import 'functions/crud.dart';
@@ -231,11 +232,16 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver {
// EN: Initiates an outgoing call. // EN: Initiates an outgoing call.
// AR: يبدأ مكالمة صادرة. // AR: يبدأ مكالمة صادرة.
/// [sessionEndpoint] و [sessionPayload] يسمحان لوحدات أخرى (طلبات الطعام)
/// بإعادة استخدام نفس قناة WebRTC المقنّعة بواجهة إنشاء جلسة خاصة بها،
/// بدل تكرار منطق الإشارات والصوت. القيمة الافتراضية هي مكالمة الرحلة.
Future<void> startCall({ Future<void> startCall({
required String rideIdVal, required String rideIdVal,
required String driverId, required String driverId,
required String passengerId, required String passengerId,
required String remoteNameVal, required String remoteNameVal,
String? sessionEndpoint,
Map<String, String>? sessionPayload,
}) async { }) async {
if (state.value != VoiceCallState.idle) return; 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. // 2. EN: Call PHP Backend to create Node.js session & notify Driver via FCM.
// AR: استدعاء واجهة PHP لإنشاء الجلسة على Node.js وإشعار السائق عبر FCM. // AR: استدعاء واجهة PHP لإنشاء الجلسة على Node.js وإشعار السائق عبر FCM.
final response = await CRUD().post( final response = await CRUD().post(
link: "${AppLink.server}/ride/call/passenger/create_call_session.php", link: sessionEndpoint ??
payload: {'ride_id': rideIdVal}, "${AppLink.server}/ride/call/passenger/create_call_session.php",
payload: sessionPayload ?? {'ride_id': rideIdVal},
); );
if (response == null || if (response == null ||
@@ -278,8 +285,15 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver {
return; return;
} }
final data = response['data']; // واجهات الرحلات تُعيد الجلسة في 'data'، وواجهات وحدة الطعام تُعيدها في
sessionId.value = data['session_id']; // '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: الاتصال بخادم الإشارات // 3. EN: Connect to WebRTC signaling server / AR: الاتصال بخادم الإشارات
await _signaling.connect(sessionId.value, currentUserId); 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 الاحتياطية // EN: Fallback STUN servers / AR: خوادم STUN الاحتياطية
iceServers.addAll([ iceServers.addAll([
{"urls": "stun:stun.l.google.com:19302"}, {"urls": "stun:stun.l.google.com:19302"},
@@ -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<List<Map<String, dynamic>>> 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>()
.map((s) => Map<String, dynamic>.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<Map<String, dynamic>>? _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>()
.map((s) => Map<String, dynamic>.from(s))
.toList();
return servers.isEmpty ? null : servers;
} catch (_) {
return null;
}
}
static void clearCache() => box.remove(_cacheKey);
}
@@ -4,9 +4,11 @@ import 'package:get/get.dart';
import '../../constant/box_name.dart'; import '../../constant/box_name.dart';
import '../../constant/colors.dart'; import '../../constant/colors.dart';
import '../../constant/links.dart';
import '../../constant/style.dart'; import '../../constant/style.dart';
import '../../main.dart'; import '../../main.dart';
import '../../controller/food/food_controller.dart'; import '../../controller/food/food_controller.dart';
import '../../controller/voice_call_controller.dart';
import '../../controller/food/food_models.dart'; import '../../controller/food/food_models.dart';
import '../widgets/my_scafold.dart'; import '../widgets/my_scafold.dart';
import 'food_home_page.dart'; import 'food_home_page.dart';
@@ -127,7 +129,27 @@ class _FoodOrderTrackingPageState extends State<FoodOrderTrackingPage> {
Text(foodFormatPrice(order.grandTotal), style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)), 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') if (order.status == 'pending')
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@@ -172,6 +194,27 @@ class _FoodOrderTrackingPageState extends State<FoodOrderTrackingPage> {
); );
} }
// القناة نفسها المستعملة في مكالمات الرحلات (WebRTC عبر خادم الإشارات)،
// لكن الجلسة تُنشأ من واجهة الطعام التي تتحقق أن الطلب لي وأنه قيد التوصيل.
Future<void> _callCourier(int orderId) async {
// مسجَّل عالمياً بـ lazyPut(fenix) في app_bindings — والـ put احتياط
VoiceCallController voiceCtrl;
try {
voiceCtrl = Get.find<VoiceCallController>();
} 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) { Widget _statusTimeline(String currentStatus) {
final currentIndex = foodOrderStatusFlow.indexOf(currentStatus); final currentIndex = foodOrderStatusFlow.indexOf(currentStatus);
return Column( return Column(
+6 -6
View File
@@ -1297,10 +1297,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.19" version: "0.12.18"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -1313,10 +1313,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.17.0"
mime: mime:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1901,10 +1901,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.9"
timezone: timezone:
dependency: transitive dependency: transitive
description: description: