diff --git a/backend/core/Auth/JwtService.php b/backend/core/Auth/JwtService.php index 685a4993..9e9d67fa 100644 --- a/backend/core/Auth/JwtService.php +++ b/backend/core/Auth/JwtService.php @@ -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 بالنص الصريح إلى 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'); } diff --git a/backend/core/Database/Database.php b/backend/core/Database/Database.php index 7e49abd9..554150d1 100644 --- a/backend/core/Database/Database.php +++ b/backend/core/Database/Database.php @@ -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 diff --git a/backend/food/admin/merchant_approve.php b/backend/food/admin/merchant_approve.php new file mode 100644 index 00000000..f8725a2d --- /dev/null +++ b/backend/food/admin/merchant_approve.php @@ -0,0 +1,23 @@ +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]); diff --git a/backend/food/admin/merchant_create.php b/backend/food/admin/merchant_create.php new file mode 100644 index 00000000..8973ed26 --- /dev/null +++ b/backend/food/admin/merchant_create.php @@ -0,0 +1,48 @@ +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'); diff --git a/backend/food/admin/merchants.php b/backend/food/admin/merchants.php new file mode 100644 index 00000000..f2ec507e --- /dev/null +++ b/backend/food/admin/merchants.php @@ -0,0 +1,22 @@ +prepare($sql); +$st->execute($params); + +jsonSuccess(['merchants' => $st->fetchAll()]); diff --git a/backend/food/admin/payouts.php b/backend/food/admin/payouts.php new file mode 100644 index 00000000..b2838e2b --- /dev/null +++ b/backend/food/admin/payouts.php @@ -0,0 +1,39 @@ +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'); diff --git a/backend/food/cart/quote.php b/backend/food/cart/quote.php new file mode 100644 index 00000000..15476cfd --- /dev/null +++ b/backend/food/cart/quote.php @@ -0,0 +1,110 @@ +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, +]); diff --git a/backend/food/connect_admin.php b/backend/food/connect_admin.php new file mode 100644 index 00000000..af8c63b3 --- /dev/null +++ b/backend/food/connect_admin.php @@ -0,0 +1,28 @@ +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; +} diff --git a/backend/food/connect_app.php b/backend/food/connect_app.php new file mode 100644 index 00000000..e70d2fec --- /dev/null +++ b/backend/food/connect_app.php @@ -0,0 +1,37 @@ + '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; +} diff --git a/backend/food/connect_courier.php b/backend/food/connect_courier.php new file mode 100644 index 00000000..780a7216 --- /dev/null +++ b/backend/food/connect_courier.php @@ -0,0 +1,34 @@ + '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; +} diff --git a/backend/food/connect_merchant.php b/backend/food/connect_merchant.php new file mode 100644 index 00000000..561197a7 --- /dev/null +++ b/backend/food/connect_merchant.php @@ -0,0 +1,41 @@ + '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']; diff --git a/backend/food/courier/active.php b/backend/food/courier/active.php new file mode 100644 index 00000000..bbf91f1b --- /dev/null +++ b/backend/food/courier/active.php @@ -0,0 +1,26 @@ +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]); diff --git a/backend/food/courier/delivered.php b/backend/food/courier/delivered.php new file mode 100644 index 00000000..f15f4a29 --- /dev/null +++ b/backend/food/courier/delivered.php @@ -0,0 +1,34 @@ +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']); diff --git a/backend/food/courier/offer_respond.php b/backend/food/courier/offer_respond.php new file mode 100644 index 00000000..514c1cc2 --- /dev/null +++ b/backend/food/courier/offer_respond.php @@ -0,0 +1,76 @@ +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']); diff --git a/backend/food/courier/pending_offers.php b/backend/food/courier/pending_offers.php new file mode 100644 index 00000000..33d74568 --- /dev/null +++ b/backend/food/courier/pending_offers.php @@ -0,0 +1,19 @@ +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()]); diff --git a/backend/food/courier/picked_up.php b/backend/food/courier/picked_up.php new file mode 100644 index 00000000..6040afcb --- /dev/null +++ b/backend/food/courier/picked_up.php @@ -0,0 +1,11 @@ + $orderId, 'status' => 'picked_up']); diff --git a/backend/food/courier/toggle_availability.php b/backend/food/courier/toggle_availability.php new file mode 100644 index 00000000..23ba1fdd --- /dev/null +++ b/backend/food/courier/toggle_availability.php @@ -0,0 +1,11 @@ + $food_courier_id, 'delivery_mode_enabled' => $enable]); diff --git a/backend/food/cron_order_timeouts.php b/backend/food/cron_order_timeouts.php new file mode 100644 index 00000000..84814539 --- /dev/null +++ b/backend/food/cron_order_timeouts.php @@ -0,0 +1,65 @@ +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"; diff --git a/backend/food/functions.php b/backend/food/functions.php new file mode 100644 index 00000000..4661a9bf --- /dev/null +++ b/backend/food/functions.php @@ -0,0 +1,472 @@ +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); +} diff --git a/backend/food/merchant/browse.php b/backend/food/merchant/browse.php new file mode 100644 index 00000000..755ef82d --- /dev/null +++ b/backend/food/merchant/browse.php @@ -0,0 +1,32 @@ +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]); diff --git a/backend/food/merchant/details.php b/backend/food/merchant/details.php new file mode 100644 index 00000000..3c265d2d --- /dev/null +++ b/backend/food/merchant/details.php @@ -0,0 +1,59 @@ +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]); diff --git a/backend/food/merchant/search.php b/backend/food/merchant/search.php new file mode 100644 index 00000000..392dd8fa --- /dev/null +++ b/backend/food/merchant/search.php @@ -0,0 +1,24 @@ +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()]); diff --git a/backend/food/merchant_auth/login.php b/backend/food/merchant_auth/login.php new file mode 100644 index 00000000..34eb092d --- /dev/null +++ b/backend/food/merchant_auth/login.php @@ -0,0 +1,41 @@ +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'); diff --git a/backend/food/merchant_ops/accept.php b/backend/food/merchant_ops/accept.php new file mode 100644 index 00000000..1a10b437 --- /dev/null +++ b/backend/food/merchant_ops/accept.php @@ -0,0 +1,11 @@ + $orderId, 'status' => 'merchant_accepted']); diff --git a/backend/food/merchant_ops/incoming.php b/backend/food/merchant_ops/incoming.php new file mode 100644 index 00000000..2a59049f --- /dev/null +++ b/backend/food/merchant_ops/incoming.php @@ -0,0 +1,39 @@ +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]); diff --git a/backend/food/merchant_ops/items_toggle.php b/backend/food/merchant_ops/items_toggle.php new file mode 100644 index 00000000..2f84340e --- /dev/null +++ b/backend/food/merchant_ops/items_toggle.php @@ -0,0 +1,17 @@ +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]); diff --git a/backend/food/merchant_ops/preparing.php b/backend/food/merchant_ops/preparing.php new file mode 100644 index 00000000..5c7d770c --- /dev/null +++ b/backend/food/merchant_ops/preparing.php @@ -0,0 +1,11 @@ + $orderId, 'status' => 'preparing']); diff --git a/backend/food/merchant_ops/ready.php b/backend/food/merchant_ops/ready.php new file mode 100644 index 00000000..a57d4881 --- /dev/null +++ b/backend/food/merchant_ops/ready.php @@ -0,0 +1,36 @@ +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]); diff --git a/backend/food/merchant_ops/reject.php b/backend/food/merchant_ops/reject.php new file mode 100644 index 00000000..76cac6bf --- /dev/null +++ b/backend/food/merchant_ops/reject.php @@ -0,0 +1,24 @@ +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']); diff --git a/backend/food/order/cancel.php b/backend/food/order/cancel.php new file mode 100644 index 00000000..f3e08619 --- /dev/null +++ b/backend/food/order/cancel.php @@ -0,0 +1,28 @@ +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']); diff --git a/backend/food/order/create.php b/backend/food/order/create.php new file mode 100644 index 00000000..88548e58 --- /dev/null +++ b/backend/food/order/create.php @@ -0,0 +1,113 @@ +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'); diff --git a/backend/food/order/history.php b/backend/food/order/history.php new file mode 100644 index 00000000..2736bcc5 --- /dev/null +++ b/backend/food/order/history.php @@ -0,0 +1,20 @@ +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]); diff --git a/backend/food/order/rate.php b/backend/food/order/rate.php new file mode 100644 index 00000000..43f4fadd --- /dev/null +++ b/backend/food/order/rate.php @@ -0,0 +1,34 @@ + 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]); diff --git a/backend/food/order/status.php b/backend/food/order/status.php new file mode 100644 index 00000000..f160875f --- /dev/null +++ b/backend/food/order/status.php @@ -0,0 +1,15 @@ +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]); diff --git a/backend/food/ping.php b/backend/food/ping.php new file mode 100644 index 00000000..352eb305 --- /dev/null +++ b/backend/food/ping.php @@ -0,0 +1,6 @@ +query('SELECT 1'); +jsonSuccess(['service' => 'food', 'db' => 'ok'], 'pong'); diff --git a/backend/food/schema_food.sql b/backend/food/schema_food.sql new file mode 100644 index 00000000..84b7447a --- /dev/null +++ b/backend/food/schema_food.sql @@ -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} +-- ----------------------------------------------------------------- diff --git a/backend/login.php b/backend/login.php index c68d83c9..c403831c 100644 --- a/backend/login.php +++ b/backend/login.php @@ -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, + ]); } } diff --git a/docker/.env.example b/docker/.env.example index 602fcf7b..3260f0c5 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -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 diff --git a/docker/README.md b/docker/README.md index 9a574f70..a2583123 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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` (قراءة فقط). + + **لم يُبنَ بعد**: المرحلة الخامسة (التقسية والإطلاق — مراجعة أمنية مخصصة، اختبار ضغط، إطلاق تدريجي)، وربط الصرف الفعلي بمجرد تأكيد النقاط الثلاث أعلاه. diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7bee486a..cf0c2335 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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: diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index 1e09dcc2..7947e9b1 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -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; diff --git a/docker/php/Dockerfile.fpm.transit b/docker/php/Dockerfile.fpm.transit new file mode 100644 index 00000000..813ff26b --- /dev/null +++ b/docker/php/Dockerfile.fpm.transit @@ -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 diff --git a/docker/php/food-pool.conf b/docker/php/food-pool.conf new file mode 100644 index 00000000..089c982d --- /dev/null +++ b/docker/php/food-pool.conf @@ -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 diff --git a/docker/php/www-pool.transit.conf b/docker/php/www-pool.transit.conf new file mode 100644 index 00000000..7898713e --- /dev/null +++ b/docker/php/www-pool.transit.conf @@ -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 diff --git a/docs/10_food_orders/FOOD_ORDERS_PLAN_AR.md b/docs/10_food_orders/FOOD_ORDERS_PLAN_AR.md index c7fe9353..078ff09b 100644 --- a/docs/10_food_orders/FOOD_ORDERS_PLAN_AR.md +++ b/docs/10_food_orders/FOOD_ORDERS_PLAN_AR.md @@ -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) --- diff --git a/food_server/composer.json b/food_server/composer.json new file mode 100644 index 00000000..4e645d86 --- /dev/null +++ b/food_server/composer.json @@ -0,0 +1,6 @@ +{ + "require": { + "firebase/php-jwt": "^7.0", + "workerman/phpsocket.io": "^2.2" + } +} diff --git a/food_server/food_socket.php b/food_server/food_socket.php new file mode 100644 index 00000000..11753d3e --- /dev/null +++ b/food_server/food_socket.php @@ -0,0 +1,231 @@ +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(); diff --git a/siro_driver/lib/constant/info.dart b/siro_driver/lib/constant/info.dart index 88688b51..2b8d5fc3 100755 --- a/siro_driver/lib/constant/info.dart +++ b/siro_driver/lib/constant/info.dart @@ -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 {

7. Account Deletion & Contact

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.

-

Email: support@intaleqapp.com

+

Email: support@siromove.com

@@ -283,7 +283,7 @@ class AppInformation {

7. حذف الحساب والتواصل

لديك الحق في طلب حذف حسابك وبياناتك الشخصية. للقيام بذلك، أو لأي استفسارات أخرى، يرجى التواصل معنا. سنرد على طلبات الحذف في غضون 30 يومًا.

-

البريد الإلكتروني: support@intaleqapp.com

+

البريد الإلكتروني: support@siromove.com

diff --git a/siro_driver/lib/constant/links.dart b/siro_driver/lib/constant/links.dart index f5d2e600..4b152432 100755 --- a/siro_driver/lib/constant/links.dart +++ b/siro_driver/lib/constant/links.dart @@ -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"; diff --git a/siro_driver/lib/controller/auth/captin/invit_controller.dart b/siro_driver/lib/controller/auth/captin/invit_controller.dart index d8a0ac12..de117053 100755 --- a/siro_driver/lib/controller/auth/captin/invit_controller.dart +++ b/siro_driver/lib/controller/auth/captin/invit_controller.dart @@ -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); } diff --git a/siro_driver/lib/controller/auth/captin/login_captin_controller.dart b/siro_driver/lib/controller/auth/captin/login_captin_controller.dart index c8fe3e1e..c63ebd1f 100755 --- a/siro_driver/lib/controller/auth/captin/login_captin_controller.dart +++ b/siro_driver/lib/controller/auth/captin/login_captin_controller.dart @@ -228,10 +228,33 @@ class LoginDriverController extends GetxController { return ''; } - getJWT() async { + static Future? _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 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 _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 getLocationPermission() async { var status = await Permission.locationAlways.status; if (!status.isGranted) { diff --git a/siro_driver/lib/controller/food_delivery/food_delivery_controller.dart b/siro_driver/lib/controller/food_delivery/food_delivery_controller.dart new file mode 100644 index 00000000..47126830 --- /dev/null +++ b/siro_driver/lib/controller/food_delivery/food_delivery_controller.dart @@ -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 pendingOffers = []; + List activeTasks = []; + bool isLoadingTasks = false; + + final Set _respondingOfferIds = {}; + final Set _busyTaskIds = {}; + + Timer? _pollTimer; + + @override + void onInit() { + super.onInit(); + _refreshAll(); + _pollTimer = Timer.periodic(const Duration(seconds: 4), (_) => _refreshAll()); + } + + Future _refreshAll() async { + if (isDeliveryModeEnabled) { + await _fetchPendingOffers(); + } + await fetchActiveTasks(); + } + + Future 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 _fetchPendingOffers() async { + final res = await FoodDeliveryService.getPendingOffers(); + if (res.success) { + pendingOffers = res.data ?? []; + update(); + } + } + + Future 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 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 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 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(); + } +} diff --git a/siro_driver/lib/controller/food_delivery/food_delivery_models.dart b/siro_driver/lib/controller/food_delivery/food_delivery_models.dart new file mode 100644 index 00000000..12e6b81b --- /dev/null +++ b/siro_driver/lib/controller/food_delivery/food_delivery_models.dart @@ -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 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 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'; +} diff --git a/siro_driver/lib/controller/food_delivery/food_delivery_service.dart b/siro_driver/lib/controller/food_delivery/food_delivery_service.dart new file mode 100644 index 00000000..ca96ba0f --- /dev/null +++ b/siro_driver/lib/controller/food_delivery/food_delivery_service.dart @@ -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 { + 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> 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>> getActiveTasks() async { + final res = await CRUD().post(link: '$_base/courier/active.php'); + if (res is Map && res['status'] == 'success') { + final msg = res['message']; + final list = (msg is Map && msg['orders'] is List) + ? (msg['orders'] as List) + .map((o) => FoodDeliveryTask.fromJson(Map.from(o))) + .toList() + : []; + return FoodDeliveryApiResult(true, list, 'ok'); + } + return FoodDeliveryApiResult(false, null, _errMsg(res)); + } + + static Future>> getPendingOffers() async { + final res = await CRUD().post(link: '$_base/courier/pending_offers.php'); + if (res is Map && res['status'] == 'success') { + final msg = res['message']; + final list = (msg is Map && msg['offers'] is List) + ? (msg['offers'] as List) + .map((o) => FoodDeliveryOffer.fromJson(Map.from(o))) + .toList() + : []; + return FoodDeliveryApiResult(true, list, 'ok'); + } + return FoodDeliveryApiResult(false, null, _errMsg(res)); + } + + static Future> 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> 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> 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 'حدث خطأ، حاول مجدداً'; + } +} diff --git a/siro_driver/lib/controller/functions/app_update_controller.dart b/siro_driver/lib/controller/functions/app_update_controller.dart index 7892f027..b83bad52 100644 --- a/siro_driver/lib/controller/functions/app_update_controller.dart +++ b/siro_driver/lib/controller/functions/app_update_controller.dart @@ -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, diff --git a/siro_driver/lib/controller/functions/crud.dart b/siro_driver/lib/controller/functions/crud.dart index 33a83afa..d52569dd 100755 --- a/siro_driver/lib/controller/functions/crud.dart +++ b/siro_driver/lib/controller/functions/crud.dart @@ -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 _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? payload, required Map 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.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? 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 get({ required String link, Map? 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()); diff --git a/siro_driver/lib/controller/functions/package_info.dart b/siro_driver/lib/controller/functions/package_info.dart index 57c53e91..de3b4608 100755 --- a/siro_driver/lib/controller/functions/package_info.dart +++ b/siro_driver/lib/controller/functions/package_info.dart @@ -48,8 +48,8 @@ Future 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, diff --git a/siro_driver/lib/controller/gamification/referral_controller.dart b/siro_driver/lib/controller/gamification/referral_controller.dart index e5035a9b..2df21b70 100644 --- a/siro_driver/lib/controller/gamification/referral_controller.dart +++ b/siro_driver/lib/controller/gamification/referral_controller.dart @@ -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 { diff --git a/siro_driver/lib/controller/rate/rate_app_controller.dart b/siro_driver/lib/controller/rate/rate_app_controller.dart index f275852b..518bbd59 100755 --- a/siro_driver/lib/controller/rate/rate_app_controller.dart +++ b/siro_driver/lib/controller/rate/rate_app_controller.dart @@ -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))) { diff --git a/siro_driver/lib/views/auth/captin/invite_driver_screen.dart b/siro_driver/lib/views/auth/captin/invite_driver_screen.dart index 55c1f6d5..209f62ca 100755 --- a/siro_driver/lib/views/auth/captin/invite_driver_screen.dart +++ b/siro_driver/lib/views/auth/captin/invite_driver_screen.dart @@ -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); }, diff --git a/siro_driver/lib/views/food_delivery/food_delivery_home_page.dart b/siro_driver/lib/views/food_delivery/food_delivery_home_page.dart new file mode 100644 index 00000000..c90f5405 --- /dev/null +++ b/siro_driver/lib/views/food_delivery/food_delivery_home_page.dart @@ -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( + 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), + ), + ), + ], + ), + ), + ); + } +} diff --git a/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart b/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart index a706af05..722af9d2 100755 --- a/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart +++ b/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart @@ -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, diff --git a/siro_driver/pubspec.lock b/siro_driver/pubspec.lock index 8acfc053..1bfc6081 100644 --- a/siro_driver/pubspec.lock +++ b/siro_driver/pubspec.lock @@ -1521,10 +1521,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -1537,10 +1537,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -2125,10 +2125,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.9" timezone: dependency: transitive description: diff --git a/siro_rider/ios/Runner/Runner.entitlements b/siro_rider/ios/Runner/Runner.entitlements index 52bd7aa8..f517b8dc 100644 --- a/siro_rider/ios/Runner/Runner.entitlements +++ b/siro_rider/ios/Runner/Runner.entitlements @@ -10,7 +10,8 @@ com.apple.developer.associated-domains - applinks:intaleqapp.com + applinks:siromove.com + applinks:www.siromove.com com.apple.security.application-groups diff --git a/siro_rider/lib/constant/info.dart b/siro_rider/lib/constant/info.dart index cb794081..d71a923f 100644 --- a/siro_rider/lib/constant/info.dart +++ b/siro_rider/lib/constant/info.dart @@ -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 = ''' @@ -140,7 +140,7 @@ class AppInformation {

7. Account Deletion & Contact

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.

-

Email: support@intaleqapp.com

+

Email: support@siromove.com

@@ -280,7 +280,7 @@ class AppInformation {

7. حذف الحساب والتواصل

لديك الحق في طلب حذف حسابك وبياناتك الشخصية. للقيام بذلك، أو لأي استفسارات أخرى، يرجى التواصل معنا. سنرد على طلبات الحذف في غضون 30 يومًا.

-

البريد الإلكتروني: support@intaleqapp.com

+

البريد الإلكتروني: support@siromove.com

diff --git a/siro_rider/lib/controller/auth/login_controller.dart b/siro_rider/lib/controller/auth/login_controller.dart index d82386a2..658cc59b 100644 --- a/siro_rider/lib/controller/auth/login_controller.dart +++ b/siro_rider/lib/controller/auth/login_controller.dart @@ -81,26 +81,55 @@ class LoginController extends GetxController { // • firstTimeLoadKey != false ← أول مرة يفتح التطبيق → loginFirstTime // • firstTimeLoadKey == false ← مستخدم موجود → loginJwtRider // ───────────────────────────────────────────────────────────── - Future getJWT({bool force = false}) async { - // إذا كان التوكن الحالي لا يزال صالحاً، لا داعي لطلب واحد جديد + static Future? _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 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 _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 getJwtWallet() async { dev = Platform.isAndroid ? 'android' : 'ios'; + // نعيد حساب البصمة أولاً كي لا نرسل قيمة GCM قديمة عالقة في التخزين + // من نسخة سابقة من التطبيق (مثل getJWT تماماً). + await DeviceHelper.getDeviceFingerprint(); final String fp = box.read(BoxName.deviceFpEncrypted) ?? ''; var payload = { diff --git a/siro_rider/lib/controller/food/food_controller.dart b/siro_rider/lib/controller/food/food_controller.dart new file mode 100644 index 00000000..0bf8a0b7 --- /dev/null +++ b/siro_rider/lib/controller/food/food_controller.dart @@ -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 merchants = []; + String city = _defaultCityForCountry(); + + // ── تفاصيل مطعم ── + bool isLoadingMenu = false; + FoodMerchant? selectedMerchant; + List categories = []; + + // ── السلة (مقيّدة بمطعم واحد فقط) ── + int? cartMerchantId; + final Map _cart = {}; + List 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 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 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 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; + } else { + mySnackbarWarning(res.message); + } + update(); + } + + // ── إدارة السلة ── + + bool addToCart(FoodMenuItem item, {int quantity = 1, Map>? 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>? 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 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 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 _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 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 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.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(); + } +} diff --git a/siro_rider/lib/controller/food/food_models.dart b/siro_rider/lib/controller/food/food_models.dart new file mode 100644 index 00000000..ce3f3de7 --- /dev/null +++ b/siro_rider/lib/controller/food/food_models.dart @@ -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 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 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 choices; + + FoodItemOptionGroup({ + required this.id, + required this.groupNameAr, + required this.isRequired, + required this.maxSelect, + required this.choices, + }); + + factory FoodItemOptionGroup.fromJson(Map 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.from(c))) + .toList() + : [], + ); +} + +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 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 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.from(o))) + .toList() + : [], + ); +} + +class FoodMenuCategory { + final int id; + final String nameAr; + final String? nameEn; + final List items; + + FoodMenuCategory({ + required this.id, + required this.nameAr, + this.nameEn, + this.items = const [], + }); + + factory FoodMenuCategory.fromJson(Map 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.from(i))) + .toList() + : [], + ); +} + +// ── سطر في السلة (محلي فقط قبل الإرسال للخادم) ── +class FoodCartLine { + final FoodMenuItem item; + int quantity; + // key: option_group_id, value: قائمة choice_id المختارة + final Map> selectedOptions; + + FoodCartLine({ + required this.item, + this.quantity = 1, + Map>? 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 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 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 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 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 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.from(i))) + .toList() + : [], + ); +} + +// ترتيب حالات الطلب لعرض خط زمني تصاعدي في شاشة التتبع +const List 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'; +} diff --git a/siro_rider/lib/controller/food/food_service.dart b/siro_rider/lib/controller/food/food_service.dart new file mode 100644 index 00000000..1eb4d72f --- /dev/null +++ b/siro_rider/lib/controller/food/food_service.dart @@ -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 { + 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>> browseMerchants({ + required String city, + String? category, + }) async { + final payload = {'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.from(m))) + .toList() + : []; + return FoodApiResult(true, list, 'ok'); + } + return FoodApiResult(false, null, _errMsg(res)); + } + + static Future>> 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.from(m))) + .toList() + : []; + return FoodApiResult(true, list, 'ok'); + } + return FoodApiResult(false, null, _errMsg(res)); + } + + static Future>> 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.from(msg['merchant'])); + final categories = (msg['categories'] is List) + ? (msg['categories'] as List) + .map((c) => FoodMenuCategory.fromJson(Map.from(c))) + .toList() + : []; + return FoodApiResult(true, {'merchant': merchant, 'categories': categories}, 'ok'); + } + } + return FoodApiResult(false, null, _errMsg(res)); + } + + static Future> getQuote({ + required int merchantId, + required List 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.from(msg)), 'ok'); + } + return FoodApiResult(false, null, _errMsg(res), code: _errCode(res)); + } + + static Future> 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> 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.from(msg['order'])), 'ok'); + } + } + return FoodApiResult(false, null, _errMsg(res)); + } + + static Future> 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> 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>> 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.from(o))) + .toList() + : []; + 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 'حدث خطأ، حاول مجدداً'; + } +} diff --git a/siro_rider/lib/controller/functions/crud.dart b/siro_rider/lib/controller/functions/crud.dart index c9f5fc06..08baa631 100644 --- a/siro_rider/lib/controller/functions/crud.dart +++ b/siro_rider/lib/controller/functions/crud.dart @@ -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 _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 _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 _makeRequest({ required String link, Map? payload, required Map 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.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? 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? 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', diff --git a/siro_rider/lib/controller/functions/encrypt_decrypt.dart b/siro_rider/lib/controller/functions/encrypt_decrypt.dart index e10ba2f2..14ff05a6 100644 --- a/siro_rider/lib/controller/functions/encrypt_decrypt.dart +++ b/siro_rider/lib/controller/functions/encrypt_decrypt.dart @@ -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 { diff --git a/siro_rider/lib/controller/functions/package_info.dart b/siro_rider/lib/controller/functions/package_info.dart index 37dcb926..abe152db 100644 --- a/siro_rider/lib/controller/functions/package_info.dart +++ b/siro_rider/lib/controller/functions/package_info.dart @@ -190,15 +190,11 @@ void showUpdateDialog(BuildContext context) { class DeviceHelper { static Future 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; diff --git a/siro_rider/lib/controller/home/map/location_search_controller.dart b/siro_rider/lib/controller/home/map/location_search_controller.dart index abdf10f7..755fce6d 100644 --- a/siro_rider/lib/controller/home/map/location_search_controller.dart +++ b/siro_rider/lib/controller/home/map/location_search_controller.dart @@ -163,6 +163,7 @@ class LocationSearchController extends GetxController { ]; readyWayPoints(); getLocation(); + _listenForDeepLink(); } void readyWayPoints() { diff --git a/siro_rider/lib/controller/home/map/map_engine_controller.dart b/siro_rider/lib/controller/home/map/map_engine_controller.dart index e432b320..e248c19c 100644 --- a/siro_rider/lib/controller/home/map/map_engine_controller.dart +++ b/siro_rider/lib/controller/home/map/map_engine_controller.dart @@ -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(); diff --git a/siro_rider/lib/controller/local/ar_eg.dart b/siro_rider/lib/controller/local/ar_eg.dart index 945fbcda..ca876dd7 100644 --- a/siro_rider/lib/controller/local/ar_eg.dart +++ b/siro_rider/lib/controller/local/ar_eg.dart @@ -796,6 +796,19 @@ final Map 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": "تم قبول الطلب", diff --git a/siro_rider/lib/controller/local/ar_jo.dart b/siro_rider/lib/controller/local/ar_jo.dart index a6248bbf..c916ebba 100644 --- a/siro_rider/lib/controller/local/ar_jo.dart +++ b/siro_rider/lib/controller/local/ar_jo.dart @@ -795,6 +795,19 @@ final Map 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": "تم قبول الطلب", diff --git a/siro_rider/lib/controller/local/ar_sy.dart b/siro_rider/lib/controller/local/ar_sy.dart index 7dbf5937..50368d5e 100644 --- a/siro_rider/lib/controller/local/ar_sy.dart +++ b/siro_rider/lib/controller/local/ar_sy.dart @@ -796,6 +796,19 @@ final Map 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": "تم قبول الطلب", diff --git a/siro_rider/lib/controller/local/de.dart b/siro_rider/lib/controller/local/de.dart index ed771f68..3ff9cbcc 100644 --- a/siro_rider/lib/controller/local/de.dart +++ b/siro_rider/lib/controller/local/de.dart @@ -762,6 +762,19 @@ final Map 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", diff --git a/siro_rider/lib/controller/local/el.dart b/siro_rider/lib/controller/local/el.dart index 9d3bdfe2..cf48a95e 100644 --- a/siro_rider/lib/controller/local/el.dart +++ b/siro_rider/lib/controller/local/el.dart @@ -762,6 +762,19 @@ final Map 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", diff --git a/siro_rider/lib/controller/local/es.dart b/siro_rider/lib/controller/local/es.dart index 597d5224..e43944b6 100644 --- a/siro_rider/lib/controller/local/es.dart +++ b/siro_rider/lib/controller/local/es.dart @@ -762,6 +762,19 @@ final Map 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", diff --git a/siro_rider/lib/controller/local/fa.dart b/siro_rider/lib/controller/local/fa.dart index 8c048e39..5de3f0ea 100644 --- a/siro_rider/lib/controller/local/fa.dart +++ b/siro_rider/lib/controller/local/fa.dart @@ -762,6 +762,19 @@ final Map 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", diff --git a/siro_rider/lib/controller/local/fr.dart b/siro_rider/lib/controller/local/fr.dart index 34a5fe70..391473bb 100644 --- a/siro_rider/lib/controller/local/fr.dart +++ b/siro_rider/lib/controller/local/fr.dart @@ -762,6 +762,19 @@ final Map 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", diff --git a/siro_rider/lib/controller/local/hi.dart b/siro_rider/lib/controller/local/hi.dart index b05d5152..c8a94928 100644 --- a/siro_rider/lib/controller/local/hi.dart +++ b/siro_rider/lib/controller/local/hi.dart @@ -762,6 +762,19 @@ final Map 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", diff --git a/siro_rider/lib/controller/local/it.dart b/siro_rider/lib/controller/local/it.dart index 443ede97..f2ced3be 100644 --- a/siro_rider/lib/controller/local/it.dart +++ b/siro_rider/lib/controller/local/it.dart @@ -762,6 +762,19 @@ final Map 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", diff --git a/siro_rider/lib/controller/local/ru.dart b/siro_rider/lib/controller/local/ru.dart index 4348b0e6..9ceac7e5 100644 --- a/siro_rider/lib/controller/local/ru.dart +++ b/siro_rider/lib/controller/local/ru.dart @@ -762,6 +762,19 @@ final Map 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", diff --git a/siro_rider/lib/controller/local/tr.dart b/siro_rider/lib/controller/local/tr.dart index 4deb4db0..a2b3928c 100644 --- a/siro_rider/lib/controller/local/tr.dart +++ b/siro_rider/lib/controller/local/tr.dart @@ -762,6 +762,19 @@ final Map 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", diff --git a/siro_rider/lib/controller/local/ur.dart b/siro_rider/lib/controller/local/ur.dart index 1fc3d240..56087f6b 100644 --- a/siro_rider/lib/controller/local/ur.dart +++ b/siro_rider/lib/controller/local/ur.dart @@ -762,6 +762,19 @@ final Map 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", diff --git a/siro_rider/lib/controller/local/zh.dart b/siro_rider/lib/controller/local/zh.dart index a5ba4b3f..19766ea9 100644 --- a/siro_rider/lib/controller/local/zh.dart +++ b/siro_rider/lib/controller/local/zh.dart @@ -762,6 +762,19 @@ final Map 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", diff --git a/siro_rider/lib/views/food/food_cart_page.dart b/siro_rider/lib/views/food/food_cart_page.dart new file mode 100644 index 00000000..3479935b --- /dev/null +++ b/siro_rider/lib/views/food/food_cart_page.dart @@ -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 createState() => _FoodCartPageState(); +} + +class _FoodCartPageState extends State { + 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 _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( + 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 _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( + contentPadding: EdgeInsets.zero, + value: 'wallet', + groupValue: _paymentMethod, + title: Text(_isAr ? 'المحفظة' : 'Wallet'), + onChanged: (v) => setState(() => _paymentMethod = v!), + ), + RadioListTile( + contentPadding: EdgeInsets.zero, + value: 'cash', + groupValue: _paymentMethod, + title: Text(_isAr ? 'نقداً عند الاستلام' : 'Cash on delivery'), + onChanged: (v) => setState(() => _paymentMethod = v!), + ), + ], + ); + } +} diff --git a/siro_rider/lib/views/food/food_home_page.dart b/siro_rider/lib/views/food/food_home_page.dart new file mode 100644 index 00000000..569dcbe4 --- /dev/null +++ b/siro_rider/lib/views/food/food_home_page.dart @@ -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( + 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)), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/siro_rider/lib/views/food/food_merchant_page.dart b/siro_rider/lib/views/food/food_merchant_page.dart new file mode 100644 index 00000000..6ab06ec5 --- /dev/null +++ b/siro_rider/lib/views/food/food_merchant_page.dart @@ -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(); + c.openMerchant(merchantId); + + return GetBuilder( + 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> 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> 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.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; + }); + }, + ); + }), + ], + ), + ); + } +} diff --git a/siro_rider/lib/views/food/food_order_history_page.dart b/siro_rider/lib/views/food/food_order_history_page.dart new file mode 100644 index 00000000..6298452e --- /dev/null +++ b/siro_rider/lib/views/food/food_order_history_page.dart @@ -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 createState() => _FoodOrderHistoryPageState(); +} + +class _FoodOrderHistoryPageState extends State { + bool get _isAr => box.read(BoxName.lang) == 'ar'; + bool _isLoading = true; + List _orders = []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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)), + ), + ); + } +} diff --git a/siro_rider/lib/views/food/food_order_tracking_page.dart b/siro_rider/lib/views/food/food_order_tracking_page.dart new file mode 100644 index 00000000..9964e093 --- /dev/null +++ b/siro_rider/lib/views/food/food_order_tracking_page.dart @@ -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 createState() => _FoodOrderTrackingPageState(); +} + +class _FoodOrderTrackingPageState extends State { + bool get _isAr => box.read(BoxName.lang) == 'ar'; + + @override + void initState() { + super.initState(); + Get.find().startTrackingOrder(widget.orderId); + } + + static const Map _statusLabelsAr = { + 'pending': 'بانتظار قبول المطعم', + 'merchant_accepted': 'المطعم قبل طلبك', + 'preparing': 'جاري التحضير', + 'ready': 'جاهز، بانتظار سائق', + 'courier_assigned': 'تم تعيين سائق', + 'picked_up': 'السائق في الطريق إليك', + 'delivered': 'تم التسليم', + 'rejected': 'رُفض الطلب', + 'cancelled_by_customer': 'أُلغي الطلب', + 'cancelled_by_merchant': 'أُلغي من المطعم', + 'cancelled_system': 'أُلغي تلقائياً', + }; + + static const Map _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( + 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'), + ), + ], + ), + ), + ); + } +} diff --git a/siro_rider/lib/views/home/map_widget.dart/map_menu_widget.dart b/siro_rider/lib/views/home/map_widget.dart/map_menu_widget.dart index 5c2129e8..6972b72d 100644 --- a/siro_rider/lib/views/home/map_widget.dart/map_menu_widget.dart +++ b/siro_rider/lib/views/home/map_widget.dart/map_menu_widget.dart @@ -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, diff --git a/siro_rider/lib/views/home/profile/order_history.dart b/siro_rider/lib/views/home/profile/order_history.dart index 0d91e6c7..10d910e9 100644 --- a/siro_rider/lib/views/home/profile/order_history.dart +++ b/siro_rider/lib/views/home/profile/order_history.dart @@ -1,20 +1,35 @@ import 'package:siro_rider/constant/currency.dart'; +import 'dart:convert'; import 'dart:math' as math; -import 'dart:typed_data'; import 'package:siro_rider/env/env.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:get/get.dart'; +import 'package:http/http.dart' as http; import 'package:intaleq_maps/intaleq_maps.dart'; +import '../../../controller/home/decode_polyline_isolate.dart'; +import '../../../print.dart'; + import '../../../constant/colors.dart'; +import '../../../constant/links.dart'; import '../../../constant/style.dart'; +import '../../../controller/functions/crud.dart'; import '../../../controller/functions/launch.dart'; +import '../../../controller/home/map/location_search_controller.dart'; +import '../../../controller/home/map/map_engine_controller.dart'; +import '../../../controller/home/map/ride_lifecycle_controller.dart'; import '../../../controller/home/profile/order_history_controller.dart'; +import '../../widgets/error_snakbar.dart'; import '../../widgets/my_scafold.dart'; import '../../widgets/mycircular.dart'; +// ───────────────────────────────────────────────────────────────────────────── +// ألوان موحّدة لنقطتي الانطلاق والوصول — أخضر = من أين، أحمر = إلى أين +// ───────────────────────────────────────────────────────────────────────────── +const Color _kStartColor = AppColor.greenColor; +const Color _kEndColor = AppColor.redColor; + // ───────────────────────────────────────────────────────────────────────────── // Main Screen // ───────────────────────────────────────────────────────────────────────────── @@ -39,9 +54,16 @@ class OrderHistory extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.route_outlined, - size: 80, color: AppColor.writeColor.withOpacity(0.3)), - const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(22), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColor.primaryColor.withOpacity(0.07), + ), + child: Icon(Icons.route_outlined, + size: 56, color: AppColor.primaryColor), + ), + const SizedBox(height: 18), Text('No trip history found'.tr, style: AppStyle.headTitle2), const SizedBox(height: 6), @@ -52,9 +74,9 @@ class OrderHistory extends StatelessWidget { ); } return ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 28), itemCount: controller.orderHistoryListPassenger.length, - separatorBuilder: (_, __) => const SizedBox(height: 14), + separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, index) { final ride = controller.orderHistoryListPassenger[index]; return _HistoryCard( @@ -71,8 +93,10 @@ class OrderHistory extends StatelessWidget { } // ───────────────────────────────────────────────────────────────────────────── -// Coordinate helpers +// Coordinate + address helpers // ───────────────────────────────────────────────────────────────────────────── +const LatLng _kDamascus = LatLng(33.5, 36.3); + LatLng _parseLatLng(String raw, LatLng fallback) { try { final parts = raw.split(','); @@ -82,121 +106,380 @@ LatLng _parseLatLng(String raw, LatLng fallback) { } } -const LatLng _kDamascus = LatLng(33.5, 36.3); +/// كاش على مستوى الملف — كل إحداثية تُترجم لعنوان مرّة واحدة فقط، +/// ويُعاد استخدام النتيجة في البطاقة وفي ورقة التفاصيل. +final Map _addressCache = {}; + +String _coordKey(LatLng p) => + '${p.latitude.toStringAsFixed(5)},${p.longitude.toStringAsFixed(5)}'; + +Future _addressOf(LatLng p) async { + final key = _coordKey(p); + final cached = _addressCache[key]; + if (cached != null) return cached; + + try { + final res = await CRUD().getMapSaas( + link: '${AppLink.reverseGeocoding}?lat=${p.latitude}&lng=${p.longitude}', + ); + if (res is List && res.isNotEmpty) { + final name = res[0]['name_ar'] ?? res[0]['name']; + if (name != null && name.toString().trim().isNotEmpty) { + final value = name.toString().trim(); + _addressCache[key] = value; + return value; + } + } + } catch (_) { + // نتجاهل الخطأ ونعرض الإحداثيات كبديل + } + + final fallback = + '${p.latitude.toStringAsFixed(4)}, ${p.longitude.toStringAsFixed(4)}'; + _addressCache[key] = fallback; + return fallback; +} // ───────────────────────────────────────────────────────────────────────────── -// Lightweight card — NO native map in the list +// جلب هندسة المسار الحقيقي (polyline) — بالتتابع وبكاش دائم +// ───────────────────────────────────────────────────────────────────────────── + +/// كاش المسارات: مفتاح "بداية→نهاية" ← قائمة النقاط المفكوكة. +final Map> _routeCache = {}; + +/// طابور تسلسلي: نطلب مسار رحلة واحدة في كل مرة بدل إغراق سيرفر الخرائط +/// بعشرة طلبات متوازية عند فتح الصفحة. +Future _routeQueue = Future.value(); + +String _routeKey(LatLng a, LatLng b) => '${_coordKey(a)}|${_coordKey(b)}'; + +/// الطلبات الجارية — تمنع إطلاق طلب ثانٍ لنفس الرحلة عند إعادة بناء البطاقة +/// أثناء التمرير قبل وصول الرد الأول. +final Map>> _routeInflight = {}; + +Future> _routeOf(LatLng start, LatLng end) { + final key = _routeKey(start, end); + final cached = _routeCache[key]; + if (cached != null) return Future.value(cached); + + final inflight = _routeInflight[key]; + if (inflight != null) return inflight; + + // نُلحق الطلب بالطابور فيُنفَّذ بعد انتهاء سابقه + final result = _routeQueue + .then((_) => _fetchRoute(start, end, key)) + .whenComplete(() => _routeInflight.remove(key)); + _routeInflight[key] = result; + // الطابور نفسه لا يجب أن ينكسر إذا فشل أحد الطلبات + _routeQueue = result.catchError((_) => []); + return result; +} + +Future> _fetchRoute(LatLng start, LatLng end, String key) async { + final cached = _routeCache[key]; + if (cached != null) return cached; + + try { + final uri = Uri.parse(AppLink.mapSaasRoute).replace(queryParameters: { + 'fromLat': '${start.latitude}', + 'fromLng': '${start.longitude}', + 'toLat': '${end.latitude}', + 'toLng': '${end.longitude}', + }); + + final res = await http + .get(uri, headers: {'x-api-key': Env.mapSaasKey}) + .timeout(const Duration(seconds: 12)); + + if (res.statusCode == 200) { + final body = json.decode(res.body); + final encoded = (body['points'] ?? '').toString(); + if (encoded.isNotEmpty) { + // فكّ التشفير مباشرة — المسار بضع مئات النقاط، لا يستدعي إنشاء Isolate + final points = decodePolylineIsolate(encoded); + if (points.length >= 2) { + Log.print('🗺️ Route loaded: ${points.length} points'); + _routeCache[key] = points; + return points; + } + } + } + Log.print('⚠️ Route request failed: HTTP ${res.statusCode}'); + } catch (e) { + Log.print('⚠️ Route request error: $e'); + } + + // بديل: خط مستقيم بين النقطتين حتى لا تبقى البطاقة فارغة + final fallback = [start, end]; + _routeCache[key] = fallback; + return fallback; +} + +String _prettyTime(dynamic raw) { + final s = (raw ?? '').toString(); + final parts = s.split(':'); + return parts.length >= 2 ? '${parts[0]}:${parts[1]}' : s; +} + +// ───────────────────────────────────────────────────────────────────────────── +// شريط المسار: نقطة خضراء (الانطلاق) ← خط منقّط ← نقطة حمراء (الوصول) +// ───────────────────────────────────────────────────────────────────────────── +class _RouteStrip extends StatelessWidget { + final LatLng start; + final LatLng end; + + /// النسخة المضغوطة تُستخدم داخل بطاقة القائمة، والموسّعة داخل ورقة التفاصيل. + final bool compact; + + const _RouteStrip({ + required this.start, + required this.end, + this.compact = false, + }); + + @override + Widget build(BuildContext context) { + final gap = compact ? 22.0 : 30.0; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // العمود البصري: دائرة خضراء، خط منقّط، دائرة حمراء + Padding( + padding: const EdgeInsets.only(top: 3), + child: Column( + children: [ + _Dot(color: _kStartColor), + SizedBox( + height: gap, + child: CustomPaint( + size: Size(2, gap), + painter: const _DottedConnector(), + ), + ), + _Dot(color: _kEndColor), + ], + ), + ), + const SizedBox(width: 12), + + // العناوين + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AddressLine( + point: start, + label: 'From'.tr, + color: _kStartColor, + compact: compact, + ), + SizedBox(height: gap - (compact ? 4 : 2)), + _AddressLine( + point: end, + label: 'To'.tr, + color: _kEndColor, + compact: compact, + ), + ], + ), + ), + ], + ); + } +} + +class _Dot extends StatelessWidget { + final Color color; + const _Dot({required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + width: 14, + height: 14, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color.withOpacity(0.18), + border: Border.all(color: color, width: 2.5), + ), + ); + } +} + +class _DottedConnector extends CustomPainter { + const _DottedConnector(); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = AppColor.grayColor.withOpacity(0.45) + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round; + + const dash = 3.0; + const gap = 4.0; + double y = 0; + while (y < size.height) { + canvas.drawLine( + Offset(size.width / 2, y), + Offset(size.width / 2, math.min(y + dash, size.height)), + paint, + ); + y += dash + gap; + } + } + + @override + bool shouldRepaint(_DottedConnector old) => false; +} + +/// سطر عنوان واحد — يجلب الاسم من خدمة الـ reverse geocoding مرّة واحدة. +class _AddressLine extends StatelessWidget { + final LatLng point; + final String label; + final Color color; + final bool compact; + + const _AddressLine({ + required this.point, + required this.label, + required this.color, + required this.compact, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: AppStyle.subtitle.copyWith( + fontSize: compact ? 10 : 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + color: color, + ), + ), + const SizedBox(height: 1), + FutureBuilder( + future: _addressOf(point), + initialData: _addressCache[_coordKey(point)], + builder: (_, snap) => Text( + snap.data ?? '${'Locating'.tr}…', + maxLines: compact ? 1 : 2, + overflow: TextOverflow.ellipsis, + style: AppStyle.title.copyWith( + fontSize: compact ? 13 : 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// بطاقة الرحلة في القائمة // ───────────────────────────────────────────────────────────────────────────── class _HistoryCard extends StatelessWidget { final Map ride; - const _HistoryCard({Key? key, required this.ride}) : super(key: key); + const _HistoryCard({super.key, required this.ride}); @override Widget build(BuildContext context) { final start = _parseLatLng(ride['start_location'] ?? '', _kDamascus); final end = _parseLatLng(ride['end_location'] ?? '', _kDamascus); final status = ride['status'] ?? ''; + final distance = double.tryParse('${ride['distance'] ?? 0}') ?? 0; return Material( color: Colors.transparent, child: InkWell( onTap: () => _openDetail(context, ride, start, end), - borderRadius: BorderRadius.circular(18), + borderRadius: BorderRadius.circular(20), child: Ink( decoration: BoxDecoration( - color: AppColor.secondaryColor, - borderRadius: BorderRadius.circular(18), + color: AppColor.cardColor, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColor.borderColor), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.12), - blurRadius: 10, - offset: const Offset(0, 4), + color: Colors.black.withOpacity(0.05), + blurRadius: 14, + offset: const Offset(0, 5), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // ── Lightweight route preview (pure Flutter, zero native cost) ── - ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(18), - topRight: Radius.circular(18), - ), - child: SizedBox( - height: 130, - width: double.infinity, - child: CustomPaint( - painter: _RoutePainter(start: start, end: end), - child: Align( - alignment: Alignment.bottomRight, - child: Padding( - padding: const EdgeInsets.all(8), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.45), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.map_outlined, - color: Colors.white, size: 13), - const SizedBox(width: 4), - Text('View Map'.tr, - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w600)), - ], - ), + // ── الرأس: التاريخ والوقت + حالة الرحلة ──────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 14, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(Icons.calendar_today_rounded, + size: 13, color: AppColor.grayColor), + const SizedBox(width: 6), + Text( + '${ride['date']} · ${_prettyTime(ride['time'])}', + style: AppStyle.subtitle.copyWith( + fontSize: 12, color: AppColor.grayColor), ), - ), + ], ), - ), + Flexible(child: _StatusChip(status: status)), + ], ), ), - // ── Details ────────────────────────────────────────────────── + // ── المسار: أخضر ← أحمر ───────────────────────────────────── Padding( - padding: const EdgeInsets.fromLTRB(14, 10, 14, 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: _RouteStrip(start: start, end: end, compact: true), + ), + + // ── التذييل: التفاصيل + السعر ────────────────────────────── + Container( + padding: const EdgeInsets.fromLTRB(16, 11, 16, 12), + decoration: BoxDecoration( + color: AppColor.primaryColor.withOpacity(0.04), + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(20)), + border: Border( + top: BorderSide(color: AppColor.borderColor)), + ), + child: Row( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon(Icons.access_time_rounded, - size: 14, - color: AppColor.writeColor.withOpacity(0.5)), - const SizedBox(width: 4), - Text( - '${ride['date']} · ${ride['time']}', - style: AppStyle.subtitle.copyWith( - fontSize: 12, - color: AppColor.writeColor.withOpacity(0.6)), + Expanded( + child: Wrap( + spacing: 8, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _MetaPill( + icon: Icons.directions_car_filled_outlined, + text: '${ride['carType'] ?? ''}'.tr, + ), + if (distance > 0) + _MetaPill( + icon: Icons.straighten_rounded, + text: '${distance.toStringAsFixed(1)} ${'km'.tr}', ), - ], - ), - _StatusChip(status: status), - ], + ], + ), ), - const Divider(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Total Price'.tr, - style: AppStyle.title.copyWith(fontSize: 15)), - Text( - '${ride['price']} ${CurrencyHelper.currency}', - style: AppStyle.headTitle.copyWith( - fontSize: 20, color: AppColor.primaryColor), - ), - ], + const SizedBox(width: 8), + Text( + '${ride['price']} ${CurrencyHelper.currency}', + style: AppStyle.headTitle.copyWith( + fontSize: 19, color: AppColor.primaryColor), ), ], ), @@ -219,181 +502,128 @@ class _HistoryCard extends StatelessWidget { } } -// ───────────────────────────────────────────────────────────────────────────── -// Pure-Flutter route painter — grid background + animated dashed line -// ───────────────────────────────────────────────────────────────────────────── -class _RoutePainter extends CustomPainter { - final LatLng start; - final LatLng end; - - const _RoutePainter({required this.start, required this.end}); +/// شارة معلومات صغيرة (نوع السيارة، المسافة، طريقة الدفع…) +class _MetaPill extends StatelessWidget { + final IconData icon; + final String text; + const _MetaPill({required this.icon, required this.text}); @override - void paint(Canvas canvas, Size size) { - // Background gradient - final bgPaint = Paint() - ..shader = LinearGradient( - colors: [ - AppColor.primaryColor.withOpacity(0.08), - AppColor.primaryColor.withOpacity(0.18), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); - canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), bgPaint); - - // Subtle grid - final gridPaint = Paint() - ..color = AppColor.primaryColor.withOpacity(0.06) - ..strokeWidth = 1; - const step = 20.0; - for (double x = 0; x < size.width; x += step) { - canvas.drawLine(Offset(x, 0), Offset(x, size.height), gridPaint); - } - for (double y = 0; y < size.height; y += step) { - canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint); - } - - // Map lat/lng to canvas — simple linear projection - final minLat = math.min(start.latitude, end.latitude); - final maxLat = math.max(start.latitude, end.latitude); - final minLng = math.min(start.longitude, end.longitude); - final maxLng = math.max(start.longitude, end.longitude); - - final latRange = maxLat - minLat; - final lngRange = maxLng - minLng; - - final pad = 32.0; - - Offset project(LatLng p) { - double x, y; - if (lngRange < 0.0005) { - x = size.width / 2; - } else { - x = pad + ((p.longitude - minLng) / lngRange) * (size.width - 2 * pad); - } - if (latRange < 0.0005) { - y = size.height / 2; - } else { - // Invert y (latitude grows up, canvas grows down) - y = size.height - - pad - - ((p.latitude - minLat) / latRange) * (size.height - 2 * pad); - } - return Offset(x, y); - } - - final startPt = project(start); - final endPt = project(end); - - // Dashed route line - final linePaint = Paint() - ..color = AppColor.primaryColor - ..strokeWidth = 2.5 - ..strokeCap = StrokeCap.round - ..style = PaintingStyle.stroke; - - _drawDashedLine(canvas, startPt, endPt, linePaint, 8, 5); - - // Glow behind markers - final glowPaint = Paint() - ..color = AppColor.primaryColor.withOpacity(0.2) - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8); - canvas.drawCircle(startPt, 14, glowPaint); - canvas.drawCircle(endPt, 14, glowPaint); - - // Start dot (A) - _drawMarker(canvas, startPt, AppColor.primaryColor, 'A'); - - // End dot (B) - _drawMarker(canvas, endPt, AppColor.redColor, 'B'); + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: AppColor.grayColor), + const SizedBox(width: 4), + Text(text, + style: AppStyle.subtitle + .copyWith(fontSize: 12, color: AppColor.grayColor)), + ], + ); } - - void _drawDashedLine(Canvas canvas, Offset p1, Offset p2, Paint paint, - double dashLen, double gapLen) { - final dx = p2.dx - p1.dx; - final dy = p2.dy - p1.dy; - final dist = math.sqrt(dx * dx + dy * dy); - if (dist == 0) return; - final ux = dx / dist; - final uy = dy / dist; - double traveled = 0; - bool drawing = true; - while (traveled < dist) { - final segLen = drawing ? dashLen : gapLen; - final next = math.min(traveled + segLen, dist); - if (drawing) { - canvas.drawLine( - Offset(p1.dx + ux * traveled, p1.dy + uy * traveled), - Offset(p1.dx + ux * next, p1.dy + uy * next), - paint, - ); - } - traveled = next; - drawing = !drawing; - } - } - - void _drawMarker(Canvas canvas, Offset center, Color color, String label) { - // Outer ring - canvas.drawCircle(center, 12, Paint()..color = color.withOpacity(0.25)); - // Solid circle - canvas.drawCircle(center, 8, Paint()..color = color); - // White inner - canvas.drawCircle(center, 4, Paint()..color = Colors.white); - // Label text - final tp = TextPainter( - text: TextSpan( - text: label, - style: TextStyle( - color: color, fontSize: 7, fontWeight: FontWeight.w900)), - textDirection: TextDirection.ltr, - )..layout(); - tp.paint(canvas, center - Offset(tp.width / 2, tp.height / 2)); - } - - @override - bool shouldRepaint(_RoutePainter old) => old.start != start || old.end != end; } // ───────────────────────────────────────────────────────────────────────────── -// Status chip +// Status chip — يحوّل رموز السيرفر الخام إلى نص مقروء ومترجم // ───────────────────────────────────────────────────────────────────────────── + +/// وصف مرئي لحالة الرحلة: نص مترجم + لون + أيقونة. +class _StatusStyle { + final String label; + final Color color; + final IconData icon; + const _StatusStyle(this.label, this.color, this.icon); +} + +/// السيرفر يرجّع رموزاً خاماً بصيغ مختلفة (`cancelled_by_passenger`, +/// `CancelFromDriverAfterApply`, `finished`…). نوحّدها هنا ونعرض نصاً مترجماً +/// بدل عرض الرمز كما هو للمستخدم. +_StatusStyle _statusStyleOf(String raw) { + final key = raw.toLowerCase().replaceAll('_', ''); + + switch (key) { + case 'finished': + case 'completed': + return _StatusStyle( + 'Finished'.tr, AppColor.greenColor, Icons.check_circle_outline); + + case 'cancelledbypassenger': + case 'cancelfrompassenger': + return _StatusStyle('Canceled by you'.tr, AppColor.redColor, + Icons.person_off_outlined); + + case 'cancelledbydriver': + case 'cancelfromdriver': + return _StatusStyle('Canceled by driver'.tr, AppColor.redColor, + Icons.no_transfer_outlined); + + case 'cancelfromdriverafterapply': + return _StatusStyle('Canceled by driver after accepting'.tr, + AppColor.redColor, Icons.no_transfer_outlined); + + case 'cancelled': + case 'canceled': + return _StatusStyle( + 'Canceled'.tr, AppColor.redColor, Icons.cancel_outlined); + + case 'waiting': + return _StatusStyle('Searching for a driver'.tr, AppColor.yellowColor, + Icons.hourglass_empty_rounded); + + case 'accepted': + return _StatusStyle( + 'Driver on the way'.tr, AppColor.yellowColor, Icons.directions_car); + + case 'arrived': + return _StatusStyle( + 'Arrived'.tr, AppColor.yellowColor, Icons.flag_outlined); + + case 'start': + case 'started': + return _StatusStyle('Trip in progress'.tr, AppColor.yellowColor, + Icons.navigation_outlined); + + case 'nothing': + return _StatusStyle('Not completed'.tr, AppColor.grayColor, + Icons.remove_circle_outline); + + default: + // رمز غير معروف — نعرضه كما هو بدل إخفاء المعلومة، بلون محايد + return _StatusStyle(raw, AppColor.grayColor, Icons.info_outline); + } +} + class _StatusChip extends StatelessWidget { final String status; const _StatusChip({required this.status}); @override Widget build(BuildContext context) { - Color color; - IconData icon; - - if (status == 'Canceled'.tr) { - color = AppColor.redColor; - icon = Icons.cancel_outlined; - } else if (status == 'Finished'.tr) { - color = AppColor.greenColor; - icon = Icons.check_circle_outline; - } else { - color = AppColor.yellowColor; - icon = Icons.hourglass_empty_rounded; - } + final s = _statusStyleOf(status); + final color = s.color; + final icon = s.icon; return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + constraints: const BoxConstraints(maxWidth: 190), decoration: BoxDecoration( - color: color.withOpacity(0.12), + // معتم بالكامل — الشارة تُعرض فوق الخريطة المصغّرة + color: Color.alphaBlend(color.withOpacity(0.14), AppColor.cardColor), borderRadius: BorderRadius.circular(20), - border: Border.all(color: color.withOpacity(0.3), width: 1), + border: Border.all(color: color.withOpacity(0.35), width: 1), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, color: color, size: 13), const SizedBox(width: 4), - Text(status, - style: AppStyle.subtitle.copyWith( - color: color, fontWeight: FontWeight.bold, fontSize: 11)), + Flexible( + child: Text(s.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppStyle.subtitle.copyWith( + color: color, fontWeight: FontWeight.bold, fontSize: 11)), + ), ], ), ); @@ -418,25 +648,31 @@ class _RideDetailSheetState extends State<_RideDetailSheet> { IntaleqMapController? _mc; Set _markers = {}; Set _polylines = {}; + Set _circles = {}; + + /// حدود تشمل المسار كاملاً — لا نقطتي البداية والنهاية فقط، وإلا خرج + /// جزء من المسار المنحني عن إطار الكاميرا. + LatLngBounds? _boundsOf(List points) { + if (points.isEmpty) return null; + + double minLat = points.first.latitude, maxLat = points.first.latitude; + double minLng = points.first.longitude, maxLng = points.first.longitude; + + for (final p in points) { + minLat = math.min(minLat, p.latitude); + maxLat = math.max(maxLat, p.latitude); + minLng = math.min(minLng, p.longitude); + maxLng = math.max(maxLng, p.longitude); + } + + if ((maxLat - minLat) < 0.0005 && (maxLng - minLng) < 0.0005) return null; - LatLngBounds? get _bounds { - final latDiff = (widget.start.latitude - widget.end.latitude).abs(); - final lngDiff = (widget.start.longitude - widget.end.longitude).abs(); - if (latDiff < 0.0005 && lngDiff < 0.0005) return null; return LatLngBounds( - northeast: LatLng( - math.max(widget.start.latitude, widget.end.latitude), - math.max(widget.start.longitude, widget.end.longitude), - ), - southwest: LatLng( - math.min(widget.start.latitude, widget.end.latitude), - math.min(widget.start.longitude, widget.end.longitude), - ), + northeast: LatLng(maxLat, maxLng), + southwest: LatLng(minLat, minLng), ); } - void _onMapCreated(IntaleqMapController c) => _mc = c; - Future _onStyleLoaded() async { WidgetsBinding.instance.addPostFrameCallback((_) async { await Future.delayed(const Duration(milliseconds: 400)); @@ -445,19 +681,72 @@ class _RideDetailSheetState extends State<_RideDetailSheet> { }); } + /// نصف قطر الهالة بالأمتار — متناسب مع طول الرحلة حتى تبقى ظاهرة + /// عند أي مستوى تكبير، مع حد أدنى وأقصى معقولين. + double get _haloRadius { + final latDiff = (widget.start.latitude - widget.end.latitude).abs(); + final lngDiff = (widget.start.longitude - widget.end.longitude).abs(); + final spanMeters = math.max(latDiff, lngDiff) * 111000; + return (spanMeters * 0.035).clamp(40.0, 900.0); + } + Future _draw() async { if (!mounted) return; + // نرسم فوراً بالخط المستقيم — لا ننتظر الشبكة إطلاقاً، حتى لو تعطّلت + // خدمة المسارات تبقى الخريطة والعلامات ظاهرة. + _applyRoute([widget.start, widget.end], animate: true); + + // ثم نرقّي الخط للمسار الحقيقي عند وصوله (بدون await يحجب الرسم) + _upgradeToRealRoute(); + } + + /// يجلب هندسة المسار الحقيقية ويستبدل الخط المستقيم بها عند نجاحه. + void _upgradeToRealRoute() { + _routeOf(widget.start, widget.end) + .timeout(const Duration(seconds: 15)) + .then((points) { + if (!mounted || points.length < 3) return; + Log.print('🗺️ Upgrading detail map to real route (${points.length} pts)'); + _applyRoute(points, animate: true); + }).catchError((e) { + Log.print('⚠️ Route upgrade skipped: $e'); + }); + } + + void _applyRoute(List routePoints, {required bool animate}) { + if (!mounted) return; + setState(() { _polylines = { Polyline( polylineId: const PolylineId('route'), - points: [widget.start, widget.end], + points: routePoints, color: AppColor.primaryColor, width: 4, ), }; + // هالة خضراء عند الانطلاق وحمراء عند الوصول + _circles = { + Circle( + circleId: const CircleId('start_halo'), + center: widget.start, + radius: _haloRadius, + fillColor: _kStartColor.withOpacity(0.22), + strokeColor: _kStartColor, + strokeWidth: 2, + ), + Circle( + circleId: const CircleId('end_halo'), + center: widget.end, + radius: _haloRadius, + fillColor: _kEndColor.withOpacity(0.22), + strokeColor: _kEndColor, + strokeWidth: 2, + ), + }; + _markers = { Marker( markerId: const MarkerId('start'), @@ -474,18 +763,56 @@ class _RideDetailSheetState extends State<_RideDetailSheet> { }; }); - final b = _bounds; + if (!animate) return; + + final b = _boundsOf(routePoints); if (b != null) { - await _mc?.animateCamera(CameraUpdate.newLatLngBounds(b, + _mc?.animateCamera(CameraUpdate.newLatLngBounds(b, left: 60, top: 60, right: 60, bottom: 60)); } else { - await _mc?.animateCamera(CameraUpdate.newLatLngZoom(widget.start, 14)); + _mc?.animateCamera(CameraUpdate.newLatLngZoom(widget.start, 14)); } } - @override - void dispose() { - super.dispose(); + /// يعيد رسم نفس الرحلة على الخريطة الرئيسية وجاهزة للطلب. + /// [reverse] يعكس الاتجاه: نقطة الوصول تصبح نقطة الانطلاق والعكس. + Future _rebook({required bool reverse}) async { + if (!Get.isRegistered() || + !Get.isRegistered() || + !Get.isRegistered()) { + mySnackbarInfo('Please open the map first'.tr); + return; + } + + final from = reverse ? widget.end : widget.start; + final to = reverse ? widget.start : widget.end; + + // إغلاق ورقة التفاصيل ثم العودة لصفحة الخريطة + Navigator.of(context).pop(); + Get.back(); + + final locSearch = Get.find(); + final mapEngine = Get.find(); + final rideLife = Get.find(); + + // إغلاق القائمة الجانبية (الدراور) حتى تظهر الخريطة كاملة + mapEngine.closeDrawerMenu(); + + // تأخير بسيط لضمان إعادة بناء الخريطة قبل الرسم + await Future.delayed(const Duration(milliseconds: 400)); + + mapEngine.clearPolyline(); + locSearch.waypoints.clear(); + locSearch.clearAllMenuWaypoints(); + + await rideLife.getDirectionMap( + '${from.latitude},${from.longitude}', + '${to.latitude},${to.longitude}', + ); + + mapEngine.isBottomSheetShown = true; + mapEngine.heightBottomSheetShown = 250; + locSearch.update(); } @override @@ -495,22 +822,23 @@ class _RideDetailSheetState extends State<_RideDetailSheet> { (widget.start.latitude + widget.end.latitude) / 2, (widget.start.longitude + widget.end.longitude) / 2, ); + final distance = double.tryParse('${ride['distance'] ?? 0}') ?? 0; return DraggableScrollableSheet( - initialChildSize: 0.88, + initialChildSize: 0.9, minChildSize: 0.5, maxChildSize: 0.95, builder: (_, scrollController) => Container( decoration: BoxDecoration( color: AppColor.secondaryColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + borderRadius: const BorderRadius.vertical(top: Radius.circular(26)), ), child: Column( children: [ // Handle Container( margin: const EdgeInsets.symmetric(vertical: 10), - width: 40, + width: 44, height: 4, decoration: BoxDecoration( color: AppColor.writeColor.withOpacity(0.2), @@ -520,72 +848,187 @@ class _RideDetailSheetState extends State<_RideDetailSheet> { // Map — only ONE instance, created on demand Expanded( - child: ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - child: IntaleqMap( - apiKey: Env.mapSaasKey, - styleUrl: Get.isDarkMode - ? 'assets/style_dark.json' - : 'assets/style.json', - initialCameraPosition: - CameraPosition(target: center, zoom: 12), - onMapCreated: (c) { - _mc = c; - _onStyleLoaded(); - }, - myLocationEnabled: false, - markers: _markers, - polylines: _polylines, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: Stack( + children: [ + Positioned.fill( + child: IntaleqMap( + apiKey: Env.mapSaasKey, + styleUrl: Get.isDarkMode + ? 'assets/style_dark.json' + : 'assets/style.json', + initialCameraPosition: + CameraPosition(target: center, zoom: 12), + onMapCreated: (c) { + _mc = c; + _onStyleLoaded(); + }, + myLocationEnabled: false, + markers: _markers, + polylines: _polylines, + circles: _circles, + ), + ), + + // مفتاح الألوان فوق الخريطة + Positioned( + top: 10, + left: 10, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: AppColor.cardColor.withOpacity(0.92), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.12), + blurRadius: 8, + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _LegendDot( + color: _kStartColor, label: 'From'.tr), + const SizedBox(width: 12), + _LegendDot(color: _kEndColor, label: 'To'.tr), + ], + ), + ), + ), + ], + ), ), ), ), - // Trip info strip + // Trip info Container( - padding: const EdgeInsets.fromLTRB(20, 14, 20, 20), + padding: const EdgeInsets.fromLTRB(18, 16, 18, 20), child: Column( children: [ + // السعر + الحالة Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('${ride['date']} · ${ride['time']}', + Text( + '${ride['date']} · ${_prettyTime(ride['time'])}', style: AppStyle.subtitle.copyWith( - fontSize: 12, - color: - AppColor.writeColor.withOpacity(0.55))), + fontSize: 12, color: AppColor.grayColor)), const SizedBox(height: 2), Text('${ride['price']} ${CurrencyHelper.currency}', style: AppStyle.headTitle.copyWith( - fontSize: 22, color: AppColor.primaryColor)), + fontSize: 24, color: AppColor.primaryColor)), ], ), _StatusChip(status: ride['status'] ?? ''), ], ), - const SizedBox(height: 14), + + const SizedBox(height: 16), + + // المسار بالتفصيل: أخضر ← أحمر + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColor.cardColor, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColor.borderColor), + ), + child: _RouteStrip( + start: widget.start, end: widget.end), + ), + + const SizedBox(height: 12), + + // تفاصيل إضافية + Row( + children: [ + _MetaPill( + icon: Icons.directions_car_filled_outlined, + text: '${ride['carType'] ?? ''}'.tr, + ), + const SizedBox(width: 14), + if (distance > 0) + _MetaPill( + icon: Icons.straighten_rounded, + text: '${distance.toStringAsFixed(1)} ${'km'.tr}', + ), + const SizedBox(width: 14), + _MetaPill( + icon: Icons.payments_outlined, + text: '${ride['paymentMethod'] ?? ''}'.tr, + ), + ], + ), + + const SizedBox(height: 16), + + // إعادة الحجز / عكس الرحلة + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => _rebook(reverse: false), + icon: const Icon(Icons.replay_rounded, size: 18), + label: Text('Rebook'.tr, + style: const TextStyle( + fontSize: 13, fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: AppColor.primaryColor, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14)), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: () => _rebook(reverse: true), + icon: const Icon(Icons.swap_vert_rounded, size: 18), + label: Text('Reverse trip'.tr, + style: const TextStyle( + fontSize: 13, fontWeight: FontWeight.w600)), + style: OutlinedButton.styleFrom( + foregroundColor: AppColor.primaryColor, + side: BorderSide( + color: AppColor.primaryColor, width: 1.4), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14)), + ), + ), + ), + ], + ), + + const SizedBox(height: 8), + // Open in Google Maps SizedBox( width: double.infinity, - child: ElevatedButton.icon( + child: TextButton.icon( onPressed: () { final url = 'https://www.google.com/maps/dir/${ride['start_location']}/${ride['end_location']}/'; showInBrowser(url); }, - icon: const Icon(Icons.open_in_new, size: 16), - label: Text('Open in Google Maps'.tr), - style: ElevatedButton.styleFrom( - backgroundColor: AppColor.primaryColor, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 13), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), + icon: const Icon(Icons.open_in_new, size: 15), + label: Text('Open in Google Maps'.tr, + style: const TextStyle(fontSize: 12.5)), + style: TextButton.styleFrom( + foregroundColor: AppColor.grayColor, ), ), ), @@ -598,3 +1041,27 @@ class _RideDetailSheetState extends State<_RideDetailSheet> { ); } } + +class _LegendDot extends StatelessWidget { + final Color color; + final String label; + const _LegendDot({required this.color, required this.label}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 9, + height: 9, + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + ), + const SizedBox(width: 5), + Text(label, + style: AppStyle.subtitle.copyWith( + fontSize: 11, fontWeight: FontWeight.w700, color: color)), + ], + ); + } +} diff --git a/siro_rider/pubspec.lock b/siro_rider/pubspec.lock index def65d08..1b8d51ce 100644 --- a/siro_rider/pubspec.lock +++ b/siro_rider/pubspec.lock @@ -1297,10 +1297,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -1313,10 +1313,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: "direct main" description: @@ -1901,10 +1901,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.9" timezone: dependency: transitive description: