diff --git a/backend/.env.example b/backend/.env.example index a5e0bd8e..70e595b4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -89,6 +89,24 @@ FEMALE_GENDER_HASH= FIREBASE_PROJECT_ID=siro-project FIREBASE_API_KEY= +# ============================================================================= +# Payment Gateway Configuration +# ============================================================================= +PAYMENT_GATEWAY_URL=https://api.paymentprovider.com +PAYMENT_GATEWAY_KEY= +PAYMENT_GATEWAY_SECRET= +PAYMENT_WEBHOOK_SECRET= + +# ============================================================================= +# Siro Commissions per Country +# ============================================================================= +# Set the commission percentage Siro takes from drivers in each country +# (0.15 = 15%, 0.12 = 12%, 0.10 = 10%) +SIRO_COMMISSION_JO=0.15 +SIRO_COMMISSION_SY=0.12 +SIRO_COMMISSION_EG=0.10 +SIRO_COMMISSION_IQ=0.10 + # ============================================================================= # SMS Configuration (for OTP) # ============================================================================= diff --git a/backend/Admin/geofence/get_heatmap.php b/backend/Admin/geofence/get_heatmap.php index 06ff0a93..9c45ddcd 100644 --- a/backend/Admin/geofence/get_heatmap.php +++ b/backend/Admin/geofence/get_heatmap.php @@ -1,29 +1,77 @@ = DATE_SUB(NOW(), INTERVAL :days DAY) - ORDER BY created_at DESC LIMIT 5000"; // Limit to prevent massive payloads - - $stmt = $con->prepare($sql); - $stmt->bindValue(':days', (int) $days, PDO::PARAM_INT); - $stmt->execute(); - - $locations = $stmt->fetchAll(PDO::FETCH_ASSOC); - - echo json_encode([ - "status" => "success", - "data" => $locations - ]); - + $redis = getRedisConnection(); + $cacheJson = $redis->get('siro:cache:heatmap:data'); } catch (Exception $e) { - error_log("Error fetching heatmap data: " . $e->getMessage()); - echo json_encode(["status" => "error", "message" => "Server error"]); + echo json_encode(['status' => 'error', 'message' => 'Redis connection failed']); + exit; } + +if (!$cacheJson) { + echo json_encode(['status' => 'error', 'message' => 'Cache not generated yet']); + exit; +} + +$cacheData = json_decode($cacheJson, true); + +if (!$cacheData || !isset($cacheData['data'])) { + echo json_encode(['status' => 'error', 'message' => 'Invalid cache data']); + exit; +} + +$limitDate = date('Y-m-d', strtotime("-$days days")); + +$filteredLocations = []; +$stats = ['geofence' => 0, 'app_usage' => 0, 'silent_push' => 0]; + +$dataByCountry = $cacheData['data']; + +// تحديد الدول التي سنسحب منها +$countriesToSearch = ($countryCode === 'ALL') ? array_keys($dataByCountry) : [$countryCode]; + +foreach ($countriesToSearch as $cc) { + if (!isset($dataByCountry[$cc])) continue; + + foreach ($dataByCountry[$cc] as $loc) { + // فلتر الأيام + if ($loc['date'] < $limitDate) continue; + + // فلتر المصدر + if ($source !== 'all' && $loc['source'] !== $source) continue; + + $filteredLocations[] = [ + 'latitude' => $loc['lat'], + 'longitude' => $loc['lng'], + 'source' => $loc['source'] + ]; + + if (isset($stats[$loc['source']])) { + $stats[$loc['source']]++; + } + } +} + +echo json_encode([ + 'status' => 'success', + 'data' => $filteredLocations, + 'total' => count($filteredLocations), + 'stats' => $stats, + 'source' => 'redis_cache' +], JSON_UNESCAPED_UNICODE); + +// تم إزالة دالة filterRequest من هنا لتجنب خطأ Redeclaration لأنها معرفة في functions.php ?> diff --git a/backend/api/ride/get_competitor_context.php b/backend/api/ride/get_competitor_context.php new file mode 100644 index 00000000..24ea12d1 --- /dev/null +++ b/backend/api/ride/get_competitor_context.php @@ -0,0 +1,120 @@ + "error", "message" => "Missing parameters"]); + exit; +} + +$lat = (float)$lat; +$lng = (float)$lng; +$countryCode = strtoupper($countryCode); + +try { + $redis = getRedisConnection(); + $cacheJson = $redis->get('siro:cache:pricing:grids'); +} catch (Exception $e) { + echo json_encode(["status" => "error", "message" => "Redis connection failed"]); + exit; +} + +if (!$cacheJson) { + echo json_encode(["status" => "success", "data" => null, "message" => "Cache not generated yet"]); + exit; +} + +$cacheData = json_decode($cacheJson, true); + +if (!$cacheData || !isset($cacheData['grids'])) { + echo json_encode(["status" => "success", "data" => null, "message" => "Invalid cache data"]); + exit; +} + +// محاولة إيجاد الشبكة (Grid) للراكب +$gridSize = 0.025; +$gLat = round($lat / $gridSize) * $gridSize; +$gLng = round($lng / $gridSize) * $gridSize; +$gridKey = "{$countryCode}_" . number_format($gLat, 3) . "_" . number_format($gLng, 3); + +$grids = $cacheData['grids']; + +if (isset($grids[$gridKey])) { + $compData = $grids[$gridKey]; +} else { + // إذا لم نجد، نستخدم الـ Fallback للدولة + $fallbackKey = "{$countryCode}_FALLBACK"; + if (isset($grids[$fallbackKey])) { + $compData = $grids[$fallbackKey]; + } else { + echo json_encode(["status" => "success", "data" => null, "message" => "No data for this region"]); + exit; + } +} + +$avgPrice = $compData['avg_price']; +$topComp = $compData['top_competitor'] ?? 'Other'; + +// جلب سعر Siro للمقارنة السريعة +$sqlKazan = "SELECT (price_km + start_price) AS siro_base + FROM kazan + WHERE country_code = :cc AND type = 'speed' + LIMIT 1"; +$stmtKazan = $con->prepare($sqlKazan); +$stmtKazan->execute([':cc' => $countryCode]); +$kazanRow = $stmtKazan->fetch(PDO::FETCH_ASSOC); + +$siroPrice = $kazanRow ? (float)$kazanRow['siro_base'] : $avgPrice * 0.9; + +$savingsPct = 0; +if ($avgPrice > 0 && $siroPrice < $avgPrice) { + $savingsPct = (($avgPrice - $siroPrice) / $avgPrice) * 100; +} + +$passengerMessage = null; +$driverMessage = null; +$extraEarnings = 0; + +if ($savingsPct > 2) { + $compNameAr = match(strtolower($topComp)) { + 'careem' => 'كريم', + 'uber' => 'أوبر', + 'yallago' => 'يلا غو', + 'jenny' => 'جيني', + default => 'التطبيقات الأخرى' + }; + + $passengerMessage = "أوفر بـ " . number_format($savingsPct, 1) . "% من $compNameAr ⚡"; + + // قراءة نسبة عمولة سيرو ديناميكياً من الإنفيرومنت، والنسبة الافتراضية 10% إذا لم تكن موجودة + $siroCommissionRate = (float)(getenv('SIRO_COMMISSION_' . $countryCode) ?: 0.10); + $extraEarnings = $siroPrice * $siroCommissionRate; + + $driverMessage = "رحلة مربحة! تكسب أكثر مقارنة بـ $compNameAr 💰"; +} + +echo json_encode([ + "status" => "success", + "data" => [ + "competitor_avg_price" => $avgPrice, + "top_competitor" => $topComp, + "savings_percent" => $savingsPct, + "passenger_badge_text" => $passengerMessage, + "driver_badge_text" => $driverMessage, + "driver_extra_earnings" => round($extraEarnings, 2), + "source" => "redis_cache" + ] +]); +?> diff --git a/backend/api/ride/get_hotzones.php b/backend/api/ride/get_hotzones.php new file mode 100644 index 00000000..a6a6e1f2 --- /dev/null +++ b/backend/api/ride/get_hotzones.php @@ -0,0 +1,30 @@ +get('siro:cache:ai:hotzones'); +} catch (Exception $e) { + echo json_encode(["status" => "error", "message" => "Redis connection failed"]); + exit; +} + +if (!$hotZonesJson) { + echo json_encode(["status" => "success", "data" => [], "message" => "No hot zones available"]); + exit; +} + +// الـ JSON القادم من Redis تم تصميمه بالفعل بالشكل النهائي المطلوب للتطبيق +echo $hotZonesJson; +?> diff --git a/backend/auth/loginUsingCredentialsWithoutGooglePassenger.php b/backend/auth/loginUsingCredentialsWithoutGooglePassenger.php index c607b003..ec665bf9 100644 --- a/backend/auth/loginUsingCredentialsWithoutGooglePassenger.php +++ b/backend/auth/loginUsingCredentialsWithoutGooglePassenger.php @@ -58,9 +58,9 @@ try { $encryptedBirthdate = $encryptionHelper->encryptData('1990-01-01'); $encryptedSite = $encryptionHelper->encryptData('Jordan'); - // Insert passenger - $insert = $con->prepare("INSERT INTO passengers (id, phone, email, password, gender, birthdate, site, first_name, last_name) - VALUES (:id, :phone, :email, :password, :gender, :birthdate, :site, :first_name, :last_name)"); + // Insert passenger with verified = 1 so app doesn't reject + $insert = $con->prepare("INSERT INTO passengers (id, phone, email, password, gender, birthdate, site, first_name, last_name, is_test, verified) + VALUES (:id, :phone, :email, :password, :gender, :birthdate, :site, :first_name, :last_name, 1, 1)"); $insert->execute([ ':id' => $passengerId, ':phone' => $encryptedPhone, @@ -131,6 +131,11 @@ try { if(isset($data['employmentType'])) $data['employmentType'] = $encryptionHelper->decryptData($data['employmentType']); if(isset($data['maritalStatus'])) $data['maritalStatus'] = $encryptionHelper->decryptData($data['maritalStatus']); + // Force verified = 1 for the test user so the Rider app doesn't reject the login + if (isset($data['is_test']) && $data['is_test'] == 1) { + $data['verified'] = 1; + } + // توليد الـ JWT بصلاحية (tester) لتميزهم عن المستخدمين الفعليين $jwtService = new JwtService($redis); $jwt = $jwtService->generateAccessToken($data['id'], 'tester', $audience, $fingerprint); diff --git a/backend/bot/ai_formula_solver.php b/backend/bot/ai_formula_solver.php new file mode 100644 index 00000000..d79871f5 --- /dev/null +++ b/backend/bot/ai_formula_solver.php @@ -0,0 +1,176 @@ +getMessage() . "\n"); +} + +echo "Starting AI Formula Discovery Engine...\n"; + +// نجلب التطبيقات التي لديها بيانات (مسافة ووقت وسعر) +$sqlApps = "SELECT DISTINCT competitor_name, country_code + FROM scraped_competitor_prices + WHERE distance_km > 0 AND duration_min > 0 AND price_amount > 0"; +$stmtApps = $con->query($sqlApps); +$apps = $stmtApps->fetchAll(PDO::FETCH_ASSOC); + +if (empty($apps)) { + echo "No sufficient data (Distance/Duration) found to perform regression.\n"; + exit; +} + +// دالة لحل نظام معادلات خطية (Gaussian Elimination) +function solveLinearSystem($A, $B) { + $n = count($A); + for ($i = 0; $i < $n; $i++) { + // Search for maximum in this column + $maxEl = abs($A[$i][$i]); + $maxRow = $i; + for ($k = $i + 1; $k < $n; $k++) { + if (abs($A[$k][$i]) > $maxEl) { + $maxEl = abs($A[$k][$i]); + $maxRow = $k; + } + } + + // Swap maximum row with current row + for ($k = $i; $k < $n; $k++) { + $tmp = $A[$maxRow][$k]; + $A[$maxRow][$k] = $A[$i][$k]; + $A[$i][$k] = $tmp; + } + $tmp = $B[$maxRow]; + $B[$maxRow] = $B[$i]; + $B[$i] = $tmp; + + // Make all rows below this one 0 in current column + for ($k = $i + 1; $k < $n; $k++) { + if ($A[$i][$i] == 0) continue; + $c = -$A[$k][$i] / $A[$i][$i]; + for ($j = $i; $j < $n; $j++) { + if ($i == $j) { + $A[$k][$j] = 0; + } else { + $A[$k][$j] += $c * $A[$i][$j]; + } + } + $B[$k] += $c * $B[$i]; + } + } + + // Solve equation Ax=b for an upper triangular matrix A + $x = array_fill(0, $n, 0); + for ($i = $n - 1; $i >= 0; $i--) { + if ($A[$i][$i] == 0) continue; + $x[$i] = $B[$i] / $A[$i][$i]; + for ($k = $i - 1; $k >= 0; $k--) { + $B[$k] -= $A[$k][$i] * $x[$i]; + } + } + return $x; +} + +foreach ($apps as $app) { + $competitor = $app['competitor_name']; + $countryCode = $app['country_code']; + + echo "Analyzing: $competitor ($countryCode)...\n"; + + // سحب أحدث 5000 رحلة لتكوين نموذج رياضي دقيق + $sqlData = "SELECT distance_km, duration_min, price_amount + FROM scraped_competitor_prices + WHERE competitor_name = :comp + AND country_code = :country + AND distance_km > 0 AND duration_min > 0 AND price_amount > 0 + ORDER BY id DESC LIMIT 5000"; + $stmtData = $con->prepare($sqlData); + $stmtData->execute([':comp' => $competitor, ':country' => $countryCode]); + $samples = $stmtData->fetchAll(PDO::FETCH_ASSOC); + + $N = count($samples); + if ($N < 10) { + echo " -> Not enough samples ($N). Skipping.\n"; + continue; + } + + // بناء مصفوفات Least Squares (X^T X) * Beta = (X^T Y) + // Beta = [Base_Fare, KM_Price, Min_Price] + $sum_x1 = 0; $sum_x2 = 0; $sum_y = 0; + $sum_x1_sq = 0; $sum_x2_sq = 0; $sum_x1_x2 = 0; + $sum_x1_y = 0; $sum_x2_y = 0; + + foreach ($samples as $s) { + $x1 = (float)$s['distance_km']; + $x2 = (float)$s['duration_min']; + $y = (float)$s['price_amount']; + + $sum_x1 += $x1; + $sum_x2 += $x2; + $sum_y += $y; + + $sum_x1_sq += ($x1 * $x1); + $sum_x2_sq += ($x2 * $x2); + $sum_x1_x2 += ($x1 * $x2); + + $sum_x1_y += ($x1 * $y); + $sum_x2_y += ($x2 * $y); + } + + $matrixA = [ + [$N, $sum_x1, $sum_x2], + [$sum_x1, $sum_x1_sq, $sum_x1_x2], + [$sum_x2, $sum_x1_x2, $sum_x2_sq] + ]; + + $matrixB = [ + $sum_y, + $sum_x1_y, + $sum_x2_y + ]; + + // حل المصفوفة + try { + $beta = solveLinearSystem($matrixA, $matrixB); + + $baseFare = round(max(0, $beta[0]), 3); // Base fare cannot be negative + $kmPrice = round(max(0, $beta[1]), 3); + $minPrice = round(max(0, $beta[2]), 3); + + echo " -> [DISCOVERED] Base Fare: $baseFare, KM: $kmPrice, Min: $minPrice\n"; + + // حفظ في جدول المعادلات السرية + $sqlUpsert = "INSERT INTO competitor_secret_formulas + (competitor_name, country_code, base_fare, price_per_km, price_per_min, sample_size) + VALUES (:comp, :country, :base, :km, :min, :size) + ON DUPLICATE KEY UPDATE + base_fare = :base, price_per_km = :km, price_per_min = :min, sample_size = :size, last_updated = NOW()"; + + $stmtUp = $con->prepare($sqlUpsert); + $stmtUp->execute([ + ':comp' => $competitor, + ':country' => $countryCode, + ':base' => $baseFare, + ':km' => $kmPrice, + ':min' => $minPrice, + ':size' => $N + ]); + + echo " -> Saved successfully.\n"; + + } catch (Exception $e) { + echo " -> Error solving matrix: " . $e->getMessage() . "\n"; + } +} + +echo "Done.\n"; +?> diff --git a/backend/bot/cron_ai_engine.php b/backend/bot/cron_ai_engine.php new file mode 100644 index 00000000..a181d44a --- /dev/null +++ b/backend/bot/cron_ai_engine.php @@ -0,0 +1,172 @@ +getMessage() . "\n"); +} + +echo "Starting Siro AI Engine...\n"; + +// نسبة الخصم المستهدفة (6.5% من إجمالي سعر الرحلة) +$targetMargin = 0.065; + +// ========================================== +// 1. وحدة التسعير الديناميكي بناءً على السعر الإجمالي +// ========================================== +echo "1. Running Smart Pricing Module (Total Price Formula)...\n"; +try { + $sql = "SELECT country_code, + AVG(price_per_km) as avg_price_km, + MIN(price_per_km) as min_price_km + FROM scraped_competitor_prices + WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 HOUR) + AND price_per_km > 0 + GROUP BY country_code"; + + $stmt = $con->query($sql); + $competitorRates = $stmt->fetchAll(PDO::FETCH_ASSOC); + + foreach ($competitorRates as $rate) { + $country = $rate['country_code']; + $countryNameMap = ['JO' => 'Jordan', 'SY' => 'Syria', 'EG' => 'Egypt', 'IQ' => 'Iraq']; + $countryName = $countryNameMap[$country] ?? null; + + if ($countryName) { + $avgKmPrice = (float)$rate['avg_price_km']; + $minKmPrice = (float)$rate['min_price_km']; + + // 1. حساب السعر الفعّال للكيلومتر بناءً على المتوسط والأرخص + $effectiveCompetitorPrice = round($avgKmPrice * (1 - $targetMargin), 2); + if ($effectiveCompetitorPrice > $minKmPrice) { + $effectiveCompetitorPrice = round(($effectiveCompetitorPrice + $minKmPrice) / 2, 2); + } + + // 2. الهندسة العكسية للسعر الإجمالي (Reverse Engineering) + // بما أن سيرو يضيف سعر الدقيقة (والتي تعادل دقيقتين لكل كيلومتر تقريباً)، فإن التكلفة الإضافية للدقائق ترفع السعر الإجمالي بمقدار 1.5x + // لضمان أن يكون السعر النهائي أقل بـ 6%، نقسم الناتج على 1.5 ليمتص تكلفة الدقائق. + $calculatedSpeedPrice = round($effectiveCompetitorPrice / 1.5, 3); + + // 3. تسعير الفئات المتعددة + $newSpeedPrice = $calculatedSpeedPrice; + $newComfortPrice = round($newSpeedPrice * 1.30, 3); + $newLadyPrice = round($newSpeedPrice * 1.10, 3); + $newElectricPrice = round($newSpeedPrice * 1.20, 3); + $newVanPrice = round($newSpeedPrice * 1.50, 3); + $newDeliveryPrice = round($newSpeedPrice * 0.90, 3); + $newMishwarVipPrice = round($newSpeedPrice * 1.40, 3); + $newFixedPrice = $newSpeedPrice; + $newAwfarPrice = round($newSpeedPrice * 0.85, 3); + + // أسعار الدقائق + $newNormalMin = round($newSpeedPrice / 4, 3); + $newPeakMin = round($newNormalMin * 1.15, 3); + $newLateMin = round($newNormalMin * 1.25, 3); + + $updateSql = "UPDATE kazan + SET speedPrice = :speedPrice, + comfortPrice = :comfortPrice, + ladyPrice = :ladyPrice, + electricPrice = :electricPrice, + vanPrice = :vanPrice, + deliveryPrice = :deliveryPrice, + mishwarVipPrice = :mishwarVipPrice, + fixedPrice = :fixedPrice, + awfarPrice = :awfarPrice, + normalMinPrice = :normalMin, + peakMinPrice = :peakMin, + lateMinPrice = :lateMin + WHERE country = :countryName"; + $upStmt = $con->prepare($updateSql); + $upStmt->execute([ + ':speedPrice' => $newSpeedPrice, + ':comfortPrice' => $newComfortPrice, + ':ladyPrice' => $newLadyPrice, + ':electricPrice' => $newElectricPrice, + ':vanPrice' => $newVanPrice, + ':deliveryPrice' => $newDeliveryPrice, + ':mishwarVipPrice' => $newMishwarVipPrice, + ':fixedPrice' => $newFixedPrice, + ':awfarPrice' => $newAwfarPrice, + ':normalMin' => $newNormalMin, + ':peakMin' => $newPeakMin, + ':lateMin' => $newLateMin, + ':countryName' => $countryName + ]); + + echo " -> Updated $countryName (Total Price Math applied): Speed=$newSpeedPrice JOD/KM, NormalMin=$newNormalMin JOD/MIN\n"; + } + } +} catch (Exception $e) { + echo " Error in Pricing Module: " . $e->getMessage() . "\n"; +} + +// ========================================== +// 2. وحدة توجيه السائقين (Demand Predictor) +// ========================================== +echo "2. Running Demand Predictor Module...\n"; +try { + $cacheJson = $redis->get('siro:cache:pricing:grids'); + $hotZones = []; + if ($cacheJson) { + $grids = json_decode($cacheJson, true)['grids'] ?? []; + foreach ($grids as $key => $data) { + if (strpos($key, 'FALLBACK') !== false) continue; + + if ($data['avg_price'] > 0) { + $parts = explode('_', $key); + if (count($parts) == 3) { + $hotZones[] = [ + 'latitude' => (float)$parts[1], + 'longitude' => (float)$parts[2], + 'avg_price' => $data['avg_price'], + 'top_competitor' => $data['top_competitor'], + 'timestamp' => time() + ]; + } + } + } + + $redis->set('siro:cache:ai:hotzones', json_encode(['status' => 'success', 'data' => $hotZones], JSON_UNESCAPED_UNICODE)); + echo " -> Saved " . count($hotZones) . " Hot Zones to Redis for Driver Map Guidance.\n"; + } +} catch (Exception $e) { + echo " Error in Demand Predictor: " . $e->getMessage() . "\n"; +} + +// ========================================== +// 3. وحدة استهداف الركاب الخاملين (Smart Retention) +// ========================================== +echo "3. Running Smart Retention Module...\n"; +try { + $sql = "SELECT source, COUNT(*) as opens + FROM passenger_opening_locations + WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 HOUR) + GROUP BY source + HAVING opens >= 3"; + + $stmt = $con->query($sql); + $idleRiders = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $notifiedCount = count($idleRiders); + echo " -> Identified $notifiedCount idle riders requiring push notifications.\n"; +} catch (Exception $e) { + echo " Error in Smart Retention: " . $e->getMessage() . "\n"; +} + +echo "AI Engine finished successfully.\n"; +?> diff --git a/backend/bot/cron_empty_results_to_db.php b/backend/bot/cron_empty_results_to_db.php index f3d9a604..a4764554 100644 --- a/backend/bot/cron_empty_results_to_db.php +++ b/backend/bot/cron_empty_results_to_db.php @@ -35,7 +35,7 @@ if (empty($data) || !is_array($data)) { } $insertedCount = 0; -$stmt = $con->prepare("INSERT INTO scraped_competitor_prices (task_id, app_name, competitor_name, start_location, end_location, start_lat, start_lng, end_lat, end_lng, price_amount, price_per_km, currency, country_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); +$stmt = $con->prepare("INSERT INTO scraped_competitor_prices (task_id, app_name, competitor_name, start_location, end_location, start_lat, start_lng, end_lat, end_lng, price_amount, price_per_km, distance_km, duration_min, currency, country_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); foreach ($data as $row) { if (isset($row['status']) && $row['status'] !== 'success') { @@ -46,6 +46,7 @@ foreach ($data as $row) { $resultData = $row['result_data'] ?? []; $appName = $resultData['app'] ?? $row['app'] ?? 'Unknown'; + $competitorName = $appName; // Assuming app_name is competitor_name for now $startLoc = $resultData['start_location'] ?? $row['start_location'] ?? ''; if (empty($startLoc) && !empty($resultData['start_lat'])) { @@ -84,19 +85,34 @@ foreach ($data as $row) { } $distanceKm = (float)($resultData['distance_km'] ?? 1); - if ($distanceKm <= 0) $distanceKm = 1; + if ($distanceKm <= 0) $distanceKm = 1; // Prevent division by zero $pricePerKm = $amount / $distanceKm; + + $durationMin = isset($resultData['duration_min']) ? (int)$resultData['duration_min'] : null; $startLat = $resultData['start_lat'] ?? null; $startLng = $resultData['start_lng'] ?? null; $endLat = $resultData['end_lat'] ?? null; $endLng = $resultData['end_lng'] ?? null; - $countryCode = 'JO'; // Default for now, as scraping is in Jordan + + $countryCode = $row['country_code'] ?? 'JO'; // Default if ($stmt->execute([ - $taskId, $appName, $appName, $startLoc, $endLoc, - $startLat, $startLng, $endLat, $endLng, - $amount, $pricePerKm, $currency, $countryCode + $taskId, + $appName, + $competitorName, + $startLoc, + $endLoc, + $startLat, + $startLng, + $endLat, + $endLng, + $amount, + $pricePerKm, + $distanceKm, + $durationMin, + $currency, + $countryCode ])) { $insertedCount++; } else { diff --git a/backend/bot/cron_generate_heatmap_cache.php b/backend/bot/cron_generate_heatmap_cache.php new file mode 100644 index 00000000..ee051115 --- /dev/null +++ b/backend/bot/cron_generate_heatmap_cache.php @@ -0,0 +1,103 @@ +getMessage() . "\n"); +} + +echo "Starting Heatmap Cache Generation (Redis)...\n"; + +// مربعات المدن الكبرى لتمثيل الدول (لتجنب حساب المضلعات المعقدة) +// الأردن (عمان والزرقاء) +// سوريا (دمشق) +// مصر (القاهرة والإسكندرية) +// العراق (بغداد) +$cityBounds = [ + 'JO' => [ // Amman & Zarqa rough bounding box + 'lat' => [31.80, 32.20], + 'lng' => [35.80, 36.20] + ], + 'SY' => [ // Damascus + 'lat' => [33.40, 33.60], + 'lng' => [36.20, 36.40] + ], + 'EG' => [ // Cairo & Alexandria + 'lat' => [29.80, 31.30], + 'lng' => [29.80, 31.50] + ], + 'IQ' => [ // Baghdad + 'lat' => [33.10, 33.50], + 'lng' => [44.20, 44.60] + ] +]; + +try { + $sql = "SELECT latitude, longitude, source, created_at + FROM passenger_opening_locations + WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) + ORDER BY created_at DESC + LIMIT 20000"; + + $stmt = $con->query($sql); + $locations = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $stats = ['geofence' => 0, 'app_usage' => 0, 'silent_push' => 0]; + + // تقسيم البيانات حسب الدولة (لتسهيل قراءتها من الـ API) + $countryData = [ + 'JO' => [], 'SY' => [], 'EG' => [], 'IQ' => [], 'OTHER' => [] + ]; + + foreach ($locations as $loc) { + $lat = (float)$loc['latitude']; + $lng = (float)$loc['longitude']; + if ($lat == 0 || $lng == 0) continue; + + $src = $loc['source'] ?? 'app_usage'; + $date = substr($loc['created_at'], 0, 10); + + $assignedCountry = 'OTHER'; + // البحث عن المربع الذي يقع فيه الإحداثي + foreach ($cityBounds as $cc => $b) { + if ($lat >= $b['lat'][0] && $lat <= $b['lat'][1] && + $lng >= $b['lng'][0] && $lng <= $b['lng'][1]) { + $assignedCountry = $cc; + break; + } + } + + if (isset($stats[$src])) $stats[$src]++; + + $countryData[$assignedCountry][] = [ + 'lat' => $lat, + 'lng' => $lng, + 'source' => $src, + 'date' => $date + ]; + } + + $redisData = [ + 'last_updated' => date('Y-m-d H:i:s'), + 'total' => count($locations), + 'stats' => $stats, + 'data' => $countryData // مقسمة وجاهزة + ]; + + $redis->set('siro:cache:heatmap:data', json_encode($redisData, JSON_UNESCAPED_UNICODE)); + + echo "Heatmap Cache Generated Successfully. Points: " . count($locations) . "\n"; + +} catch (Exception $e) { + error_log("Error generating heatmap cache: " . $e->getMessage()); + echo "Error: " . $e->getMessage(); +} +?> diff --git a/backend/bot/cron_generate_pricing_cache.php b/backend/bot/cron_generate_pricing_cache.php new file mode 100644 index 00000000..3fc2bf1e --- /dev/null +++ b/backend/bot/cron_generate_pricing_cache.php @@ -0,0 +1,119 @@ +getMessage() . "\n"); +} + +echo "Starting Pricing Cache Generation (Redis)...\n"; + +try { + // 1. جلب متوسط الأسعار من المنافسين في آخر ساعة للشبكة + $sql = "SELECT country_code, + ROUND(latitude / 0.025) * 0.025 AS grid_lat, + ROUND(longitude / 0.025) * 0.025 AS grid_lng, + competitor_name, + AVG(price) as avg_price, + COUNT(*) as requests_count + FROM competitor_prices + WHERE created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) + GROUP BY country_code, grid_lat, grid_lng, competitor_name"; + + $stmt = $con->query($sql); + $results = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // بناء مصفوفة الشبكات + $grids = []; + foreach ($results as $row) { + $cc = strtoupper($row['country_code']); + $gLat = number_format((float)$row['grid_lat'], 3); + $gLng = number_format((float)$row['grid_lng'], 3); + + $key = "{$cc}_{$gLat}_{$gLng}"; + + if (!isset($grids[$key])) { + $grids[$key] = [ + 'competitors' => [], + 'total_price' => 0, + 'total_competitors' => 0 + ]; + } + + $grids[$key]['competitors'][$row['competitor_name']] = (float)$row['avg_price']; + $grids[$key]['total_price'] += (float)$row['avg_price']; + $grids[$key]['total_competitors']++; + } + + $processedGrids = []; + $fallbackData = []; // لمتوسط البلد بالكامل كبديل + + foreach ($grids as $key => $data) { + if ($data['total_competitors'] == 0) continue; + + $overallAvg = $data['total_price'] / $data['total_competitors']; + + // إيجاد المنافس الأرخص في هذا المربع + $cheapestComp = ''; + $cheapestPrice = 999999; + foreach ($data['competitors'] as $name => $price) { + if ($price < $cheapestPrice) { + $cheapestPrice = $price; + $cheapestComp = $name; + } + } + + $gridInfo = [ + 'avg_price' => round($overallAvg, 2), + 'top_competitor' => $cheapestComp, + 'cheapest_price' => round($cheapestPrice, 2) + ]; + + $processedGrids[$key] = $gridInfo; + + // حفظ المتوسط للـ Fallback + $cc = explode('_', $key)[0]; + if (!isset($fallbackData[$cc])) { + $fallbackData[$cc] = ['sum' => 0, 'count' => 0, 'cheapest_comp' => $cheapestComp]; + } + $fallbackData[$cc]['sum'] += $overallAvg; + $fallbackData[$cc]['count']++; + } + + // إضافة Fallback لكل دولة (في حال الراكب كان في مربع فارغ) + foreach ($fallbackData as $cc => $d) { + if ($d['count'] > 0) { + $processedGrids["{$cc}_FALLBACK"] = [ + 'avg_price' => round($d['sum'] / $d['count'], 2), + 'top_competitor' => $d['cheapest_comp'], + 'cheapest_price' => round($d['sum'] / $d['count'], 2) + ]; + } + } + + // 2. الحفظ في Redis + // نحفظ المصفوفة بالكامل كـ JSON String داخل مفتاح رئيسي واحد للسرعة العالية في القراءة + // مفتاح: siro:cache:pricing:grids + $redisData = [ + 'last_updated' => date('Y-m-d H:i:s'), + 'grids' => $processedGrids + ]; + + $redis->set('siro:cache:pricing:grids', json_encode($redisData, JSON_UNESCAPED_UNICODE)); + + echo "Pricing Cache Generated Successfully in Redis. Grids: " . count($processedGrids) . "\n"; + +} catch (Exception $e) { + error_log("Error generating pricing cache (Redis): " . $e->getMessage()); + echo "Error: " . $e->getMessage(); +} +?> diff --git a/backend/bot/generate_price_tasks.php b/backend/bot/generate_price_tasks.php index dfcfaa5f..c40476cd 100644 --- a/backend/bot/generate_price_tasks.php +++ b/backend/bot/generate_price_tasks.php @@ -30,6 +30,8 @@ CREATE TABLE IF NOT EXISTS `scraped_competitor_prices` ( `end_lng` decimal(10,7) DEFAULT NULL, `price_amount` decimal(8,2) NOT NULL, `price_per_km` decimal(8,2) NOT NULL, + `distance_km` decimal(8,2) DEFAULT NULL, + `duration_min` int DEFAULT NULL, `currency` varchar(10) NOT NULL DEFAULT 'JOD', `country_code` varchar(10) NOT NULL DEFAULT 'JO', `scraped_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -42,6 +44,32 @@ CREATE TABLE IF NOT EXISTS `scraped_competitor_prices` ( "; $con->exec($sql); +// [AI Formula Discovery] Create table for reverse-engineered formulas +$sqlFormula = " +CREATE TABLE IF NOT EXISTS `competitor_secret_formulas` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `competitor_name` varchar(100) NOT NULL, + `country_code` varchar(10) NOT NULL, + `base_fare` decimal(8,3) NOT NULL, + `price_per_km` decimal(8,3) NOT NULL, + `price_per_min` decimal(8,3) NOT NULL, + `confidence_score` decimal(5,2) DEFAULT 0, + `sample_size` int DEFAULT 0, + `last_updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY `idx_comp_country` (`competitor_name`, `country_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +"; +$con->exec($sqlFormula); + +// Auto-patch existing table if upgrading +try { + $con->exec("ALTER TABLE `scraped_competitor_prices` ADD COLUMN `distance_km` decimal(8,2) DEFAULT NULL AFTER `price_per_km`"); + $con->exec("ALTER TABLE `scraped_competitor_prices` ADD COLUMN `duration_min` int DEFAULT NULL AFTER `distance_km`"); +} catch (Exception $e) { + // Columns might already exist, ignore error +} + + // 2. Ten Key Regions in Damascus (Syria) and Amman (Jordan) $countriesConfig = [ 'SY' => [ diff --git a/backend/core/Services/SiroGeminiService.php b/backend/core/Services/SiroGeminiService.php index 0c7384c8..afff87f1 100644 --- a/backend/core/Services/SiroGeminiService.php +++ b/backend/core/Services/SiroGeminiService.php @@ -72,6 +72,62 @@ class SiroGeminiService { } "; + return $this->callGemini($prompt, $model); + } + + /** + * يُولّد إشعاراً مخصصاً لحدث دخول الجيوفينس بناءً على بيانات أسعار حقيقية. + * + * @param string $zoneName اسم منطقة السياج الجغرافي + * @param string $countryCode رمز الدولة (SY, JO, EG, IQ) + * @param float $savingsPct نسبة التوفير (مثال: 8.5) + * @param string $topCompetitor اسم أبرز منافس في المنطقة + * @param string $model + * @return array|null + */ + public function generateGeofenceMessage( + string $zoneName, + string $countryCode, + float $savingsPct, + string $topCompetitor = 'كريم', + string $model = 'gemini-flash-lite-latest' + ): ?array { + if (!$this->apiKey) return null; + + $dialect = match (strtoupper($countryCode)) { + 'SY' => 'السورية الشامية', + 'JO' => 'الأردنية', + 'EG' => 'المصرية العامية', + 'IQ' => 'العراقية', + default => 'العربية الفصحى' + }; + + $savingsFormatted = number_format($savingsPct, 1); + + $prompt = " + أنت كاتب إشعارات تسويقية ذكية لتطبيق Siro لخدمات نقل الركاب. + المستخدم الآن موجود بالقرب من '$zoneName'. + سعر سيرو أقل بـ $savingsFormatted% من $topCompetitor وبقية التطبيقات في هذه المنطقة. + + المطلوب: اكتب إشعاراً push قصيراً جداً (عنوان + جسم) باللهجة $dialect. + - العنوان: لا يتجاوز 5 كلمات، مثير للاهتمام + - الجسم: لا يتجاوز 12 كلمة، يذكر التوفير الفعلي ويحفّز على الطلب الآن + - اجعله طبيعياً كأنه يكتبه شخص حقيقي وليس روبوت + + الخرج (JSON فقط، بدون أي شرح): + { + \"push_title\": \"...\", + \"push_body\": \"...\" + } + "; + + return $this->callGemini($prompt, $model); + } + + /** + * Helper: يُرسل prompt لـ Gemini ويُعيد JSON مُفكَّك + */ + private function callGemini(string $prompt, string $model): ?array { $apiUrl = $this->baseUrl . $model . ":generateContent?key=" . $this->apiKey; $payload = [ @@ -95,7 +151,7 @@ class SiroGeminiService { ]); $response = curl_exec($ch); - $curlErr = curl_error($ch); + $curlErr = curl_error($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); diff --git a/backend/ride/heatmap/heatmap_live.php b/backend/ride/heatmap/heatmap_live.php index 088fb9fb..a46a7126 100644 --- a/backend/ride/heatmap/heatmap_live.php +++ b/backend/ride/heatmap/heatmap_live.php @@ -66,6 +66,31 @@ foreach ($keys as $key) { } } +// === MERGE AI HOT ZONES === +// ندمج مناطق الذروة الخاصة بالمنافسين (التي استخرجها الذكاء الاصطناعي) لتظهر باللون الأحمر (High) +try { + $aiHotZonesJson = $redis->get('siro:cache:ai:hotzones'); + if ($aiHotZonesJson) { + $aiZonesData = json_decode($aiHotZonesJson, true); + if (isset($aiZonesData['status']) && $aiZonesData['status'] == 'success' && isset($aiZonesData['data'])) { + foreach ($aiZonesData['data'] as $zone) { + // نضيفها للمصفوفة ليقوم تطبيق الفلاتر برسمها كمربعات حمراء تلقائياً + $heatmap_data[] = [ + "lat" => (float)$zone['latitude'], + "lng" => (float)$zone['longitude'], + "count" => 99, // رقم كبير لإجبار التطبيق على تلوينها بالأحمر (High) + "intensity" => "high", + "is_ai_surge" => true, + "competitor" => $zone['top_competitor'] ?? '', + "price" => $zone['avg_price'] ?? 0 + ]; + } + } + } +} catch (Exception $e) { + error_log("[heatmap_live.php] AI Hotzones merge error: " . $e->getMessage()); +} + // Output the JSON array as expected by home_captain_controller.dart header('Content-Type: application/json'); echo json_encode($heatmap_data); diff --git a/siro_admin/lib/routes.dart b/siro_admin/lib/routes.dart index 8f5812a0..644c841b 100644 --- a/siro_admin/lib/routes.dart +++ b/siro_admin/lib/routes.dart @@ -7,6 +7,7 @@ import 'views/admin/pricing/kazan_editor_page.dart'; import 'views/admin/complaints/complaint_list_page.dart'; import 'views/admin/drivers/driver_documents_review_page.dart'; import 'views/admin/marketing/marketing_page.dart'; +import 'views/admin/marketing/heatmap_page.dart'; List> routes = [ GetPage(name: "/", page: () => const AdminHomePage()), @@ -17,5 +18,5 @@ List> routes = [ GetPage(name: "/complaints", page: () => ComplaintListPage()), GetPage(name: "/driver-docs", page: () => DriverDocsReviewPage()), GetPage(name: "/marketing", page: () => const MarketingPage()), + GetPage(name: "/heatmap", page: () => const HeatmapPage()), ]; - diff --git a/siro_admin/lib/views/admin/marketing/heatmap_page.dart b/siro_admin/lib/views/admin/marketing/heatmap_page.dart index 5608d67a..e8337534 100644 --- a/siro_admin/lib/views/admin/marketing/heatmap_page.dart +++ b/siro_admin/lib/views/admin/marketing/heatmap_page.dart @@ -15,6 +15,22 @@ class _HeatmapPageState extends State { List _markers = []; bool _isLoading = true; + // ─── فلاتر ─── + String _selectedSource = 'all'; // all | geofence | app_usage | silent_push + String _selectedCountry = 'all'; // all | JO | SY | EG | IQ + int _daysFilter = 7; + + final MapController _mapController = MapController(); + + // مراكز كل دولة للانتقال السريع + final Map _countryCenters = { + 'all': const LatLng(31.9522, 35.9334), // عمان + 'JO': const LatLng(31.9522, 35.9334), + 'SY': const LatLng(33.5138, 36.2765), + 'EG': const LatLng(30.0444, 31.2357), + 'IQ': const LatLng(33.3152, 44.3661), + }; + @override void initState() { super.initState(); @@ -22,27 +38,40 @@ class _HeatmapPageState extends State { } Future _fetchHeatmapData() async { + setState(() => _isLoading = true); try { - final response = await Dio().get('${AppLink.server}/Admin/geofence/get_heatmap.php?days=7'); - + final queryParams = { + 'days': _daysFilter.toString(), + if (_selectedSource != 'all') 'source': _selectedSource, + if (_selectedCountry != 'all') 'country_code': _selectedCountry, + }; + + final response = await Dio().get( + '${AppLink.server}/Admin/geofence/get_heatmap.php', + queryParameters: queryParams, + ); + if (response.statusCode == 200 && response.data['status'] == 'success') { final data = response.data['data'] as List; - + setState(() { _markers = data.map((point) { - final lat = double.tryParse(point['latitude'].toString()) ?? 0.0; - final lng = double.tryParse(point['longitude'].toString()) ?? 0.0; + final lat = double.tryParse(point['latitude'].toString()) ?? 0.0; + final lng = double.tryParse(point['longitude'].toString()) ?? 0.0; final source = point['source'].toString(); - - // Color logic: - // Geofence trigger = Green (High intent/Campaign) - // App Usage = Blue (Normal usage) - // Silent Push = Orange (Background wake) - Color markerColor = Colors.blue.withOpacity(0.5); - if (source == 'geofence') { - markerColor = Colors.green.withOpacity(0.7); - } else if (source == 'silent_push') { - markerColor = Colors.orange.withOpacity(0.5); + + // ألوان حسب المصدر + Color markerColor; + switch (source) { + case 'geofence': + markerColor = Colors.green.withOpacity(0.65); // 🟢 دخل منطقة + break; + case 'silent_push': + markerColor = Colors.orange.withOpacity(0.55); // 🟠 إيقاظ صامت + break; + case 'app_usage': + default: + markerColor = Colors.blue.withOpacity(0.5); // 🔵 فتح عادي } return CircleMarker( @@ -50,7 +79,7 @@ class _HeatmapPageState extends State { color: markerColor, borderStrokeWidth: 0, useRadiusInMeter: true, - radius: 150, // 150 meters radius for visualization + radius: 150, ); }).toList(); _isLoading = false; @@ -58,36 +87,265 @@ class _HeatmapPageState extends State { } } catch (e) { debugPrint("Error fetching heatmap: $e"); - setState(() { - _isLoading = false; - }); + setState(() => _isLoading = false); } } + void _applyFilter() { + _fetchHeatmapData(); + // تحريك الخريطة لمركز الدولة المختارة + final center = _countryCenters[_selectedCountry] ?? _countryCenters['all']!; + _mapController.move(center, 11.0); + } + @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: const Color(0xFF0F0F1A), appBar: AppBar( - title: const Text('خريطة النشاط الحرارية (Heatmap)'), + title: const Text( + 'خريطة النشاط الحرارية', + style: TextStyle(fontWeight: FontWeight.w700, fontSize: 15), + ), + backgroundColor: const Color(0xFF1A1A2E), + foregroundColor: Colors.white, centerTitle: true, - ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : FlutterMap( - options: const MapOptions( - initialCenter: LatLng(31.9522, 35.9334), // Default to Amman - initialZoom: 12.0, - ), - children: [ - TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'com.siro.admin', - ), - CircleLayer( - circles: _markers, - ), - ], + actions: [ + IconButton( + icon: const Icon(Icons.refresh_rounded), + tooltip: 'تحديث', + onPressed: _applyFilter, ), + ], + ), + body: Column( + children: [ + // ─── شريط الفلاتر ─── + Container( + color: const Color(0xFF1A1A2E), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + children: [ + // فلتر المصدر + _buildFilterRow( + label: 'المصدر:', + options: const { + 'all': 'الكل', + 'geofence': '🟢 سياج', + 'app_usage': '🔵 فتح', + 'silent_push': '🟠 صامت', + }, + selected: _selectedSource, + onChanged: (v) => setState(() => _selectedSource = v), + ), + const SizedBox(height: 6), + // فلتر الدولة + _buildFilterRow( + label: 'الدولة:', + options: const { + 'all': 'الكل', + 'JO': '🇯🇴 الأردن', + 'SY': '🇸🇾 سوريا', + 'EG': '🇪🇬 مصر', + 'IQ': '🇮🇶 العراق', + }, + selected: _selectedCountry, + onChanged: (v) => setState(() => _selectedCountry = v), + ), + const SizedBox(height: 6), + // فلتر الفترة الزمنية + زر تطبيق + Row( + children: [ + const Text('الفترة:', style: TextStyle(color: Colors.white54, fontSize: 11, fontWeight: FontWeight.bold)), + const SizedBox(width: 8), + ...[1, 7, 30].map((days) => Padding( + padding: const EdgeInsets.only(right: 6), + child: GestureDetector( + onTap: () => setState(() => _daysFilter = days), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: _daysFilter == days + ? const Color(0xFF6366F1) + : const Color(0xFF2D2D42), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: _daysFilter == days + ? const Color(0xFF818CF8) + : Colors.transparent, + ), + ), + child: Text( + '$days يوم', + style: TextStyle( + color: _daysFilter == days ? Colors.white : Colors.white54, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + )), + const Spacer(), + ElevatedButton.icon( + onPressed: _applyFilter, + icon: const Icon(Icons.filter_alt_rounded, size: 14), + label: const Text('تطبيق', style: TextStyle(fontSize: 11)), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6366F1), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + ), + ], + ), + ], + ), + ), + + // ─── الخريطة ─── + Expanded( + child: Stack( + children: [ + _isLoading + ? const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: Color(0xFF6366F1)), + SizedBox(height: 12), + Text('جاري تحميل البيانات...', style: TextStyle(color: Colors.white54, fontSize: 12)), + ], + ), + ) + : FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: _countryCenters[_selectedCountry] ?? _countryCenters['all']!, + initialZoom: 12.0, + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.siro.admin', + ), + CircleLayer(circles: _markers), + ], + ), + + // ─── مفتاح الألوان ─── + Positioned( + bottom: 12, + right: 12, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFF1A1A2E).withOpacity(0.9), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white12), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _LegendItem(color: Colors.green, label: 'دخل منطقة سياج'), + SizedBox(height: 4), + _LegendItem(color: Colors.blue, label: 'فتح عادي للتطبيق'), + SizedBox(height: 4), + _LegendItem(color: Colors.orange, label: 'إيقاظ صامت'), + ], + ), + ), + ), + + // ─── عدد النقاط ─── + Positioned( + top: 12, + left: 12, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFF1A1A2E).withOpacity(0.9), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white12), + ), + child: Text( + '${_markers.length} نقطة', + style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildFilterRow({ + required String label, + required Map options, + required String selected, + required ValueChanged onChanged, + }) { + return Row( + children: [ + Text(label, style: const TextStyle(color: Colors.white54, fontSize: 11, fontWeight: FontWeight.bold)), + const SizedBox(width: 8), + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: options.entries.map((e) { + final isSelected = selected == e.key; + return Padding( + padding: const EdgeInsets.only(right: 6), + child: GestureDetector( + onTap: () => onChanged(e.key), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF6366F1) : const Color(0xFF2D2D42), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected ? const Color(0xFF818CF8) : Colors.transparent, + ), + ), + child: Text( + e.value, + style: TextStyle( + color: isSelected ? Colors.white : Colors.white54, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + }).toList(), + ), + ), + ), + ], + ); + } +} + +class _LegendItem extends StatelessWidget { + final Color color; + final String label; + const _LegendItem({required this.color, required this.label}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + const SizedBox(width: 6), + Text(label, style: const TextStyle(color: Colors.white70, fontSize: 10)), + ], ); } } diff --git a/siro_driver/lib/controller/home/captin/map_driver_controller.dart b/siro_driver/lib/controller/home/captin/map_driver_controller.dart index 18e39887..6dde41a4 100755 --- a/siro_driver/lib/controller/home/captin/map_driver_controller.dart +++ b/siro_driver/lib/controller/home/captin/map_driver_controller.dart @@ -186,6 +186,11 @@ class MapDriverController extends GetxController _navigationTimer?.cancel(); _navigationTimer = null; + // [Fix Leak-1] إيقاف تايمر تحديث الكاميرا أثناء الرحلة لمنع crash + // بعد إغلاق الكنترولر كان يحاول استدعاء mapController!.animateCamera() + _updateLocationTimer?.cancel(); + _updateLocationTimer = null; + _posSub?.cancel(); _posSub = null; diff --git a/siro_driver/lib/views/home/Captin/orderCaptin/order_request_page.dart b/siro_driver/lib/views/home/Captin/orderCaptin/order_request_page.dart index d43f3e93..3faa5241 100755 --- a/siro_driver/lib/views/home/Captin/orderCaptin/order_request_page.dart +++ b/siro_driver/lib/views/home/Captin/orderCaptin/order_request_page.dart @@ -5,6 +5,7 @@ import 'package:siro_driver/constant/api_key.dart'; import 'package:siro_driver/constant/colors.dart'; import 'package:siro_driver/controller/home/captin/order_request_controller.dart'; import 'package:siro_driver/constant/currency.dart'; +import 'package:siro_driver/views/widgets/driver_earnings_badge.dart'; class OrderRequestPage extends StatelessWidget { const OrderRequestPage({super.key}); @@ -236,6 +237,17 @@ class OrderRequestPage extends StatelessWidget { ], ), + // ── Earnings Compare Badge ────────────────── + // يتم جلبه من API مشابه أو يمكن ربطه ببيانات الكنترولر لاحقاً. + // نتركه متاحاً للتحديث عبر الكنترولر (controller.driverBadgeText, controller.extraEarnings). + // سنستخدم قيم افتراضية للتأكد من الريندر (UI render) الآن + // سيتم تفعيله من الباك إند + const SizedBox(height: 5), + const DriverEarningsBadge( + earningsLabel: "أرباحك أعلى هنا مقارنة بـ المنافسين", + extraAmount: 2.50, // القيمة هنا ستُسحب من الـ Socket / API قريباً + ), + const SizedBox(height: 15), // الصف الثاني: شريط المعلومات diff --git a/siro_driver/lib/views/notification/available_rides_page.dart b/siro_driver/lib/views/notification/available_rides_page.dart index befcc51c..c4ba9754 100755 --- a/siro_driver/lib/views/notification/available_rides_page.dart +++ b/siro_driver/lib/views/notification/available_rides_page.dart @@ -17,6 +17,7 @@ import '../home/Captin/driver_map_page.dart'; import '../widgets/my_scafold.dart'; import '../widgets/mycircular.dart'; import '../widgets/mydialoug.dart'; +import '../widgets/driver_earnings_badge.dart'; // ── إضافة ويدجت الأرباح ── class AvailableRidesPage extends StatelessWidget { const AvailableRidesPage({super.key}); @@ -126,7 +127,13 @@ class RideAvailableCard extends StatelessWidget { _buildRouteInfo(), const Divider(height: 24, thickness: 0.5), _buildRideDetails(), - const SizedBox(height: 20), + const SizedBox(height: 12), + // ── إضافة شارة الأرباح هنا ── + const DriverEarningsBadge( + earningsLabel: "أرباحك أعلى هنا مقارنة بـ المنافسين", + extraAmount: 2.50, // القيمة هنا ستُسحب من الـ Socket / API قريباً + ), + const SizedBox(height: 12), _buildAcceptButton(), ], ), @@ -237,14 +244,23 @@ class RideAvailableCard extends StatelessWidget { } Widget _buildRideDetails() { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _infoItem(Icons.social_distance, '${rideInfo['distance']} KM'), - _infoItem(Icons.access_time, '${rideInfo['duration']} Min'), - _infoItem(Icons.star, '${rideInfo['passengerRate'] ?? 5.0}', - iconColor: Colors.amber), - ], + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + decoration: BoxDecoration( + color: Theme.of(Get.context!).colorScheme.surfaceVariant.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Theme.of(Get.context!).dividerColor.withOpacity(0.5)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _infoItem(Icons.social_distance_rounded, '${rideInfo['distance']} KM', iconColor: AppColor.primaryColor), + Container(height: 20, width: 1, color: Theme.of(Get.context!).dividerColor), + _infoItem(Icons.access_time_filled_rounded, '${rideInfo['duration']} Min', iconColor: Colors.blueAccent), + Container(height: 20, width: 1, color: Theme.of(Get.context!).dividerColor), + _infoItem(Icons.star_rounded, '${rideInfo['passengerRate'] ?? 5.0}', iconColor: Colors.amber), + ], + ), ); } diff --git a/siro_driver/lib/views/widgets/driver_earnings_badge.dart b/siro_driver/lib/views/widgets/driver_earnings_badge.dart new file mode 100644 index 00000000..3c29b7d6 --- /dev/null +++ b/siro_driver/lib/views/widgets/driver_earnings_badge.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +/// DriverEarningsBadge +/// ───────────────────── +/// ويدجت يعرض للسائق مقارنة أرباحه مع سيرو مقارنةً بالمنافسين. +/// يُستخدم في شاشة قبول الطلب ليحفّز السائق على القبول. +class DriverEarningsBadge extends StatelessWidget { + /// النص الكامل الجاهز من السيرفر + final String? earningsLabel; + + /// المبلغ الإضافي الذي يكسبه السائق (موجب = أكثر مع سيرو) + final double? extraAmount; + + /// رمز العملة + final String currency; + + const DriverEarningsBadge({ + super.key, + this.earningsLabel, + this.extraAmount, + this.currency = 'ل.س', + }); + + @override + Widget build(BuildContext context) { + if (earningsLabel == null || earningsLabel!.isEmpty) { + return const SizedBox.shrink(); + } + + final double extra = extraAmount ?? 0; + final bool isPositive = extra >= 0; + + return AnimatedOpacity( + opacity: 1.0, + duration: const Duration(milliseconds: 350), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: isPositive + ? [const Color(0xFF064E3B), const Color(0xFF065F46)] + : [const Color(0xFF1E1B4B), const Color(0xFF312E81)], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isPositive + ? const Color(0xFF10B981).withOpacity(0.4) + : const Color(0xFF818CF8).withOpacity(0.4), + ), + ), + child: Row( + children: [ + Icon( + isPositive ? Icons.attach_money_rounded : Icons.info_outline_rounded, + color: isPositive + ? const Color(0xFF34D399) + : const Color(0xFFA5B4FC), + size: 22, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + earningsLabel!, + style: TextStyle( + color: isPositive + ? const Color(0xFF34D399) + : const Color(0xFFA5B4FC), + fontSize: 11.5, + fontWeight: FontWeight.w700, + height: 1.4, + ), + ), + ), + if (extra > 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF10B981).withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: const Color(0xFF10B981).withOpacity(0.3), + ), + ), + child: Text( + '+${extra.toStringAsFixed(2)} $currency', + style: const TextStyle( + color: Color(0xFF34D399), + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/siro_rider/lib/constant/links.dart b/siro_rider/lib/constant/links.dart index 0725d56f..8b19834a 100644 --- a/siro_rider/lib/constant/links.dart +++ b/siro_rider/lib/constant/links.dart @@ -392,6 +392,7 @@ class AppLink { // Endpoint لجلب التسعيرة من السيرفر (Server-Side Pricing) static String get getPrices => "$server/ride/pricing/get.php"; + static String get getCompetitorContext => "$server/api/ride/get_competitor_context.php"; static String get addRateToDriver => "$server/ride/rate/addRateToDriver.php"; static String get getDriverRate => "$server/ride/rate/getDriverRate.php"; diff --git a/siro_rider/lib/controller/auth/login_controller.dart b/siro_rider/lib/controller/auth/login_controller.dart index 78e98e6c..a68e856f 100644 --- a/siro_rider/lib/controller/auth/login_controller.dart +++ b/siro_rider/lib/controller/auth/login_controller.dart @@ -199,7 +199,8 @@ class LoginController extends GetxController { final Map payload = jsonDecode(decodedPayload); if (payload['token_type'] == 'registration') { - Log.print("isTokenValid: Token is a registration token, treating as invalid for session."); + Log.print( + "isTokenValid: Token is a registration token, treating as invalid for session."); return false; } @@ -288,13 +289,14 @@ class LoginController extends GetxController { ); if (res == 'token_expired' || res == 'failure' || res == 'error') { - Log.print('loginUsingCredentials: $res. Redirecting to PhoneNumberScreen to re-verify.'); + Log.print( + 'loginUsingCredentials: $res. Redirecting to PhoneNumberScreen to re-verify.'); box.erase(); storage.deleteAll(); Get.offAll(() => PhoneNumberScreen()); return; } - + // 2) فك JSON مرة واحدة والتحقق من النجاح final decoded = res is String ? jsonDecode(res) : res; if (decoded is! Map || decoded.isEmpty) return; @@ -355,7 +357,8 @@ class LoginController extends GetxController { final localFP = (await DeviceHelper.getDeviceFingerprint()).toString(); await storage.write(key: BoxName.fingerPrint, value: localFP); await box.write(BoxName.firstTimeLoadKey, 'false'); - await getJWT(force: true); // Fetch access token after clearing firstTimeLoadKey + await getJWT( + force: true); // Fetch access token after clearing firstTimeLoadKey // ── 5. المقارنة: FCM token + fingerprint ────────────────────── if (email != '962798583052@intaleqapp.com') { @@ -364,7 +367,8 @@ class LoginController extends GetxController { tokenResp != 'failure' && tokenResp != 'error' && tokenResp != 'token_expired') { - final tokenJson = tokenResp is String ? jsonDecode(tokenResp) : tokenResp; + final tokenJson = + tokenResp is String ? jsonDecode(tokenResp) : tokenResp; final serverData = tokenJson['data'] ?? tokenJson['message']; if (serverData is Map) { serverFCM = serverData['token']?.toString() ?? ''; @@ -484,6 +488,7 @@ class LoginController extends GetxController { if (response.statusCode == 200 || response.statusCode == 201) { var jsonDecoeded = jsonDecode(response.body); + Log.print("Tester Login Response: $jsonDecoeded"); if (jsonDecoeded['status'] == 'success' && jsonDecoeded['data'][0]['verified'].toString() == '1') { var d = jsonDecoeded['data'][0]; @@ -507,11 +512,15 @@ class LoginController extends GetxController { sendPassengerLocation(); Get.offAll(() => const MapPagePassenger()); } else { + Log.print( + "Tester Login Failed due to condition mismatch: status=${jsonDecoeded['status']}, verified=${jsonDecoeded['data']?[0]?['verified']}"); Get.offAll(() => LoginPage()); isloading = false; update(); } } else { + Log.print( + "Tester Login Failed with status code: ${response.statusCode}, body: ${response.body}"); isloading = false; update(); } diff --git a/siro_rider/lib/controller/home/map/ride_lifecycle_controller.dart b/siro_rider/lib/controller/home/map/ride_lifecycle_controller.dart index a4661aeb..4902c232 100644 --- a/siro_rider/lib/controller/home/map/ride_lifecycle_controller.dart +++ b/siro_rider/lib/controller/home/map/ride_lifecycle_controller.dart @@ -1947,6 +1947,15 @@ class RideLifecycleController extends GetxController { double costForDriver = 0; + // ─── بيانات مقارنة أسعار المنافسين ─── + bool hasCompetitorData = false; + double? competitorAvgPrice; + double? savingsPercent; + String? savingsLabel; + String? topCompetitor; + String? driverExtraLabel; + double? driverExtraAmount; + Future bottomSheet() async { durationToAdd = Duration(seconds: durationToRide); hours = durationToAdd.inHours; @@ -2019,6 +2028,46 @@ class RideLifecycleController extends GetxController { update(); mapEngine.changeBottomSheetShown(forceValue: true); + + // جلب مقارنة أسعار المنافسين في الخلفية بعد عرض الأسعار + fetchCompetitorContext(); + } + + /// يجلب مقارنة سعر سيرو مع المنافسين ويحدّث الواجهة بشكل غير مُوقِف + Future fetchCompetitorContext() async { + try { + final String passengerLat = mapEngine.polylineCoordinates.isNotEmpty + ? mapEngine.polylineCoordinates.first.latitude.toString() + : newMyLocation.latitude.toString(); + final String passengerLng = mapEngine.polylineCoordinates.isNotEmpty + ? mapEngine.polylineCoordinates.first.longitude.toString() + : newMyLocation.longitude.toString(); + + final res = await CRUD().post( + link: AppLink.getCompetitorContext, + payload: { + 'passenger_lat': passengerLat, + 'passenger_lng': passengerLng, + 'distance': distance.toString(), + 'country': box.read(BoxName.countryCode) ?? '', + 'siro_price': totalPassengerSpeed, + 'car_type': 'Speed', + }, + ); + + if (res is Map && res['status'] == 'success') { + hasCompetitorData = res['has_competitor_data'] == true; + competitorAvgPrice = double.tryParse(res['competitor_avg_price']?.toString() ?? ''); + savingsPercent = double.tryParse(res['savings_percent']?.toString() ?? ''); + savingsLabel = res['savings_label']?.toString(); + topCompetitor = res['top_competitor']?.toString(); + driverExtraLabel = res['driver_extra_label']?.toString(); + driverExtraAmount = double.tryParse(res['driver_extra_amount']?.toString() ?? ''); + update(); + } + } catch (e) { + Log.print('[CompetitorContext] fetch failed: $e'); + } } // حساب المسار بين السائق والراكب وعرضه على الخريطة. @@ -2426,7 +2475,12 @@ class RideLifecycleController extends GetxController { double tripDurationInMinutes = durationToPassenger / 5; int loopCount = tripDurationInMinutes.ceil(); for (var i = 0; i < loopCount; i++) { + // [Fix Loop-1] فحص إغلاق الكنترولر قبل وبعد التأخير لمنع crash + if (isClosed || + currentRideState.value == RideState.finished || + currentRideState.value == RideState.preCheckReview) return; await Future.delayed(const Duration(seconds: 5)); + if (isClosed) return; if (rideTimerBegin == true || statusRide == 'Apply') { await getDriverCarsLocationToPassengerAfterApplied(); } @@ -2438,7 +2492,12 @@ class RideLifecycleController extends GetxController { int loopCount = tripDurationInMinutes.ceil(); mapEngine.clearMarkersExceptStartEndAndDriver(); for (var i = 0; i < loopCount; i++) { + // [Fix Loop-2] فحص إغلاق الكنترولر لمنع استدعاء الدوال على كنترولر مغلق + if (isClosed || + currentRideState.value == RideState.finished || + currentRideState.value == RideState.preCheckReview) return; await Future.delayed(const Duration(seconds: 4)); + if (isClosed) return; await getDriverCarsLocationToPassengerAfterApplied(); } } diff --git a/siro_rider/lib/views/Rate/rating_driver_bottom.dart b/siro_rider/lib/views/Rate/rating_driver_bottom.dart index 49a4ad2e..543a2b54 100644 --- a/siro_rider/lib/views/Rate/rating_driver_bottom.dart +++ b/siro_rider/lib/views/Rate/rating_driver_bottom.dart @@ -6,7 +6,6 @@ import 'package:get/get.dart'; import '../../constant/colors.dart'; import '../../constant/links.dart'; import '../../constant/style.dart'; -import '../../controller/home/map_passenger_controller.dart'; import '../../controller/rate/rate_conroller.dart'; import '../widgets/elevated_btn.dart'; import '../widgets/my_scafold.dart'; diff --git a/siro_rider/lib/views/home/map_widget.dart/car_details_widget_to_go.dart b/siro_rider/lib/views/home/map_widget.dart/car_details_widget_to_go.dart index 71e391fd..7d8f346b 100644 --- a/siro_rider/lib/views/home/map_widget.dart/car_details_widget_to_go.dart +++ b/siro_rider/lib/views/home/map_widget.dart/car_details_widget_to_go.dart @@ -16,6 +16,7 @@ import '../../../constant/info.dart'; import '../../../controller/functions/tts.dart'; import '../../../controller/home/map/ride_lifecycle_controller.dart'; import '../../widgets/mydialoug.dart'; +import '../../widgets/competitor_price_badge.dart'; // ───────────────────────────────────────────────────────────────────────────── // CAR TYPE MODEL @@ -156,6 +157,17 @@ class CarDetailsTypeToChoose extends StatelessWidget { ), ), + // ── Competitor Price Savings Badge ───────────────────── + if (controller.savingsLabel != null && + controller.savingsLabel!.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + child: CompetitorPriceBadge( + badgeText: controller.savingsLabel, + savingsPercent: controller.savingsPercent ?? 0.0, + ), + ), + // ── Promo Code & Actions ───────────────────────────── _buildPromoButton(context, controller), diff --git a/siro_rider/lib/views/widgets/competitor_price_badge.dart b/siro_rider/lib/views/widgets/competitor_price_badge.dart new file mode 100644 index 00000000..594cfd96 --- /dev/null +++ b/siro_rider/lib/views/widgets/competitor_price_badge.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; + +/// CompetitorPriceBadge +/// ───────────────────── +/// ويدجت يعرض مقارنة ذكية بين سعر سيرو وسعر المنافسين. +/// يُستخدم في شاشة عرض الأسعار (bottomSheet) ويظهر فقط إذا توفرت +/// بيانات المنافسين من API (has_competitor_data = true). +class CompetitorPriceBadge extends StatelessWidget { + /// نسبة التوفير للراكب (مثال: 8.5 تعني "أوفر بـ 8.5%") + final double? savingsPercent; + + /// النص التوضيحي الجاهز من السيرفر (مثال: "أوفر بـ 8% من التطبيقات الأخرى ⚡") + final String? savingsLabel; + + /// اسم أكبر منافس للمقارنة (مثال: "Careem") + final String? topCompetitor; + + const CompetitorPriceBadge({ + super.key, + this.savingsPercent, + this.savingsLabel, + this.topCompetitor, + }); + + @override + Widget build(BuildContext context) { + // لا تُظهر الويدجت إذا لم تتوفر بيانات + if (savingsLabel == null || savingsLabel!.isEmpty) { + return const SizedBox.shrink(); + } + + final double pct = savingsPercent ?? 0.0; + final bool isSignificant = pct >= 5.0; + final bool isModerate = pct >= 2.0 && pct < 5.0; + + // اختيار ألوان حسب قوة التوفير + final Color bgColor = isSignificant + ? const Color(0xFF1A3A2A) + : isModerate + ? const Color(0xFF1A2A3A) + : const Color(0xFF1F1F2E); + final Color borderColor = isSignificant + ? const Color(0xFF34D399) + : isModerate + ? const Color(0xFF60A5FA) + : const Color(0xFF818CF8); + final Color textColor = isSignificant + ? const Color(0xFF34D399) + : isModerate + ? const Color(0xFF60A5FA) + : const Color(0xFFA5B4FC); + + return AnimatedOpacity( + opacity: 1.0, + duration: const Duration(milliseconds: 400), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor.withOpacity(0.5), width: 1), + boxShadow: [ + BoxShadow( + color: borderColor.withOpacity(0.12), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + // أيقونة التوفير + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: borderColor.withOpacity(0.15), + shape: BoxShape.circle, + ), + child: Icon( + isSignificant ? Icons.trending_down_rounded : Icons.price_check_rounded, + color: textColor, + size: 18, + ), + ), + const SizedBox(width: 10), + // نص المقارنة + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + savingsLabel!, + style: TextStyle( + color: textColor, + fontSize: 12, + fontWeight: FontWeight.w700, + height: 1.3, + ), + ), + if (topCompetitor != null && topCompetitor!.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + 'مقارنةً بـ ${_formatCompetitorName(topCompetitor!)} والتطبيقات الأخرى', + style: TextStyle( + color: textColor.withOpacity(0.6), + fontSize: 10, + fontWeight: FontWeight.w400, + ), + ), + ], + ], + ), + ), + // شارة النسبة + if (pct >= 2.0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: borderColor.withOpacity(0.18), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '-${pct.toStringAsFixed(1)}%', + style: TextStyle( + color: textColor, + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ), + ); + } + + String _formatCompetitorName(String raw) { + final names = { + 'careem': 'كريم', + 'uber': 'أوبر', + 'yallago': 'يلاكو', + 'zaken': 'زاكن', + 'tufaddal': 'تفضل', + 'jeeny': 'جيني', + 'taxif': 'تاكسيف', + }; + return names[raw.toLowerCase()] ?? raw; + } +}