Update: 2026-08-02 17:52:28

This commit is contained in:
Hamza-Ayed
2026-08-02 17:52:28 +03:00
parent b78a6797d5
commit 4620e84d34
96 changed files with 6250 additions and 476 deletions
+12 -3
View File
@@ -156,7 +156,9 @@ class JwtService
// 3. Issuer (Only check if configured)
if (!empty($this->issuer) && ($decoded->iss ?? '') !== $this->issuer) {
self::abort(401, 'Invalid token issuer: expected ' . $this->issuer . ' but got ' . ($decoded->iss ?? 'none'));
// التفاصيل في اللوج فقط — لا تُكشف في الرد.
error_log('[SECURITY] Issuer mismatch | expected: ' . $this->issuer . ' | got: ' . ($decoded->iss ?? 'none'));
self::abort(401, 'Invalid token issuer');
}
// 3.1 App Signature Verification (Service Only)
@@ -228,8 +230,15 @@ class JwtService
}
if ($fpInToken === null || $fpHeader === null) {
$allHeaders = json_encode(getallheaders());
error_log("[SECURITY] Fingerprint missing | user: $userId | fpInToken: " . ($fpInToken ?? 'NULL') . " | fpHeader: " . ($fpHeader ?? 'NULL') . " | Headers: $allHeaders");
// ملاحظة: ممنوع تسجيل الهيدرز كاملة — كانت تُسرّب الـ
// Authorization: Bearer <token> بالنص الصريح إلى error_log.
// نسجّل فقط أيّ الطرفين ناقص، دون أي قيمة.
error_log(sprintf(
"[SECURITY] Fingerprint missing | user: %s | inToken: %s | inHeader: %s",
$userId,
$fpInToken === null ? 'no' : 'yes',
$fpHeader === null ? 'no' : 'yes'
));
self::abort(403, 'Device verification required');
}
+6
View File
@@ -34,6 +34,12 @@ class Database
'user' => 'DB_TRANSIT_USER',
'pass' => 'DB_TRANSIT_PASS',
],
'food' => [
'name' => 'DB_FOOD_NAME',
'host' => 'DB_FOOD_HOST',
'user' => 'DB_FOOD_USER',
'pass' => 'DB_FOOD_PASS',
],
];
public static function get(string $name = 'main'): PDO
+23
View File
@@ -0,0 +1,23 @@
<?php
// food/admin/merchant_approve.php — اعتماد أو رفض مطعم بحالة pending_approval
require_once __DIR__ . '/../connect_admin.php';
$merchantId = filterRequest('merchant_id', 'int');
$action = filterRequest('action'); // 'approve' | 'reject'
if (!$merchantId || !in_array($action, ['approve', 'reject'], true)) {
jsonError('merchant_id and action (approve|reject) are required');
}
$st = $food_con->prepare("SELECT id, status FROM food_merchants WHERE id=? LIMIT 1");
$st->execute([$merchantId]);
$merchant = $st->fetch();
if (!$merchant) jsonError('Merchant not found', 404);
if ($merchant['status'] !== 'pending_approval') jsonError('Merchant is not pending approval', 409);
$newStatus = $action === 'approve' ? 'active' : 'rejected';
$food_con->prepare(
"UPDATE food_merchants SET status=?, approved_by=?, approved_at=NOW() WHERE id=?"
)->execute([$newStatus, $food_admin_id, $merchantId]);
jsonSuccess(['merchant_id' => $merchantId, 'status' => $newStatus]);
+48
View File
@@ -0,0 +1,48 @@
<?php
// food/admin/merchant_create.php — إنشاء مطعم جديد بحالة pending_approval + حساب مالكه
require_once __DIR__ . '/../connect_admin.php';
requireFoodFields(['name_ar', 'city', 'address', 'latitude', 'longitude', 'owner_name', 'owner_phone', 'owner_password']);
$nameAr = filterRequest('name_ar');
$nameEn = filterRequest('name_en');
$city = filterRequest('city');
$address = filterRequest('address');
$lat = filterRequest('latitude', 'float');
$lng = filterRequest('longitude', 'float');
$category = filterRequest('category');
$commission = filterRequest('commission_percent', 'float') ?? (float)(getenv('FOOD_COMMISSION_PERCENT') ?: 15);
$ownerName = filterRequest('owner_name');
$ownerPhone = normalizePhone(filterRequest('owner_phone'));
$ownerPassword = filterRequest('owner_password');
if (strlen($ownerPassword) < 8) jsonError('owner_password must be at least 8 characters');
$dupSt = $food_con->prepare("SELECT id FROM food_merchant_users WHERE phone=? LIMIT 1");
$dupSt->execute([$ownerPhone]);
if ($dupSt->fetch()) jsonError('A merchant account already uses this phone', 409);
$food_con->beginTransaction();
try {
$food_con->prepare(
"INSERT INTO food_merchants
(name_ar, name_en, city, address, latitude, longitude, category, commission_percent, status)
VALUES (?,?,?,?,?,?,?,?,'pending_approval')"
)->execute([$nameAr, $nameEn, $city, $address, $lat, $lng, $category, $commission]);
$merchantId = (int)$food_con->lastInsertId();
$food_con->prepare(
"INSERT INTO food_merchant_users (merchant_id, name, phone, role, password_hash)
VALUES (?,?,?,'owner',?)"
)->execute([$merchantId, $ownerName, $ownerPhone, password_hash($ownerPassword, PASSWORD_DEFAULT)]);
$food_con->commit();
} catch (Throwable $e) {
$food_con->rollBack();
appLog('[FOOD][ADMIN][merchant_create] ' . $e->getMessage(), 'ERROR');
jsonError('Failed to create merchant', 500);
}
jsonSuccess(['merchant_id' => $merchantId, 'status' => 'pending_approval'], 'Merchant created — awaiting approval');
+22
View File
@@ -0,0 +1,22 @@
<?php
// food/admin/merchants.php — قائمة المطاعم لإدارة سيرو (فلترة حسب الحالة)
require_once __DIR__ . '/../connect_admin.php';
$status = filterRequest('status') ?: 'all';
$allowed = ['all', 'pending_approval', 'active', 'paused', 'suspended', 'rejected'];
if (!in_array($status, $allowed, true)) $status = 'all';
$sql = "SELECT id, name_ar, name_en, city, category, status, commission_percent,
rating_avg, rating_count, created_at, approved_at
FROM food_merchants";
$params = [];
if ($status !== 'all') {
$sql .= " WHERE status=?";
$params[] = $status;
}
$sql .= " ORDER BY created_at DESC";
$st = $food_con->prepare($sql);
$st->execute($params);
jsonSuccess(['merchants' => $st->fetchAll()]);
+39
View File
@@ -0,0 +1,39 @@
<?php
// food/admin/payouts.php — تقرير تسويات المطاعم للفترة المطلوبة (توليد لا صرف آلي)
//
// ⚠️ هذا يُنتج تقريراً محاسبياً (food_merchant_payouts بحالة pending) — لا يحوّل
// أموالاً فعلياً. الصرف الفعلي للمطاعم والسائقين خارج نطاق هذه الوحدة حتى يُؤكَّد
// عقد S2S مخصص لذلك (انظر التنبيهات في food/functions.php حول foodWalletMove).
require_once __DIR__ . '/../connect_admin.php';
$merchantId = filterRequest('merchant_id', 'int');
$periodStart = filterRequest('period_start');
$periodEnd = filterRequest('period_end');
requireFoodFields(['merchant_id', 'period_start', 'period_end']);
$st = $food_con->prepare(
"SELECT COUNT(*) orders_count, COALESCE(SUM(items_total),0) gross_amount, COALESCE(SUM(commission_amount),0) commission_amount
FROM food_orders
WHERE merchant_id=? AND status='delivered' AND delivered_at BETWEEN ? AND ?"
);
$st->execute([$merchantId, $periodStart, $periodEnd]);
$summary = $st->fetch();
$netPayout = (int)$summary['gross_amount'] - (int)$summary['commission_amount'];
$food_con->prepare(
"INSERT INTO food_merchant_payouts (merchant_id, period_start, period_end, orders_count, gross_amount, commission_amount, net_payout, status)
VALUES (?,?,?,?,?,?,?,'pending')"
)->execute([
$merchantId, $periodStart, $periodEnd, $summary['orders_count'],
$summary['gross_amount'], $summary['commission_amount'], $netPayout,
]);
jsonSuccess([
'merchant_id' => $merchantId,
'orders_count' => (int)$summary['orders_count'],
'gross_amount' => (int)$summary['gross_amount'],
'commission_amount' => (int)$summary['commission_amount'],
'net_payout' => $netPayout,
'status' => 'pending',
], 'Payout report generated');
+110
View File
@@ -0,0 +1,110 @@
<?php
// food/cart/quote.php — يحسب السعر من الخادم فقط ويوقّعه لمدة 10 دقائق
// body: merchant_id, items:[{item_id, quantity, options:[{option_group_id, choice_ids:[]}]}]
require_once __DIR__ . '/../connect_app.php';
$merchantId = filterRequest('merchant_id', 'int');
$itemsRaw = json_decode(filterRequest('items') ?? '[]', true);
if (!$merchantId || !$itemsRaw || !is_array($itemsRaw)) {
jsonError('merchant_id and items are required');
}
$merchantSt = $food_con->prepare(
"SELECT id, min_order_amount, is_open_override, working_hours FROM food_merchants WHERE id=? AND status='active' LIMIT 1"
);
$merchantSt->execute([$merchantId]);
$merchant = $merchantSt->fetch();
if (!$merchant) jsonError('Merchant not found', 404);
if (!foodIsMerchantOpen($merchant)) jsonError('Merchant is currently closed', 409);
$lines = [];
$itemsTotal = 0;
foreach ($itemsRaw as $line) {
$itemId = (int)($line['item_id'] ?? 0);
$quantity = max(1, (int)($line['quantity'] ?? 1));
if (!$itemId) jsonError('Invalid item in cart');
$itemSt = $food_con->prepare(
"SELECT id, merchant_id, name_ar, price, is_available FROM food_menu_items WHERE id=? LIMIT 1"
);
$itemSt->execute([$itemId]);
$item = $itemSt->fetch();
if (!$item || (int)$item['merchant_id'] !== $merchantId) jsonError("Item $itemId does not belong to this merchant", 400);
if (!$item['is_available']) jsonError("{$item['name_ar']} is currently unavailable", 409);
$optionsSt = $food_con->prepare("SELECT id, choices, is_required, max_select FROM food_item_options WHERE item_id=?");
$optionsSt->execute([$itemId]);
$optionGroups = $optionsSt->fetchAll();
$chosenOptionsOut = [];
$optionsTotalPerUnit = 0;
$requestedGroups = $line['options'] ?? [];
foreach ($optionGroups as $group) {
$choices = json_decode($group['choices'], true) ?: [];
$choiceById = array_column($choices, null, 'id');
$requested = null;
foreach ($requestedGroups as $rg) {
if ((int)($rg['option_group_id'] ?? 0) === (int)$group['id']) { $requested = $rg; break; }
}
$choiceIds = $requested['choice_ids'] ?? [];
if ($group['is_required'] && empty($choiceIds)) {
jsonError("Required option group missing for item {$item['name_ar']}", 400);
}
if (count($choiceIds) > (int)$group['max_select']) {
jsonError("Too many choices selected for item {$item['name_ar']}", 400);
}
foreach ($choiceIds as $cid) {
if (!isset($choiceById[$cid])) jsonError("Invalid option choice for item {$item['name_ar']}", 400);
$optionsTotalPerUnit += (int)$choiceById[$cid]['price'];
$chosenOptionsOut[] = ['group_id' => (int)$group['id'], 'choice_id' => $cid, 'price' => (int)$choiceById[$cid]['price']];
}
}
$unitPrice = (int)$item['price'] + $optionsTotalPerUnit;
$lineTotal = $unitPrice * $quantity;
$itemsTotal += $lineTotal;
$lines[] = [
'item_id' => $itemId,
'name_ar_snapshot' => $item['name_ar'],
'unit_price' => $unitPrice,
'quantity' => $quantity,
'options' => $chosenOptionsOut,
'line_total' => $lineTotal,
];
}
if ($itemsTotal < (int)$merchant['min_order_amount']) {
jsonError('Order does not meet minimum order amount', 409, ['min_order_amount' => (int)$merchant['min_order_amount']]);
}
// رسم التوصيل — ثابت من env حتى يُربط بمحرك التسعير القائم في مرحلة لاحقة
$deliveryFee = (int)(getenv('FOOD_DELIVERY_BASE_FEE') ?: 1000);
$serviceFee = 0;
$grandTotal = $itemsTotal + $deliveryFee + $serviceFee;
$quote = [
'merchant_id' => $merchantId,
'passenger_id' => $food_passenger_id,
'lines' => $lines,
'items_total' => $itemsTotal,
'delivery_fee' => $deliveryFee,
'service_fee' => $serviceFee,
'grand_total' => $grandTotal,
];
$token = foodSignQuote($quote);
jsonSuccess([
'quote_token' => $token,
'items_total' => $itemsTotal,
'delivery_fee' => $deliveryFee,
'service_fee' => $serviceFee,
'grand_total' => $grandTotal,
'lines' => $lines,
'expires_in' => 600,
]);
+28
View File
@@ -0,0 +1,28 @@
<?php
// ============================================================
// food/connect_admin.php — بوابة إدارة سيرو (اعتماد المطاعم، التسويات...)
// يستخدم JWT الإداري نفسه المستخدم في backend/Admin (role admin/super_admin)
// ============================================================
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();
$food_admin_role = $decoded->role ?? '';
if (!in_array($food_admin_role, ['admin', 'super_admin'], true)) {
jsonError('Forbidden — admin token required', 403);
}
$food_admin_id = (string)($decoded->user_id ?? '');
try {
$food_con = Database::get('food');
} catch (Exception $e) {
http_response_code(503);
echo json_encode(['status' => 'failure', 'message' => 'Food service unavailable']);
exit;
}
+37
View File
@@ -0,0 +1,37 @@
<?php
// ============================================================
// food/connect_app.php — بوابة الزبون (تبويب «طعام» داخل siro_rider)
// يستخدم JWT الرئيسي نفسه — الزبون هو الراكب نفسه، لا حساب ثانٍ
// ============================================================
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/../functions.php';
require_once __DIR__ . '/functions.php';
if (getenv('FOOD_ENABLED') === 'false') {
http_response_code(503);
echo json_encode(['status' => 'failure', 'message' => 'Food service is currently disabled']);
exit;
}
// Rate limiting — نفس حد API العادي
$limiter = new RateLimiter($redis);
$limiter->enforce(RateLimiter::identifier(), 'api');
// JWT المعتاد — راكب فقط (لا يُقبل توكن سائق هنا)
$jwtService = new JwtService($redis);
$decoded = $jwtService->authenticate();
if (($decoded->role ?? '') !== 'passenger') {
jsonError('Forbidden — passenger token required', 403);
}
$food_passenger_id = (string)($decoded->user_id ?? '');
// اتصال قاعدة بيانات food فقط — ممنوع Database::get('main') في ملفات food/
try {
$food_con = Database::get('food');
} catch (Exception $e) {
http_response_code(503);
echo json_encode(['status' => 'failure', 'message' => 'Food service unavailable']);
exit;
}
+34
View File
@@ -0,0 +1,34 @@
<?php
// ============================================================
// food/connect_courier.php — بوابة السائق (نوع مهمة «توصيل» داخل تدفّق العروض القائم)
// يستخدم JWT الرئيسي نفسه — السائق هو الكابتن نفسه، لا حساب ثانٍ
// ============================================================
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/../functions.php';
require_once __DIR__ . '/functions.php';
if (getenv('FOOD_ENABLED') === 'false') {
http_response_code(503);
echo json_encode(['status' => 'failure', 'message' => 'Food service is currently disabled']);
exit;
}
$limiter = new RateLimiter($redis);
$limiter->enforce(RateLimiter::identifier(), 'api');
$jwtService = new JwtService($redis);
$decoded = $jwtService->authenticate();
if (($decoded->role ?? '') !== 'driver') {
jsonError('Forbidden — driver token required', 403);
}
$food_courier_id = (string)($decoded->user_id ?? '');
try {
$food_con = Database::get('food');
} catch (Exception $e) {
http_response_code(503);
echo json_encode(['status' => 'failure', 'message' => 'Food service unavailable']);
exit;
}
+41
View File
@@ -0,0 +1,41 @@
<?php
// ============================================================
// food/connect_merchant.php — بوابة لوحة المطعم (ويب متجاوب)
// المصادقة عبر session token (هاتف + كلمة مرور) — مستقلة عن JWT
// ============================================================
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/functions.php';
if (getenv('FOOD_ENABLED') === 'false') {
http_response_code(503);
echo json_encode(['status' => 'failure', 'message' => 'Food service is currently disabled']);
exit;
}
// CORS للوحة المطعم
$merchantOrigins = array_map('trim', explode(',',
getenv('FOOD_MERCHANT_ORIGINS') ?: 'https://food-merchant.siromove.com,https://admin.siromove.com'
));
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $merchantOrigins)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
$limiter = new RateLimiter($redis);
$limiter->enforce(RateLimiter::identifier(), 'api');
try {
$food_con = Database::get('food');
} catch (Exception $e) {
http_response_code(503);
echo json_encode(['status' => 'failure', 'message' => 'Food service unavailable']);
exit;
}
// التحقق من الـ session (يُعيد ['merchant_user_id'=>X, 'merchant_id'=>Y])
$food_merchant_session = foodAuthMerchant();
$food_merchant_user_id = (int)$food_merchant_session['merchant_user_id'];
$food_merchant_id = (int)$food_merchant_session['merchant_id'];
+26
View File
@@ -0,0 +1,26 @@
<?php
// food/courier/active.php — طلبات التوصيل الحالية للسائق (polling fallback + شاشة المهمة)
require_once __DIR__ . '/../connect_courier.php';
$st = $food_con->prepare(
"SELECT o.id, o.status, o.merchant_id, m.name_ar AS merchant_name_ar, m.latitude AS merchant_lat,
m.longitude AS merchant_lng, m.address AS merchant_address, o.delivery_fee,
o.delivery_address, o.delivery_lat, o.delivery_lng, o.created_at,
CASE WHEN o.status IN ('courier_assigned','picked_up') THEN o.delivery_address ELSE NULL END AS visible_address
FROM food_orders o
JOIN food_merchants m ON m.id = o.merchant_id
WHERE o.courier_id = ? AND o.status IN ('courier_assigned','picked_up')
ORDER BY o.courier_assigned_at ASC"
);
$st->execute([$food_courier_id]);
$orders = $st->fetchAll();
// بيانات الزبون (العنوان) تظهر فقط بعد courier_assigned وتُحجب بعد delivered — لا نُعيد أي طلب مسلَّم هنا أصلاً
foreach ($orders as &$o) {
unset($o['delivery_address']);
$o['delivery_address'] = $o['visible_address'];
unset($o['visible_address']);
}
unset($o);
jsonSuccess(['orders' => $orders]);
+34
View File
@@ -0,0 +1,34 @@
<?php
// food/courier/delivered.php — تسليم الطلب — تثبيت المال (capture) وقيد أرباح السائق
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);
food_transition_status($orderId, 'delivered', 'courier', $food_courier_id);
if ($order['payment_method'] === 'wallet') {
// المبلغ خُصم بالكامل عند الإنشاء (انظر ملاحظة foodWalletMove) — capture هنا تسجيل محاسبي فقط
$food_con->prepare(
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'capture',?,'success')"
)->execute([$orderId, (int)$order['grand_total']]);
} else {
// نقداً: السائق حصّل grand_total كاملاً من الزبون. يحتفظ بـ delivery_fee (أجرته)
// ويبقى ديناً عليه الباقي (items_total+service_fee — حصة المطعم والمنصة) حتى يُسوّى
// إدارياً. هذا قيد محاسبي فقط هنا — لا حركة مالية آلية فعلية بعد (انظر التنبيه في
// admin/payouts.php حول عدم وجود عقد API مؤكد لتحصيل ديون السائق النقدية آلياً).
$courierOwed = (int)$order['grand_total'] - (int)$order['delivery_fee'];
$food_con->prepare(
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'cash_settlement',?,'pending')"
)->execute([$orderId, $courierOwed]);
}
// قيد أرباح السائق (أجرة التوصيل) — مُتراكم، يُسوَّى عبر دورة تسوية منفصلة
// (لا نقتطع فعلياً من/إلى محفظة السائق هنا: عقد S2S المؤكد فقط لتحويلات
// سائق↔سائق (driverWallet/transfer.php) لا لإيداع أرباح من المنصة مباشرة).
$food_con->prepare(
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'courier_payout',?,'pending')"
)->execute([$orderId, (int)$order['delivery_fee']]);
jsonSuccess(['order_id' => $orderId, 'status' => 'delivered']);
+76
View File
@@ -0,0 +1,76 @@
<?php
// food/courier/offer_respond.php — قبول/رفض عرض توصيل
// body: order_id, response ('accept'|'reject')
require_once __DIR__ . '/../connect_courier.php';
$orderId = filterRequest('order_id', 'int');
$response = filterRequest('response');
if (!$orderId || !in_array($response, ['accept', 'reject'], true)) {
jsonError('order_id and response (accept|reject) are required');
}
$assignSt = $food_con->prepare(
"SELECT id FROM food_courier_assignments WHERE order_id=? AND courier_id=? AND status='offered'
ORDER BY offered_at DESC LIMIT 1"
);
$assignSt->execute([$orderId, $food_courier_id]);
$assignment = $assignSt->fetch();
if (!$assignment) jsonError('No pending offer for this order', 404);
if ($response === 'reject') {
$food_con->prepare("UPDATE food_courier_assignments SET status='rejected', responded_at=NOW() WHERE id=?")
->execute([$assignment['id']]);
// أعد المحاولة على مرشّح آخر — يعيد استخدام نفس منطق ready.php
$orderSt = $food_con->prepare("SELECT merchant_id FROM food_orders WHERE id=? AND status='ready'");
$orderSt->execute([$orderId]);
if ($order = $orderSt->fetch()) {
$merchantSt = $food_con->prepare("SELECT latitude, longitude FROM food_merchants WHERE id=?");
$merchantSt->execute([$order['merchant_id']]);
if ($merchant = $merchantSt->fetch()) {
$candidates = foodFindNearbyCouriers((float)$merchant['latitude'], (float)$merchant['longitude']);
$priorSt = $food_con->prepare("SELECT courier_id FROM food_courier_assignments WHERE order_id=?");
$priorSt->execute([$orderId]);
$prior = array_column($priorSt->fetchAll(), 'courier_id');
foreach ($candidates as $candidateId) {
if (in_array($candidateId, $prior, true)) continue;
foodOfferOrderToCourier($orderId, $candidateId);
break;
}
}
}
jsonSuccess(['order_id' => $orderId, 'response' => 'rejected']);
}
// accept — القفل الذرّي يمنع سباق القبول لو انتهت مهلة سابقة وعُرض على اثنين معاً
if (!foodLockOrderForCourier($orderId, $food_courier_id)) {
$owner = foodOrderLockOwner($orderId);
if ($owner !== $food_courier_id) {
jsonError('Order was already claimed by another courier', 409);
}
}
// الطلب لم يُسند لأي سائق بعد عند هذه النقطة — لا فحص ملكية هنا، فقط فحص الحالة أدناه
$orderSt = $food_con->prepare("SELECT status FROM food_orders WHERE id=? LIMIT 1");
$orderSt->execute([$orderId]);
$orderRow = $orderSt->fetch();
if (!$orderRow || $orderRow['status'] !== 'ready') {
jsonError('Order is no longer available for assignment', 409);
}
$food_con->beginTransaction();
try {
$food_con->prepare("UPDATE food_orders SET courier_id=? WHERE id=?")->execute([$food_courier_id, $orderId]);
$food_con->prepare("UPDATE food_courier_assignments SET status='accepted', responded_at=NOW() WHERE id=?")
->execute([$assignment['id']]);
$food_con->commit();
} catch (Throwable $e) {
$food_con->rollBack();
appLog('[FOOD][COURIER][offer_respond] ' . $e->getMessage(), 'ERROR');
jsonError('Failed to accept offer', 500);
}
food_transition_status($orderId, 'courier_assigned', 'courier', $food_courier_id);
jsonSuccess(['order_id' => $orderId, 'response' => 'accepted']);
+19
View File
@@ -0,0 +1,19 @@
<?php
// food/courier/pending_offers.php — عروض التوصيل المعروضة على هذا السائق حالياً
// (polling fallback — food_socket يدفع 'food_delivery_offer' لحظياً، وهذا احتياطي
// لو انقطع اتصال السوكيت أو كان التطبيق لا يحمل اتصالاً حياً بالسوكيت)
require_once __DIR__ . '/../connect_courier.php';
$st = $food_con->prepare(
"SELECT a.order_id, a.offered_at, o.merchant_id, m.name_ar AS merchant_name_ar,
m.latitude AS merchant_lat, m.longitude AS merchant_lng, o.delivery_fee,
o.delivery_lat, o.delivery_lng
FROM food_courier_assignments a
JOIN food_orders o ON o.id = a.order_id
JOIN food_merchants m ON m.id = o.merchant_id
WHERE a.courier_id = ? AND a.status = 'offered' AND a.offered_at > (NOW() - INTERVAL 20 SECOND)
ORDER BY a.offered_at ASC"
);
$st->execute([$food_courier_id]);
jsonSuccess(['offers' => $st->fetchAll()]);
+11
View File
@@ -0,0 +1,11 @@
<?php
// food/courier/picked_up.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);
food_transition_status($orderId, 'picked_up', 'courier', $food_courier_id);
jsonSuccess(['order_id' => $orderId, 'status' => 'picked_up']);
@@ -0,0 +1,11 @@
<?php
// food/courier/toggle_availability.php — تفعيل/إيقاف "وضع التوصيل" للسائق
// السائق لا يظهر لعروض التوصيل إلا إذا كان أيضاً متاحاً فعلياً في geo:drivers:available
require_once __DIR__ . '/../connect_courier.php';
$enable = filterRequest('enabled', 'bool');
if ($enable === null) jsonError('enabled (true|false) is required');
foodCourierOptIn($food_courier_id, $enable);
jsonSuccess(['courier_id' => $food_courier_id, 'delivery_mode_enabled' => $enable]);
+65
View File
@@ -0,0 +1,65 @@
<?php
// food/cron_order_timeouts.php — يُشغَّل كل دقيقة من crontab المضيف (مثل cron_* في transit)
// 1) عروض توصيل معروضة أكثر من 20 ثانية بلا رد → timed_out + إعادة العرض لمرشح آخر
// 2) طلبات pending أكثر من 5 دقائق بلا رد المطعم → إلغاء نظامي + استرجاع فوري
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/functions.php';
try { $food_con = Database::get('food'); }
catch (Exception $e) { error_log('[FOOD][CRON] DB unavailable: ' . $e->getMessage()); exit(1); }
// ── 1) عروض منتهية المهلة ──
$expiredSt = $food_con->query(
"SELECT id, order_id, courier_id FROM food_courier_assignments
WHERE status='offered' AND offered_at < (NOW() - INTERVAL 20 SECOND)"
);
foreach ($expiredSt->fetchAll() as $assignment) {
$food_con->prepare("UPDATE food_courier_assignments SET status='timed_out', responded_at=NOW() WHERE id=?")
->execute([$assignment['id']]);
$orderSt = $food_con->prepare("SELECT merchant_id FROM food_orders WHERE id=? AND status='ready'");
$orderSt->execute([$assignment['order_id']]);
$order = $orderSt->fetch();
if (!$order) continue;
$merchantSt = $food_con->prepare("SELECT latitude, longitude FROM food_merchants WHERE id=?");
$merchantSt->execute([$order['merchant_id']]);
$merchant = $merchantSt->fetch();
if (!$merchant) continue;
$candidates = foodFindNearbyCouriers((float)$merchant['latitude'], (float)$merchant['longitude']);
$priorSt = $food_con->prepare("SELECT courier_id FROM food_courier_assignments WHERE order_id=?");
$priorSt->execute([$assignment['order_id']]);
$prior = array_column($priorSt->fetchAll(), 'courier_id');
foreach ($candidates as $candidateId) {
if (in_array($candidateId, $prior, true)) continue;
foodOfferOrderToCourier((int)$assignment['order_id'], $candidateId);
error_log("[FOOD][CRON] Re-offered order {$assignment['order_id']} to courier {$candidateId} after timeout");
break;
}
}
// ── 2) طلبات pending تجاوزت مهلة رد المطعم ──
$stalePendingSt = $food_con->query(
"SELECT id, passenger_id, payment_method, grand_total FROM food_orders
WHERE status='pending' AND created_at < (NOW() - INTERVAL 5 MINUTE)"
);
foreach ($stalePendingSt->fetchAll() as $order) {
food_transition_status((int)$order['id'], 'cancelled_system', 'system', 'cron', 'Merchant did not respond within timeout');
if ($order['payment_method'] === 'wallet') {
$refunded = foodWalletMove(
(string)$order['passenger_id'], (int)$order['grand_total'], 'add',
"food-refund-{$order['id']}", "Food order #{$order['id']} auto-cancelled (merchant timeout)"
);
$food_con->prepare(
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'release',?,?)"
)->execute([$order['id'], (int)$order['grand_total'], $refunded ? 'success' : 'failed']);
if (!$refunded) error_log("[FOOD][CRON] refund FAILED for auto-cancelled order {$order['id']} — needs manual reconciliation");
}
}
echo "food cron_order_timeouts done\n";
+472
View File
@@ -0,0 +1,472 @@
<?php
// ============================================================
// food/functions.php — دوال خاصة بوحدة طلبات الطعام فقط
//
// ما لا يوجد هنا (يُستخدم مباشرة من النظام الأصلي بعد bootstrap):
// • filterRequest() ← core/helpers.php
// • jsonSuccess() / jsonError() ← core/helpers.php
// • appLog() / securityLog() ← core/helpers.php
// • $redis ← مهيَّأ في bootstrap.php
// ============================================================
require_once __DIR__ . '/../core/Services/FcmService.php';
// ── حقول مطلوبة (غلاف رفيع يستخدم filterRequest + jsonError) ──
function requireFoodFields(array $fields): void
{
foreach ($fields as $f) {
if (filterRequest($f) === null) {
jsonError("Missing required field: $f", 400);
}
}
}
// ── ملكية المطعم — يتحقق أن merchant_user يخص هذا المطعم فعلاً ──
function foodAssertMerchantOwnership(int $merchantUserId, int $merchantId): void
{
$con = Database::get('food');
$st = $con->prepare("SELECT id FROM food_merchant_users WHERE id=? AND merchant_id=? AND is_active=1 LIMIT 1");
$st->execute([$merchantUserId, $merchantId]);
if (!$st->fetch()) jsonError('Forbidden', 403);
}
// ── ملكية الطلب — كل نقطة تأخذ order_id يجب أن تتحقق أن الفاعل يملكه ──
// $actorType: customer | merchant | courier
function foodAssertOrderOwnership(int $orderId, string $actorType, string $actorId): array
{
$con = Database::get('food');
$st = $con->prepare("SELECT * FROM food_orders WHERE id=? LIMIT 1");
$st->execute([$orderId]);
$order = $st->fetch();
if (!$order) jsonError('Order not found', 404);
$ok = match ($actorType) {
'customer' => (string)$order['passenger_id'] === $actorId,
'courier' => (string)$order['courier_id'] === $actorId,
'merchant' => (string)$order['merchant_id'] === $actorId,
default => false,
};
if (!$ok) {
appLog("[FOOD][IDOR] actor_type=$actorType actor_id=$actorId tried order_id=$orderId", 'WARNING');
jsonError('Forbidden', 403);
}
return $order;
}
// ── آلة الحالة — كل انتقال يمر من هنا فقط، لا UPDATE مبعثر ──
const FOOD_STATUS_TRANSITIONS = [
'pending' => ['merchant_accepted', 'rejected', 'cancelled_by_customer', 'cancelled_system'],
'merchant_accepted' => ['preparing', 'cancelled_by_merchant', 'cancelled_system'],
'preparing' => ['ready', 'cancelled_by_merchant', 'cancelled_system'],
'ready' => ['courier_assigned', 'cancelled_system'],
'courier_assigned' => ['picked_up', 'cancelled_system'],
'picked_up' => ['delivered'],
'delivered' => [],
'rejected' => [],
'cancelled_by_customer' => [],
'cancelled_by_merchant' => [],
'cancelled_system' => [],
];
function food_transition_status(int $orderId, string $toStatus, string $actorType, string $actorId, ?string $note = null): void
{
$con = Database::get('food');
$con->beginTransaction();
try {
$st = $con->prepare("SELECT status FROM food_orders WHERE id=? FOR UPDATE");
$st->execute([$orderId]);
$row = $st->fetch();
if (!$row) { $con->rollBack(); jsonError('Order not found', 404); }
$fromStatus = $row['status'];
$allowed = FOOD_STATUS_TRANSITIONS[$fromStatus] ?? [];
if (!in_array($toStatus, $allowed, true)) {
$con->rollBack();
jsonError("Invalid status transition: $fromStatus -> $toStatus", 409);
}
$timestampColumn = match ($toStatus) {
'merchant_accepted' => 'merchant_accepted_at',
'ready' => 'ready_at',
'courier_assigned' => 'courier_assigned_at',
'picked_up' => 'picked_up_at',
'delivered' => 'delivered_at',
default => str_starts_with($toStatus, 'cancelled') || $toStatus === 'rejected' ? 'cancelled_at' : null,
};
$sql = "UPDATE food_orders SET status=?" . ($timestampColumn ? ", {$timestampColumn}=NOW()" : '') . " WHERE id=?";
$params = [$toStatus, $orderId];
$con->prepare($sql)->execute($params);
$con->prepare(
"INSERT INTO food_order_status_log (order_id, from_status, to_status, actor_type, actor_id, note)
VALUES (?,?,?,?,?,?)"
)->execute([$orderId, $fromStatus, $toStatus, $actorType, $actorId, $note]);
$con->commit();
} catch (Throwable $e) {
if ($con->inTransaction()) $con->rollBack();
appLog('[FOOD][STATUS] ' . $e->getMessage(), 'ERROR');
throw $e;
}
foodNotifyOrderEvent($orderId, $toStatus);
}
// ── إشعارات — سوكيت (internal HTTP push، نفس نمط passenger_socket) + FCM ──
function foodNotifyOrderEvent(int $orderId, string $status): void
{
$con = Database::get('food');
$st = $con->prepare("SELECT passenger_id, merchant_id, courier_id FROM food_orders WHERE id=?");
$st->execute([$orderId]);
$order = $st->fetch();
if (!$order) return;
foodPushToSocket('order_status_update', [
'order_id' => $orderId,
'status' => $status,
'passenger_id' => (string)$order['passenger_id'],
'merchant_id' => (int)$order['merchant_id'],
'courier_id' => $order['courier_id'] ? (string)$order['courier_id'] : null,
]);
$titles = [
'merchant_accepted' => 'المطعم قبل طلبك',
'preparing' => 'جاري تحضير طلبك',
'ready' => 'طلبك جاهز، بانتظار السائق',
'courier_assigned' => 'تم تعيين سائق لتوصيل طلبك',
'picked_up' => 'السائق استلم طلبك وفي الطريق إليك',
'delivered' => 'تم تسليم طلبك، بالهنا والشفا',
'rejected' => 'اعتذر المطعم عن تنفيذ طلبك',
];
if (isset($titles[$status])) {
foodSendNotificationToPassenger((string)$order['passenger_id'], $titles[$status], '', ['type' => 'food_order_' . $status, 'order_id' => (string)$orderId]);
}
}
// ── نداء HTTP داخلي لـ socket_food — نفس نمط broadcast_bus_location في transit ──
function foodPushToSocket(string $action, array $payload): void
{
$url = getenv('FOOD_SOCKET_URL') ?: 'http://socket_food:4041';
$key = getInternalSocketKey();
if (!$key) { appLog('[FOOD][SOCKET] INTERNAL_SOCKET_KEY missing — skip push', 'WARNING'); return; }
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query(['action' => $action, 'payload' => json_encode($payload)]),
CURLOPT_HTTPHEADER => ["X-Internal-Key: $key"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 2,
CURLOPT_CONNECTTIMEOUT => 1,
]);
$result = curl_exec($ch);
if ($result === false) {
appLog('[FOOD][SOCKET] push failed: ' . curl_error($ch), 'WARNING');
}
curl_close($ch);
}
function foodSendNotificationToPassenger(string $passengerId, string $title, string $body, array $data = []): void
{
global $redis;
if (!$passengerId) return;
$fcm = new FcmService($redis);
$result = $fcm->sendToTopic('passenger_' . $passengerId, $title, $body, $data);
if ($result['status'] !== 'success') {
appLog("[FOOD][FCM] push failed for passenger {$passengerId}: " . json_encode($result), 'WARNING');
}
}
// ── Session مطعم (هاتف+كلمة مرور — مستقلة عن JWT، مثل transit) ──
function foodCreateMerchantSession(int $merchantUserId, int $merchantId): string
{
global $redis;
$token = bin2hex(random_bytes(32));
$hash = hash('sha256', $token);
$expiresAt = date('Y-m-d H:i:s', time() + 86400);
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$con = Database::get('food');
$con->prepare(
"INSERT INTO food_merchant_sessions (merchant_user_id, merchant_id, token_hash, ip, user_agent, expires_at)
VALUES (?,?,?,?,?,?)"
)->execute([$merchantUserId, $merchantId, $hash, $ip, $ua, $expiresAt]);
if ($redis) {
$redis->setEx(
"food:merchant_session:{$hash}",
86400,
json_encode(['merchant_user_id' => $merchantUserId, 'merchant_id' => $merchantId])
);
}
return $token;
}
function foodAuthMerchant(): array
{
global $redis;
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['HTTP_X_FOOD_MERCHANT_TOKEN'] ?? '';
$token = str_replace('Bearer ', '', $header);
if (!$token) jsonError('Missing merchant session token', 401);
$hash = hash('sha256', $token);
if ($redis) {
$val = $redis->get("food:merchant_session:{$hash}");
if ($val) {
$data = json_decode($val, true);
if ($data) return $data;
}
jsonError('Session expired or invalid', 401);
}
$con = Database::get('food');
$st = $con->prepare(
"SELECT merchant_user_id, merchant_id FROM food_merchant_sessions
WHERE token_hash=? AND expires_at > NOW() LIMIT 1"
);
$st->execute([$hash]);
$row = $st->fetch();
if (!$row) jsonError('Session expired or invalid', 401);
return $row;
}
// ── هل المطعم مفتوح الآن؟ يحترم الإغلاق/الفتح اليدوي أولاً، ثم working_hours ──
function foodIsMerchantOpen(array $merchant): bool
{
if (isset($merchant['is_open_override']) && $merchant['is_open_override'] !== null) {
return (bool)$merchant['is_open_override'];
}
$hours = $merchant['working_hours'] ?? null;
if (!$hours) return true; // بلا جدول محدد = مفتوح افتراضياً
$hours = is_string($hours) ? json_decode($hours, true) : $hours;
if (!$hours) return true;
$dayKeys = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
$today = $dayKeys[(int)date('w')];
$ranges = $hours[$today] ?? [];
if (!$ranges) return false;
$now = date('H:i');
foreach ($ranges as $range) {
if (!isset($range[0], $range[1])) continue;
if ($now >= $range[0] && $now <= $range[1]) return true;
}
return false;
}
// ============================================================
// المحفظة — S2S عبر سيرفر المحفظة القُطري (نفس عقد backend/api/payments/initiate_prime.php)
//
// ⚠️ ملاحظة صريحة: سيرفر المحفظة الفعلي لا يعرض API حجز-ثم-التقاط (hold/capture)
// حقيقياً — العقد المتاح هو خصم/إضافة فوري فقط (action=subtract|add). لذلك
// "الحجز" هنا هو **خصم فوري عند الإنشاء + استرجاع كامل عند الرفض/الإلغاء**،
// وليس حجزاً بالمعنى المصرفي. إن أُضيف hold حقيقي لاحقاً في سيرفر المحفظة
// فهذه الدالة أول مكان يُعدَّل.
//
// ⚠️ ملاحظة ثانية: عامل تحويل "أصغر وحدة نقدية" (fils/qirsh) إلى المبلغ
// العشري الذي يتوقعه سيرفر المحفظة (كما في initiate_prime.php: 3.00 JOD)
// غير مؤكد لكل دولة — FOOD_CURRENCY_DIVISOR افتراضي 1000 (مثل JOD/fils).
// يجب تأكيده مع فريق المحفظة قبل أي تشغيل فعلي بمال حقيقي.
// ============================================================
function foodWalletServerUrl(): string
{
// bootstrap.php يعرّف الثابت GLOBAL_COUNTRY فقط (لا putenv) — استخدم الثابت لا getenv
$country = strtolower(defined('GLOBAL_COUNTRY') ? GLOBAL_COUNTRY : (getenv('GLOBAL_COUNTRY') ?: 'jordan'));
return match ($country) {
'egypt' => getenv('WALLET_SERVER_EGYPT') ?: 'https://wallet-egypt.siromove.com',
'syria' => getenv('WALLET_SERVER_SYRIA') ?: 'https://wallet-syria.siromove.com',
default => getenv('WALLET_SERVER_JORDAN') ?: 'https://walletintaleq.intaleq.xyz',
};
}
function foodSmallestUnitToDecimal(int $amount): float
{
$divisor = (float)(getenv('FOOD_CURRENCY_DIVISOR') ?: 1000);
return round($amount / $divisor, 3);
}
function foodWalletGetBalance(string $userId, string $userType = 'passenger'): ?float
{
$s2sKey = getenv('S2S_SHARED_KEY');
if (!$s2sKey) { appLog('[FOOD][WALLET] S2S_SHARED_KEY missing', 'ERROR'); return null; }
$url = foodWalletServerUrl() . '/v2/main/ride/passengerWallet/getWalletByPassenger.php';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query(['passenger_id' => $userId]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', "X-S2S-Api-Key: $s2sKey"],
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (!$raw || $code !== 200) { appLog("[FOOD][WALLET] balance fetch failed HTTP $code", 'ERROR'); return null; }
$data = json_decode($raw, true);
$bal = $data['message'][0]['total'] ?? $data['total'] ?? null;
return $bal !== null ? (float)$bal : null;
}
// $amountSmallestUnit موجب دائماً؛ $action = subtract (خصم) أو add (إضافة/استرجاع)
function foodWalletMove(string $userId, int $amountSmallestUnit, string $action, string $paymentId, string $reason, string $userType = 'passenger'): bool
{
$s2sKey = getenv('S2S_SHARED_KEY');
if (!$s2sKey) { appLog('[FOOD][WALLET] S2S_SHARED_KEY missing', 'ERROR'); return false; }
$decimalAmount = foodSmallestUnitToDecimal($amountSmallestUnit);
$signedAmount = $action === 'subtract' ? -1 * $decimalAmount : $decimalAmount;
$url = foodWalletServerUrl() . '/v2/main/ride/payment/add.php';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'user_id' => $userId,
'user_type' => $userType,
'amount' => $signedAmount,
'action' => $action,
'paymentID' => $paymentId,
'paymentMethod' => 'food-order',
'reason' => $reason,
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', "X-S2S-Api-Key: $s2sKey"],
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($err || $code !== 200) {
appLog("[FOOD][WALLET] move failed user=$userId action=$action HTTP $code err=$err", 'ERROR');
return false;
}
$res = json_decode($raw, true);
$ok = ($res['status'] ?? '') === 'success';
if (!$ok) appLog("[FOOD][WALLET] move rejected by wallet server: $raw", 'ERROR');
return $ok;
}
// ── توقيع عرض السعر (السلة) — يثبّت المجموع 10 دقائق، الخادم لا يثق بسعر العميل ──
function foodSignQuote(array $quote): string
{
$secret = getenv('SECRET_KEY_HMAC') ?: '';
$quote['expires_at'] = time() + 600;
$payload = base64_encode(json_encode($quote));
$sig = hash_hmac('sha256', $payload, $secret);
return $payload . '.' . $sig;
}
function foodVerifyQuote(string $token): array
{
$secret = getenv('SECRET_KEY_HMAC') ?: '';
$parts = explode('.', $token, 2);
if (count($parts) !== 2) jsonError('Invalid quote token', 400);
[$payload, $sig] = $parts;
$expected = hash_hmac('sha256', $payload, $secret);
if (!hash_equals($expected, $sig)) jsonError('Quote token signature mismatch', 400);
$quote = json_decode(base64_decode($payload), true);
if (!$quote) jsonError('Invalid quote token', 400);
if (($quote['expires_at'] ?? 0) < time()) jsonError('Quote expired — refresh your cart', 409);
return $quote;
}
// ============================================================
// أسطول التوصيل — نفس السائقين مع تمييز اختياري بالدور (can_deliver)
//
// ⚠️ لا نكتب على أي مفتاح Redis يديره loction_server/driver_socket.php —
// التعديل على تلك العملية الدائمة الحية خارج نطاق هذه الوحدة ومخاطرته
// عالية (تخدم كل مطابقة الرحلات). بدلاً من ذلك: سائق يفعّل "وضع التوصيل"
// من تطبيقه فيُضاف إلى SET مستقلة `food:couriers:opted_in`، ونتقاطع مع
// `geo:drivers:available` (يقرأها هذا الملف فقط — لا يكتب عليها أبداً)
// لإيجاد سائقين متاحين للرحلات فعلياً وأيضاً منضمّين لوضع التوصيل.
// ============================================================
function foodCourierOptIn(string $courierId, bool $enable): void
{
global $redis;
if (!$redis) return;
if ($enable) {
$redis->sAdd('food:couriers:opted_in', $courierId);
} else {
$redis->sRem('food:couriers:opted_in', $courierId);
}
}
function foodFindNearbyCouriers(float $lat, float $lng, float $radiusKm = 5, int $limit = 10): array
{
global $redisLocation, $redis;
if (!$redisLocation || !$redis) return [];
$nearby = $redisLocation->georadius(
'geo:drivers:available', $lng, $lat, $radiusKm, 'km', ['COUNT' => $limit, 'SORT' => 'ASC']
);
if (!$nearby) return [];
$optedIn = $redis->sMembers('food:couriers:opted_in');
if (!$optedIn) return [];
return array_values(array_intersect($nearby, $optedIn));
}
function foodOfferOrderToCourier(int $orderId, string $courierId): void
{
$con = Database::get('food');
$con->prepare(
"INSERT INTO food_courier_assignments (order_id, courier_id, status) VALUES (?,?,'offered')"
)->execute([$orderId, $courierId]);
// ملاحظة: لا FCM هنا عمداً — توكن جهاز السائق في جدول driverToken على main DB،
// وممنوع Database::get('main') داخل backend/food/ (نفس قاعدة transit). الإشعار
// اللحظي يمر فقط عبر socket_food (السائق متصل بسوكيته أثناء وضع التوصيل)،
// والتطبيق يعتمد أيضاً على courier/active.php كـ polling fallback عند الانقطاع.
foodPushToSocket('courier_offer', ['order_id' => $orderId, 'courier_id' => $courierId]);
}
// SET NX EX 20 — القابل الأول فقط يفوز، ذرّياً (يمنع سباق القبول)
function foodLockOrderForCourier(int $orderId, string $courierId): bool
{
global $redis;
if (!$redis) return false;
return (bool)$redis->set("food:order:{$orderId}:lock", $courierId, ['NX', 'EX' => 20]);
}
function foodOrderLockOwner(int $orderId): ?string
{
global $redis;
if (!$redis) return null;
$v = $redis->get("food:order:{$orderId}:lock");
return $v ?: null;
}
// ── حساب مبالغ الطلب من الخادم — العميل لا يُملي السعر أبداً ──
function foodComputeItemsTotal(array $lines): int
{
$total = 0;
foreach ($lines as $line) {
$total += (int)$line['line_total'];
}
return $total;
}
function foodComputeCommission(int $itemsTotal, float $commissionPercent): int
{
return (int) round($itemsTotal * $commissionPercent / 100);
}
+32
View File
@@ -0,0 +1,32 @@
<?php
// food/merchant/browse.php — تصفح المطاعم النشطة في مدينة الزبون
require_once __DIR__ . '/../connect_app.php';
$city = filterRequest('city');
if (!$city) jsonError('city is required');
$category = filterRequest('category');
$sql = "SELECT id, name_ar, name_en, logo_url, cover_url, city, category,
min_order_amount, avg_prep_minutes, rating_avg, rating_count,
is_open_override, working_hours
FROM food_merchants WHERE city=? AND status='active'";
$params = [$city];
if ($category) {
$sql .= " AND category=?";
$params[] = $category;
}
$sql .= " ORDER BY rating_avg DESC, rating_count DESC";
$st = $food_con->prepare($sql);
$st->execute($params);
$merchants = $st->fetchAll();
foreach ($merchants as &$m) {
$m['is_open'] = foodIsMerchantOpen($m);
unset($m['working_hours'], $m['is_open_override']);
}
unset($m);
jsonSuccess(['merchants' => $merchants]);
+59
View File
@@ -0,0 +1,59 @@
<?php
// food/merchant/details.php — تفاصيل مطعم واحد + قائمته الكاملة
require_once __DIR__ . '/../connect_app.php';
$merchantId = filterRequest('merchant_id', 'int');
if (!$merchantId) jsonError('merchant_id is required');
$st = $food_con->prepare(
"SELECT id, name_ar, name_en, logo_url, cover_url, description_ar, city, address,
latitude, longitude, category, min_order_amount, avg_prep_minutes,
rating_avg, rating_count, is_open_override, working_hours
FROM food_merchants WHERE id=? AND status='active' LIMIT 1"
);
$st->execute([$merchantId]);
$merchant = $st->fetch();
if (!$merchant) jsonError('Merchant not found', 404);
$merchant['is_open'] = foodIsMerchantOpen($merchant);
unset($merchant['working_hours'], $merchant['is_open_override']);
$catSt = $food_con->prepare(
"SELECT id, name_ar, name_en, sort_order FROM food_menu_categories
WHERE merchant_id=? AND is_active=1 ORDER BY sort_order ASC"
);
$catSt->execute([$merchantId]);
$categories = $catSt->fetchAll();
$itemSt = $food_con->prepare(
"SELECT id, category_id, name_ar, name_en, description_ar, image_url, price,
is_available, prep_minutes, sort_order
FROM food_menu_items WHERE merchant_id=? ORDER BY sort_order ASC"
);
$itemSt->execute([$merchantId]);
$items = $itemSt->fetchAll();
if ($items) {
$itemIds = implode(',', array_map('intval', array_column($items, 'id')));
$optSt = $food_con->query(
"SELECT id, item_id, group_name_ar, is_required, max_select, choices, sort_order
FROM food_item_options WHERE item_id IN ($itemIds) ORDER BY sort_order ASC"
);
$options = $optSt ? $optSt->fetchAll() : [];
$byItem = [];
foreach ($options as $o) {
$o['choices'] = json_decode($o['choices'], true);
$byItem[$o['item_id']][] = $o;
}
foreach ($items as &$it) {
$it['options'] = $byItem[$it['id']] ?? [];
}
unset($it);
}
foreach ($categories as &$c) {
$c['items'] = array_values(array_filter($items, fn($i) => (int)$i['category_id'] === (int)$c['id']));
}
unset($c);
jsonSuccess(['merchant' => $merchant, 'categories' => $categories]);
+24
View File
@@ -0,0 +1,24 @@
<?php
// food/merchant/search.php — بحث نصي في اسم المطعم أو أصنافه ضمن مدينة الزبون
require_once __DIR__ . '/../connect_app.php';
$city = filterRequest('city');
$q = filterRequest('q');
if (!$city) jsonError('city is required');
if (!$q || mb_strlen($q) < 2) jsonError('q must be at least 2 characters');
$like = '%' . $q . '%';
$st = $food_con->prepare(
"SELECT DISTINCT m.id, m.name_ar, m.name_en, m.logo_url, m.city, m.category,
m.rating_avg, m.rating_count
FROM food_merchants m
LEFT JOIN food_menu_items i ON i.merchant_id = m.id
WHERE m.city = ? AND m.status='active'
AND (m.name_ar LIKE ? OR m.name_en LIKE ? OR i.name_ar LIKE ? OR i.name_en LIKE ?)
ORDER BY m.rating_avg DESC
LIMIT 50"
);
$st->execute([$city, $like, $like, $like, $like]);
jsonSuccess(['merchants' => $st->fetchAll()]);
+41
View File
@@ -0,0 +1,41 @@
<?php
// food/merchant_auth/login.php — دخول لوحة المطعم (هاتف + كلمة مرور)
require_once __DIR__ . '/../../core/bootstrap.php';
require_once __DIR__ . '/../functions.php';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
$limiter = new RateLimiter($redis);
$limiter->enforce(RateLimiter::identifier(), 'login');
requireFoodFields(['phone', 'password']);
$phone = normalizePhone(filterRequest('phone'));
$password = filterRequest('password');
try {
$food_con = Database::get('food');
} catch (Exception $e) {
jsonError('Food service unavailable', 503);
}
$st = $food_con->prepare(
"SELECT id, merchant_id, password_hash, is_active FROM food_merchant_users WHERE phone=? LIMIT 1"
);
$st->execute([$phone]);
$user = $st->fetch();
if (!$user || !$user['is_active'] || !password_verify($password, $user['password_hash'])) {
appLog("[FOOD][MERCHANT_LOGIN] failed attempt for phone hash " . hash('sha256', $phone), 'WARNING');
jsonError('Invalid credentials', 401);
}
$merchantSt = $food_con->prepare("SELECT status FROM food_merchants WHERE id=?");
$merchantSt->execute([$user['merchant_id']]);
$merchant = $merchantSt->fetch();
if (!$merchant || !in_array($merchant['status'], ['active', 'paused'], true)) {
jsonError('Merchant account is not active', 403);
}
$token = foodCreateMerchantSession((int)$user['id'], (int)$user['merchant_id']);
jsonSuccess(['session_token' => $token, 'merchant_id' => (int)$user['merchant_id']], 'Login successful');
+11
View File
@@ -0,0 +1,11 @@
<?php
// food/merchant_ops/accept.php — المطعم يقبل الطلب
require_once __DIR__ . '/../connect_merchant.php';
$orderId = filterRequest('order_id', 'int');
if (!$orderId) jsonError('order_id is required');
foodAssertOrderOwnership($orderId, 'merchant', (string)$food_merchant_id);
food_transition_status($orderId, 'merchant_accepted', 'merchant', (string)$food_merchant_user_id);
jsonSuccess(['order_id' => $orderId, 'status' => 'merchant_accepted']);
+39
View File
@@ -0,0 +1,39 @@
<?php
// food/merchant_ops/incoming.php — الطلبات الحالية للمطعم
require_once __DIR__ . '/../connect_merchant.php';
$statusFilter = filterRequest('status') ?: 'active';
$allowed = ['active', 'pending', 'merchant_accepted', 'preparing', 'ready', 'history'];
if (!in_array($statusFilter, $allowed, true)) $statusFilter = 'active';
if ($statusFilter === 'active') {
$sql = "SELECT id, passenger_id, status, items_total, delivery_fee, grand_total, created_at
FROM food_orders WHERE merchant_id=? AND status IN ('pending','merchant_accepted','preparing','ready','courier_assigned','picked_up')
ORDER BY created_at ASC";
$params = [$food_merchant_id];
} elseif ($statusFilter === 'history') {
$sql = "SELECT id, passenger_id, status, items_total, delivery_fee, grand_total, created_at, delivered_at
FROM food_orders WHERE merchant_id=? AND status IN ('delivered','rejected','cancelled_by_customer','cancelled_by_merchant','cancelled_system')
ORDER BY created_at DESC LIMIT 100";
$params = [$food_merchant_id];
} else {
$sql = "SELECT id, passenger_id, status, items_total, delivery_fee, grand_total, created_at
FROM food_orders WHERE merchant_id=? AND status=? ORDER BY created_at ASC";
$params = [$food_merchant_id, $statusFilter];
}
$st = $food_con->prepare($sql);
$st->execute($params);
$orders = $st->fetchAll();
if ($orders) {
$ids = implode(',', array_map('intval', array_column($orders, 'id')));
$itemsSt = $food_con->query("SELECT order_id, name_ar_snapshot, quantity FROM food_order_items WHERE order_id IN ($ids)");
$allItems = $itemsSt ? $itemsSt->fetchAll() : [];
foreach ($orders as &$o) {
$o['items'] = array_values(array_filter($allItems, fn($i) => (int)$i['order_id'] === (int)$o['id']));
}
unset($o);
}
jsonSuccess(['orders' => $orders]);
@@ -0,0 +1,17 @@
<?php
// food/merchant_ops/items_toggle.php — تفعيل/إيقاف صنف (نفد من المخزون مثلاً)
require_once __DIR__ . '/../connect_merchant.php';
$itemId = filterRequest('item_id', 'int');
if (!$itemId) jsonError('item_id is required');
$st = $food_con->prepare("SELECT id, merchant_id, is_available FROM food_menu_items WHERE id=? LIMIT 1");
$st->execute([$itemId]);
$item = $st->fetch();
if (!$item) jsonError('Item not found', 404);
if ((int)$item['merchant_id'] !== $food_merchant_id) jsonError('Forbidden', 403);
$newAvailability = $item['is_available'] ? 0 : 1;
$food_con->prepare("UPDATE food_menu_items SET is_available=? WHERE id=?")->execute([$newAvailability, $itemId]);
jsonSuccess(['item_id' => $itemId, 'is_available' => (bool)$newAvailability]);
+11
View File
@@ -0,0 +1,11 @@
<?php
// food/merchant_ops/preparing.php — المطعم بدأ التحضير
require_once __DIR__ . '/../connect_merchant.php';
$orderId = filterRequest('order_id', 'int');
if (!$orderId) jsonError('order_id is required');
foodAssertOrderOwnership($orderId, 'merchant', (string)$food_merchant_id);
food_transition_status($orderId, 'preparing', 'merchant', (string)$food_merchant_user_id);
jsonSuccess(['order_id' => $orderId, 'status' => 'preparing']);
+36
View File
@@ -0,0 +1,36 @@
<?php
// food/merchant_ops/ready.php — الطلب جاهز — يبدأ حلقة إسناد السائق
require_once __DIR__ . '/../connect_merchant.php';
$orderId = filterRequest('order_id', 'int');
if (!$orderId) jsonError('order_id is required');
foodAssertOrderOwnership($orderId, 'merchant', (string)$food_merchant_id);
food_transition_status($orderId, 'ready', 'merchant', (string)$food_merchant_user_id);
$merchantSt = $food_con->prepare("SELECT latitude, longitude FROM food_merchants WHERE id=?");
$merchantSt->execute([$food_merchant_id]);
$merchant = $merchantSt->fetch();
$offered = false;
if ($merchant) {
$candidates = foodFindNearbyCouriers((float)$merchant['latitude'], (float)$merchant['longitude']);
// استبعاد من سبق عرض هذا الطلب عليه (رفض أو انتهت مهلته)
$priorSt = $food_con->prepare("SELECT courier_id FROM food_courier_assignments WHERE order_id=?");
$priorSt->execute([$orderId]);
$prior = array_column($priorSt->fetchAll(), 'courier_id');
foreach ($candidates as $courierId) {
if (in_array($courierId, $prior, true)) continue;
foodOfferOrderToCourier($orderId, $courierId);
$offered = true;
break;
}
}
if (!$offered) {
appLog("[FOOD][DELIVERY] No available courier found for order $orderId at ready-time", 'WARNING');
}
jsonSuccess(['order_id' => $orderId, 'status' => 'ready', 'courier_offer_sent' => $offered]);
+24
View File
@@ -0,0 +1,24 @@
<?php
// food/merchant_ops/reject.php — المطعم يرفض الطلب — استرجاع فوري إن دُفع من المحفظة
require_once __DIR__ . '/../connect_merchant.php';
$orderId = filterRequest('order_id', 'int');
$reason = filterRequest('reason');
if (!$orderId) jsonError('order_id is required');
$order = foodAssertOrderOwnership($orderId, 'merchant', (string)$food_merchant_id);
food_transition_status($orderId, 'rejected', 'merchant', (string)$food_merchant_user_id, $reason);
if ($order['payment_method'] === 'wallet') {
$refunded = foodWalletMove(
(string)$order['passenger_id'], (int)$order['grand_total'], 'add',
"food-refund-{$orderId}", "Food order #{$orderId} rejected by merchant"
);
$food_con->prepare(
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'release',?,?)"
)->execute([$orderId, (int)$order['grand_total'], $refunded ? 'success' : 'failed']);
if (!$refunded) appLog("[FOOD][MERCHANT][reject] refund FAILED for order $orderId — needs manual reconciliation", 'ERROR');
}
jsonSuccess(['order_id' => $orderId, 'status' => 'rejected']);
+28
View File
@@ -0,0 +1,28 @@
<?php
// food/order/cancel.php — إلغاء الزبون قبل قبول المطعم فقط (بعده يتطلب اتصالاً بالمطعم)
require_once __DIR__ . '/../connect_app.php';
$orderId = filterRequest('order_id', 'int');
$reason = filterRequest('reason');
if (!$orderId) jsonError('order_id is required');
$order = foodAssertOrderOwnership($orderId, 'customer', $food_passenger_id);
if ($order['status'] !== 'pending') {
jsonError('Order can no longer be cancelled directly — it has already been accepted by the merchant', 409);
}
food_transition_status($orderId, 'cancelled_by_customer', 'customer', $food_passenger_id, $reason);
if ($order['payment_method'] === 'wallet') {
$refunded = foodWalletMove(
$food_passenger_id, (int)$order['grand_total'], 'add',
"food-refund-{$orderId}", "Food order #{$orderId} cancelled"
);
$food_con->prepare(
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'release',?,?)"
)->execute([$orderId, (int)$order['grand_total'], $refunded ? 'success' : 'failed']);
if (!$refunded) appLog("[FOOD][ORDER][cancel] refund FAILED for order $orderId — needs manual reconciliation", 'ERROR');
}
jsonSuccess(['order_id' => $orderId, 'status' => 'cancelled_by_customer']);
+113
View File
@@ -0,0 +1,113 @@
<?php
// food/order/create.php — إنشاء الطلب من عرض سعر موقّع + خصم فوري من المحفظة
// body: quote_token, client_order_uuid, delivery_address, delivery_lat, delivery_lng,
// payment_method (wallet|cash), customer_note
require_once __DIR__ . '/../connect_app.php';
requireFoodFields(['quote_token', 'client_order_uuid', 'delivery_address', 'delivery_lat', 'delivery_lng']);
$quoteToken = filterRequest('quote_token');
$clientUuid = filterRequest('client_order_uuid');
$address = filterRequest('delivery_address');
$lat = filterRequest('delivery_lat', 'float');
$lng = filterRequest('delivery_lng', 'float');
$paymentMethod = filterRequest('payment_method') ?: 'wallet';
$note = filterRequest('customer_note');
if (!in_array($paymentMethod, ['wallet', 'cash'], true)) jsonError('Invalid payment_method');
if (!preg_match('/^[0-9a-fA-F-]{36}$/', $clientUuid)) jsonError('client_order_uuid must be a valid UUID');
$quote = foodVerifyQuote($quoteToken);
if ((string)$quote['passenger_id'] !== $food_passenger_id) jsonError('Quote does not belong to this session', 403);
// idempotency — الضغط المزدوج أو إعادة محاولة الشبكة يرجع نفس الطلب لا طلباً جديداً
$dupSt = $food_con->prepare("SELECT id FROM food_orders WHERE client_order_uuid=? LIMIT 1");
$dupSt->execute([$clientUuid]);
if ($existing = $dupSt->fetch()) {
jsonSuccess(['order_id' => (int)$existing['id'], 'idempotent_replay' => true], 'Order already exists');
}
$maxActive = (int)(getenv('FOOD_MAX_ACTIVE_ORDERS_PER_USER') ?: 3);
$activeSt = $food_con->prepare(
"SELECT COUNT(*) c FROM food_orders WHERE passenger_id=? AND status NOT IN
('delivered','rejected','cancelled_by_customer','cancelled_by_merchant','cancelled_system')"
);
$activeSt->execute([$food_passenger_id]);
if ((int)$activeSt->fetch()['c'] >= $maxActive) {
jsonError("You already have $maxActive active orders — finish or cancel one first", 409);
}
$merchantSt = $food_con->prepare("SELECT id, status, commission_percent FROM food_merchants WHERE id=? LIMIT 1");
$merchantSt->execute([$quote['merchant_id']]);
$merchant = $merchantSt->fetch();
if (!$merchant || $merchant['status'] !== 'active') jsonError('Merchant is no longer available', 409);
$itemsTotal = (int)$quote['items_total'];
$deliveryFee = (int)$quote['delivery_fee'];
$serviceFee = (int)$quote['service_fee'];
$grandTotal = (int)$quote['grand_total'];
$commission = foodComputeCommission($itemsTotal, (float)$merchant['commission_percent']);
if ($paymentMethod === 'wallet') {
$balance = foodWalletGetBalance($food_passenger_id);
if ($balance === null) jsonError('Unable to verify wallet balance. Please try again.', 503);
if ($balance < foodSmallestUnitToDecimal($grandTotal)) {
jsonError('Insufficient wallet balance', 402, ['current_balance' => $balance]);
}
}
$food_con->beginTransaction();
try {
$food_con->prepare(
"INSERT INTO food_orders
(client_order_uuid, passenger_id, merchant_id, status, items_total, delivery_fee, service_fee,
discount, grand_total, commission_amount, payment_method, delivery_address, delivery_lat,
delivery_lng, customer_note)
VALUES (?,?,?,'pending',?,?,?,0,?,?,?,?,?,?,?)"
)->execute([
$clientUuid, $food_passenger_id, $quote['merchant_id'], $itemsTotal, $deliveryFee, $serviceFee,
$grandTotal, $commission, $paymentMethod, $address, $lat, $lng, $note,
]);
$orderId = (int)$food_con->lastInsertId();
$insertLine = $food_con->prepare(
"INSERT INTO food_order_items (order_id, item_id, name_ar_snapshot, unit_price, quantity, option_price_json, line_total)
VALUES (?,?,?,?,?,?,?)"
);
foreach ($quote['lines'] as $line) {
$insertLine->execute([
$orderId, $line['item_id'], $line['name_ar_snapshot'], $line['unit_price'],
$line['quantity'], json_encode($line['options']), $line['line_total'],
]);
}
$food_con->prepare(
"INSERT INTO food_order_status_log (order_id, from_status, to_status, actor_type, actor_id)
VALUES (?,NULL,'pending','customer',?)"
)->execute([$orderId, $food_passenger_id]);
$food_con->commit();
} catch (Throwable $e) {
$food_con->rollBack();
appLog('[FOOD][ORDER][create] ' . $e->getMessage(), 'ERROR');
jsonError('Failed to create order', 500);
}
if ($paymentMethod === 'wallet') {
$debited = foodWalletMove($food_passenger_id, $grandTotal, 'subtract', "food-order-{$orderId}", "Food order #{$orderId}");
if (!$debited) {
// فشل الخصم بعد إنشاء السجل — نُلغي الطلب فوراً بدل ترك طلب بلا دفع
food_transition_status($orderId, 'cancelled_system', 'system', 'wallet', 'Wallet debit failed');
jsonError('Wallet payment failed — order cancelled', 402);
}
$food_con->prepare(
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'hold',?,'success')"
)->execute([$orderId, $grandTotal]);
}
foodPushToSocket('order_status_update', [
'order_id' => $orderId, 'status' => 'pending', 'passenger_id' => $food_passenger_id,
'merchant_id' => (int)$quote['merchant_id'], 'courier_id' => null,
]);
jsonSuccess(['order_id' => $orderId, 'status' => 'pending', 'grand_total' => $grandTotal], 'Order placed');
+20
View File
@@ -0,0 +1,20 @@
<?php
// food/order/history.php — سجل طلبات الزبون
require_once __DIR__ . '/../connect_app.php';
$page = max(1, (int)(filterRequest('page', 'int') ?? 1));
$limit = 20;
$offset = ($page - 1) * $limit;
$st = $food_con->prepare(
"SELECT o.id, o.status, o.grand_total, o.created_at, o.delivered_at, o.rating,
m.name_ar AS merchant_name_ar, m.logo_url AS merchant_logo_url
FROM food_orders o
JOIN food_merchants m ON m.id = o.merchant_id
WHERE o.passenger_id = ?
ORDER BY o.created_at DESC
LIMIT $limit OFFSET $offset"
);
$st->execute([$food_passenger_id]);
jsonSuccess(['orders' => $st->fetchAll(), 'page' => $page]);
+34
View File
@@ -0,0 +1,34 @@
<?php
// food/order/rate.php — تقييم الطلب بعد التسليم (يحدّث متوسط تقييم المطعم)
require_once __DIR__ . '/../connect_app.php';
$orderId = filterRequest('order_id', 'int');
$rating = filterRequest('rating', 'int');
$comment = filterRequest('comment');
if (!$orderId || !$rating || $rating < 1 || $rating > 5) jsonError('order_id and rating (1-5) are required');
$order = foodAssertOrderOwnership($orderId, 'customer', $food_passenger_id);
if ($order['status'] !== 'delivered') jsonError('Only delivered orders can be rated', 409);
if ($order['rating'] !== null) jsonError('Order already rated', 409);
$food_con->beginTransaction();
try {
$food_con->prepare("UPDATE food_orders SET rating=?, rating_comment=? WHERE id=?")
->execute([$rating, $comment, $orderId]);
$food_con->prepare(
"UPDATE food_merchants SET
rating_avg = ((rating_avg * rating_count) + ?) / (rating_count + 1),
rating_count = rating_count + 1
WHERE id=?"
)->execute([$rating, $order['merchant_id']]);
$food_con->commit();
} catch (Throwable $e) {
$food_con->rollBack();
appLog('[FOOD][ORDER][rate] ' . $e->getMessage(), 'ERROR');
jsonError('Failed to save rating', 500);
}
jsonSuccess(['order_id' => $orderId, 'rating' => $rating]);
+15
View File
@@ -0,0 +1,15 @@
<?php
// food/order/status.php — حالة الطلب الحالية (مصدر الحقيقة عند إعادة الاتصال بالسوكيت)
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);
$itemsSt = $food_con->prepare("SELECT name_ar_snapshot, unit_price, quantity, line_total FROM food_order_items WHERE order_id=?");
$itemsSt->execute([$orderId]);
$order['items'] = $itemsSt->fetchAll();
// عناوين/أرقام الزبون تُحجب عن السائق قبل courier_assigned وبعد delivered — هنا هي بوابة الزبون نفسه فلا حجب
jsonSuccess(['order' => $order]);
+6
View File
@@ -0,0 +1,6 @@
<?php
// food/ping.php — فحص الأساس: الحاوية php_food + قاعدة siro_food تعملان
require_once __DIR__ . '/connect_app.php';
$food_con->query('SELECT 1');
jsonSuccess(['service' => 'food', 'db' => 'ok'], 'pong');
+289
View File
@@ -0,0 +1,289 @@
-- =============================================================
-- schema_food.sql — قاعدة بيانات وحدة طلبات الطعام (siro_food)
-- عزل كامل عن main/ride/transit — ممنوع أي JOIN خارجي
-- الربط بالنظام الرئيسي عبر passenger_id / courier_id (main driver id) فقط
-- =============================================================
SET FOREIGN_KEY_CHECKS = 0;
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
SET time_zone = "+00:00";
-- -----------------------------------------------------------------
-- 1. food_merchants — المطاعم/المتاجر
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_merchants` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name_ar` VARCHAR(150) NOT NULL,
`name_en` VARCHAR(150) DEFAULT NULL,
`logo_url` VARCHAR(500) DEFAULT NULL,
`cover_url` VARCHAR(500) DEFAULT NULL,
`description_ar` VARCHAR(500) DEFAULT NULL,
`city` VARCHAR(80) NOT NULL,
`address` VARCHAR(300) DEFAULT NULL,
`latitude` DECIMAL(10,7) NOT NULL,
`longitude` DECIMAL(10,7) NOT NULL,
`category` VARCHAR(60) DEFAULT NULL COMMENT 'مطبخ عربي، بيتزا، حلويات...',
`commission_percent` DECIMAL(5,2) NOT NULL DEFAULT 15.00,
`min_order_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'أصغر وحدة نقدية',
`avg_prep_minutes` SMALLINT UNSIGNED NOT NULL DEFAULT 20,
`rating_avg` DECIMAL(3,2) NOT NULL DEFAULT 0.00,
`rating_count` INT UNSIGNED NOT NULL DEFAULT 0,
`status` ENUM('pending_approval','active','paused','suspended','rejected') NOT NULL DEFAULT 'pending_approval',
`is_open_override` TINYINT(1) DEFAULT NULL COMMENT 'NULL=يتبع working_hours، 0/1=إغلاق/فتح يدوي فوري',
`working_hours` JSON DEFAULT NULL COMMENT '{"sun":[["09:00","23:00"]], ...}',
`approved_by` INT UNSIGNED DEFAULT NULL COMMENT 'admin id من النظام الرئيسي',
`approved_at` TIMESTAMP DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_city_status` (`city`, `status`),
KEY `idx_location` (`latitude`, `longitude`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 2. food_merchant_users — حسابات دخول أصحاب المطاعم (دور merchant، هوية منفصلة عن الراكب)
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_merchant_users` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`merchant_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(120) NOT NULL,
`phone` VARCHAR(25) NOT NULL COMMENT 'مشفّر بنفس EncryptionHelper',
`role` ENUM('owner','staff') NOT NULL DEFAULT 'owner',
`password_hash` VARCHAR(255) NOT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_phone` (`phone`),
KEY `idx_merchant` (`merchant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 3. food_menu_categories — أقسام قائمة المطعم
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_menu_categories` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`merchant_id` INT UNSIGNED NOT NULL,
`name_ar` VARCHAR(100) NOT NULL,
`name_en` VARCHAR(100) DEFAULT NULL,
`sort_order` SMALLINT NOT NULL DEFAULT 0,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_merchant_sort` (`merchant_id`, `sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 4. food_menu_items — أصناف القائمة
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_menu_items` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`merchant_id` INT UNSIGNED NOT NULL,
`category_id` INT UNSIGNED NOT NULL,
`name_ar` VARCHAR(150) NOT NULL,
`name_en` VARCHAR(150) DEFAULT NULL,
`description_ar` VARCHAR(500) DEFAULT NULL,
`image_url` VARCHAR(500) DEFAULT NULL,
`price` BIGINT UNSIGNED NOT NULL COMMENT 'أصغر وحدة نقدية',
`is_available` TINYINT(1) NOT NULL DEFAULT 1,
`prep_minutes` SMALLINT UNSIGNED DEFAULT NULL COMMENT 'NULL = يستخدم avg_prep_minutes للمطعم',
`sort_order` SMALLINT NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_merchant_available` (`merchant_id`, `is_available`),
KEY `idx_category` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 5. food_item_options — مجموعات خيارات الصنف (حجم، إضافات...)
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_item_options` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`item_id` INT UNSIGNED NOT NULL,
`group_name_ar` VARCHAR(100) NOT NULL COMMENT 'مثال: الحجم، الإضافات',
`is_required` TINYINT(1) NOT NULL DEFAULT 0,
`max_select` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=اختيار واحد، أكثر=متعدد',
`choices` JSON NOT NULL COMMENT '[{"id":"lg","label_ar":"كبير","price":500}]',
`sort_order` SMALLINT NOT NULL DEFAULT 0,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_item` (`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 6. food_orders — الطلب
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_orders` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`client_order_uuid` CHAR(36) NOT NULL COMMENT 'idempotency — يمنع الضغط المزدوج من إنشاء طلبين',
`passenger_id` VARCHAR(100) NOT NULL COMMENT 'مرجع منطقي فقط — لا FK خارجي',
`merchant_id` INT UNSIGNED NOT NULL,
`courier_id` VARCHAR(100) DEFAULT NULL COMMENT 'main driver id بعد الإسناد',
`status` ENUM(
'pending','merchant_accepted','preparing','ready',
'courier_assigned','picked_up','delivered',
'rejected','cancelled_by_customer','cancelled_by_merchant','cancelled_system'
) NOT NULL DEFAULT 'pending',
`items_total` BIGINT UNSIGNED NOT NULL COMMENT 'مجموع أصناف الطلب — أصغر وحدة نقدية',
`delivery_fee` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`service_fee` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`discount` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`grand_total` BIGINT UNSIGNED NOT NULL COMMENT 'items_total+delivery_fee+service_fee-discount',
`commission_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'نصيب المنصة من items_total',
`payment_method` ENUM('wallet','cash') NOT NULL DEFAULT 'wallet',
`payment_hold_ref` VARCHAR(100) DEFAULT NULL COMMENT 'مرجع حجز المحفظة في payment_server/v2',
`delivery_address` VARCHAR(300) NOT NULL,
`delivery_lat` DECIMAL(10,7) NOT NULL,
`delivery_lng` DECIMAL(10,7) NOT NULL,
`customer_note` VARCHAR(300) DEFAULT NULL,
`promo_code` VARCHAR(40) DEFAULT NULL,
`rating` TINYINT UNSIGNED DEFAULT NULL,
`rating_comment` VARCHAR(300) DEFAULT NULL,
`merchant_accepted_at` TIMESTAMP DEFAULT NULL,
`ready_at` TIMESTAMP DEFAULT NULL,
`courier_assigned_at` TIMESTAMP DEFAULT NULL,
`picked_up_at` TIMESTAMP DEFAULT NULL,
`delivered_at` TIMESTAMP DEFAULT NULL,
`cancelled_at` TIMESTAMP DEFAULT NULL,
`cancel_reason` VARCHAR(300) DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_client_uuid` (`client_order_uuid`),
KEY `idx_passenger_created` (`passenger_id`, `created_at`),
KEY `idx_merchant_status` (`merchant_id`, `status`),
KEY `idx_courier_status` (`courier_id`, `status`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 7. food_order_items — أصناف الطلب بسعر مجمّد وقت الطلب
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_order_items` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`order_id` INT UNSIGNED NOT NULL,
`item_id` INT UNSIGNED NOT NULL COMMENT 'مرجع فقط — لا JOIN للسعر',
`name_ar_snapshot` VARCHAR(150) NOT NULL,
`unit_price` BIGINT UNSIGNED NOT NULL COMMENT 'سعر الوحدة وقت الطلب — مجمّد',
`quantity` SMALLINT UNSIGNED NOT NULL DEFAULT 1,
`option_price_json` JSON DEFAULT NULL COMMENT 'الخيارات المختارة وأسعارها وقت الطلب',
`line_total` BIGINT UNSIGNED NOT NULL COMMENT '(unit_price+مجموع الخيارات)×quantity',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 8. food_order_status_log — كل انتقال حالة — مصدر الحقيقة للنزاعات
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_order_status_log` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`order_id` INT UNSIGNED NOT NULL,
`from_status` VARCHAR(30) DEFAULT NULL,
`to_status` VARCHAR(30) NOT NULL,
`actor_type` ENUM('customer','merchant','courier','admin','system') NOT NULL,
`actor_id` VARCHAR(100) DEFAULT NULL,
`note` VARCHAR(300) DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 9. food_order_payments — مرجع معاملة المحفظة/الدفع + التسوية
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_order_payments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`order_id` INT UNSIGNED NOT NULL,
`type` ENUM('hold','capture','release','cash_settlement','courier_payout') NOT NULL,
`amount` BIGINT UNSIGNED NOT NULL,
`wallet_ref` VARCHAR(100) DEFAULT NULL COMMENT 'مرجع من payment_server/v2',
`status` ENUM('pending','success','failed') NOT NULL DEFAULT 'pending',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 10. food_courier_assignments — محاولات إسناد الطلب لسائق
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_courier_assignments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`order_id` INT UNSIGNED NOT NULL,
`courier_id` VARCHAR(100) NOT NULL COMMENT 'main driver id',
`status` ENUM('offered','accepted','rejected','timed_out') NOT NULL DEFAULT 'offered',
`offered_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`responded_at` TIMESTAMP DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_order` (`order_id`),
KEY `idx_courier` (`courier_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 11. food_merchant_payouts — مستحقات المطاعم ودورات التسوية
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_merchant_payouts` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`merchant_id` INT UNSIGNED NOT NULL,
`period_start` DATE NOT NULL,
`period_end` DATE NOT NULL,
`orders_count` INT UNSIGNED NOT NULL DEFAULT 0,
`gross_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`commission_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`net_payout` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`status` ENUM('pending','paid') NOT NULL DEFAULT 'pending',
`paid_at` TIMESTAMP DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_merchant_period` (`merchant_id`, `period_start`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 12. food_promo_codes — أكواد خصم خاصة بالطعام (منفصلة عن أكواد الرحلات)
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_promo_codes` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`code` VARCHAR(40) NOT NULL,
`discount_type` ENUM('percent','fixed') NOT NULL DEFAULT 'fixed',
`discount_value` INT UNSIGNED NOT NULL,
`max_discount` BIGINT UNSIGNED DEFAULT NULL,
`min_order_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`usage_limit` INT UNSIGNED DEFAULT NULL,
`usage_count` INT UNSIGNED NOT NULL DEFAULT 0,
`valid_from` TIMESTAMP DEFAULT NULL,
`valid_until` TIMESTAMP DEFAULT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------------
-- 13. food_merchant_sessions — جلسات دخول لوحة المطعم (هاتف+كلمة مرور،
-- session token مستقل عن JWT — نفس نمط transit_sessions، لأن لوحة
-- المطعم ويب متجاوب لا يملك device fingerprint كتطبيق الجوال)
-- -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `food_merchant_sessions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`merchant_user_id` INT UNSIGNED NOT NULL,
`merchant_id` INT UNSIGNED NOT NULL,
`token_hash` VARCHAR(64) NOT NULL COMMENT 'sha256 للـ session token',
`ip` VARCHAR(45) DEFAULT NULL,
`user_agent` VARCHAR(300) DEFAULT NULL,
`expires_at` TIMESTAMP NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_token` (`token_hash`),
KEY `idx_merchant_user` (`merchant_user_id`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET FOREIGN_KEY_CHECKS = 1;
-- -----------------------------------------------------------------
-- ملاحظات Redis (مفاتيح تُكتب من PHP — ليست في SQL):
-- food:order:{id}:lock → SET NX EX 20 عند قبول سائق (يمنع سباق القبول)
-- geo:couriers:available → مجموعة جغرافية للسائقين can_deliver=1 المتفرغين
-- food:merchant:{id}:cache → كاش بيانات مطعم للقراءة السريعة (اختياري لاحقاً)
-- Redis channels للسوكيت: food:order:{id} / food:merchant:{id} / food:courier:{id}
-- -----------------------------------------------------------------
+49 -7
View File
@@ -14,6 +14,25 @@ header('Content-Type: application/json');
$startTime = microtime(true);
/**
* هل البصمة المخزّنة بالصيغة القديمة (AES-GCM) التي يستحيل مطابقتها؟
*
* • '' ⇒ مستخدم بلا بصمة مسجّلة بعد → يُسمح بالترحيل
* • 'GCM:…' ⇒ ناتج AES-GCM خام (addToken/verify_otp) → يُسمح بالترحيل
* • 64 محرف hex ⇒ hash لناتج GCM كتبه الترحيل القديم → يُسمح بالترحيل
* • غير ذلك ⇒ AES-CBC حتمي (الصيغة الجديدة) → مقارنة صارمة
*/
function isLegacyGcmFingerprint(string $storedFp): bool
{
if ($storedFp === '') {
return true;
}
if (str_starts_with($storedFp, 'GCM:')) {
return true;
}
return (bool)preg_match('/^[0-9a-f]{64}$/i', $storedFp);
}
try {
$limiter = new RateLimiter($redis);
$limiter->enforce(RateLimiter::identifier(), 'login');
@@ -26,6 +45,20 @@ try {
jsonError('Missing required parameters', 400);
}
// ── التحقق من الـ audience (نفس منطق loginFirstTime.php) ────────
// يمنع استخراج توكن بـ audience المحفظة من مسار الراكب العادي.
$allowed1 = getenv('allowed1');
$allowed2 = getenv('allowed2');
$allowedAudiences = array_values(array_filter([$allowed1, $allowed2]));
if (!in_array($audience, $allowedAudiences, true)) {
securityLog("Login rejected: invalid audience", [
'passengerId' => $passengerId,
'audience' => $audience,
]);
jsonError('Invalid audience', 400);
}
$con = Database::get('main');
// التحقق من الجهاز من خلال البصمة
@@ -39,11 +72,10 @@ try {
$row = $stmt->fetch();
$fpVerified = false;
$fpJustSaved = false;
if ($row) {
$fpPepper = getenv('FP_PEPPER') ?: '';
$storedFp = $row['fingerPrint'] ?? $row['fingerprint'] ?? '';
// دعم الطريقة الجديدة (hash) والقديمة (مباشر)
if ($fpPepper) {
$expectedHash = hash('sha256', $fingerprint . $fpPepper);
@@ -55,13 +87,23 @@ try {
$fpVerified = hash_equals($storedFp, $fingerprint);
}
// بصمة GCM تتغير في كل مرة (random IV) لذا نقبل أي بصمة جديدة ونحدثها
if (!$fpVerified && !empty($fingerprint)) {
$fpPepper = getenv('FP_PEPPER') ?: '';
$newHash = $fpPepper ? hash('sha256', $fingerprint . $fpPepper) : $fingerprint;
// ── ترحيل لمرة واحدة: بصمات AES-GCM القديمة ────────────────
// النسخ القديمة من تطبيق الراكب كانت تشفّر البصمة بـ AES-GCM
// بـ IV عشوائي، فالناتج يختلف في كل مرة ولا يمكن مطابقته إطلاقاً.
// النسخة الجديدة تستخدم AES-CBC بـ IV ثابت (ناتج حتمي).
// لذلك نقبل استبدال البصمة مرة واحدة فقط عندما تكون المخزّنة
// بصيغة GCM القديمة؛ وأي بصمة بالصيغة الجديدة تُقارن بصرامة
// ويتكفّل مسار الـ OTP بتغيير الجهاز.
// ⚠️ يُحذف هذا الفرع بعد اكتمال ترحيل المستخدمين.
if (!$fpVerified && !empty($fingerprint) && isLegacyGcmFingerprint($storedFp)) {
// نخزّن القيمة الخام (كما يفعل addToken.php و verify_otp.php)
// كي تبقى الصيغة قابلة للتمييز في الطلبات القادمة.
$updateStmt = $con->prepare('UPDATE tokens SET fingerPrint = :fp WHERE passengerID = :pid');
$updateStmt->execute([':fp' => $newHash, ':pid' => $passengerId]);
$updateStmt->execute([':fp' => $fingerprint, ':pid' => $passengerId]);
$fpVerified = true;
securityLog("Legacy GCM fingerprint migrated to CBC", [
'passengerId' => $passengerId,
]);
}
}
+15
View File
@@ -45,6 +45,12 @@ DB_TRANSIT_NAME=siroTransitDb
DB_TRANSIT_USER=siroTransitUser
DB_TRANSIT_PASS=eZiDYxCJ2yyGfC32zG0Z
DB_FOOD_HOST=mysql
DB_FOOD_PORT=3306
DB_FOOD_NAME=siro_food
DB_FOOD_USER=siro_food
DB_FOOD_PASS=CHANGE_ME_STRONG_PASSWORD
# --- 3. Redis ---
REDIS_HOST=redis
REDIS_PORT=6379
@@ -131,6 +137,15 @@ APP_DEEP_LINK_BASE=https://siromove.com/driver/transit-activate
SIRO_COMMISSION_JO=0.15
SIRO_COMMISSION_SY=0.12
# --- 10. Food Orders (طلبات الطعام) ---
FOOD_ENABLED=true
FOOD_SOCKET_URL=http://socket_food:4041
FOOD_SOCKET_PORT=14040
FOOD_MERCHANT_ORIGINS=https://food-merchant.siromove.com,https://admin.siromove.com
FOOD_COMMISSION_PERCENT=15
FOOD_DELIVERY_BASE_FEE=
FOOD_MAX_ACTIVE_ORDERS_PER_USER=3
SIRO_COMMISSION_EG=0.10
SIRO_COMMISSION_IQ=0.14
+31
View File
@@ -77,3 +77,34 @@ watch -n2 'curl -s http://localhost:8080/fpm-status' # طابور
- المكان الوحيد الذي يُخسر فيه أداء فعلاً: كتابة قاعدة البيانات فوق overlayfs — **محلول** بـ volume مسمّى (`mysql-data`).
- أداء PHP الحقيقي يصنعه opcache + مقاس حوض fpm + فهارس MySQL — كلها مضبوطة في `php/` وليست متعلقة بالدوكر أصلاً.
- التقسيم إلى 6 حاويات لا يكلف أداءً (نفس العمليات) ويعطي: إعادة تشغيل خدمة وحدها، حدود ذاكرة لكل خدمة، لوغات منفصلة، واستنساخ عميل = `clone + .env + up`.
## 6) Mawasalati (transit) — حاوية php-fpm معزولة
- الخدمة `php_transit` منفصلة تماماً عن `php` الرئيسي: صورة مختلفة (`php/Dockerfile.fpm.transit`، بدون composer/vendor)، حوض fpm أصغر (`php/www-pool.transit.conf`، 16 عامل)، وحدّ ذاكرة 512m خاص بها.
- الملفات المرَكَّبة داخلها فقط: `backend/core` (read-only — JWT/Database/RateLimiter المشتركة) و`backend/functions.php` (read-only) و`backend/transit` (rw). لا صلة بـ`backend` الرئيسي كاملاً ولا `payment_server/v2` ولا `loction_server`.
- التوجيه في nginx: أي طلب يطابق `^/backend/transit/.*\.php$` يذهب إلى `php_transit:9000` بدل `php:9000` — هذا location يجب أن يبقى **قبل** الـ `location ~ \.php$` العام في `nginx/default.conf`.
- المشترك الوحيد فعلياً مع النظام الأساسي: مصادقة JWT للسائق/الراكب (`backend/core`)، ونفس `mysql`/`redis` (قاعدة `transit` منفصلة أصلاً عبر `Database::get('transit')`).
- انهيار أو ازدحام `php` الرئيسي لا يوقف Mawasalati، والعكس صحيح.
- **cron jobs** (`cron_sync_members.php`, `cron_cleanup.php`, `cron_approaching_alerts.php`): تبقى تُستدعى من crontab المضيف كما هي حالياً (لا تغيير) — الملفات ما تزال متاحة من حاوية `php` الرئيسية أيضاً لأن `../backend` كاملة ما تزال مركّبة فيها.
## 7) طلبات الطعام (food) — نفس نمط العزل
- الخدمتان `php_food` و`socket_food` منفصلتان تماماً عن `php` الرئيسي وعن `php_transit`، بنفس منطق §6: صورة `php/Dockerfile.fpm.transit` (fpm خفيفة بلا composer، مُعاد استخدامها لأن الطعام أيضاً لا يحتاج vendor)، حوض `php/food-pool.conf` (24 عاملاً)، حد ذاكرة 768m لـ`php_food` و512m لـ`socket_food`.
- الملفات المرَكَّبة داخل `php_food` فقط: `backend/core` (ro)، `backend/functions.php` (ro)، `backend/food` (rw). لا صلة بـ`backend` الرئيسي ولا `payment_server/v2` ولا `loction_server`.
- التوجيه في nginx: `^/backend/food/.*\.php$` يذهب إلى `php_food:9000`، ويجب أن يبقى **قبل** الـ location العام.
- `socket_food` — WS بورت 4040 (يتطلب TLS من nginx المضيف مثل بقية السوكيتات، انظر §… أعلاه) + HTTP داخلي 4041 يستقبل نداءات من `php_food` بمفتاح `X-Internal-Key` (نفس `INTERNAL_SOCKET_KEY`) لدفع تحديثات حالة الطلب لحظياً — نفس نمط `broadcast_bus_location` في مواصلاتي، وليس Redis pub/sub.
- قاعدة `siro_food` منفصلة تماماً (`Database::get('food')`، ممنوع `Database::get('main')` داخل `backend/food/`)، والمحفظة/الدفع/JWT/FCM مشتركة مع النظام الرئيسي — نفس مبدأ transit تماماً، موثّق بالتفصيل في [docs/10_food_orders/FOOD_ORDERS_PLAN_AR.md](../docs/10_food_orders/FOOD_ORDERS_PLAN_AR.md).
- `FOOD_ENABLED=false` في `.env` يُرجع 503 من كل بوابات `backend/food/*` فوراً بلا نشر جديد — مفتاح التراجع الأول.
- **الحالة الحالية: المراحل صفر–الرابعة مبنية على فرع `feature/food-delivery-module`** (غير مُلتزم بها على main، وغير مُختبرة على بيئة حقيقية):
- **صفر — التأسيس**: حاويتان، nginx، `Database::get('food')`، `schema_food.sql`، بوابات الزبون/المطعم/السائق/الإدارة.
- **الأولى — الكتالوج**: تصفح/بحث/تفاصيل مطعم، اعتماد إداري للمطاعم، دخول لوحة المطعم، تفعيل/إيقاف صنف.
- **الثانية — الطلب**: `cart/quote.php` (تسعير من الخادم فقط، موقّع بـ HMAC صالح 10 دقائق)، `order/create.php` (idempotent عبر `client_order_uuid`، آلة الحالة `food_transition_status()`)، `order/status.php|cancel.php|rate.php|history.php`، `merchant_ops/accept.php|reject.php|preparing.php|ready.php`.
- **الثالثة — التوصيل**: `courier/toggle_availability.php|offer_respond.php|picked_up.php|delivered.php|active.php`، قفل ذرّي `SET NX EX 20` يمنع سباق القبول، `cron_order_timeouts.php` لإعادة العرض بعد المهلة وإلغاء الطلبات المعلّقة.
- **الرابعة — المال**: خصم/استرجاع فوري من المحفظة عبر نفس عقد `initiate_prime.php` S2S، `admin/payouts.php` لتوليد تقرير تسوية المطاعم.
**⚠️ ثلاث نقاط تحتاج تأكيداً حقيقياً قبل أي تشغيل بمال فعلي — موثّقة كتعليقات صريحة في الكود نفسه (`food/functions.php`)، وليست تفاصيل تنفيذ ثانوية**:
1. **لا يوجد حجز حقيقي (hold/capture)**: سيرفر المحفظة الخارجي لا يعرض API حجز مسبق — التنفيذ الحالي هو خصم فوري عند إنشاء الطلب + استرجاع كامل عند الرفض/الإلغاء. إن أُضيف hold حقيقي لاحقاً، `foodWalletMove()` هو أول مكان يُعدَّل.
2. **عامل تحويل العملة غير مؤكَّد**: `FOOD_CURRENCY_DIVISOR` (افتراضي 1000، أي fils) يحوّل "أصغر وحدة نقدية" في `siro_food` إلى المبلغ العشري الذي يتوقعه سيرفر المحفظة — يجب تأكيده مع فريق المحفظة.
3. **لا تحويل آلي لأرباح السائقين**: العقد S2S المؤكد فقط لتحويلات سائق↔سائق (`driverWallet/transfer.php`)، لا لإيداع أرباح من المنصة مباشرة إلى محفظة سائق. أرباح التوصيل (`delivery_fee`) وديون التحصيل النقدي تُسجَّل محاسبياً فقط في `food_order_payments` (نوع `courier_payout` / `cash_settlement`) بحالة `pending` — **لا صرف فعلي بعد**.
4. **أسطول التوصيل معزول عمداً عن `loction_server`**: لا تعديل على `driver_socket.php` الحي. السائق يُفعّل "وضع التوصيل" فيُضاف إلى SET مستقلة `food:couriers:opted_in` نتقاطع معها مع `geo:drivers:available` (قراءة فقط).
**لم يُبنَ بعد**: المرحلة الخامسة (التقسية والإطلاق — مراجعة أمنية مخصصة، اختبار ضغط، إطلاق تدريجي)، وربط الصرف الفعلي بمجرد تأكيد النقاط الثلاث أعلاه.
+71
View File
@@ -23,6 +23,8 @@ services:
- ../dashboard:/var/www/dashboard:ro
depends_on:
- php
- php_transit
- php_food
mem_limit: 256m
restart: unless-stopped
@@ -48,6 +50,75 @@ services:
mem_limit: 2g
restart: unless-stopped
# php-fpm معزولة لخدمة Mawasalati (transit) — حاوية مستقلة عن الباك إند الرئيسي.
# المشترك الوحيد مع النظام الأساسي: JWT ومصادقة السائق/الراكب عبر backend/core
# (نُركّبها read-only)، ونفس mysql/redis. لا صلة بـ backend الرئيسي ولا payment v2،
# ولا تتأثر Mawasalati إذا امتلأ pool الباك إند الرئيسي أو انهار.
php_transit:
build:
context: ..
dockerfile: docker/php/Dockerfile.fpm.transit
args:
PHP_VERSION: "${PHP_VERSION:-8.2}"
volumes:
- ../backend/core:/var/www/backend/core:ro
- ../backend/functions.php:/var/www/backend/functions.php:ro
- ../backend/transit:/var/www/backend/transit
- ./php/opcache.ini:/usr/local/etc/php/conf.d/zz-opcache.ini:ro
- ./php/www-pool.transit.conf:/usr/local/etc/php-fpm.d/zz-pool.conf:ro
- ./keys:/keys:ro
env_file: .env
depends_on:
- mysql
- redis
mem_limit: 512m
restart: unless-stopped
# php-fpm معزولة لوحدة طلبات الطعام — نفس منطق عزل php_transit.
# المشترك الوحيد: JWT (backend/core) ونفس mysql/redis. قاعدة siro_food منفصلة.
php_food:
build:
context: ..
dockerfile: docker/php/Dockerfile.fpm.transit
args:
PHP_VERSION: "${PHP_VERSION:-8.2}"
volumes:
- ../backend/core:/var/www/backend/core:ro
- ../backend/functions.php:/var/www/backend/functions.php:ro
- ../backend/food:/var/www/backend/food
- ./php/opcache.ini:/usr/local/etc/php/conf.d/zz-opcache.ini:ro
- ./php/food-pool.conf:/usr/local/etc/php-fpm.d/zz-pool.conf:ro
- ./keys:/keys:ro
env_file: .env
depends_on:
- mysql
- redis
mem_limit: 768m
restart: unless-stopped
# سوكيت الطعام — WS بورت 4040 + HTTP داخلي 4041
socket_food:
build:
context: ./php
dockerfile: Dockerfile.socket
args:
PHP_VERSION: "${PHP_VERSION:-8.2}"
command: ["php", "food_socket.php", "start"]
working_dir: /app
volumes:
- ../food_server:/app
- ./keys:/keys:ro
env_file: .env
ports:
# نفس منطق سوكيت السائقين: لا نفتح 4040 للعالم مباشرة — nginx على
# المضيف يستمع 4040 بالشهادة ويمرّر إلى 14040 هنا. 4041 داخلي فقط
# (الباك إند يناديه عبر http://socket_food:4041).
- "127.0.0.1:${FOOD_SOCKET_PORT:-14040}:4040"
depends_on:
- redis
mem_limit: 512m
restart: unless-stopped
# سوكيت السائقين (Workerman + PHPSocketIO) — WS بورت 2020 + HTTP داخلي 2021
# عملية دائمة، لا علاقة لها بـ fpm — حاوية مستقلة إجبارياً.
socket_driver:
+24
View File
@@ -17,6 +17,30 @@ server {
access_log /dev/stdout;
error_log /dev/stderr warn;
# Mawasalati (transit) — حاوية php-fpm معزولة عن الباك إند الرئيسي.
# يجب أن يسبق location ~ \.php$ العام حتى لا يلتقطه أولاً.
location ~ ^/backend/transit/.*\.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_pass php_transit:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_read_timeout 60s;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
}
# طلبات الطعام — حاوية php-fpm معزولة عن الباك إند الرئيسي.
# يجب أن يسبق location ~ \.php$ العام حتى لا يلتقطه أولاً.
location ~ ^/backend/food/.*\.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_pass php_food:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_read_timeout 60s;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
}
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
+10
View File
@@ -0,0 +1,10 @@
# صورة معزولة لخدمة Mawasalati (transit) — لا composer، لا vendor مشترك
# نفس الامتدادات الأساسية فقط. لا صلة بـ backend الرئيسي أو payment v2.
ARG PHP_VERSION=8.2
FROM php:${PHP_VERSION}-fpm
ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN chmod +x /usr/local/bin/install-php-extensions && \
install-php-extensions mysqli pdo_mysql redis opcache gd
WORKDIR /var/www
+16
View File
@@ -0,0 +1,16 @@
; حوض fpm لوحدة الطعام — معزول عن حوض الرحلات الرئيسي
[www]
pm = dynamic
pm.max_children = 24
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 1000
pm.status_path = /status
slowlog = /proc/self/fd/2
request_slowlog_timeout = 3s
catch_workers_output = yes
decorate_workers_output = no
php_admin_value[error_log] = /proc/self/fd/2
php_admin_flag[log_errors] = on
+17
View File
@@ -0,0 +1,17 @@
; حوض fpm صغير — ترافيك Mawasalati أقل بكثير من الباك إند الرئيسي
; حصة الحاوية 512m، ما في داعي لنفس حجم pool الرئيسي (48 عامل)
[www]
pm = dynamic
pm.max_children = 16
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 1000
pm.status_path = /status
slowlog = /proc/self/fd/2
request_slowlog_timeout = 3s
catch_workers_output = yes
decorate_workers_output = no
php_admin_value[error_log] = /proc/self/fd/2
php_admin_flag[log_errors] = on
+2 -2
View File
@@ -1,7 +1,7 @@
# خطة إضافة «طلبات الطعام» إلى منصة سيرو — المعمارية والدوكر والتنفيذ
> الحالة: مقترح للتنفيذ — لم يُكتب أي كود بعد.
> التاريخ: 2026-07-30
> الحالة: **قيد التنفيذ** — المراحل صفر–الرابعة (التأسيس، الكتالوج، الطلب، التوصيل، المال) مبنية على فرع `feature/food-delivery-module`. انظر `docker/README.md §7` لتفاصيل ما هو موجود فعلياً والتنبيهات المالية غير المؤكدة بعد (لا حجز حقيقي، عامل تحويل العملة، لا صرف آلي لأرباح السائقين). المرحلة الخامسة فقط (التقسية والإطلاق) لم تُبنَ.
> التاريخ: 2026-07-30 (آخر تحديث للتنفيذ: 2026-08-01)
> المرجع المعماري: [docker/docker-compose.yml](../../docker/docker-compose.yml) و [docs/30-siro-port-plan.md](../30-siro-port-plan.md)
---
+6
View File
@@ -0,0 +1,6 @@
{
"require": {
"firebase/php-jwt": "^7.0",
"workerman/phpsocket.io": "^2.2"
}
}
+231
View File
@@ -0,0 +1,231 @@
<?php
/**
* food_socket.php
* =====================
* WebSocket Server لطلبات الطعام — بورت 4040
* Internal HTTP Server — بورت 4041
*
* غرف Socket.IO:
* customer_food_{passenger_id} — الزبون (JWT role=passenger)
* courier_food_{driver_id} — السائق (JWT role=driver)
* merchant_food_{merchant_id} — لوحة المطعم (food session token)
*
* السوكيت ناقل إشعار لا مخزن حالة — عند إعادة الاتصال يسحب التطبيق
* order/status.php ويُصحّح نفسه. المصدر الوحيد للحقيقة هو قاعدة siro_food.
*/
use Workerman\Worker;
use PHPSocketIO\SocketIO;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
require_once __DIR__ . '/vendor/autoload.php';
$LOG_FILE = __DIR__ . '/socket_debug.log';
function socket_log($message, $data = null) {
global $LOG_FILE;
$logMsg = '[' . date('Y-m-d H:i:s') . "] $message";
if ($data !== null) {
$logMsg .= ' | DATA: ' . (is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_UNICODE));
}
$logMsg .= PHP_EOL;
echo $logMsg;
@file_put_contents($LOG_FILE, $logMsg, FILE_APPEND);
}
socket_log('=== STARTING FOOD SOCKET SERVER ===');
function loadEnvironment(string $filePath): void {
if (!file_exists($filePath)) {
socket_log("[WARNING] .env not found: $filePath");
return;
}
foreach (file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) continue;
[$name, $value] = explode('=', $line, 2);
putenv(trim($name) . '=' . trim($value, "\"'"));
}
socket_log('✅ Environment loaded.');
}
$envPaths = [__DIR__ . '/../docker/.env', __DIR__ . '/.env'];
foreach ($envPaths as $p) {
if (file_exists($p)) { loadEnvironment($p); break; }
}
function getInternalKey(): string {
$keyPath = getenv('INTERNAL_SOCKET_KEY_PATH');
if ($keyPath && file_exists($keyPath)) return trim((string)@file_get_contents($keyPath));
if (file_exists('/keys/.internal_socket_key')) return trim((string)@file_get_contents('/keys/.internal_socket_key'));
return getenv('INTERNAL_SOCKET_KEY') ?: '';
}
function getJwtSecret(): string {
$keyPath = getenv('JWT_SECRET_KEY_PATH');
if ($keyPath && file_exists($keyPath)) return trim(file_get_contents($keyPath));
if (file_exists('/keys/jwt_secret_key')) return trim(file_get_contents('/keys/jwt_secret_key'));
return getenv('JWT_SECRET_KEY') ?: (getenv('JWT_SECRET') ?: '');
}
// اتصال Redis — للتحقق من صلاحية جلسة لوحة المطعم (food:merchant_session:{hash})
$_redis = null;
function getFoodRedis(): ?\Redis {
global $_redis;
if ($_redis !== null) {
try { $_redis->ping(); return $_redis; }
catch (\Exception $_) { $_redis = null; }
}
try {
$redisPass = getenv('REDIS_MAIN_PASSWORD') ?: getenv('REDIS_PASSWORD') ?: '';
$host = getenv('REDIS_MAIN_HOST') ?: getenv('REDIS_HOST') ?: (file_exists('/.dockerenv') ? 'redis' : '127.0.0.1');
$r = new \Redis();
$r->connect($host, (int)(getenv('REDIS_MAIN_PORT') ?: getenv('REDIS_PORT') ?: 6379), 1.5);
if ($redisPass) $r->auth($redisPass);
$r->setOption(\Redis::OPT_PREFIX, 'siro:');
$_redis = $r;
return $r;
} catch (\Exception $e) {
socket_log('[REDIS_ERROR] Food Redis unavailable: ' . $e->getMessage());
return null;
}
}
$INTERNAL_KEY = getInternalKey();
if (empty($INTERNAL_KEY)) {
socket_log('[CRITICAL_ERROR] Internal key missing! Exiting.');
exit(1);
}
$PORT = 4040;
$INTERNAL_PORT = 4041;
$io = new SocketIO($PORT);
$io->on('workerStart', function () use ($io, $INTERNAL_KEY, $INTERNAL_PORT) {
$innerHttp = new Worker("http://0.0.0.0:$INTERNAL_PORT");
$innerHttp->onMessage = function ($connection, $request) use ($io, $INTERNAL_KEY) {
$headers = $request->header();
$clientIp = $connection->getRemoteIp();
if (($headers['x-internal-key'] ?? '') !== $INTERNAL_KEY) {
socket_log("[HTTP_ERROR] Unauthorized internal request from IP: $clientIp");
$connection->send('Unauthorized');
return;
}
$post = $request->post();
$action = trim($post['action'] ?? '');
$rawPayload = $post['payload'] ?? null;
$payload = is_string($rawPayload) ? (json_decode($rawPayload, true) ?? []) : ($rawPayload ?? []);
if ($action === 'order_status_update') {
$orderId = $payload['order_id'] ?? null;
$passengerId = $payload['passenger_id'] ?? null;
$merchantId = $payload['merchant_id'] ?? null;
$courierId = $payload['courier_id'] ?? null;
if (!$orderId) {
$connection->send('Error: Missing order_id');
return;
}
if ($passengerId) $io->to('customer_food_' . $passengerId)->emit('food_order_update', $payload);
if ($merchantId) $io->to('merchant_food_' . $merchantId)->emit('food_order_update', $payload);
if ($courierId) $io->to('courier_food_' . $courierId)->emit('food_order_update', $payload);
socket_log("[HTTP_SUCCESS] order_status_update pushed for order #$orderId", $payload);
$connection->send('OK');
} elseif ($action === 'courier_offer') {
$courierId = $payload['courier_id'] ?? null;
if (!$courierId) { $connection->send('Error: Missing courier_id'); return; }
$io->to('courier_food_' . $courierId)->emit('food_delivery_offer', $payload);
socket_log("[HTTP_SUCCESS] courier_offer pushed to courier #$courierId", $payload);
$connection->send('OK');
} else {
socket_log("[HTTP_WARNING] Unknown action received: $action", $post);
$connection->send('Unknown action: ' . $action);
}
};
$innerHttp->listen();
socket_log("[INFO] Internal HTTP started on port $INTERNAL_PORT");
});
$io->on('connection', function ($socket) {
$query = $socket->handshake['query'] ?? [];
$role = $query['role'] ?? ''; // passenger | driver | merchant
$clientIp = $socket->conn->remoteAddress ?? 'Unknown';
if ($role === 'passenger' || $role === 'driver') {
$userId = $query['id'] ?? null;
$jwtToken = $query['jwt'] ?? '';
if (!$userId || !$jwtToken) {
socket_log("[SOCKET_REJECTED] Missing id/jwt for role=$role from IP: $clientIp");
$socket->disconnect();
return;
}
try {
$secretKey = getJwtSecret();
if (empty($secretKey)) {
socket_log('[WARNING] JWT secret not configured!');
$socket->disconnect();
return;
}
$decoded = JWT::decode($jwtToken, new Key($secretKey, 'HS256'));
$expectedRole = $role === 'passenger' ? 'passenger' : 'driver';
if ((string)($decoded->user_id ?? $decoded->sub ?? '') !== (string)$userId || ($decoded->role ?? '') !== $expectedRole) {
socket_log("[SOCKET_REJECTED] Invalid JWT for $role #$userId from IP: $clientIp");
$socket->disconnect();
return;
}
} catch (\Exception $e) {
socket_log('[SOCKET_REJECTED] JWT verification failed -> ' . $e->getMessage());
$socket->disconnect();
return;
}
$room = ($role === 'passenger' ? 'customer_food_' : 'courier_food_') . $userId;
$socket->join($room);
socket_log("[SOCKET_CONNECTED] $role #$userId joined $room (IP: $clientIp)");
} elseif ($role === 'merchant') {
$merchantId = $query['id'] ?? null;
$sessionToken = $query['token'] ?? '';
if (!$merchantId || !$sessionToken) {
socket_log("[SOCKET_REJECTED] Missing id/token for role=merchant from IP: $clientIp");
$socket->disconnect();
return;
}
$redis = getFoodRedis();
if (!$redis) { $socket->disconnect(); return; }
$hash = hash('sha256', $sessionToken);
$val = $redis->get("food:merchant_session:{$hash}");
if (!$val) {
socket_log("[SOCKET_REJECTED] Invalid/expired merchant session from IP: $clientIp");
$socket->disconnect();
return;
}
$data = json_decode($val, true);
if (!$data || (string)($data['merchant_id'] ?? '') !== (string)$merchantId) {
socket_log("[SOCKET_REJECTED] Merchant session/id mismatch from IP: $clientIp");
$socket->disconnect();
return;
}
$room = 'merchant_food_' . $merchantId;
$socket->join($room);
socket_log("[SOCKET_CONNECTED] merchant #$merchantId joined $room (IP: $clientIp)");
} else {
socket_log("[SOCKET_REJECTED] Unknown role '$role' from IP: $clientIp");
$socket->disconnect();
return;
}
$socket->on('heartbeat', function () {});
});
Worker::runAll();
+4 -4
View File
@@ -5,8 +5,8 @@ class AppInformation {
static const String phoneNumber = '';
static const String linkedInProfile =
'https://www.linkedin.com/in/hamza-ayed/';
static const String website = 'https://intaleqapp.com';
static const String email = 'support@intaleqapp.com';
static const String website = 'https://siromove.com';
static const String email = 'support@siromove.com';
static const String complaintPrompt =
'for this data for complaint from driver or passenger i collect all data i want you analyze this complaint and show what is reason and what is solution .this data collected from many table to find solution if payment in visa not complete and if ride status is finished it will be paymnet in payment table if ride status is not finished there is no need to pay and payment table is null for this ride and if paymentFromPaymentTable not null and visa type not cash the payment sucssessed . if ratingpassenger is low or passengr rating drivers low grade then dont mine of this passenger ,look at driver too like passengerratingdriver with rating or ratingtopassenger .in json add status of complaint and message to passenger and message to driver and message to call center write in arabic in json output with key in english .for output please just json i want';
static const String addd = 'BlBlNl';
@@ -143,7 +143,7 @@ class AppInformation {
<h2>7. Account Deletion & Contact</h2>
<p>You have the right to request the deletion of your account and personal data. To do so, or for any other questions, please contact us. We will respond to deletion requests within 30 days.</p>
<p><strong>Email:</strong> <a href="mailto:support@intaleqapp.com">support@intaleqapp.com</a></p>
<p><strong>Email:</strong> <a href="mailto:support@siromove.com">support@siromove.com</a></p>
</body>
</html>
@@ -283,7 +283,7 @@ class AppInformation {
<h2>7. حذف الحساب والتواصل</h2>
<p>لديك الحق في طلب حذف حسابك وبياناتك الشخصية. للقيام بذلك، أو لأي استفسارات أخرى، يرجى التواصل معنا. سنرد على طلبات الحذف في غضون 30 يومًا.</p>
<p><strong>البريد الإلكتروني:</strong> <a href="mailto:support@intaleqapp.com">support@intaleqapp.com</a></p>
<p><strong>البريد الإلكتروني:</strong> <a href="mailto:support@siromove.com">support@siromove.com</a></p>
</body>
</html>
+13
View File
@@ -21,6 +21,19 @@ class AppLink {
static const String appDomain = 'siromove.com';
// ── روابط متجر تطبيق السائق (سيرو كابتن) ────────────────────────────────
// تُستخدم في كل رسائل مشاركة/دعوة السائقين.
static const String driverAppStoreIOS =
'https://apps.apple.com/jo/app/id6785282536';
static const String driverAppStoreAndroid =
'https://play.google.com/store/apps/details?id=com.siro.siro_driver';
/// سطرا التحميل الجاهزان للإلصاق في نص المشاركة — نرسل الرابطين معاً
/// لأننا لا نعرف نظام تشغيل المستلم عند مشاركة نص عبر واتساب.
static String get driverDownloadLinks =>
'📱 Android: $driverAppStoreAndroid\n🍏 iPhone: $driverAppStoreIOS';
static String get inviteRedirectUrl {
if (currentCountry == 'Syria') {
return "https://siromove.com/inviteSyria.php";
@@ -45,8 +45,8 @@ class InviteController extends GetxController {
final String shareText =
'''Join Siro as a driver using my referral code!
Use code: $driverCouponCode
Download the Siro Driver app now and earn rewards:
https://siromove.com/invite.php?code=$driverCouponCode&app=driver
Download the Siro Captain app now and earn rewards:
${AppLink.driverDownloadLinks}
''';
await Share.share(shareText);
}
@@ -228,10 +228,33 @@ class LoginDriverController extends GetxController {
return '';
}
getJWT() async {
static Future<bool>? _jwtFuture;
static DateTime _jwtCooldownUntil = DateTime(2000);
static int _jwtFailures = 0;
static Duration get jwtCooldownRemaining {
final d = _jwtCooldownUntil.difference(DateTime.now());
return d.isNegative ? Duration.zero : d;
}
Future<bool> getJWT() async {
if (_jwtFuture != null) {
Log.print('⏳ getJWT: تجديد قيد التنفيذ — إعادة استخدام نفس الـ future.');
return _jwtFuture!;
}
_jwtFuture = _getJwtInternal().catchError((e) {
return _onJwtFailure('exception: $e');
});
try {
return await _jwtFuture!;
} finally {
_jwtFuture = null;
}
}
Future<bool> _getJwtInternal() async {
await EncryptionHelper.initialize();
// 1. Check secure storage first to avoid redundant API calls
String? secureJwt = await storage.read(key: BoxName.jwt);
if (secureJwt != null && secureJwt.isNotEmpty) {
bool isTokenValid = false;
@@ -250,7 +273,6 @@ class LoginDriverController extends GetxController {
final decoded = jsonDecode(utf8.decode(base64Url.decode(payload)));
final exp = decoded['exp'];
if (exp != null) {
// Check if token is valid with a 30-second buffer
isTokenValid =
DateTime.now().millisecondsSinceEpoch < (exp * 1000 - 30000);
}
@@ -259,14 +281,23 @@ class LoginDriverController extends GetxController {
if (isTokenValid) {
Log.print('🔑 Valid JWT found in secure storage. Skipping generation.');
return;
_jwtFailures = 0;
_jwtCooldownUntil = DateTime(2000);
return true;
}
}
if (DateTime.now().isBefore(_jwtCooldownUntil)) {
Log.print(
'🛑 getJWT: بـ cooldown لمدة ${jwtCooldownRemaining.inSeconds}ث — تخطّي التجديد.');
return false;
}
dev = Platform.isAndroid ? 'android' : 'ios';
Log.print(
'box.read(BoxName.firstTimeLoadKey): ${box.read(BoxName.firstTimeLoadKey)}');
if (box.read(BoxName.firstTimeLoadKey).toString() != 'false') {
final driverId = box.read(BoxName.driverID);
final isRegistering = driverId == null || driverId.toString().isEmpty;
if (isRegistering && box.read(BoxName.firstTimeLoadKey).toString() != 'false') {
var payload = {
'id': box.read(BoxName.driverID) ?? AK.newId,
'password': AK.passnpassenger,
@@ -274,18 +305,21 @@ class LoginDriverController extends GetxController {
'fingerPrint': box.read(BoxName.deviceFingerprint) ??
await DeviceHelper.getDeviceFingerprint(),
};
// Log.print('payload: ${payload}');
var response0 = await http.post(
Uri.parse(AppLink.loginFirstTimeDriver),
body: payload,
);
Log.print('response0: ${response0.body}');
Log.print('request: ${response0.request}');
).timeout(const Duration(seconds: 30));
if (response0.statusCode == 429) {
final retryAfter = int.tryParse(response0.headers['retry-after'] ?? '') ?? 60;
_jwtCooldownUntil = DateTime.now().add(Duration(seconds: retryAfter));
Log.print('🛑 getJWT(firstTime): 429 — cooldown ${retryAfter}s');
return false;
}
if (response0.statusCode == 200) {
final decodedResponse1 = jsonDecode(response0.body);
Log.print('decodedResponse1: ${decodedResponse1}');
String? jwt;
if (decodedResponse1['message'] is Map &&
decodedResponse1['message']['jwt'] != null) {
@@ -295,18 +329,20 @@ class LoginDriverController extends GetxController {
}
if (jwt != null) {
// box.write(BoxName.jwt, c(jwt));
await storage.write(key: BoxName.jwt, value: jwt);
await EncryptionHelper.initialize();
return _onJwtSuccess();
}
// ✅ بعد التأكد أن كل المفاتيح موجودة
await EncryptionHelper.initialize();
// await AppInitializer().getKey();
} else {}
return _onJwtFailure('firstTime: لا يوجد jwt بالرد');
}
return _onJwtFailure('firstTime: HTTP ${response0.statusCode}');
} else {
await EncryptionHelper.initialize();
if (isRegistering) {
return _onJwtFailure('renew: لا يوجد driverID');
}
var payload = {
'id': box.read(BoxName.driverID),
'password': box.read(BoxName.emailDriver),
@@ -314,18 +350,21 @@ class LoginDriverController extends GetxController {
'fingerPrint': box.read(BoxName.deviceFingerprint) ??
await DeviceHelper.getDeviceFingerprint(),
};
// print(payload);
var response1 = await http.post(
Uri.parse(AppLink.loginJwtDriver),
body: payload,
);
Log.print('response1.request: ${response1.request}');
Log.print('response1.body: ${response1.body}');
).timeout(const Duration(seconds: 30));
if (response1.statusCode == 429) {
final retryAfter = int.tryParse(response1.headers['retry-after'] ?? '') ?? 60;
_jwtCooldownUntil = DateTime.now().add(Duration(seconds: retryAfter));
Log.print('🛑 getJWT: 429 — cooldown ${retryAfter}ث');
return false;
}
if (response1.statusCode == 200) {
final decodedResponse1 = jsonDecode(response1.body);
// Log.print('decodedResponse1: ${decodedResponse1}');
String? jwt;
if (decodedResponse1['message'] is Map &&
decodedResponse1['message']['jwt'] != null) {
@@ -335,15 +374,30 @@ class LoginDriverController extends GetxController {
}
if (jwt != null) {
// await box.write(BoxName.jwt, c(jwt));
await storage.write(key: BoxName.jwt, value: jwt);
return _onJwtSuccess();
}
// await AppInitializer().getKey();
return _onJwtFailure('renew: لا يوجد jwt بالرد');
}
return _onJwtFailure('renew: HTTP ${response1.statusCode}');
}
}
bool _onJwtSuccess() {
_jwtFailures = 0;
_jwtCooldownUntil = DateTime(2000);
Log.print('✅ getJWT: تم توليد توكن جديد بنجاح.');
return true;
}
bool _onJwtFailure(String reason) {
_jwtFailures++;
final seconds = _jwtFailures >= 6 ? 60 : (1 << _jwtFailures);
_jwtCooldownUntil = DateTime.now().add(Duration(seconds: seconds));
Log.print('❌ getJWT فشل ($reason) — محاولة #$_jwtFailures، cooldown ${seconds}ث');
return false;
}
Future<void> getLocationPermission() async {
var status = await Permission.locationAlways.status;
if (!status.isGranted) {
@@ -0,0 +1,129 @@
// food_delivery_controller.dart — حالة تبويب التوصيل (وضع التوصيل، العروض، المهام النشطة)
import 'dart:async';
import 'package:get/get.dart';
import '../../views/widgets/error_snakbar.dart';
import 'food_delivery_models.dart';
import 'food_delivery_service.dart';
class FoodDeliveryController extends GetxController {
bool isDeliveryModeEnabled = false;
bool isTogglingMode = false;
List<FoodDeliveryOffer> pendingOffers = [];
List<FoodDeliveryTask> activeTasks = [];
bool isLoadingTasks = false;
final Set<int> _respondingOfferIds = {};
final Set<int> _busyTaskIds = {};
Timer? _pollTimer;
@override
void onInit() {
super.onInit();
_refreshAll();
_pollTimer = Timer.periodic(const Duration(seconds: 4), (_) => _refreshAll());
}
Future<void> _refreshAll() async {
if (isDeliveryModeEnabled) {
await _fetchPendingOffers();
}
await fetchActiveTasks();
}
Future<void> toggleDeliveryMode(bool enable) async {
isTogglingMode = true;
update();
final res = await FoodDeliveryService.toggleAvailability(enable);
isTogglingMode = false;
if (res.success) {
isDeliveryModeEnabled = enable;
if (!enable) pendingOffers = [];
} else {
mySnackbarWarning(res.message);
}
update();
}
Future<void> _fetchPendingOffers() async {
final res = await FoodDeliveryService.getPendingOffers();
if (res.success) {
pendingOffers = res.data ?? [];
update();
}
}
Future<void> fetchActiveTasks() async {
isLoadingTasks = true;
final res = await FoodDeliveryService.getActiveTasks();
isLoadingTasks = false;
if (res.success) activeTasks = res.data ?? [];
update();
}
bool isRespondingToOffer(int orderId) => _respondingOfferIds.contains(orderId);
bool isTaskBusy(int orderId) => _busyTaskIds.contains(orderId);
Future<void> respondToOffer(int orderId, bool accept) async {
if (_respondingOfferIds.contains(orderId)) return;
_respondingOfferIds.add(orderId);
update();
final res = await FoodDeliveryService.respondToOffer(orderId, accept);
_respondingOfferIds.remove(orderId);
pendingOffers.removeWhere((o) => o.orderId == orderId);
if (!res.success) {
mySnackbarWarning(res.message);
} else if (accept) {
mySnackbarSuccess('تم قبول طلب التوصيل');
}
update();
await fetchActiveTasks();
}
Future<void> markPickedUp(int orderId) async {
if (_busyTaskIds.contains(orderId)) return;
_busyTaskIds.add(orderId);
update();
final res = await FoodDeliveryService.markPickedUp(orderId);
_busyTaskIds.remove(orderId);
if (res.success) {
mySnackbarSuccess('تم استلام الطلب من المطعم');
await fetchActiveTasks();
} else {
mySnackbarWarning(res.message);
}
update();
}
Future<void> markDelivered(int orderId) async {
if (_busyTaskIds.contains(orderId)) return;
_busyTaskIds.add(orderId);
update();
final res = await FoodDeliveryService.markDelivered(orderId);
_busyTaskIds.remove(orderId);
if (res.success) {
mySnackbarSuccess('تم تسليم الطلب بنجاح');
await fetchActiveTasks();
} else {
mySnackbarWarning(res.message);
}
update();
}
@override
void onClose() {
_pollTimer?.cancel();
super.onClose();
}
}
@@ -0,0 +1,92 @@
// food_delivery_models.dart — نماذج بيانات وحدة التوصيل (جهة السائق)
class FoodDeliveryTask {
final int id;
final String status; // courier_assigned | picked_up
final int merchantId;
final String merchantNameAr;
final double? merchantLat;
final double? merchantLng;
final String? merchantAddress;
final int deliveryFee;
final String? deliveryAddress;
final double deliveryLat;
final double deliveryLng;
final DateTime? createdAt;
FoodDeliveryTask({
required this.id,
required this.status,
required this.merchantId,
required this.merchantNameAr,
required this.deliveryFee,
required this.deliveryLat,
required this.deliveryLng,
this.merchantLat,
this.merchantLng,
this.merchantAddress,
this.deliveryAddress,
this.createdAt,
});
factory FoodDeliveryTask.fromJson(Map<String, dynamic> j) => FoodDeliveryTask(
id: int.tryParse(j['id'].toString()) ?? 0,
status: j['status']?.toString() ?? '',
merchantId: int.tryParse(j['merchant_id'].toString()) ?? 0,
merchantNameAr: j['merchant_name_ar']?.toString() ?? '',
merchantLat: double.tryParse(j['merchant_lat']?.toString() ?? ''),
merchantLng: double.tryParse(j['merchant_lng']?.toString() ?? ''),
merchantAddress: j['merchant_address']?.toString(),
deliveryFee: int.tryParse(j['delivery_fee']?.toString() ?? '0') ?? 0,
deliveryAddress: j['delivery_address']?.toString(),
deliveryLat: double.tryParse(j['delivery_lat']?.toString() ?? '0') ?? 0,
deliveryLng: double.tryParse(j['delivery_lng']?.toString() ?? '0') ?? 0,
createdAt: DateTime.tryParse(j['created_at']?.toString() ?? ''),
);
}
// عرض توصيل وارد — من polling على food/courier/pending_offers.php
class FoodDeliveryOffer {
final int orderId;
final int merchantId;
final String merchantNameAr;
final double? merchantLat;
final double? merchantLng;
final int deliveryFee;
final DateTime? offeredAt;
FoodDeliveryOffer({
required this.orderId,
required this.merchantId,
required this.merchantNameAr,
required this.deliveryFee,
this.merchantLat,
this.merchantLng,
this.offeredAt,
});
factory FoodDeliveryOffer.fromJson(Map<String, dynamic> j) => FoodDeliveryOffer(
orderId: int.tryParse(j['order_id'].toString()) ?? 0,
merchantId: int.tryParse(j['merchant_id'].toString()) ?? 0,
merchantNameAr: j['merchant_name_ar']?.toString() ?? '',
merchantLat: double.tryParse(j['merchant_lat']?.toString() ?? ''),
merchantLng: double.tryParse(j['merchant_lng']?.toString() ?? ''),
deliveryFee: int.tryParse(j['delivery_fee']?.toString() ?? '0') ?? 0,
offeredAt: DateTime.tryParse(j['offered_at']?.toString() ?? ''),
);
// مهلة العرض 20 ثانية من الخادم (SET NX EX 20) — عدّاد تقريبي للعرض فقط،
// الخادم هو الحكم الفعلي (رفض offer_respond إن انتهت المهلة فعلاً).
int get secondsRemaining {
if (offeredAt == null) return 20;
final elapsed = DateTime.now().difference(offeredAt!).inSeconds;
return (20 - elapsed).clamp(0, 20);
}
}
const int foodCurrencyDivisor = 1000;
String foodFormatPrice(int smallestUnit, {String currencySymbol = 'د.أ'}) {
final decimal = smallestUnit / foodCurrencyDivisor;
return '${decimal.toStringAsFixed(3)} $currencySymbol';
}
@@ -0,0 +1,89 @@
// food_delivery_service.dart — طبقة الاتصال بـ backend/food (جهة السائق/الموصّل)
import '../functions/crud.dart';
import '../../constant/links.dart';
import 'food_delivery_models.dart';
class FoodDeliveryApiResult<T> {
final bool success;
final T? data;
final String message;
FoodDeliveryApiResult(this.success, this.data, this.message);
}
class FoodDeliveryService {
static String get _base => '${AppLink.server}/food';
static Future<FoodDeliveryApiResult<bool>> toggleAvailability(bool enabled) async {
final res = await CRUD().post(
link: '$_base/courier/toggle_availability.php',
payload: {'enabled': enabled.toString()},
);
if (res is Map && res['status'] == 'success') return FoodDeliveryApiResult(true, enabled, 'ok');
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
static Future<FoodDeliveryApiResult<List<FoodDeliveryTask>>> getActiveTasks() async {
final res = await CRUD().post(link: '$_base/courier/active.php');
if (res is Map && res['status'] == 'success') {
final msg = res['message'];
final list = (msg is Map && msg['orders'] is List)
? (msg['orders'] as List)
.map((o) => FoodDeliveryTask.fromJson(Map<String, dynamic>.from(o)))
.toList()
: <FoodDeliveryTask>[];
return FoodDeliveryApiResult(true, list, 'ok');
}
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
static Future<FoodDeliveryApiResult<List<FoodDeliveryOffer>>> getPendingOffers() async {
final res = await CRUD().post(link: '$_base/courier/pending_offers.php');
if (res is Map && res['status'] == 'success') {
final msg = res['message'];
final list = (msg is Map && msg['offers'] is List)
? (msg['offers'] as List)
.map((o) => FoodDeliveryOffer.fromJson(Map<String, dynamic>.from(o)))
.toList()
: <FoodDeliveryOffer>[];
return FoodDeliveryApiResult(true, list, 'ok');
}
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
static Future<FoodDeliveryApiResult<String>> respondToOffer(int orderId, bool accept) async {
final res = await CRUD().post(
link: '$_base/courier/offer_respond.php',
payload: {'order_id': orderId.toString(), 'response': accept ? 'accept' : 'reject'},
);
if (res is Map && res['status'] == 'success') {
final msg = res['message'];
return FoodDeliveryApiResult(true, (msg is Map ? msg['response']?.toString() : null) ?? '', 'ok');
}
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
static Future<FoodDeliveryApiResult<void>> markPickedUp(int orderId) async {
final res = await CRUD().post(
link: '$_base/courier/picked_up.php',
payload: {'order_id': orderId.toString()},
);
if (res is Map && res['status'] == 'success') return FoodDeliveryApiResult(true, null, 'ok');
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
static Future<FoodDeliveryApiResult<void>> markDelivered(int orderId) async {
final res = await CRUD().post(
link: '$_base/courier/delivered.php',
payload: {'order_id': orderId.toString()},
);
if (res is Map && res['status'] == 'success') return FoodDeliveryApiResult(true, null, 'ok');
return FoodDeliveryApiResult(false, null, _errMsg(res));
}
static String _errMsg(dynamic res) {
if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت';
if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً';
if (res is Map && res['message'] is String) return res['message'];
return 'حدث خطأ، حاول مجدداً';
}
}
@@ -67,8 +67,8 @@ class AppUpdateController extends GetxController {
void _showStoreUpdateDialog() {
final String storeUrl = Platform.isAndroid
? 'https://play.google.com/store/apps/details?id=com.siro_driver'
: 'https://apps.apple.com/jo/app/siro-driver/id6482995159';
? AppLink.driverAppStoreAndroid
: AppLink.driverAppStoreIOS;
Get.defaultDialog(
title: "تحديث جديد متوفر".tr,
+56 -40
View File
@@ -22,7 +22,6 @@ import 'ssl_pinning.dart';
class CRUD {
final NetGuard _netGuard = NetGuard();
final _client = SslPinning.createPinnedClient();
static bool _isRefreshingJWT = false;
static String _lastErrorSignature = '';
static DateTime _lastErrorTimestamp = DateTime(2000);
static const Duration _errorLogDebounceDuration = Duration(minutes: 1);
@@ -107,6 +106,18 @@ class CRUD {
}
}
// ═══════════════════════════════════════════════════════════════
// _ensureJwt — يضمن وجود توكن صالح قبل الإرسال
// ═══════════════════════════════════════════════════════════════
Future<String> _ensureJwt() async {
String token = await _getJwt();
if (_isJwtValid(token)) return token;
final ok = await Get.put(LoginDriverController()).getJWT();
if (!ok) return '';
return await _getJwt();
}
// ═══════════════════════════════════════════════════════════════
// _makeRequest — دالة مركزية لكل الطلبات
// ───────────────────────────────────────────────────────────────
@@ -119,6 +130,7 @@ class CRUD {
required String link,
Map<String, dynamic>? payload,
required Map<String, String> headers,
bool allowRefresh = true,
}) async {
// timeouts مرتفعة مناسبة للإنترنت الضعيف في سوريا
const totalTimeout = Duration(seconds: 60);
@@ -186,19 +198,33 @@ class CRUD {
}
}
// 401 → تجديد التوكن (مع حماية من الحلقة اللانهائية)
// 429 → السيرفر رافض بسبب الضغط؛ ممنوع نجدّد التوكن أو نعيد المحاولة
if (sc == 429) {
Log.print('🛑 [RES-$requestId] 429 rate limited — $link');
return 'rate_limited';
}
// 401 → تجديد التوكن مرة واحدة ثم إعادة الطلب مرة واحدة فقط
if (sc == 401) {
// تخطي تجديد التوكن لـ endpoints غير حرجة (مثل تسجيل الأخطاء)
final isNonCritical = link.contains('errorApp.php');
if (!_isRefreshingJWT && !isNonCritical) {
_isRefreshingJWT = true;
try {
await Get.put(LoginDriverController()).getJWT();
} finally {
_isRefreshingJWT = false;
}
}
return 'token_expired';
if (isNonCritical || !allowRefresh) return 'token_expired';
final refreshed = await Get.put(LoginDriverController()).getJWT();
if (!refreshed) return 'token_expired';
final newToken = await _getJwt();
if (newToken.isEmpty) return 'token_expired';
// إعادة الطلب بالتوكن الجديد — allowRefresh: false يمنع أي تكرار إضافي
final retryHeaders = Map<String, String>.from(headers)
..['Authorization'] = 'Bearer $newToken';
return await _makeRequest(
link: link,
payload: payload,
headers: retryHeaders,
allowRefresh: false,
);
}
// 5xx
@@ -218,16 +244,11 @@ class CRUD {
required String link,
Map<String, dynamic>? payload,
}) async {
String token = await _getJwt();
// فحص صلاحية التوكن قبل الإرسال — تجنب طلب مضمون الرفض
if (!_isJwtValid(token) && !_isRefreshingJWT) {
_isRefreshingJWT = true;
try {
await Get.put(LoginDriverController()).getJWT();
token = await _getJwt();
} finally {
_isRefreshingJWT = false;
String token = await _ensureJwt();
if (token.isEmpty) {
// إذا فشل الحصول على توكن، لا ترسل الطلب للباك إند لأنّه سيرفض حتماً.
if (!link.contains('login') && !link.contains('errorApp.php')) {
return 'token_expired';
}
}
@@ -247,19 +268,11 @@ class CRUD {
Future<dynamic> get({
required String link,
Map<String, dynamic>? payload,
bool allowRefresh = true,
}) async {
try {
// فحص صلاحية التوكن قبل الإرسال
String token = await _getJwt();
if (!_isJwtValid(token) && !_isRefreshingJWT) {
_isRefreshingJWT = true;
try {
await Get.put(LoginDriverController()).getJWT();
token = await _getJwt();
} finally {
_isRefreshingJWT = false;
}
}
final String token = await _ensureJwt();
var url = Uri.parse(link);
var response = await _client.post(
@@ -280,15 +293,18 @@ class CRUD {
if (jsonData['status'] == 'success') return response.body;
return jsonData['status'];
} else if (response.statusCode == 401) {
if (!_isRefreshingJWT) {
_isRefreshingJWT = true;
try {
await Get.put(LoginDriverController()).getJWT();
} finally {
_isRefreshingJWT = false;
}
}
return 'token_expired';
// تجديد التوكن مرة واحدة ثم إعادة الطلب مرة واحدة فقط
// (allowRefresh: false تمنع أي تكرار إضافي) — نفس سلوك _makeRequest.
if (!allowRefresh) return 'token_expired';
final refreshed = await Get.put(LoginDriverController()).getJWT();
if (!refreshed) return 'token_expired';
return await get(
link: link,
payload: payload,
allowRefresh: false,
);
} else if (response.statusCode >= 500) {
addError('Non-200: ${response.statusCode}', 'crud().get - Other',
url.toString());
@@ -48,8 +48,8 @@ Future<String> getPackageInfo() async {
void showUpdateDialog(BuildContext context) {
final String storeUrl = Platform.isAndroid
? 'https://play.google.com/store/apps/details?id=com.siro_driver'
: 'https://apps.apple.com/jo/app/siro-driver/id6482995159';
? AppLink.driverAppStoreAndroid
: AppLink.driverAppStoreIOS;
showGeneralDialog(
context: context,
@@ -137,7 +137,7 @@ class ReferralController extends GetxController {
String get shareMessage {
final appName = 'Siro';
return 'Join $appName as a driver! Use my code: $referralCode\nDownload: https://siro.app/driver?ref=$referralCode';
return 'Join $appName as a driver! Use my code: $referralCode\n${AppLink.driverDownloadLinks}';
}
String get shareMessagePassenger {
@@ -22,10 +22,8 @@ class RatingController extends GetxController {
void _redirectToAppStore() async {
// URLs for App Store and Google Play Store
const appStoreUrl =
'https://apps.apple.com/st/app/siro-driver/id6482995159';
const playStoreUrl =
'https://play.google.com/store/apps/details?id=com.siro_driver';
const appStoreUrl = AppLink.driverAppStoreIOS;
const playStoreUrl = AppLink.driverAppStoreAndroid;
final url = GetPlatform.isIOS ? appStoreUrl : playStoreUrl;
if (await launchUrl(Uri.parse(url))) {
@@ -229,7 +229,7 @@ class InviteScreen extends StatelessWidget {
final appName = 'Siro';
final isDriver = controller.selectedTab == 0;
final shareText = isDriver
? 'Join $appName as a driver! Use my referral code: ${rc.referralCode}\nDownload: ${AppLink.inviteRedirectUrl}?code=${rc.referralCode}&app=driver\n\n💡 Note: If the link is not clickable, save this number or reply to activate links!'
? 'Join $appName as a driver! Use my referral code: ${rc.referralCode}\n${AppLink.driverDownloadLinks}\n\n💡 Note: If the link is not clickable, save this number or reply to activate links!'
: 'Get a ride with $appName! Use my referral code: ${rc.referralCode} for a discount.\nDownload: ${AppLink.inviteRedirectUrl}?code=${rc.referralCode}&app=rider\n\n💡 Note: If the link is not clickable, save this number or reply to activate links!';
Share.share(shareText);
},
@@ -0,0 +1,235 @@
// food_delivery_home_page.dart — تبويب التوصيل: تفعيل وضع التوصيل، عروض واردة، مهام نشطة
import 'package:flutter/material.dart';
import 'package:get/get.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 '../widgets/elevated_btn.dart';
import '../widgets/my_scafold.dart';
class FoodDeliveryHomePage extends StatelessWidget {
const FoodDeliveryHomePage({super.key});
@override
Widget build(BuildContext context) {
Get.put(FoodDeliveryController());
return GetBuilder<FoodDeliveryController>(
builder: (c) => MyScafolld(
title: 'Delivery'.tr,
isleading: true,
body: [
Column(
children: [
_modeToggleBar(c),
if (c.pendingOffers.isNotEmpty) _offersSection(c),
Expanded(
child: RefreshIndicator(
onRefresh: c.fetchActiveTasks,
child: c.isLoadingTasks && c.activeTasks.isEmpty
? const Center(child: CircularProgressIndicator())
: c.activeTasks.isEmpty
? _emptyState(c)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: c.activeTasks.length,
itemBuilder: (_, i) => _taskCard(c, c.activeTasks[i]),
),
),
),
],
),
],
),
);
}
Widget _modeToggleBar(FoodDeliveryController c) {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColor.cardColor,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColor.borderColor),
),
child: Row(
children: [
Icon(
c.isDeliveryModeEnabled ? Icons.delivery_dining_rounded : Icons.moped_outlined,
color: c.isDeliveryModeEnabled ? AppColor.greenColor : AppColor.grayColor,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Delivery Mode'.tr, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
Text(
c.isDeliveryModeEnabled
? 'You will receive delivery offers'.tr
: 'Turn on to receive delivery offers'.tr,
style: AppStyle.subtitle,
),
],
),
),
c.isTogglingMode
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: Switch(
value: c.isDeliveryModeEnabled,
activeColor: AppColor.greenColor,
onChanged: (v) => c.toggleDeliveryMode(v),
),
],
),
);
}
Widget _offersSection(FoodDeliveryController c) {
return SizedBox(
height: 150,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: c.pendingOffers.length,
itemBuilder: (_, i) => _offerCard(c, c.pendingOffers[i]),
),
);
}
Widget _offerCard(FoodDeliveryController c, FoodDeliveryOffer offer) {
final isResponding = c.isRespondingToOffer(offer.orderId);
return Container(
width: 260,
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColor.primaryColor,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.15), blurRadius: 12, offset: const Offset(0, 4))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.notifications_active_rounded, color: Colors.white, size: 18),
const SizedBox(width: 6),
Expanded(
child: Text('New Delivery Offer'.tr,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
),
Text('${offer.secondsRemaining}s', style: const TextStyle(color: Colors.amber, fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 6),
Text(offer.merchantNameAr,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15),
maxLines: 1, overflow: TextOverflow.ellipsis),
Text(foodFormatPrice(offer.deliveryFee),
style: const TextStyle(color: Colors.white70, fontSize: 13)),
const Spacer(),
Row(
children: [
Expanded(
child: SizedBox(
height: 36,
child: isResponding
? const Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)))
: OutlinedButton(
style: OutlinedButton.styleFrom(side: const BorderSide(color: Colors.white54)),
onPressed: () => c.respondToOffer(offer.orderId, false),
child: Text('Reject'.tr, style: const TextStyle(color: Colors.white70, fontSize: 12)),
),
),
),
const SizedBox(width: 8),
Expanded(
child: SizedBox(
height: 36,
child: isResponding
? const SizedBox.shrink()
: MyElevatedButton(
title: 'Accept'.tr,
kolor: AppColor.greenColor,
onPressed: () => c.respondToOffer(offer.orderId, true),
),
),
),
],
),
],
),
);
}
Widget _emptyState(FoodDeliveryController c) {
return ListView(
children: [
const SizedBox(height: 100),
Icon(Icons.moped_outlined, size: 72, color: AppColor.grayColor),
const SizedBox(height: 16),
Center(child: Text('No delivery tasks right now'.tr, style: AppStyle.title)),
],
);
}
Widget _taskCard(FoodDeliveryController c, FoodDeliveryTask task) {
final isPickedUp = task.status == 'picked_up';
final isBusy = c.isTaskBusy(task.id);
return Card(
margin: const EdgeInsets.only(bottom: 14),
color: AppColor.cardColor,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: isPickedUp ? AppColor.greenColor : AppColor.borderColor, width: isPickedUp ? 1.5 : 1),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(isPickedUp ? Icons.location_on_rounded : Icons.storefront_rounded,
color: AppColor.accentColor),
const SizedBox(width: 8),
Expanded(
child: Text(
isPickedUp ? (task.deliveryAddress ?? '') : task.merchantNameAr,
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(foodFormatPrice(task.deliveryFee), style: AppStyle.subtitle),
],
),
const SizedBox(height: 4),
Text(
isPickedUp ? 'Deliver to customer'.tr : (task.merchantAddress ?? ''),
style: AppStyle.subtitle,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 44,
child: isBusy
? const Center(child: CircularProgressIndicator())
: MyElevatedButton(
title: isPickedUp ? 'Mark Delivered'.tr : 'Picked Up from Restaurant'.tr,
kolor: isPickedUp ? AppColor.greenColor : AppColor.accentColor,
onPressed: () => isPickedUp ? c.markDelivered(task.id) : c.markPickedUp(task.id),
),
),
],
),
),
);
}
}
@@ -33,6 +33,7 @@ import '../About Us/video_page.dart';
import '../assurance_health_page.dart';
import '../maintain_center_page.dart';
import '../../../transit/transit_driver_home_page.dart';
import '../../../food_delivery/food_delivery_home_page.dart';
// 1. إنشاء Class لتعريف بيانات كل عنصر في القائمة
class DrawerItem {
@@ -61,6 +62,11 @@ class AppDrawer extends StatelessWidget {
// icon: Icons.directions_bus_filled_rounded,
// color: Colors.teal,
// onTap: () => Get.to(() => const TransitDriverHomePage())),
DrawerItem(
title: 'Delivery'.tr,
icon: Icons.delivery_dining_rounded,
color: Colors.deepOrange,
onTap: () => Get.to(() => const FoodDeliveryHomePage())),
DrawerItem(
title: 'Balance'.tr,
icon: Icons.account_balance_wallet,
+6 -6
View File
@@ -1521,10 +1521,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
@@ -1537,10 +1537,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -2125,10 +2125,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.9"
timezone:
dependency: transitive
description:
+2 -1
View File
@@ -10,7 +10,8 @@
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:intaleqapp.com</string>
<string>applinks:siromove.com</string>
<string>applinks:www.siromove.com</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
+4 -4
View File
@@ -4,8 +4,8 @@ class AppInformation {
static const String phoneNumber = '';
static const String linkedInProfile =
'https://www.linkedin.com/in/hamza-ayed/';
static const String website = 'https://intaleqapp.com';
static const String email = 'support@intaleqapp.com';
static const String website = 'https://siromove.com';
static const String email = 'support@siromove.com';
static const String addd = 'BlBlNl';
static const String privacyPolicy = '''
<!DOCTYPE html>
@@ -140,7 +140,7 @@ class AppInformation {
<h2>7. Account Deletion & Contact</h2>
<p>You have the right to request the deletion of your account and personal data. To do so, or for any other questions, please contact us. We will respond to deletion requests within 30 days.</p>
<p><strong>Email:</strong> <a href="mailto:support@intaleqapp.com">support@intaleqapp.com</a></p>
<p><strong>Email:</strong> <a href="mailto:support@siromove.com">support@siromove.com</a></p>
</body>
</html>
@@ -280,7 +280,7 @@ class AppInformation {
<h2>7. حذف الحساب والتواصل</h2>
<p>لديك الحق في طلب حذف حسابك وبياناتك الشخصية. للقيام بذلك، أو لأي استفسارات أخرى، يرجى التواصل معنا. سنرد على طلبات الحذف في غضون 30 يومًا.</p>
<p><strong>البريد الإلكتروني:</strong> <a href="mailto:support@intaleqapp.com">support@intaleqapp.com</a></p>
<p><strong>البريد الإلكتروني:</strong> <a href="mailto:support@siromove.com">support@siromove.com</a></p>
</body>
</html>
@@ -81,26 +81,55 @@ class LoginController extends GetxController {
// • firstTimeLoadKey != false ← أول مرة يفتح التطبيق → loginFirstTime
// • firstTimeLoadKey == false ← مستخدم موجود → loginJwtRider
// ─────────────────────────────────────────────────────────────
Future<void> getJWT({bool force = false}) async {
// إذا كان التوكن الحالي لا يزال صالحاً، لا داعي لطلب واحد جديد
static Future<bool>? _jwtFuture;
static DateTime _jwtCooldownUntil = DateTime(2000);
static int _jwtFailures = 0;
static Duration get jwtCooldownRemaining {
final d = _jwtCooldownUntil.difference(DateTime.now());
return d.isNegative ? Duration.zero : d;
}
Future<bool> getJWT({bool force = false}) async {
if (_jwtFuture != null) {
Log.print('⏳ getJWT: تجديد قيد التنفيذ — إعادة استخدام نفس الـ future.');
return _jwtFuture!;
}
_jwtFuture = _getJwtInternal(force: force).catchError((e) {
return _onJwtFailure('exception: $e');
});
try {
return await _jwtFuture!;
} finally {
_jwtFuture = null;
}
}
Future<bool> _getJwtInternal({bool force = false}) async {
if (!force && isTokenValid()) {
Log.print("JWT is still valid. Skipping request.");
return;
_jwtFailures = 0;
_jwtCooldownUntil = DateTime(2000);
return true;
}
if (DateTime.now().isBefore(_jwtCooldownUntil)) {
Log.print(
'🛑 getJWT: بـ cooldown لمدة ${jwtCooldownRemaining.inSeconds}ث — تخطّي التجديد.');
return false;
}
try {
dev = Platform.isAndroid ? 'android' : 'ios';
// تأكد إن البصمة محدّثة قبل أي طلب
await DeviceHelper.getDeviceFingerprint();
final String fp = box.read(BoxName.deviceFpEncrypted) ?? '';
final passengerId = box.read(BoxName.passengerID);
final isRegistering = passengerId == null || passengerId.toString().isEmpty;
if (box.read(BoxName.firstTimeLoadKey).toString() != 'false') {
// ── أول تسجيل ─────────────────────────────────────────
// نرسل البصمة المشفرة مع باقي البيانات
// السيرفر سيعمل hash لها ويخزنها في JWT payload
if (isRegistering && box.read(BoxName.firstTimeLoadKey).toString() != 'false') {
var payload = {
'id': box.read(BoxName.passengerID) ?? AK.newId,
'id': passengerId ?? AK.newId,
'password': AK.passnpassenger,
'aud': '${AK.allowed}$dev',
'fingerPrint': fp,
@@ -109,12 +138,14 @@ class LoginController extends GetxController {
var response = await http.post(
Uri.parse(AppLink.loginFirstTime),
body: payload,
);
Log.print('AppLink.loginFirstTime: ${AppLink.loginFirstTime}');
Log.print('payload: $payload');
Log.print('response code: ${response.statusCode}');
Log.print('response body: ${response.body}');
).timeout(const Duration(seconds: 30));
if (response.statusCode == 429) {
final retryAfter = int.tryParse(response.headers['retry-after'] ?? '') ?? 60;
_jwtCooldownUntil = DateTime.now().add(Duration(seconds: retryAfter));
Log.print('🛑 getJWT(firstTime): 429 — cooldown ${retryAfter}s');
return false;
}
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
@@ -125,18 +156,20 @@ class LoginController extends GetxController {
: decoded['jwt']);
if (jwt != null) {
// نشفر الـ JWT بالتشفير الثلاثي قبل التخزين في GetStorage
box.write(BoxName.jwt, c(jwt));
storage.write(key: BoxName.jwt, value: c(jwt));
await storage.write(key: BoxName.jwt, value: jwt);
await EncryptionHelper.initialize();
return _onJwtSuccess();
}
await EncryptionHelper.initialize();
return _onJwtFailure('firstTime: لا يوجد jwt بالرد');
}
return _onJwtFailure('firstTime: HTTP ${response.statusCode}');
} else {
// ── مستخدم موجود: تجديد التوكن
if (isRegistering) {
return _onJwtFailure('renew: لا يوجد passengerID');
}
var payload = {
'id': box.read(BoxName.passengerID),
'id': passengerId,
'fingerPrint': fp,
'aud': '${AK.allowed}$dev',
};
@@ -144,11 +177,15 @@ class LoginController extends GetxController {
var response = await http.post(
Uri.parse(AppLink.loginJwtRider),
body: payload,
);
Log.print('AppLink.loginJwtRider: ${AppLink.loginJwtRider}');
).timeout(const Duration(seconds: 30));
if (response.statusCode == 429) {
final retryAfter = int.tryParse(response.headers['retry-after'] ?? '') ?? 60;
_jwtCooldownUntil = DateTime.now().add(Duration(seconds: retryAfter));
Log.print('🛑 getJWT: 429 — cooldown ${retryAfter}ث');
return false;
}
Log.print('payload: $payload');
Log.print('response: ${response.body}');
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
final String? jwt = decoded['data'] != null
@@ -158,16 +195,33 @@ class LoginController extends GetxController {
: decoded['jwt']);
if (jwt != null) {
box.write(BoxName.jwt, c(jwt));
storage.write(key: BoxName.jwt, value: c(jwt));
await storage.write(key: BoxName.jwt, value: jwt);
return _onJwtSuccess();
}
return _onJwtFailure('renew: لا يوجد jwt بالرد');
}
return _onJwtFailure('renew: HTTP ${response.statusCode}');
}
} catch (e) {
Log.print('Error in getJWT: $e');
return _onJwtFailure('Error: $e');
}
}
bool _onJwtSuccess() {
_jwtFailures = 0;
_jwtCooldownUntil = DateTime(2000);
Log.print('✅ getJWT: تم توليد توكن جديد بنجاح.');
return true;
}
bool _onJwtFailure(String reason) {
_jwtFailures++;
final seconds = _jwtFailures >= 6 ? 60 : (1 << _jwtFailures);
_jwtCooldownUntil = DateTime.now().add(Duration(seconds: seconds));
Log.print('❌ getJWT فشل ($reason) — محاولة #$_jwtFailures، cooldown ${seconds}ث');
return false;
}
// ─────────────────────────────────────────────────────────────
// التحقق من صلاحية التوكن يدوياً (بدون مكاتب خارجية)
// ─────────────────────────────────────────────────────────────
@@ -233,6 +287,9 @@ class LoginController extends GetxController {
Future<String?> getJwtWallet() async {
dev = Platform.isAndroid ? 'android' : 'ios';
// نعيد حساب البصمة أولاً كي لا نرسل قيمة GCM قديمة عالقة في التخزين
// من نسخة سابقة من التطبيق (مثل getJWT تماماً).
await DeviceHelper.getDeviceFingerprint();
final String fp = box.read(BoxName.deviceFpEncrypted) ?? '';
var payload = {
@@ -0,0 +1,270 @@
// food_controller.dart — حالة تبويب الطعام (تصفح، سلة، تتبّع الطلب)
import 'dart:async';
import 'dart:math';
import 'package:get/get.dart';
import '../../constant/box_name.dart';
import '../../main.dart';
import '../../views/widgets/error_snakbar.dart';
import 'food_models.dart';
import 'food_service.dart';
class FoodController extends GetxController {
// ── تصفح ──
bool isLoadingMerchants = false;
List<FoodMerchant> merchants = [];
String city = _defaultCityForCountry();
// ── تفاصيل مطعم ──
bool isLoadingMenu = false;
FoodMerchant? selectedMerchant;
List<FoodMenuCategory> categories = [];
// ── السلة (مقيّدة بمطعم واحد فقط) ──
int? cartMerchantId;
final Map<String, FoodCartLine> _cart = {};
List<FoodCartLine> get cartLines => _cart.values.toList();
int get cartItemsCount => _cart.values.fold(0, (sum, l) => sum + l.quantity);
int get cartTotal => _cart.values.fold(0, (sum, l) => sum + l.lineTotal);
// ── عرض السعر الحالي ──
FoodQuote? currentQuote;
bool isQuoting = false;
// ── الطلب النشط (تتبّع) ──
FoodOrder? activeOrder;
Timer? _statusPollTimer;
static String _defaultCityForCountry() {
final country = box.read(BoxName.countryCode);
switch (country) {
case 'SY':
return 'دمشق';
case 'EG':
return 'القاهرة';
default:
return 'عمان';
}
}
void setCity(String newCity) {
if (newCity.trim().isEmpty) return;
city = newCity.trim();
fetchMerchants();
}
Future<void> fetchMerchants({String? category}) async {
isLoadingMerchants = true;
update();
final res = await FoodService.browseMerchants(city: city, category: category);
isLoadingMerchants = false;
if (res.success) {
merchants = res.data ?? [];
} else {
merchants = [];
mySnackbarWarning(res.message);
}
update();
}
Future<void> searchMerchants(String query) async {
if (query.trim().length < 2) return;
isLoadingMerchants = true;
update();
final res = await FoodService.searchMerchants(city: city, query: query.trim());
isLoadingMerchants = false;
if (res.success) merchants = res.data ?? [];
update();
}
Future<void> openMerchant(int merchantId) async {
isLoadingMenu = true;
selectedMerchant = null;
categories = [];
update();
final res = await FoodService.merchantDetails(merchantId);
isLoadingMenu = false;
if (res.success && res.data != null) {
selectedMerchant = res.data!['merchant'] as FoodMerchant;
categories = res.data!['categories'] as List<FoodMenuCategory>;
} else {
mySnackbarWarning(res.message);
}
update();
}
// ── إدارة السلة ──
bool addToCart(FoodMenuItem item, {int quantity = 1, Map<int, List<String>>? options}) {
final line = FoodCartLine(item: item, quantity: quantity, selectedOptions: options ?? {});
final existing = _cart[line.lineKey];
if (existing != null) {
existing.quantity += quantity;
} else {
_cart[line.lineKey] = line;
}
currentQuote = null; // أي تعديل بالسلة يُبطل العرض الموقّع القديم
update();
return true;
}
// يُستدعى من صفحة المطعم مع فحص أن السلة إما فارغة أو لنفس المطعم
bool addToCartForMerchant(int merchantId, FoodMenuItem item,
{int quantity = 1, Map<int, List<String>>? options}) {
if (_cart.isNotEmpty && cartMerchantId != null && cartMerchantId != merchantId) {
mySnackbarWarning(box.read(BoxName.lang) == 'ar'
? 'سلتك تحتوي أصنافاً من مطعم آخر. أفرغها أولاً لإضافة من هذا المطعم.'
: 'Your cart has items from another restaurant. Clear it first.');
return false;
}
cartMerchantId = merchantId;
return addToCart(item, quantity: quantity, options: options);
}
void removeFromCart(String lineKey) {
_cart.remove(lineKey);
if (_cart.isEmpty) cartMerchantId = null;
currentQuote = null;
update();
}
void updateQuantity(String lineKey, int quantity) {
final line = _cart[lineKey];
if (line == null) return;
if (quantity <= 0) {
removeFromCart(lineKey);
return;
}
line.quantity = quantity;
currentQuote = null;
update();
}
void clearCart() {
_cart.clear();
cartMerchantId = null;
currentQuote = null;
update();
}
Future<bool> refreshQuote() async {
if (cartMerchantId == null || cartLines.isEmpty) return false;
isQuoting = true;
update();
final res = await FoodService.getQuote(merchantId: cartMerchantId!, lines: cartLines);
isQuoting = false;
if (res.success && res.data != null) {
currentQuote = res.data;
update();
return true;
}
currentQuote = null;
mySnackbarWarning(res.message);
update();
return false;
}
bool _placingOrder = false;
bool get isPlacingOrder => _placingOrder;
Future<int?> placeOrder({
required String deliveryAddress,
required double deliveryLat,
required double deliveryLng,
required String paymentMethod,
String? customerNote,
}) async {
if (currentQuote == null) {
final ok = await refreshQuote();
if (!ok || currentQuote == null) return null;
}
_placingOrder = true;
update();
final res = await FoodService.createOrder(
quoteToken: currentQuote!.quoteToken,
clientOrderUuid: _generateUuidV4(),
deliveryAddress: deliveryAddress,
deliveryLat: deliveryLat,
deliveryLng: deliveryLng,
paymentMethod: paymentMethod,
customerNote: customerNote,
);
_placingOrder = false;
update();
if (res.success && res.data != null) {
clearCart();
startTrackingOrder(res.data!);
return res.data;
}
mySnackbarWarning(res.message);
return null;
}
// ── تتبّع الطلب (polling — السوكيت ليس مصدر الحقيقة، انظر ملاحظة backend) ──
void startTrackingOrder(int orderId) {
_statusPollTimer?.cancel();
_pollOrderStatus(orderId);
_statusPollTimer = Timer.periodic(const Duration(seconds: 5), (_) => _pollOrderStatus(orderId));
}
Future<void> _pollOrderStatus(int orderId) async {
final res = await FoodService.getOrderStatus(orderId);
if (res.success && res.data != null) {
activeOrder = res.data;
update();
if (activeOrder!.status == 'delivered' || foodOrderIsCancelled(activeOrder!.status)) {
_statusPollTimer?.cancel();
}
}
}
void stopTracking() {
_statusPollTimer?.cancel();
activeOrder = null;
}
Future<bool> rateActiveOrder(int rating, {String? comment}) async {
if (activeOrder == null) return false;
final res = await FoodService.rateOrder(activeOrder!.id, rating, comment: comment);
if (res.success) {
await _pollOrderStatus(activeOrder!.id);
return true;
}
mySnackbarWarning(res.message);
return false;
}
Future<bool> cancelActiveOrder({String? reason}) async {
if (activeOrder == null) return false;
final res = await FoodService.cancelOrder(activeOrder!.id, reason: reason);
if (res.success) {
await _pollOrderStatus(activeOrder!.id);
return true;
}
mySnackbarWarning(res.message);
return false;
}
static String _generateUuidV4() {
final rnd = Random.secure();
final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant
String hex(int start, int end) =>
bytes.sublist(start, end).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}';
}
@override
void onClose() {
_statusPollTimer?.cancel();
super.onClose();
}
}
@@ -0,0 +1,325 @@
// food_models.dart — نماذج بيانات وحدة الطعام (جهة الراكب)
class FoodMerchant {
final int id;
final String nameAr;
final String? nameEn;
final String? logoUrl;
final String? coverUrl;
final String? descriptionAr;
final String city;
final String? address;
final String? category;
final int minOrderAmount;
final int avgPrepMinutes;
final double ratingAvg;
final int ratingCount;
final bool isOpen;
FoodMerchant({
required this.id,
required this.nameAr,
required this.city,
this.nameEn,
this.logoUrl,
this.coverUrl,
this.descriptionAr,
this.address,
this.category,
this.minOrderAmount = 0,
this.avgPrepMinutes = 20,
this.ratingAvg = 0,
this.ratingCount = 0,
this.isOpen = true,
});
factory FoodMerchant.fromJson(Map<String, dynamic> j) => FoodMerchant(
id: int.tryParse(j['id'].toString()) ?? 0,
nameAr: j['name_ar']?.toString() ?? '',
nameEn: j['name_en']?.toString(),
logoUrl: j['logo_url']?.toString(),
coverUrl: j['cover_url']?.toString(),
descriptionAr: j['description_ar']?.toString(),
city: j['city']?.toString() ?? '',
address: j['address']?.toString(),
category: j['category']?.toString(),
minOrderAmount: int.tryParse(j['min_order_amount']?.toString() ?? '0') ?? 0,
avgPrepMinutes: int.tryParse(j['avg_prep_minutes']?.toString() ?? '20') ?? 20,
ratingAvg: double.tryParse(j['rating_avg']?.toString() ?? '0') ?? 0,
ratingCount: int.tryParse(j['rating_count']?.toString() ?? '0') ?? 0,
isOpen: j['is_open'] == true || j['is_open']?.toString() == '1',
);
}
class FoodOptionChoice {
final String id;
final String labelAr;
final int price;
FoodOptionChoice({required this.id, required this.labelAr, required this.price});
factory FoodOptionChoice.fromJson(Map<String, dynamic> j) => FoodOptionChoice(
id: j['id'].toString(),
labelAr: j['label_ar']?.toString() ?? '',
price: int.tryParse(j['price']?.toString() ?? '0') ?? 0,
);
}
class FoodItemOptionGroup {
final int id;
final String groupNameAr;
final bool isRequired;
final int maxSelect;
final List<FoodOptionChoice> choices;
FoodItemOptionGroup({
required this.id,
required this.groupNameAr,
required this.isRequired,
required this.maxSelect,
required this.choices,
});
factory FoodItemOptionGroup.fromJson(Map<String, dynamic> j) => FoodItemOptionGroup(
id: int.tryParse(j['id'].toString()) ?? 0,
groupNameAr: j['group_name_ar']?.toString() ?? '',
isRequired: j['is_required']?.toString() == '1' || j['is_required'] == true,
maxSelect: int.tryParse(j['max_select']?.toString() ?? '1') ?? 1,
choices: (j['choices'] is List)
? (j['choices'] as List)
.map((c) => FoodOptionChoice.fromJson(Map<String, dynamic>.from(c)))
.toList()
: <FoodOptionChoice>[],
);
}
class FoodMenuItem {
final int id;
final int categoryId;
final String nameAr;
final String? nameEn;
final String? descriptionAr;
final String? imageUrl;
final int price;
final bool isAvailable;
final List<FoodItemOptionGroup> options;
FoodMenuItem({
required this.id,
required this.categoryId,
required this.nameAr,
required this.price,
this.nameEn,
this.descriptionAr,
this.imageUrl,
this.isAvailable = true,
this.options = const [],
});
factory FoodMenuItem.fromJson(Map<String, dynamic> j) => FoodMenuItem(
id: int.tryParse(j['id'].toString()) ?? 0,
categoryId: int.tryParse(j['category_id'].toString()) ?? 0,
nameAr: j['name_ar']?.toString() ?? '',
nameEn: j['name_en']?.toString(),
descriptionAr: j['description_ar']?.toString(),
imageUrl: j['image_url']?.toString(),
price: int.tryParse(j['price']?.toString() ?? '0') ?? 0,
isAvailable: j['is_available']?.toString() != '0' && j['is_available'] != false,
options: (j['options'] is List)
? (j['options'] as List)
.map((o) => FoodItemOptionGroup.fromJson(Map<String, dynamic>.from(o)))
.toList()
: <FoodItemOptionGroup>[],
);
}
class FoodMenuCategory {
final int id;
final String nameAr;
final String? nameEn;
final List<FoodMenuItem> items;
FoodMenuCategory({
required this.id,
required this.nameAr,
this.nameEn,
this.items = const [],
});
factory FoodMenuCategory.fromJson(Map<String, dynamic> j) => FoodMenuCategory(
id: int.tryParse(j['id'].toString()) ?? 0,
nameAr: j['name_ar']?.toString() ?? '',
nameEn: j['name_en']?.toString(),
items: (j['items'] is List)
? (j['items'] as List)
.map((i) => FoodMenuItem.fromJson(Map<String, dynamic>.from(i)))
.toList()
: <FoodMenuItem>[],
);
}
// ── سطر في السلة (محلي فقط قبل الإرسال للخادم) ──
class FoodCartLine {
final FoodMenuItem item;
int quantity;
// key: option_group_id, value: قائمة choice_id المختارة
final Map<int, List<String>> selectedOptions;
FoodCartLine({
required this.item,
this.quantity = 1,
Map<int, List<String>>? selectedOptions,
}) : selectedOptions = selectedOptions ?? {};
int get unitPrice {
int total = item.price;
for (final group in item.options) {
final chosen = selectedOptions[group.id] ?? [];
for (final choiceId in chosen) {
final choice = group.choices.firstWhere(
(c) => c.id == choiceId,
orElse: () => FoodOptionChoice(id: '', labelAr: '', price: 0),
);
total += choice.price;
}
}
return total;
}
int get lineTotal => unitPrice * quantity;
Map<String, dynamic> toQuotePayload() => {
'item_id': item.id,
'quantity': quantity,
'options': selectedOptions.entries
.map((e) => {'option_group_id': e.key, 'choice_ids': e.value})
.toList(),
};
// مفتاح تمييز محلي: نفس الصنف بخيارات مختلفة = سطر مختلف في السلة
String get lineKey {
final optKeys = selectedOptions.entries.map((e) => '${e.key}:${e.value.join(",")}').join('|');
return '${item.id}_$optKeys';
}
}
class FoodQuote {
final String quoteToken;
final int itemsTotal;
final int deliveryFee;
final int serviceFee;
final int grandTotal;
final int expiresIn;
FoodQuote({
required this.quoteToken,
required this.itemsTotal,
required this.deliveryFee,
required this.serviceFee,
required this.grandTotal,
required this.expiresIn,
});
factory FoodQuote.fromJson(Map<String, dynamic> j) => FoodQuote(
quoteToken: j['quote_token']?.toString() ?? '',
itemsTotal: int.tryParse(j['items_total']?.toString() ?? '0') ?? 0,
deliveryFee: int.tryParse(j['delivery_fee']?.toString() ?? '0') ?? 0,
serviceFee: int.tryParse(j['service_fee']?.toString() ?? '0') ?? 0,
grandTotal: int.tryParse(j['grand_total']?.toString() ?? '0') ?? 0,
expiresIn: int.tryParse(j['expires_in']?.toString() ?? '600') ?? 600,
);
}
class FoodOrderItemLine {
final String nameArSnapshot;
final int unitPrice;
final int quantity;
final int lineTotal;
FoodOrderItemLine({
required this.nameArSnapshot,
required this.unitPrice,
required this.quantity,
required this.lineTotal,
});
factory FoodOrderItemLine.fromJson(Map<String, dynamic> j) => FoodOrderItemLine(
nameArSnapshot: j['name_ar_snapshot']?.toString() ?? '',
unitPrice: int.tryParse(j['unit_price']?.toString() ?? '0') ?? 0,
quantity: int.tryParse(j['quantity']?.toString() ?? '1') ?? 1,
lineTotal: int.tryParse(j['line_total']?.toString() ?? '0') ?? 0,
);
}
class FoodOrder {
final int id;
final String status;
final int itemsTotal;
final int deliveryFee;
final int grandTotal;
final String? deliveryAddress;
final int? rating;
final DateTime? createdAt;
final DateTime? deliveredAt;
final String? merchantNameAr;
final String? merchantLogoUrl;
final List<FoodOrderItemLine> items;
FoodOrder({
required this.id,
required this.status,
required this.itemsTotal,
required this.deliveryFee,
required this.grandTotal,
this.deliveryAddress,
this.rating,
this.createdAt,
this.deliveredAt,
this.merchantNameAr,
this.merchantLogoUrl,
this.items = const [],
});
factory FoodOrder.fromJson(Map<String, dynamic> j) => FoodOrder(
id: int.tryParse(j['id'].toString()) ?? 0,
status: j['status']?.toString() ?? 'pending',
itemsTotal: int.tryParse(j['items_total']?.toString() ?? '0') ?? 0,
deliveryFee: int.tryParse(j['delivery_fee']?.toString() ?? '0') ?? 0,
grandTotal: int.tryParse(j['grand_total']?.toString() ?? '0') ?? 0,
deliveryAddress: j['delivery_address']?.toString(),
rating: j['rating'] == null ? null : int.tryParse(j['rating'].toString()),
createdAt: DateTime.tryParse(j['created_at']?.toString() ?? ''),
deliveredAt: j['delivered_at'] == null ? null : DateTime.tryParse(j['delivered_at'].toString()),
merchantNameAr: j['merchant_name_ar']?.toString(),
merchantLogoUrl: j['merchant_logo_url']?.toString(),
items: (j['items'] is List)
? (j['items'] as List)
.map((i) => FoodOrderItemLine.fromJson(Map<String, dynamic>.from(i)))
.toList()
: <FoodOrderItemLine>[],
);
}
// ترتيب حالات الطلب لعرض خط زمني تصاعدي في شاشة التتبع
const List<String> foodOrderStatusFlow = [
'pending',
'merchant_accepted',
'preparing',
'ready',
'courier_assigned',
'picked_up',
'delivered',
];
bool foodOrderIsCancelled(String status) =>
status.startsWith('cancelled') || status == 'rejected';
// المبالغ في backend/food مخزّنة بأصغر وحدة نقدية (fils) — العامل هنا يطابق
// FOOD_CURRENCY_DIVISOR الافتراضي في docker/.env.example (1000). إن غُيّر
// هناك يجب تغييره هنا أيضاً — القيمتان يجب أن تبقيا متطابقتين دائماً.
const int foodCurrencyDivisor = 1000;
String foodFormatPrice(int smallestUnit, {String currencySymbol = 'د.أ'}) {
final decimal = smallestUnit / foodCurrencyDivisor;
return '${decimal.toStringAsFixed(3)} $currencySymbol';
}
@@ -0,0 +1,199 @@
// food_service.dart — طبقة الاتصال بـ backend/food (جهة الراكب)
import 'dart:convert';
import '../functions/crud.dart';
import '../../constant/links.dart';
import 'food_models.dart';
class FoodApiResult<T> {
final bool success;
final T? data;
final String message;
final int? code;
FoodApiResult(this.success, this.data, this.message, {this.code});
}
class FoodService {
static String get _base => '${AppLink.server}/food';
static Future<FoodApiResult<List<FoodMerchant>>> browseMerchants({
required String city,
String? category,
}) async {
final payload = <String, dynamic>{'city': city};
if (category != null && category.isNotEmpty) payload['category'] = category;
final res = await CRUD().post(link: '$_base/merchant/browse.php', payload: payload);
if (res is Map && res['status'] == 'success') {
final msg = res['message'];
final list = (msg is Map && msg['merchants'] is List)
? (msg['merchants'] as List)
.map((m) => FoodMerchant.fromJson(Map<String, dynamic>.from(m)))
.toList()
: <FoodMerchant>[];
return FoodApiResult(true, list, 'ok');
}
return FoodApiResult(false, null, _errMsg(res));
}
static Future<FoodApiResult<List<FoodMerchant>>> searchMerchants({
required String city,
required String query,
}) async {
final res = await CRUD().post(
link: '$_base/merchant/search.php',
payload: {'city': city, 'q': query},
);
if (res is Map && res['status'] == 'success') {
final msg = res['message'];
final list = (msg is Map && msg['merchants'] is List)
? (msg['merchants'] as List)
.map((m) => FoodMerchant.fromJson(Map<String, dynamic>.from(m)))
.toList()
: <FoodMerchant>[];
return FoodApiResult(true, list, 'ok');
}
return FoodApiResult(false, null, _errMsg(res));
}
static Future<FoodApiResult<Map<String, dynamic>>> merchantDetails(int merchantId) async {
final res = await CRUD().post(
link: '$_base/merchant/details.php',
payload: {'merchant_id': merchantId.toString()},
);
if (res is Map && res['status'] == 'success') {
final msg = res['message'];
if (msg is Map) {
final merchant = FoodMerchant.fromJson(Map<String, dynamic>.from(msg['merchant']));
final categories = (msg['categories'] is List)
? (msg['categories'] as List)
.map((c) => FoodMenuCategory.fromJson(Map<String, dynamic>.from(c)))
.toList()
: <FoodMenuCategory>[];
return FoodApiResult(true, {'merchant': merchant, 'categories': categories}, 'ok');
}
}
return FoodApiResult(false, null, _errMsg(res));
}
static Future<FoodApiResult<FoodQuote>> getQuote({
required int merchantId,
required List<FoodCartLine> lines,
}) async {
final res = await CRUD().post(
link: '$_base/cart/quote.php',
payload: {
'merchant_id': merchantId.toString(),
'items': jsonEncode(lines.map((l) => l.toQuotePayload()).toList()),
},
);
if (res is Map && res['status'] == 'success') {
final msg = res['message'];
if (msg is Map) return FoodApiResult(true, FoodQuote.fromJson(Map<String, dynamic>.from(msg)), 'ok');
}
return FoodApiResult(false, null, _errMsg(res), code: _errCode(res));
}
static Future<FoodApiResult<int>> createOrder({
required String quoteToken,
required String clientOrderUuid,
required String deliveryAddress,
required double deliveryLat,
required double deliveryLng,
required String paymentMethod, // wallet | cash
String? customerNote,
}) async {
final res = await CRUD().post(
link: '$_base/order/create.php',
payload: {
'quote_token': quoteToken,
'client_order_uuid': clientOrderUuid,
'delivery_address': deliveryAddress,
'delivery_lat': deliveryLat.toString(),
'delivery_lng': deliveryLng.toString(),
'payment_method': paymentMethod,
if (customerNote != null && customerNote.isNotEmpty) 'customer_note': customerNote,
},
);
if (res is Map && res['status'] == 'success') {
final msg = res['message'];
if (msg is Map && msg['order_id'] != null) {
return FoodApiResult(true, int.tryParse(msg['order_id'].toString()) ?? 0, 'ok');
}
}
return FoodApiResult(false, null, _errMsg(res), code: _errCode(res));
}
static Future<FoodApiResult<FoodOrder>> getOrderStatus(int orderId) async {
final res = await CRUD().post(
link: '$_base/order/status.php',
payload: {'order_id': orderId.toString()},
);
if (res is Map && res['status'] == 'success') {
final msg = res['message'];
if (msg is Map && msg['order'] is Map) {
return FoodApiResult(true, FoodOrder.fromJson(Map<String, dynamic>.from(msg['order'])), 'ok');
}
}
return FoodApiResult(false, null, _errMsg(res));
}
static Future<FoodApiResult<void>> cancelOrder(int orderId, {String? reason}) async {
final res = await CRUD().post(
link: '$_base/order/cancel.php',
payload: {'order_id': orderId.toString(), if (reason != null) 'reason': reason},
);
if (res is Map && res['status'] == 'success') return FoodApiResult(true, null, 'ok');
return FoodApiResult(false, null, _errMsg(res));
}
static Future<FoodApiResult<void>> rateOrder(int orderId, int rating, {String? comment}) async {
final res = await CRUD().post(
link: '$_base/order/rate.php',
payload: {
'order_id': orderId.toString(),
'rating': rating.toString(),
if (comment != null && comment.isNotEmpty) 'comment': comment,
},
);
if (res is Map && res['status'] == 'success') return FoodApiResult(true, null, 'ok');
return FoodApiResult(false, null, _errMsg(res));
}
static Future<FoodApiResult<List<FoodOrder>>> getHistory({int page = 1}) async {
final res = await CRUD().post(
link: '$_base/order/history.php',
payload: {'page': page.toString()},
);
if (res is Map && res['status'] == 'success') {
final msg = res['message'];
final list = (msg is Map && msg['orders'] is List)
? (msg['orders'] as List)
.map((o) => FoodOrder.fromJson(Map<String, dynamic>.from(o)))
.toList()
: <FoodOrder>[];
return FoodApiResult(true, list, 'ok');
}
return FoodApiResult(false, null, _errMsg(res));
}
static int? _errCode(dynamic res) {
if (res is Map && res['code'] != null) return int.tryParse(res['code'].toString());
return null;
}
static String _errMsg(dynamic res) {
if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت';
if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً';
if (res is Map && res['message'] is String) return res['message'];
return 'حدث خطأ، حاول مجدداً';
}
}
+68 -22
View File
@@ -24,7 +24,6 @@ class CRUD {
final NetGuard _netGuard = NetGuard();
final _client = SslPinning.createPinnedClient();
static bool _isRefreshingJWT = false;
static String _lastErrorSignature = '';
static DateTime _lastErrorTimestamp = DateTime(2000);
static const Duration _errorLogDebounceDuration = Duration(minutes: 1);
@@ -98,31 +97,50 @@ class CRUD {
Future<String> _getJwt() async {
try {
final String? encryptedJwt = await storage.read(key: BoxName.jwt);
if (encryptedJwt == null || encryptedJwt.isEmpty) {
final String? fallback = box.read(BoxName.jwt);
final jwt = await storage.read(key: BoxName.jwt);
if (jwt == null || jwt.toString().isEmpty) {
// إذا كان التخزين الآمن فارغاً، نحاول استخراج التوكن القديم من GetStorage للركاب القدامى
final fallback = box.read(BoxName.jwt);
if (fallback != null) {
return r(fallback).toString().split(Env.addd)[0];
try {
return r(fallback).toString().split(Env.addd)[0]; // فك تشفير القديم
} catch (_) {
return fallback.toString(); // ربما تم تخزينه بدون تشفير
}
}
return '';
}
return r(encryptedJwt).toString().split(Env.addd)[0];
} catch (e) {
Log.print('Error reading JWT from SecureStorage: $e');
final String? fallback = box.read(BoxName.jwt);
if (fallback != null) {
return r(fallback).toString().split(Env.addd)[0];
// التحقق السريع إذا كان التوكن لا يزال مشفراً (يبدأ برموز غريبة وليس ey)
if (!jwt.startsWith('ey')) {
try {
return r(jwt).toString().split(Env.addd)[0];
} catch (_) {}
}
return jwt;
} catch (_) {
return '';
}
}
// ═══════════════════════════════════════════════════════════════
// _ensureJwt — يضمن وجود توكن صالح قبل الإرسال
// ═══════════════════════════════════════════════════════════════
Future<String> _ensureJwt() async {
String token = await _getJwt();
if (_isJwtValid(token)) return token;
final ok = await Get.put(LoginController()).getJWT();
if (!ok) return '';
return await _getJwt();
}
/// Centralized request handler with retry for weak networks.
/// For Syria (3G): 60s total timeout, 3 retries, exponential backoff.
Future<dynamic> _makeRequest({
required String link,
Map<String, dynamic>? payload,
required Map<String, String> headers,
bool allowRefresh = true,
}) async {
const totalTimeout = Duration(seconds: 60);
@@ -180,17 +198,33 @@ class CRUD {
}
}
// 429 → السيرفر رافض بسبب الضغط؛ ممنوع نجدّد التوكن أو نعيد المحاولة
if (sc == 429) {
Log.print('🛑 [RES] 429 rate limited — $link');
return 'rate_limited';
}
// 401 → تجديد التوكن مرة واحدة ثم إعادة الطلب مرة واحدة فقط
if (sc == 401) {
// تخطي تجديد التوكن لـ endpoints غير حرجة (مثل تسجيل الأخطاء)
final isNonCritical = link.contains('errorApp.php');
if (!_isRefreshingJWT && !isNonCritical) {
_isRefreshingJWT = true;
try {
await Get.put(LoginController()).getJWT();
} finally {
_isRefreshingJWT = false;
}
}
return 'token_expired';
if (isNonCritical || !allowRefresh) return 'token_expired';
final refreshed = await Get.put(LoginController()).getJWT();
if (!refreshed) return 'token_expired';
final newToken = await _getJwt();
if (newToken.isEmpty) return 'token_expired';
// إعادة الطلب بالتوكن الجديد — allowRefresh: false يمنع أي تكرار إضافي
final retryHeaders = Map<String, String>.from(headers)
..['Authorization'] = 'Bearer $newToken';
return await _makeRequest(
link: link,
payload: payload,
headers: retryHeaders,
allowRefresh: false,
);
}
if (sc >= 500) {
@@ -206,7 +240,14 @@ class CRUD {
required String link,
Map<String, dynamic>? payload,
}) async {
String token = await _getJwt();
String token = await _ensureJwt();
if (token.isEmpty) {
// إذا فشل الحصول على توكن، لا ترسل الطلب للباك إند لأنّه سيرفض حتماً.
// باستثناء تسجيل الدخول لأنه لا يحتاج توكن
if (!link.contains('login') && !link.contains('errorApp.php')) {
return 'token_expired';
}
}
final headers = {
'Content-Type': 'application/x-www-form-urlencoded',
@@ -221,7 +262,12 @@ class CRUD {
required String link,
Map<String, dynamic>? payload,
}) async {
String token = await _getJwt();
String token = await _ensureJwt();
if (token.isEmpty) {
if (!link.contains('login') && !link.contains('errorApp.php')) {
return 'token_expired';
}
}
final headers = {
'Content-Type': 'application/x-www-form-urlencoded',
@@ -42,6 +42,15 @@ class EncryptionHelper {
debugPrint("EncryptionHelper initialized successfully.");
}
/// Encrypts a string using AES-256-CBC with constant IV (deterministic)
/// Same input always produces the same output
String encryptDataCbc(String plainText) {
final cbcEncrypter =
encrypt.Encrypter(encrypt.AES(key, mode: encrypt.AESMode.cbc));
final encrypted = cbcEncrypter.encrypt(plainText, iv: iv);
return encrypted.base64;
}
/// ✅ FIX H-04: Encrypts a string using AES-256-GCM (new) with random IV
String encryptData(String plainText) {
try {
@@ -190,15 +190,11 @@ void showUpdateDialog(BuildContext context) {
class DeviceHelper {
static Future<String> getDeviceFingerprint() async {
// ── التحقق من وجود بصمة مخزّنة مسبقاً ──────────────────────
// AES-GCM يستخدم IV عشوائي كل مرة، فالتشفير ينتج نتيجة مختلفة
// حتى لو النص الأصلي نفسه. لذلك نخزّن البصمة المشفرة أول مرة
// ونرجعها من التخزين في كل مرة بعدها لضمان الثبات.
final String? cachedFp = box.read(BoxName.deviceFpEncrypted);
if (cachedFp != null && cachedFp.isNotEmpty) {
return cachedFp;
}
// ── البصمة حتمية: AES-CBC بـ IV ثابت ────────────────────────
// نفس الجهاز ⇒ نفس الناتج دائماً، حتى بعد مسح بيانات التطبيق أو
// إعادة التثبيت. لذلك لا نعتمد على الكاش كمصدر للثبات (كان ضرورياً
// أيام AES-GCM بالـ IV العشوائي)، بل نعيد الحساب في كل مرة ونكتب
// القيمة في التخزين فقط لتقرأها بقية الشاشات.
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
var deviceData;
@@ -231,7 +227,7 @@ class DeviceHelper {
// Generate and return the encrypted fingerprint
final String fingerprint = '${deviceId}_$deviceModel';
final String encryptedFp =
EncryptionHelper.instance.encryptData(fingerprint);
EncryptionHelper.instance.encryptDataCbc(fingerprint);
box.write(BoxName.deviceFpEncrypted, encryptedFp);
//Log.print(EncryptionHelper.instance.encryptData(fingerprint));
return encryptedFp;
@@ -163,6 +163,7 @@ class LocationSearchController extends GetxController {
];
readyWayPoints();
getLocation();
_listenForDeepLink();
}
void readyWayPoints() {
@@ -567,6 +567,17 @@ class MapEngineController extends GetxController {
update();
}
/// إغلاق القائمة الجانبية بشكل مباشر (idempotent) — بعكس [getDrawerMenu]
/// التي تعمل كمفتاح تبديل، هذه تُغلق فقط ولا تفتح إن كانت مغلقة أصلاً.
void closeDrawerMenu() {
if (!heightMenuBool) return;
heightMenuBool = false;
widthMapTypeAndTraffic = 50;
heightMenu = 0;
widthMenu = 0;
update();
}
void changeMainBottomMenuMap() {
if (isWayPointStopsSheetUtilGetMap == true) {
changeWayPointSheet();
@@ -796,6 +796,19 @@ final Map<String, String> ar_eg = {
"Open Settings": "افتح الإعدادات",
"Open destination search": "فتح بحث الوجهات",
"Open in Google Maps": "فتح في خرائط جوجل",
"Canceled by you": "إنت لغيتها",
"Canceled by driver": "السواق لغاها",
"Canceled by driver after accepting": "السواق لغاها بعد الموافقة",
"Searching for a driver": "بندور على سواق",
"Driver on the way": "السواق في الطريق",
"Trip in progress": "الرحلة شغالة",
"Not completed": "ما اكتملتش",
"To": "إلى",
"Locating": "جاري التحديد",
"km": "كم",
"Rebook": "إعادة الحجز",
"Reverse trip": "عكس الرحلة",
"Please open the map first": "افتح الخريطة الأول",
"Or pay with Cash instead": "أو ادفع كاش",
"Order": "طلب",
"Order Accepted": "تم قبول الطلب",
@@ -795,6 +795,19 @@ final Map<String, String> ar_jo = {
"Open Settings": "افتح الإعدادات",
"Open destination search": "فتح بحث الوجهات",
"Open in Google Maps": "فتح في خرائط جوجل",
"Canceled by you": "ألغيتها أنت",
"Canceled by driver": "ألغاها السائق",
"Canceled by driver after accepting": "ألغاها السائق بعد القبول",
"Searching for a driver": "البحث عن سائق",
"Driver on the way": "السائق في الطريق",
"Trip in progress": "الرحلة جارية",
"Not completed": "لم تكتمل",
"To": "إلى",
"Locating": "جارٍ التحديد",
"km": "كم",
"Rebook": "إعادة الحجز",
"Reverse trip": "عكس الرحلة",
"Please open the map first": "افتح الخريطة أولاً",
"Or pay with Cash instead": "أو ادفع نقداً",
"Order": "طلب",
"Order Accepted": "تم قبول الطلب",
@@ -796,6 +796,19 @@ final Map<String, String> ar_sy = {
"Open Settings": "افتح الإعدادات",
"Open destination search": "فتح بحث الوجهات",
"Open in Google Maps": "فتح في خرائط جوجل",
"Canceled by you": "ألغيتها أنت",
"Canceled by driver": "ألغاها السائق",
"Canceled by driver after accepting": "ألغاها السائق بعد القبول",
"Searching for a driver": "البحث عن سائق",
"Driver on the way": "السائق في الطريق",
"Trip in progress": "الرحلة جارية",
"Not completed": "لم تكتمل",
"To": "إلى",
"Locating": "جارٍ التحديد",
"km": "كم",
"Rebook": "إعادة الحجز",
"Reverse trip": "عكس الرحلة",
"Please open the map first": "افتح الخريطة أولاً",
"Or pay with Cash instead": "أو ادفع كاش",
"Order": "طلب",
"Order Accepted": "تم قبول الطلب",
+13
View File
@@ -762,6 +762,19 @@ final Map<String, String> de = {
"Open Settings": "Einstellungen öffnen",
"Open destination search": "Open destination search",
"Open in Google Maps": "Open in Google Maps",
"Canceled by you": "Von dir storniert",
"Canceled by driver": "Vom Fahrer storniert",
"Canceled by driver after accepting": "Vom Fahrer nach Annahme storniert",
"Searching for a driver": "Fahrersuche",
"Driver on the way": "Fahrer unterwegs",
"Trip in progress": "Fahrt läuft",
"Not completed": "Nicht abgeschlossen",
"To": "Nach",
"Locating": "Wird ermittelt",
"km": "km",
"Rebook": "Erneut buchen",
"Reverse trip": "Fahrt umkehren",
"Please open the map first": "Bitte zuerst die Karte öffnen",
"Or pay with Cash instead": "Oder zahlen Sie stattdessen bar",
"Order": "Bestellung",
"Order Accepted": "Bestellung angenommen",
+13
View File
@@ -762,6 +762,19 @@ final Map<String, String> el = {
"Open Settings": "Ρυθμίσεις",
"Open destination search": "Open destination search",
"Open in Google Maps": "Open in Google Maps",
"Canceled by you": "Ακυρώθηκε από εσάς",
"Canceled by driver": "Ακυρώθηκε από τον οδηγό",
"Canceled by driver after accepting": "Ακυρώθηκε από τον οδηγό μετά την αποδοχή",
"Searching for a driver": "Αναζήτηση οδηγού",
"Driver on the way": "Ο οδηγός είναι καθ' οδόν",
"Trip in progress": "Διαδρομή σε εξέλιξη",
"Not completed": "Δεν ολοκληρώθηκε",
"To": "Προς",
"Locating": "Εντοπισμός",
"km": "χλμ",
"Rebook": "Νέα κράτηση",
"Reverse trip": "Αντιστροφή διαδρομής",
"Please open the map first": "Ανοίξτε πρώτα τον χάρτη",
"Or pay with Cash instead": "Ή πληρώστε με Μετρητά",
"Order": "Αίτημα",
"Order Accepted": "Order Accepted",
+13
View File
@@ -762,6 +762,19 @@ final Map<String, String> es = {
"Open Settings": "Abrir configuración",
"Open destination search": "Open destination search",
"Open in Google Maps": "Open in Google Maps",
"Canceled by you": "Cancelado por ti",
"Canceled by driver": "Cancelado por el conductor",
"Canceled by driver after accepting": "Cancelado por el conductor tras aceptar",
"Searching for a driver": "Buscando conductor",
"Driver on the way": "Conductor en camino",
"Trip in progress": "Viaje en curso",
"Not completed": "No completado",
"To": "Hasta",
"Locating": "Localizando",
"km": "km",
"Rebook": "Reservar de nuevo",
"Reverse trip": "Invertir el viaje",
"Please open the map first": "Abre el mapa primero",
"Or pay with Cash instead": "O pague en efectivo en su lugar",
"Order": "Pedido",
"Order Accepted": "Pedido aceptado",
+13
View File
@@ -762,6 +762,19 @@ final Map<String, String> fa = {
"Open Settings": "باز کردن تنظیمات",
"Open destination search": "Open destination search",
"Open in Google Maps": "Open in Google Maps",
"Canceled by you": "توسط شما لغو شد",
"Canceled by driver": "توسط راننده لغو شد",
"Canceled by driver after accepting": "پس از پذیرش توسط راننده لغو شد",
"Searching for a driver": "جستجوی راننده",
"Driver on the way": "راننده در راه است",
"Trip in progress": "سفر در جریان است",
"Not completed": "تکمیل نشده",
"To": "به",
"Locating": "در حال تعیین",
"km": "کیلومتر",
"Rebook": "رزرو مجدد",
"Reverse trip": "معکوس کردن سفر",
"Please open the map first": "ابتدا نقشه را باز کنید",
"Or pay with Cash instead": "یا به صورت نقدی پرداخت کنید",
"Order": "درخواست",
"Order Accepted": "Order Accepted",
+13
View File
@@ -762,6 +762,19 @@ final Map<String, String> fr = {
"Open Settings": "Ouvrir les paramètres",
"Open destination search": "Open destination search",
"Open in Google Maps": "Open in Google Maps",
"Canceled by you": "Annulé par vous",
"Canceled by driver": "Annulé par le chauffeur",
"Canceled by driver after accepting": "Annulé par le chauffeur après acceptation",
"Searching for a driver": "Recherche d'un chauffeur",
"Driver on the way": "Chauffeur en route",
"Trip in progress": "Trajet en cours",
"Not completed": "Non terminé",
"To": "À",
"Locating": "Localisation",
"km": "km",
"Rebook": "Réserver à nouveau",
"Reverse trip": "Inverser le trajet",
"Please open the map first": "Veuillez d'abord ouvrir la carte",
"Or pay with Cash instead": "Ou payez en espèces",
"Order": "Commande",
"Order Accepted": "Order Accepted",
+13
View File
@@ -762,6 +762,19 @@ final Map<String, String> hi = {
"Open Settings": "सेटिंग्स खोलें",
"Open destination search": "Open destination search",
"Open in Google Maps": "Open in Google Maps",
"Canceled by you": "आपने रद्द किया",
"Canceled by driver": "चालक ने रद्द किया",
"Canceled by driver after accepting": "स्वीकार करने के बाद चालक ने रद्द किया",
"Searching for a driver": "चालक खोजा जा रहा है",
"Driver on the way": "चालक रास्ते में है",
"Trip in progress": "यात्रा जारी है",
"Not completed": "पूर्ण नहीं हुआ",
"To": "तक",
"Locating": "पता लगाया जा रहा है",
"km": "किमी",
"Rebook": "फिर से बुक करें",
"Reverse trip": "यात्रा उलटें",
"Please open the map first": "पहले मानचित्र खोलें",
"Or pay with Cash instead": "या नकद भुगतान करें",
"Order": "ऑर्डर",
"Order Accepted": "Order Accepted",
+13
View File
@@ -762,6 +762,19 @@ final Map<String, String> it = {
"Open Settings": "Impostazioni",
"Open destination search": "Open destination search",
"Open in Google Maps": "Open in Google Maps",
"Canceled by you": "Annullato da te",
"Canceled by driver": "Annullato dall'autista",
"Canceled by driver after accepting": "Annullato dall'autista dopo l'accettazione",
"Searching for a driver": "Ricerca autista",
"Driver on the way": "Autista in arrivo",
"Trip in progress": "Viaggio in corso",
"Not completed": "Non completato",
"To": "A",
"Locating": "Localizzazione",
"km": "km",
"Rebook": "Prenota di nuovo",
"Reverse trip": "Inverti il viaggio",
"Please open the map first": "Apri prima la mappa",
"Or pay with Cash instead": "O paga in contanti",
"Order": "Ordine",
"Order Accepted": "Order Accepted",
+13
View File
@@ -762,6 +762,19 @@ final Map<String, String> ru = {
"Open Settings": "Настройки",
"Open destination search": "Open destination search",
"Open in Google Maps": "Open in Google Maps",
"Canceled by you": "Отменено вами",
"Canceled by driver": "Отменено водителем",
"Canceled by driver after accepting": "Отменено водителем после принятия",
"Searching for a driver": "Поиск водителя",
"Driver on the way": "Водитель в пути",
"Trip in progress": "Поездка выполняется",
"Not completed": "Не завершено",
"To": "Куда",
"Locating": "Определение",
"km": "км",
"Rebook": "Заказать снова",
"Reverse trip": "Обратный маршрут",
"Please open the map first": "Сначала откройте карту",
"Or pay with Cash instead": "Или оплатите наличными",
"Order": "Заказ",
"Order Accepted": "Order Accepted",
+13
View File
@@ -762,6 +762,19 @@ final Map<String, String> tr = {
"Open Settings": "Ayarları Aç",
"Open destination search": "Open destination search",
"Open in Google Maps": "Open in Google Maps",
"Canceled by you": "Sizin tarafınızdan iptal edildi",
"Canceled by driver": "Sürücü iptal etti",
"Canceled by driver after accepting": "Sürücü kabul ettikten sonra iptal etti",
"Searching for a driver": "Sürücü aranıyor",
"Driver on the way": "Sürücü yolda",
"Trip in progress": "Yolculuk sürüyor",
"Not completed": "Tamamlanmadı",
"To": "Nereye",
"Locating": "Belirleniyor",
"km": "km",
"Rebook": "Yeniden rezerve et",
"Reverse trip": "Yolculuğu ters çevir",
"Please open the map first": "Lütfen önce haritayı açın",
"Or pay with Cash instead": "Veya Nakit öde",
"Order": "Sipariş",
"Order Accepted": "Order Accepted",
+13
View File
@@ -762,6 +762,19 @@ final Map<String, String> ur = {
"Open Settings": "ترتیبات کھولیں",
"Open destination search": "Open destination search",
"Open in Google Maps": "Open in Google Maps",
"Canceled by you": "آپ نے منسوخ کیا",
"Canceled by driver": "ڈرائیور نے منسوخ کیا",
"Canceled by driver after accepting": "قبول کرنے کے بعد ڈرائیور نے منسوخ کیا",
"Searching for a driver": "ڈرائیور تلاش کیا جا رہا ہے",
"Driver on the way": "ڈرائیور راستے میں ہے",
"Trip in progress": "سفر جاری ہے",
"Not completed": "مکمل نہیں ہوا",
"To": "تک",
"Locating": "تعین ہو رہا ہے",
"km": "کلومیٹر",
"Rebook": "دوبارہ بک کریں",
"Reverse trip": "سفر الٹا کریں",
"Please open the map first": "پہلے نقشہ کھولیں",
"Or pay with Cash instead": "یا اس کے بجائے نقد ادائیگی کریں",
"Order": "آرڈر",
"Order Accepted": "Order Accepted",
+13
View File
@@ -762,6 +762,19 @@ final Map<String, String> zh = {
"Open Settings": "افتح الإعدادات",
"Open destination search": "Open destination search",
"Open in Google Maps": "Open in Google Maps",
"Canceled by you": "您已取消",
"Canceled by driver": "司机已取消",
"Canceled by driver after accepting": "司机接单后取消",
"Searching for a driver": "正在寻找司机",
"Driver on the way": "司机正在赶来",
"Trip in progress": "行程进行中",
"Not completed": "未完成",
"To": "到",
"Locating": "定位中",
"km": "公里",
"Rebook": "重新预订",
"Reverse trip": "反向行程",
"Please open the map first": "请先打开地图",
"Or pay with Cash instead": "أو ادفع كاش",
"Order": "طلب",
"Order Accepted": "Order Accepted",
@@ -0,0 +1,248 @@
// food_cart_page.dart — مراجعة السلة، عرض السعر الموقّع، وإنشاء الطلب
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:geolocator/geolocator.dart';
import '../../constant/box_name.dart';
import '../../constant/colors.dart';
import '../../constant/style.dart';
import '../../main.dart';
import '../../views/widgets/error_snakbar.dart';
import '../../controller/food/food_controller.dart';
import '../../controller/food/food_models.dart';
import '../widgets/my_scafold.dart';
import 'food_order_tracking_page.dart';
class FoodCartPage extends StatefulWidget {
const FoodCartPage({super.key});
@override
State<FoodCartPage> createState() => _FoodCartPageState();
}
class _FoodCartPageState extends State<FoodCartPage> {
final TextEditingController _addressController = TextEditingController();
final TextEditingController _noteController = TextEditingController();
String _paymentMethod = 'wallet';
Position? _position;
bool _isFetchingLocation = false;
bool get _isAr => box.read(BoxName.lang) == 'ar';
@override
void initState() {
super.initState();
_fetchLocation();
}
Future<void> _fetchLocation() async {
setState(() => _isFetchingLocation = true);
try {
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
await Geolocator.requestPermission();
}
_position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
} catch (_) {
_position = null;
}
if (mounted) setState(() => _isFetchingLocation = false);
}
@override
Widget build(BuildContext context) {
return GetBuilder<FoodController>(
builder: (c) => MyScafolld(
title: _isAr ? 'السلة' : 'Cart',
isleading: true,
body: [
c.cartLines.isEmpty ? _emptyCart() : _cartContent(c),
],
),
);
}
Widget _emptyCart() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.shopping_cart_outlined, size: 72, color: AppColor.grayColor),
const SizedBox(height: 16),
Text(_isAr ? 'سلتك فارغة' : 'Your cart is empty', style: AppStyle.title),
],
),
);
}
Widget _cartContent(FoodController c) {
return Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
...c.cartLines.map((line) => _cartLineTile(c, line)),
const SizedBox(height: 16),
_summaryRow(_isAr ? 'المجموع الفرعي' : 'Subtotal', foodFormatPrice(c.cartTotal)),
if (c.currentQuote != null) ...[
_summaryRow(_isAr ? 'رسوم التوصيل' : 'Delivery fee', foodFormatPrice(c.currentQuote!.deliveryFee)),
const Divider(),
_summaryRow(_isAr ? 'الإجمالي' : 'Total', foodFormatPrice(c.currentQuote!.grandTotal), bold: true),
],
const SizedBox(height: 20),
TextField(
controller: _addressController,
decoration: InputDecoration(
labelText: _isAr ? 'عنوان التوصيل' : 'Delivery address',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
suffixIcon: _isFetchingLocation
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
)
: Icon(_position != null ? Icons.my_location_rounded : Icons.location_off_rounded,
color: _position != null ? AppColor.greenColor : AppColor.redColor),
),
),
const SizedBox(height: 12),
TextField(
controller: _noteController,
decoration: InputDecoration(
labelText: _isAr ? 'ملاحظات (اختياري)' : 'Notes (optional)',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 16),
_paymentMethodPicker(),
],
),
),
Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, MediaQuery.of(context).padding.bottom + 16),
child: SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColor.primaryColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: c.isQuoting || c.isPlacingOrder ? null : () => _onCheckoutPressed(c),
child: c.isPlacingOrder
? const CircularProgressIndicator(color: Colors.white)
: Text(
c.currentQuote == null
? (_isAr ? 'احسب السعر' : 'Get Quote')
: '${_isAr ? "اطلب الآن" : "Place Order"} · ${foodFormatPrice(c.currentQuote!.grandTotal)}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16),
),
),
),
),
],
);
}
Future<void> _onCheckoutPressed(FoodController c) async {
if (c.currentQuote == null) {
await c.refreshQuote();
return;
}
if (_addressController.text.trim().isEmpty) {
mySnackbarWarning(_isAr ? 'أدخل عنوان التوصيل' : 'Enter a delivery address');
return;
}
if (_position == null) {
mySnackbarWarning(_isAr ? 'تعذّر تحديد موقعك — فعّل خدمة الموقع' : 'Could not get your location — enable location services');
return;
}
final orderId = await c.placeOrder(
deliveryAddress: _addressController.text.trim(),
deliveryLat: _position!.latitude,
deliveryLng: _position!.longitude,
paymentMethod: _paymentMethod,
customerNote: _noteController.text.trim(),
);
if (orderId != null) {
Get.offAll(() => FoodOrderTrackingPage(orderId: orderId));
}
}
Widget _cartLineTile(FoodController c, FoodCartLine line) {
return Card(
margin: const EdgeInsets.only(bottom: 10),
color: AppColor.cardColor,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: AppColor.borderColor),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(line.item.nameAr, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(foodFormatPrice(line.lineTotal), style: AppStyle.subtitle.copyWith(color: AppColor.accentColor)),
],
),
),
IconButton(
icon: const Icon(Icons.remove_circle_outline_rounded),
onPressed: () => c.updateQuantity(line.lineKey, line.quantity - 1),
),
Text(line.quantity.toString(), style: AppStyle.title),
IconButton(
icon: const Icon(Icons.add_circle_outline_rounded),
onPressed: () => c.updateQuantity(line.lineKey, line.quantity + 1),
),
],
),
),
);
}
Widget _summaryRow(String label, String value, {bool bold = false}) {
final style = bold
? AppStyle.title.copyWith(fontWeight: FontWeight.bold, fontSize: 18)
: AppStyle.subtitle;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text(label, style: style), Text(value, style: style)],
),
);
}
Widget _paymentMethodPicker() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_isAr ? 'طريقة الدفع' : 'Payment method', style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
RadioListTile<String>(
contentPadding: EdgeInsets.zero,
value: 'wallet',
groupValue: _paymentMethod,
title: Text(_isAr ? 'المحفظة' : 'Wallet'),
onChanged: (v) => setState(() => _paymentMethod = v!),
),
RadioListTile<String>(
contentPadding: EdgeInsets.zero,
value: 'cash',
groupValue: _paymentMethod,
title: Text(_isAr ? 'نقداً عند الاستلام' : 'Cash on delivery'),
onChanged: (v) => setState(() => _paymentMethod = v!),
),
],
);
}
}
@@ -0,0 +1,270 @@
// food_home_page.dart — الصفحة الرئيسية لتبويب "طعام": تصفح المطاعم
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../constant/box_name.dart';
import '../../constant/colors.dart';
import '../../constant/style.dart';
import '../../main.dart';
import '../../controller/food/food_controller.dart';
import '../../controller/food/food_models.dart';
import '../widgets/my_scafold.dart';
import 'food_merchant_page.dart';
import 'food_cart_page.dart';
import 'food_order_history_page.dart';
class FoodHomePage extends StatelessWidget {
const FoodHomePage({super.key});
bool get _isAr => box.read(BoxName.lang) == 'ar';
@override
Widget build(BuildContext context) {
final c = Get.put(FoodController());
c.fetchMerchants();
return GetBuilder<FoodController>(
builder: (c) => MyScafolld(
title: _isAr ? 'طعام' : 'Food',
isleading: true,
action: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.history_rounded, color: AppColor.primaryColor),
onPressed: () => Get.to(() => const FoodOrderHistoryPage()),
),
Stack(
clipBehavior: Clip.none,
children: [
IconButton(
icon: Icon(Icons.shopping_cart_rounded, color: AppColor.primaryColor),
onPressed: () => Get.to(() => const FoodCartPage()),
),
if (c.cartItemsCount > 0)
Positioned(
right: 4,
top: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: AppColor.redColor, shape: BoxShape.circle),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
child: Text(
c.cartItemsCount.toString(),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold),
),
),
),
],
),
],
),
body: [
Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: _citySearchBar(c),
),
Expanded(
child: RefreshIndicator(
onRefresh: () => c.fetchMerchants(),
child: c.isLoadingMerchants
? const Center(child: CircularProgressIndicator())
: c.merchants.isEmpty
? _emptyState()
: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
itemCount: c.merchants.length,
itemBuilder: (_, i) => _merchantCard(c.merchants[i]),
),
),
),
],
),
],
),
);
}
Widget _citySearchBar(FoodController c) {
return Row(
children: [
Expanded(
child: InkWell(
onTap: () => _showCityPicker(c),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: AppColor.cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.borderColor),
),
child: Row(
children: [
Icon(Icons.location_on_rounded, color: AppColor.accentColor, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(c.city, style: AppStyle.title, overflow: TextOverflow.ellipsis),
),
Icon(Icons.keyboard_arrow_down_rounded, color: AppColor.grayColor),
],
),
),
),
),
const SizedBox(width: 8),
InkWell(
onTap: () => _showSearchDialog(c),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColor.cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.borderColor),
),
child: Icon(Icons.search_rounded, color: AppColor.primaryColor),
),
),
],
);
}
void _showCityPicker(FoodController c) {
final controller = TextEditingController(text: c.city);
Get.dialog(
AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text(_isAr ? 'اختر المدينة' : 'Select City'),
content: TextField(
controller: controller,
decoration: InputDecoration(hintText: _isAr ? 'اسم المدينة' : 'City name'),
),
actions: [
TextButton(onPressed: () => Get.back(), child: Text(_isAr ? 'إلغاء' : 'Cancel')),
ElevatedButton(
onPressed: () {
Get.back();
c.setCity(controller.text);
},
child: Text(_isAr ? 'تم' : 'Done'),
),
],
),
);
}
void _showSearchDialog(FoodController c) {
final controller = TextEditingController();
Get.dialog(
AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text(_isAr ? 'بحث عن مطعم أو صنف' : 'Search restaurant or dish'),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(hintText: _isAr ? 'اكتب هنا...' : 'Type here...'),
),
actions: [
TextButton(onPressed: () => Get.back(), child: Text(_isAr ? 'إلغاء' : 'Cancel')),
ElevatedButton(
onPressed: () {
Get.back();
c.searchMerchants(controller.text);
},
child: Text(_isAr ? 'بحث' : 'Search'),
),
],
),
);
}
Widget _emptyState() {
return ListView(
padding: const EdgeInsets.all(24),
children: [
const SizedBox(height: 60),
Icon(Icons.restaurant_menu_rounded, size: 72, color: AppColor.grayColor),
const SizedBox(height: 16),
Text(
_isAr ? 'لا توجد مطاعم متاحة في هذه المدينة حالياً' : 'No restaurants available in this city yet',
textAlign: TextAlign.center,
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold),
),
],
);
}
Widget _merchantCard(FoodMerchant m) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
color: AppColor.cardColor,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppColor.borderColor),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => Get.to(() => FoodMerchantPage(merchantId: m.id)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (m.coverUrl != null && m.coverUrl!.isNotEmpty)
Image.network(m.coverUrl!, height: 120, width: double.infinity, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(height: 120, color: AppColor.borderColor)),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: AppColor.accentColor.withOpacity(0.15),
backgroundImage: (m.logoUrl != null && m.logoUrl!.isNotEmpty)
? NetworkImage(m.logoUrl!)
: null,
child: (m.logoUrl == null || m.logoUrl!.isEmpty)
? Icon(Icons.restaurant_rounded, color: AppColor.accentColor)
: null,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(m.nameAr, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.star_rounded, color: Color(0xFFF59E0B), size: 16),
const SizedBox(width: 2),
Text(m.ratingAvg.toStringAsFixed(1), style: AppStyle.subtitle),
const SizedBox(width: 10),
Icon(Icons.access_time_rounded, size: 14, color: AppColor.grayColor),
const SizedBox(width: 2),
Text('${m.avgPrepMinutes} ${_isAr ? "د" : "min"}', style: AppStyle.subtitle),
],
),
],
),
),
if (!m.isOpen)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColor.redColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(_isAr ? 'مغلق' : 'Closed',
style: TextStyle(color: AppColor.redColor, fontSize: 11, fontWeight: FontWeight.bold)),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,312 @@
// food_merchant_page.dart — قائمة مطعم واحد + إضافة أصناف للسلة
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../constant/box_name.dart';
import '../../constant/colors.dart';
import '../../constant/style.dart';
import '../../main.dart';
import '../../views/widgets/error_snakbar.dart';
import '../../controller/food/food_controller.dart';
import '../../controller/food/food_models.dart';
import '../widgets/my_scafold.dart';
import 'food_cart_page.dart';
class FoodMerchantPage extends StatelessWidget {
final int merchantId;
const FoodMerchantPage({super.key, required this.merchantId});
bool get _isAr => box.read(BoxName.lang) == 'ar';
@override
Widget build(BuildContext context) {
final c = Get.find<FoodController>();
c.openMerchant(merchantId);
return GetBuilder<FoodController>(
builder: (c) {
final merchant = c.selectedMerchant;
return MyScafolld(
title: merchant?.nameAr ?? (_isAr ? 'المطعم' : 'Restaurant'),
isleading: true,
action: Stack(
clipBehavior: Clip.none,
children: [
IconButton(
icon: Icon(Icons.shopping_cart_rounded, color: AppColor.primaryColor),
onPressed: () => Get.to(() => const FoodCartPage()),
),
if (c.cartItemsCount > 0)
Positioned(
right: 4,
top: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: AppColor.redColor, shape: BoxShape.circle),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
child: Text(c.cartItemsCount.toString(),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold)),
),
),
],
),
body: [
c.isLoadingMenu
? const Center(child: CircularProgressIndicator())
: merchant == null
? Center(child: Text(_isAr ? 'تعذر تحميل المطعم' : 'Failed to load restaurant'))
: _content(c, merchant),
],
);
},
);
}
Widget _content(FoodController c, FoodMerchant merchant) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
if (!merchant.isOpen)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: AppColor.redColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_isAr ? 'هذا المطعم مغلق حالياً' : 'This restaurant is currently closed',
style: TextStyle(color: AppColor.redColor, fontWeight: FontWeight.bold),
),
),
if (merchant.descriptionAr != null && merchant.descriptionAr!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(merchant.descriptionAr!, style: AppStyle.subtitle),
),
Text(
_isAr
? 'الحد الأدنى للطلب: ${(merchant.minOrderAmount / 1000).toStringAsFixed(3)} د.أ'
: 'Minimum order: ${(merchant.minOrderAmount / 1000).toStringAsFixed(3)} JOD',
style: AppStyle.subtitle.copyWith(color: AppColor.grayColor),
),
const SizedBox(height: 16),
...c.categories.map((cat) => _categorySection(c, merchant, cat)),
],
);
}
Widget _categorySection(FoodController c, FoodMerchant merchant, FoodMenuCategory cat) {
if (cat.items.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(cat.nameAr, style: AppStyle.headTitle2.copyWith(fontSize: 18)),
const SizedBox(height: 8),
...cat.items.map((item) => _itemTile(c, merchant, item)),
const SizedBox(height: 16),
],
);
}
Widget _itemTile(FoodController c, FoodMerchant merchant, FoodMenuItem item) {
return Card(
margin: const EdgeInsets.only(bottom: 10),
color: AppColor.cardColor,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: AppColor.borderColor),
),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: !item.isAvailable || !merchant.isOpen
? null
: () => _openItemSheet(c, merchant, item),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.nameAr, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
if (item.descriptionAr != null && item.descriptionAr!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(item.descriptionAr!,
style: AppStyle.subtitle, maxLines: 2, overflow: TextOverflow.ellipsis),
],
const SizedBox(height: 6),
Text(foodFormatPrice(item.price),
style: AppStyle.title.copyWith(color: AppColor.accentColor, fontWeight: FontWeight.bold)),
],
),
),
const SizedBox(width: 12),
if (item.imageUrl != null && item.imageUrl!.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.network(item.imageUrl!, width: 72, height: 72, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(width: 72, height: 72, color: AppColor.borderColor)),
),
if (!item.isAvailable)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(_isAr ? 'غير متاح' : 'Unavailable',
style: TextStyle(color: AppColor.redColor, fontSize: 12, fontWeight: FontWeight.bold)),
),
],
),
),
),
);
}
void _openItemSheet(FoodController c, FoodMerchant merchant, FoodMenuItem item) {
final Map<int, List<String>> selected = {};
int quantity = 1;
Get.bottomSheet(
isScrollControlled: true,
StatefulBuilder(
builder: (context, setState) {
int unitPrice = item.price;
for (final group in item.options) {
for (final choiceId in (selected[group.id] ?? [])) {
final choice = group.choices.firstWhere((ch) => ch.id == choiceId,
orElse: () => FoodOptionChoice(id: '', labelAr: '', price: 0));
unitPrice += choice.price;
}
}
bool canSubmit = true;
for (final group in item.options) {
if (group.isRequired && (selected[group.id] ?? []).isEmpty) canSubmit = false;
}
return Container(
constraints: BoxConstraints(maxHeight: Get.height * 0.85),
decoration: BoxDecoration(
color: AppColor.secondaryColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
Container(width: 40, height: 4,
decoration: BoxDecoration(color: Colors.grey.withOpacity(0.3), borderRadius: BorderRadius.circular(2))),
Expanded(
child: ListView(
padding: const EdgeInsets.all(20),
children: [
Text(item.nameAr, style: AppStyle.headTitle2.copyWith(fontSize: 20)),
if (item.descriptionAr != null && item.descriptionAr!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(item.descriptionAr!, style: AppStyle.subtitle),
],
const SizedBox(height: 16),
...item.options.map((group) => _optionGroupWidget(group, selected, setState)),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline_rounded),
onPressed: quantity > 1 ? () => setState(() => quantity--) : null,
),
Text(quantity.toString(), style: AppStyle.headTitle2.copyWith(fontSize: 20)),
IconButton(
icon: const Icon(Icons.add_circle_outline_rounded),
onPressed: () => setState(() => quantity++),
),
],
),
],
),
),
Padding(
padding: EdgeInsets.fromLTRB(20, 8, 20, MediaQuery.of(context).padding.bottom + 16),
child: SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: canSubmit ? AppColor.primaryColor : AppColor.grayColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: !canSubmit
? null
: () {
final ok = c.addToCartForMerchant(merchant.id, item,
quantity: quantity, options: selected);
Get.back();
if (ok) {
mySnackbarSuccess(_isAr ? 'أُضيف إلى السلة' : 'Added to cart');
}
},
child: Text(
'${_isAr ? "إضافة" : "Add"} · ${foodFormatPrice(unitPrice * quantity)}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16),
),
),
),
),
],
),
);
},
),
);
}
Widget _optionGroupWidget(
FoodItemOptionGroup group, Map<int, List<String>> selected, StateSetter setState) {
final isSingle = group.maxSelect <= 1;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(group.groupNameAr, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
if (group.isRequired) ...[
const SizedBox(width: 6),
Text(_isAr ? '(مطلوب)' : '(required)',
style: TextStyle(color: AppColor.redColor, fontSize: 12)),
],
],
),
...group.choices.map((choice) {
final isChecked = (selected[group.id] ?? []).contains(choice.id);
return CheckboxListTile(
contentPadding: EdgeInsets.zero,
dense: true,
value: isChecked,
controlAffinity: ListTileControlAffinity.leading,
title: Text(choice.labelAr),
secondary: choice.price > 0 ? Text('+${foodFormatPrice(choice.price)}') : null,
onChanged: (checked) {
setState(() {
final list = List<String>.from(selected[group.id] ?? []);
if (checked == true) {
if (isSingle) list.clear();
if (!isSingle && list.length >= group.maxSelect) return;
list.add(choice.id);
} else {
list.remove(choice.id);
}
selected[group.id] = list;
});
},
);
}),
],
),
);
}
}
@@ -0,0 +1,102 @@
// food_order_history_page.dart — سجل طلبات الطعام
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../constant/box_name.dart';
import '../../constant/colors.dart';
import '../../constant/style.dart';
import '../../main.dart';
import '../../controller/food/food_models.dart';
import '../../controller/food/food_service.dart';
import '../widgets/my_scafold.dart';
import 'food_order_tracking_page.dart';
class FoodOrderHistoryPage extends StatefulWidget {
const FoodOrderHistoryPage({super.key});
@override
State<FoodOrderHistoryPage> createState() => _FoodOrderHistoryPageState();
}
class _FoodOrderHistoryPageState extends State<FoodOrderHistoryPage> {
bool get _isAr => box.read(BoxName.lang) == 'ar';
bool _isLoading = true;
List<FoodOrder> _orders = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _isLoading = true);
final res = await FoodService.getHistory();
setState(() {
_orders = res.data ?? [];
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return MyScafolld(
title: _isAr ? 'سجل الطلبات' : 'Order History',
isleading: true,
body: [
_isLoading
? const Center(child: CircularProgressIndicator())
: _orders.isEmpty
? Center(
child: Text(_isAr ? 'لا يوجد طلبات سابقة' : 'No past orders', style: AppStyle.title),
)
: RefreshIndicator(
onRefresh: _load,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _orders.length,
itemBuilder: (_, i) => _orderTile(_orders[i]),
),
),
],
);
}
Widget _orderTile(FoodOrder order) {
final isCancelled = foodOrderIsCancelled(order.status);
return Card(
margin: const EdgeInsets.only(bottom: 10),
color: AppColor.cardColor,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: AppColor.borderColor),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: CircleAvatar(
backgroundColor: AppColor.accentColor.withOpacity(0.15),
backgroundImage: (order.merchantLogoUrl != null && order.merchantLogoUrl!.isNotEmpty)
? NetworkImage(order.merchantLogoUrl!)
: null,
child: (order.merchantLogoUrl == null || order.merchantLogoUrl!.isEmpty)
? Icon(Icons.restaurant_rounded, color: AppColor.accentColor)
: null,
),
title: Text(order.merchantNameAr ?? '#${order.id}', style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
subtitle: Text(
isCancelled
? (_isAr ? 'مُلغى' : 'Cancelled')
: order.status == 'delivered'
? (_isAr ? 'تم التسليم' : 'Delivered')
: (_isAr ? 'قيد التنفيذ' : 'In progress'),
style: AppStyle.subtitle.copyWith(
color: isCancelled ? AppColor.redColor : (order.status == 'delivered' ? AppColor.greenColor : AppColor.accentColor),
),
),
trailing: Text(foodFormatPrice(order.grandTotal), style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
onTap: () => Get.to(() => FoodOrderTrackingPage(orderId: order.id)),
),
);
}
}
@@ -0,0 +1,267 @@
// food_order_tracking_page.dart — تتبّع حالة الطلب (polling — نفس مصدر الحقيقة في backend)
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../constant/box_name.dart';
import '../../constant/colors.dart';
import '../../constant/style.dart';
import '../../main.dart';
import '../../controller/food/food_controller.dart';
import '../../controller/food/food_models.dart';
import '../widgets/my_scafold.dart';
import 'food_home_page.dart';
class FoodOrderTrackingPage extends StatefulWidget {
final int orderId;
const FoodOrderTrackingPage({super.key, required this.orderId});
@override
State<FoodOrderTrackingPage> createState() => _FoodOrderTrackingPageState();
}
class _FoodOrderTrackingPageState extends State<FoodOrderTrackingPage> {
bool get _isAr => box.read(BoxName.lang) == 'ar';
@override
void initState() {
super.initState();
Get.find<FoodController>().startTrackingOrder(widget.orderId);
}
static const Map<String, String> _statusLabelsAr = {
'pending': 'بانتظار قبول المطعم',
'merchant_accepted': 'المطعم قبل طلبك',
'preparing': 'جاري التحضير',
'ready': 'جاهز، بانتظار سائق',
'courier_assigned': 'تم تعيين سائق',
'picked_up': 'السائق في الطريق إليك',
'delivered': 'تم التسليم',
'rejected': 'رُفض الطلب',
'cancelled_by_customer': 'أُلغي الطلب',
'cancelled_by_merchant': 'أُلغي من المطعم',
'cancelled_system': 'أُلغي تلقائياً',
};
static const Map<String, String> _statusLabelsEn = {
'pending': 'Awaiting merchant',
'merchant_accepted': 'Accepted by restaurant',
'preparing': 'Preparing',
'ready': 'Ready, waiting for courier',
'courier_assigned': 'Courier assigned',
'picked_up': 'Courier on the way',
'delivered': 'Delivered',
'rejected': 'Order rejected',
'cancelled_by_customer': 'Cancelled',
'cancelled_by_merchant': 'Cancelled by restaurant',
'cancelled_system': 'Auto-cancelled',
};
@override
Widget build(BuildContext context) {
return GetBuilder<FoodController>(
builder: (c) {
final order = c.activeOrder;
return MyScafolld(
title: _isAr ? 'طلبك #${widget.orderId}' : 'Order #${widget.orderId}',
isleading: true,
body: [
order == null
? const Center(child: CircularProgressIndicator())
: _content(c, order),
],
);
},
);
}
Widget _content(FoodController c, FoodOrder order) {
final isCancelled = foodOrderIsCancelled(order.status);
final label = _isAr ? _statusLabelsAr[order.status] : _statusLabelsEn[order.status];
return ListView(
padding: const EdgeInsets.all(20),
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isCancelled ? AppColor.redColor.withOpacity(0.08) : AppColor.accentColor.withOpacity(0.08),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Icon(
isCancelled
? Icons.cancel_rounded
: order.status == 'delivered'
? Icons.check_circle_rounded
: Icons.delivery_dining_rounded,
size: 48,
color: isCancelled ? AppColor.redColor : AppColor.accentColor,
),
const SizedBox(height: 12),
Text(label ?? order.status,
style: AppStyle.headTitle2.copyWith(fontSize: 18), textAlign: TextAlign.center),
],
),
),
const SizedBox(height: 20),
if (!isCancelled) _statusTimeline(order.status),
const SizedBox(height: 20),
Text(_isAr ? 'ملخص الطلب' : 'Order Summary', style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
...order.items.map((i) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: Text('${i.quantity}× ${i.nameArSnapshot}', style: AppStyle.subtitle)),
Text(foodFormatPrice(i.lineTotal), style: AppStyle.subtitle),
],
),
)),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_isAr ? 'الإجمالي' : 'Total', style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
Text(foodFormatPrice(order.grandTotal), style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 24),
if (order.status == 'pending')
SizedBox(
width: double.infinity,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppColor.redColor),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: () => _confirmCancel(c),
child: Text(_isAr ? 'إلغاء الطلب' : 'Cancel Order', style: TextStyle(color: AppColor.redColor)),
),
),
if (order.status == 'delivered' && order.rating == null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColor.primaryColor,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: () => _showRatingDialog(c, order.id),
child: Text(_isAr ? 'قيّم الطلب' : 'Rate Order', style: const TextStyle(color: Colors.white)),
),
),
),
if (order.status == 'delivered' || isCancelled)
Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
width: double.infinity,
child: TextButton(
onPressed: () => Get.offAll(() => const FoodHomePage()),
child: Text(_isAr ? 'العودة للمطاعم' : 'Back to restaurants'),
),
),
),
],
);
}
Widget _statusTimeline(String currentStatus) {
final currentIndex = foodOrderStatusFlow.indexOf(currentStatus);
return Column(
children: List.generate(foodOrderStatusFlow.length, (i) {
final status = foodOrderStatusFlow[i];
final isDone = currentIndex >= i;
final label = _isAr ? _statusLabelsAr[status] : _statusLabelsEn[status];
return Row(
children: [
Column(
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDone ? AppColor.accentColor : AppColor.borderColor,
),
),
if (i < foodOrderStatusFlow.length - 1)
Container(width: 2, height: 28, color: isDone ? AppColor.accentColor : AppColor.borderColor),
],
),
const SizedBox(width: 12),
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(label ?? status,
style: AppStyle.subtitle.copyWith(
color: isDone ? AppColor.writeColor : AppColor.grayColor,
fontWeight: isDone ? FontWeight.bold : FontWeight.normal)),
),
],
);
}),
);
}
void _confirmCancel(FoodController c) {
Get.dialog(
AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text(_isAr ? 'إلغاء الطلب؟' : 'Cancel order?'),
content: Text(_isAr
? 'سيتم استرجاع المبلغ إلى محفظتك فوراً إن دفعت من المحفظة.'
: 'Your wallet will be refunded immediately if paid by wallet.'),
actions: [
TextButton(onPressed: () => Get.back(), child: Text(_isAr ? 'تراجع' : 'Back')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppColor.redColor),
onPressed: () {
Get.back();
c.cancelActiveOrder();
},
child: Text(_isAr ? 'إلغاء الطلب' : 'Cancel Order', style: const TextStyle(color: Colors.white)),
),
],
),
);
}
void _showRatingDialog(FoodController c, int orderId) {
int rating = 5;
Get.dialog(
StatefulBuilder(
builder: (context, setState) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text(_isAr ? 'قيّم تجربتك' : 'Rate your experience'),
content: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(5, (i) {
final star = i + 1;
return IconButton(
icon: Icon(star <= rating ? Icons.star_rounded : Icons.star_border_rounded,
color: const Color(0xFFF59E0B), size: 32),
onPressed: () => setState(() => rating = star),
);
}),
),
actions: [
TextButton(onPressed: () => Get.back(), child: Text(_isAr ? 'لاحقاً' : 'Later')),
ElevatedButton(
onPressed: () async {
Get.back();
await c.rateActiveOrder(rating);
},
child: Text(_isAr ? 'إرسال' : 'Submit'),
),
],
),
),
);
}
}
@@ -23,6 +23,7 @@ import '../HomePage/share_app_page.dart';
import '../setting_page.dart';
import '../profile/passenger_profile_page.dart';
import '../../transit/transit_home_page.dart';
import '../../food/food_home_page.dart';
// ─── ألوان النظام (Integrated with AppColor) ──────────────────────────────────
Color get _kCyan => AppColor.cyanBlue;
@@ -157,6 +158,11 @@ class MapMenuWidget extends StatelessWidget {
onTap: () =>
Get.to(() => const TransitHomePage()),
),
MenuListItem(
title: 'Food'.tr,
icon: Icons.restaurant_rounded,
onTap: () => Get.to(() => const FoodHomePage()),
),
MenuListItem(
title: 'My Balance'.tr,
icon: Icons.account_balance_wallet_outlined,
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -1297,10 +1297,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
@@ -1313,10 +1313,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.17.0"
mime:
dependency: "direct main"
description:
@@ -1901,10 +1901,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.9"
timezone:
dependency: transitive
description: