Update: 2026-07-02 05:27:04
This commit is contained in:
@@ -89,6 +89,24 @@ FEMALE_GENDER_HASH=<CHANGE_ME_FEMALE_HASH>
|
|||||||
FIREBASE_PROJECT_ID=siro-project
|
FIREBASE_PROJECT_ID=siro-project
|
||||||
FIREBASE_API_KEY=<CHANGE_ME_FIREBASE_KEY>
|
FIREBASE_API_KEY=<CHANGE_ME_FIREBASE_KEY>
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Payment Gateway Configuration
|
||||||
|
# =============================================================================
|
||||||
|
PAYMENT_GATEWAY_URL=https://api.paymentprovider.com
|
||||||
|
PAYMENT_GATEWAY_KEY=<CHANGE_ME_PAYMENT_KEY>
|
||||||
|
PAYMENT_GATEWAY_SECRET=<CHANGE_ME_PAYMENT_SECRET>
|
||||||
|
PAYMENT_WEBHOOK_SECRET=<CHANGE_ME_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)
|
# SMS Configuration (for OTP)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -1,29 +1,77 @@
|
|||||||
<?php
|
<?php
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
/**
|
||||||
require_once __DIR__ . '/../../connect.php';
|
* get_heatmap.php
|
||||||
|
* ───────────────
|
||||||
|
* تقرأ بيانات الخريطة الحرارية المجمعة من Redis
|
||||||
|
* البيانات مقسمة حسب الدولة (عبر Bounding Boxes في الـ Cron)
|
||||||
|
*/
|
||||||
|
|
||||||
// Optional filter: days
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
$days = filterRequest('days') ?? 7;
|
require_once __DIR__ . '/../../connect.php'; // Includes functions.php which has filterRequest()
|
||||||
|
|
||||||
|
$days = (int)(filterRequest('days') ?? 7);
|
||||||
|
$source = filterRequest('source') ?? 'all';
|
||||||
|
$countryCode = strtoupper(filterRequest('country_code') ?? 'all');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$sql = "SELECT latitude, longitude, source, created_at
|
$redis = getRedisConnection();
|
||||||
FROM passenger_opening_locations
|
$cacheJson = $redis->get('siro:cache:heatmap:data');
|
||||||
WHERE created_at >= 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
|
|
||||||
]);
|
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("Error fetching heatmap data: " . $e->getMessage());
|
echo json_encode(['status' => 'error', 'message' => 'Redis connection failed']);
|
||||||
echo json_encode(["status" => "error", "message" => "Server error"]);
|
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
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* get_competitor_context.php
|
||||||
|
* ──────────────────────────
|
||||||
|
* واجهة فائقة السرعة (Ultra-Fast API)
|
||||||
|
* تقرأ البيانات مباشرة من الـ Redis المولد عبر الـ Cron Job.
|
||||||
|
* زمن الاستجابة: O(1).
|
||||||
|
*/
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
require_once __DIR__ . '/../../connect.php'; // Web-safe
|
||||||
|
|
||||||
|
$lat = filterRequest('lat');
|
||||||
|
$lng = filterRequest('lng');
|
||||||
|
$countryCode = filterRequest('country_code');
|
||||||
|
|
||||||
|
if (!$lat || !$lng || !$countryCode) {
|
||||||
|
echo json_encode(["status" => "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"
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
?>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* get_hotzones.php
|
||||||
|
* ───────────────
|
||||||
|
* واجهة فائقة السرعة (Ultra-Fast API) مخصصة لتطبيق السائق (Flutter).
|
||||||
|
* تقرأ المناطق الساخنة (Hot Zones) التي حددها الذكاء الاصطناعي من الـ Redis مباشرة.
|
||||||
|
* زمن الاستجابة: O(1).
|
||||||
|
*/
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
require_once __DIR__ . '/../../connect.php';
|
||||||
|
|
||||||
|
$countryCode = strtoupper(filterRequest('country_code') ?? 'JO');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$redis = getRedisConnection();
|
||||||
|
$hotZonesJson = $redis->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;
|
||||||
|
?>
|
||||||
@@ -58,9 +58,9 @@ try {
|
|||||||
$encryptedBirthdate = $encryptionHelper->encryptData('1990-01-01');
|
$encryptedBirthdate = $encryptionHelper->encryptData('1990-01-01');
|
||||||
$encryptedSite = $encryptionHelper->encryptData('Jordan');
|
$encryptedSite = $encryptionHelper->encryptData('Jordan');
|
||||||
|
|
||||||
// Insert passenger
|
// 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)
|
$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)");
|
VALUES (:id, :phone, :email, :password, :gender, :birthdate, :site, :first_name, :last_name, 1, 1)");
|
||||||
$insert->execute([
|
$insert->execute([
|
||||||
':id' => $passengerId,
|
':id' => $passengerId,
|
||||||
':phone' => $encryptedPhone,
|
':phone' => $encryptedPhone,
|
||||||
@@ -131,6 +131,11 @@ try {
|
|||||||
if(isset($data['employmentType'])) $data['employmentType'] = $encryptionHelper->decryptData($data['employmentType']);
|
if(isset($data['employmentType'])) $data['employmentType'] = $encryptionHelper->decryptData($data['employmentType']);
|
||||||
if(isset($data['maritalStatus'])) $data['maritalStatus'] = $encryptionHelper->decryptData($data['maritalStatus']);
|
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) لتميزهم عن المستخدمين الفعليين
|
// توليد الـ JWT بصلاحية (tester) لتميزهم عن المستخدمين الفعليين
|
||||||
$jwtService = new JwtService($redis);
|
$jwtService = new JwtService($redis);
|
||||||
$jwt = $jwtService->generateAccessToken($data['id'], 'tester', $audience, $fingerprint);
|
$jwt = $jwtService->generateAccessToken($data['id'], 'tester', $audience, $fingerprint);
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* ai_formula_solver.php
|
||||||
|
* مكتشف خوارزميات المنافسين (AI Competitor Formula Solver)
|
||||||
|
* يستخدم الانحدار الخطي المتعدد (Multiple Linear Regression) لاكتشاف
|
||||||
|
* أجرة فتح العداد، وسعر الكيلومتر، وسعر الدقيقة لكل تطبيق منافس.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../core/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../functions.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$con = Database::get('main');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
die("Database connection failed: " . $e->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";
|
||||||
|
?>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* cron_ai_engine.php
|
||||||
|
* المحرك الرئيسي للذكاء الاصطناعي (AI Engine)
|
||||||
|
* يتم تشغيله كـ Cron Job كل 30 دقيقة أو ساعة لتقليل الضغط على السيرفر.
|
||||||
|
*
|
||||||
|
* يدمج 3 وحدات (Modules) ذكية:
|
||||||
|
* 1. AI Pricing (Total Price Math): تعديل جدول kazan ليكون السعر الإجمالي أرخص بـ 6.5% من المنافس الأقوى بدقة.
|
||||||
|
* 2. AI Dispatch: تحديد مناطق الذروة وتوجيه السائقين إليها.
|
||||||
|
* 3. AI Retention: اصطياد الركاب الخاملين.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../core/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../functions.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$con = Database::get('main');
|
||||||
|
$redis = getRedisConnection();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
die("Connection failed: " . $e->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";
|
||||||
|
?>
|
||||||
@@ -35,7 +35,7 @@ if (empty($data) || !is_array($data)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$insertedCount = 0;
|
$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) {
|
foreach ($data as $row) {
|
||||||
if (isset($row['status']) && $row['status'] !== 'success') {
|
if (isset($row['status']) && $row['status'] !== 'success') {
|
||||||
@@ -46,6 +46,7 @@ foreach ($data as $row) {
|
|||||||
$resultData = $row['result_data'] ?? [];
|
$resultData = $row['result_data'] ?? [];
|
||||||
|
|
||||||
$appName = $resultData['app'] ?? $row['app'] ?? 'Unknown';
|
$appName = $resultData['app'] ?? $row['app'] ?? 'Unknown';
|
||||||
|
$competitorName = $appName; // Assuming app_name is competitor_name for now
|
||||||
|
|
||||||
$startLoc = $resultData['start_location'] ?? $row['start_location'] ?? '';
|
$startLoc = $resultData['start_location'] ?? $row['start_location'] ?? '';
|
||||||
if (empty($startLoc) && !empty($resultData['start_lat'])) {
|
if (empty($startLoc) && !empty($resultData['start_lat'])) {
|
||||||
@@ -84,19 +85,34 @@ foreach ($data as $row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$distanceKm = (float)($resultData['distance_km'] ?? 1);
|
$distanceKm = (float)($resultData['distance_km'] ?? 1);
|
||||||
if ($distanceKm <= 0) $distanceKm = 1;
|
if ($distanceKm <= 0) $distanceKm = 1; // Prevent division by zero
|
||||||
$pricePerKm = $amount / $distanceKm;
|
$pricePerKm = $amount / $distanceKm;
|
||||||
|
|
||||||
|
$durationMin = isset($resultData['duration_min']) ? (int)$resultData['duration_min'] : null;
|
||||||
|
|
||||||
$startLat = $resultData['start_lat'] ?? null;
|
$startLat = $resultData['start_lat'] ?? null;
|
||||||
$startLng = $resultData['start_lng'] ?? null;
|
$startLng = $resultData['start_lng'] ?? null;
|
||||||
$endLat = $resultData['end_lat'] ?? null;
|
$endLat = $resultData['end_lat'] ?? null;
|
||||||
$endLng = $resultData['end_lng'] ?? 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([
|
if ($stmt->execute([
|
||||||
$taskId, $appName, $appName, $startLoc, $endLoc,
|
$taskId,
|
||||||
$startLat, $startLng, $endLat, $endLng,
|
$appName,
|
||||||
$amount, $pricePerKm, $currency, $countryCode
|
$competitorName,
|
||||||
|
$startLoc,
|
||||||
|
$endLoc,
|
||||||
|
$startLat,
|
||||||
|
$startLng,
|
||||||
|
$endLat,
|
||||||
|
$endLng,
|
||||||
|
$amount,
|
||||||
|
$pricePerKm,
|
||||||
|
$distanceKm,
|
||||||
|
$durationMin,
|
||||||
|
$currency,
|
||||||
|
$countryCode
|
||||||
])) {
|
])) {
|
||||||
$insertedCount++;
|
$insertedCount++;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* cron_generate_heatmap_cache.php
|
||||||
|
* يجمع بيانات الخريطة الحرارية ويخزنها في Redis
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../core/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../functions.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$con = Database::get('main');
|
||||||
|
$redis = getRedisConnection();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
die("Connection failed: " . $e->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();
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* cron_generate_pricing_cache.php
|
||||||
|
* يجمع أسعار المنافسين لكل مربع جغرافي (2.5km) ويحفظ النتيجة في Redis
|
||||||
|
* يتم تشغيله كـ Cron Job (CLI) لتخفيف الضغط تماماً عن الاستعلام المباشر للركاب.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../core/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../functions.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$con = Database::get('main');
|
||||||
|
$redis = getRedisConnection();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
die("Connection failed: " . $e->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();
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -30,6 +30,8 @@ CREATE TABLE IF NOT EXISTS `scraped_competitor_prices` (
|
|||||||
`end_lng` decimal(10,7) DEFAULT NULL,
|
`end_lng` decimal(10,7) DEFAULT NULL,
|
||||||
`price_amount` decimal(8,2) NOT NULL,
|
`price_amount` decimal(8,2) NOT NULL,
|
||||||
`price_per_km` 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',
|
`currency` varchar(10) NOT NULL DEFAULT 'JOD',
|
||||||
`country_code` varchar(10) NOT NULL DEFAULT 'JO',
|
`country_code` varchar(10) NOT NULL DEFAULT 'JO',
|
||||||
`scraped_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`scraped_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -42,6 +44,32 @@ CREATE TABLE IF NOT EXISTS `scraped_competitor_prices` (
|
|||||||
";
|
";
|
||||||
$con->exec($sql);
|
$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)
|
// 2. Ten Key Regions in Damascus (Syria) and Amman (Jordan)
|
||||||
$countriesConfig = [
|
$countriesConfig = [
|
||||||
'SY' => [
|
'SY' => [
|
||||||
|
|||||||
@@ -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;
|
$apiUrl = $this->baseUrl . $model . ":generateContent?key=" . $this->apiKey;
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
@@ -95,7 +151,7 @@ class SiroGeminiService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
$curlErr = curl_error($ch);
|
$curlErr = curl_error($ch);
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// Output the JSON array as expected by home_captain_controller.dart
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($heatmap_data);
|
echo json_encode($heatmap_data);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'views/admin/pricing/kazan_editor_page.dart';
|
|||||||
import 'views/admin/complaints/complaint_list_page.dart';
|
import 'views/admin/complaints/complaint_list_page.dart';
|
||||||
import 'views/admin/drivers/driver_documents_review_page.dart';
|
import 'views/admin/drivers/driver_documents_review_page.dart';
|
||||||
import 'views/admin/marketing/marketing_page.dart';
|
import 'views/admin/marketing/marketing_page.dart';
|
||||||
|
import 'views/admin/marketing/heatmap_page.dart';
|
||||||
|
|
||||||
List<GetPage<dynamic>> routes = [
|
List<GetPage<dynamic>> routes = [
|
||||||
GetPage(name: "/", page: () => const AdminHomePage()),
|
GetPage(name: "/", page: () => const AdminHomePage()),
|
||||||
@@ -17,5 +18,5 @@ List<GetPage<dynamic>> routes = [
|
|||||||
GetPage(name: "/complaints", page: () => ComplaintListPage()),
|
GetPage(name: "/complaints", page: () => ComplaintListPage()),
|
||||||
GetPage(name: "/driver-docs", page: () => DriverDocsReviewPage()),
|
GetPage(name: "/driver-docs", page: () => DriverDocsReviewPage()),
|
||||||
GetPage(name: "/marketing", page: () => const MarketingPage()),
|
GetPage(name: "/marketing", page: () => const MarketingPage()),
|
||||||
|
GetPage(name: "/heatmap", page: () => const HeatmapPage()),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,22 @@ class _HeatmapPageState extends State<HeatmapPage> {
|
|||||||
List<CircleMarker> _markers = [];
|
List<CircleMarker> _markers = [];
|
||||||
bool _isLoading = true;
|
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<String, LatLng> _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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -22,27 +38,40 @@ class _HeatmapPageState extends State<HeatmapPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchHeatmapData() async {
|
Future<void> _fetchHeatmapData() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
try {
|
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') {
|
if (response.statusCode == 200 && response.data['status'] == 'success') {
|
||||||
final data = response.data['data'] as List;
|
final data = response.data['data'] as List;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_markers = data.map((point) {
|
_markers = data.map((point) {
|
||||||
final lat = double.tryParse(point['latitude'].toString()) ?? 0.0;
|
final lat = double.tryParse(point['latitude'].toString()) ?? 0.0;
|
||||||
final lng = double.tryParse(point['longitude'].toString()) ?? 0.0;
|
final lng = double.tryParse(point['longitude'].toString()) ?? 0.0;
|
||||||
final source = point['source'].toString();
|
final source = point['source'].toString();
|
||||||
|
|
||||||
// Color logic:
|
// ألوان حسب المصدر
|
||||||
// Geofence trigger = Green (High intent/Campaign)
|
Color markerColor;
|
||||||
// App Usage = Blue (Normal usage)
|
switch (source) {
|
||||||
// Silent Push = Orange (Background wake)
|
case 'geofence':
|
||||||
Color markerColor = Colors.blue.withOpacity(0.5);
|
markerColor = Colors.green.withOpacity(0.65); // 🟢 دخل منطقة
|
||||||
if (source == 'geofence') {
|
break;
|
||||||
markerColor = Colors.green.withOpacity(0.7);
|
case 'silent_push':
|
||||||
} else if (source == 'silent_push') {
|
markerColor = Colors.orange.withOpacity(0.55); // 🟠 إيقاظ صامت
|
||||||
markerColor = Colors.orange.withOpacity(0.5);
|
break;
|
||||||
|
case 'app_usage':
|
||||||
|
default:
|
||||||
|
markerColor = Colors.blue.withOpacity(0.5); // 🔵 فتح عادي
|
||||||
}
|
}
|
||||||
|
|
||||||
return CircleMarker(
|
return CircleMarker(
|
||||||
@@ -50,7 +79,7 @@ class _HeatmapPageState extends State<HeatmapPage> {
|
|||||||
color: markerColor,
|
color: markerColor,
|
||||||
borderStrokeWidth: 0,
|
borderStrokeWidth: 0,
|
||||||
useRadiusInMeter: true,
|
useRadiusInMeter: true,
|
||||||
radius: 150, // 150 meters radius for visualization
|
radius: 150,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -58,36 +87,265 @@ class _HeatmapPageState extends State<HeatmapPage> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Error fetching heatmap: $e");
|
debugPrint("Error fetching heatmap: $e");
|
||||||
setState(() {
|
setState(() => _isLoading = false);
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _applyFilter() {
|
||||||
|
_fetchHeatmapData();
|
||||||
|
// تحريك الخريطة لمركز الدولة المختارة
|
||||||
|
final center = _countryCenters[_selectedCountry] ?? _countryCenters['all']!;
|
||||||
|
_mapController.move(center, 11.0);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFF0F0F1A),
|
||||||
appBar: AppBar(
|
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,
|
centerTitle: true,
|
||||||
),
|
actions: [
|
||||||
body: _isLoading
|
IconButton(
|
||||||
? const Center(child: CircularProgressIndicator())
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
: FlutterMap(
|
tooltip: 'تحديث',
|
||||||
options: const MapOptions(
|
onPressed: _applyFilter,
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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<String, String> options,
|
||||||
|
required String selected,
|
||||||
|
required ValueChanged<String> 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)),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,6 +186,11 @@ class MapDriverController extends GetxController
|
|||||||
_navigationTimer?.cancel();
|
_navigationTimer?.cancel();
|
||||||
_navigationTimer = null;
|
_navigationTimer = null;
|
||||||
|
|
||||||
|
// [Fix Leak-1] إيقاف تايمر تحديث الكاميرا أثناء الرحلة لمنع crash
|
||||||
|
// بعد إغلاق الكنترولر كان يحاول استدعاء mapController!.animateCamera()
|
||||||
|
_updateLocationTimer?.cancel();
|
||||||
|
_updateLocationTimer = null;
|
||||||
|
|
||||||
_posSub?.cancel();
|
_posSub?.cancel();
|
||||||
_posSub = null;
|
_posSub = null;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:siro_driver/constant/api_key.dart';
|
|||||||
import 'package:siro_driver/constant/colors.dart';
|
import 'package:siro_driver/constant/colors.dart';
|
||||||
import 'package:siro_driver/controller/home/captin/order_request_controller.dart';
|
import 'package:siro_driver/controller/home/captin/order_request_controller.dart';
|
||||||
import 'package:siro_driver/constant/currency.dart';
|
import 'package:siro_driver/constant/currency.dart';
|
||||||
|
import 'package:siro_driver/views/widgets/driver_earnings_badge.dart';
|
||||||
|
|
||||||
class OrderRequestPage extends StatelessWidget {
|
class OrderRequestPage extends StatelessWidget {
|
||||||
const OrderRequestPage({super.key});
|
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),
|
const SizedBox(height: 15),
|
||||||
|
|
||||||
// الصف الثاني: شريط المعلومات
|
// الصف الثاني: شريط المعلومات
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import '../home/Captin/driver_map_page.dart';
|
|||||||
import '../widgets/my_scafold.dart';
|
import '../widgets/my_scafold.dart';
|
||||||
import '../widgets/mycircular.dart';
|
import '../widgets/mycircular.dart';
|
||||||
import '../widgets/mydialoug.dart';
|
import '../widgets/mydialoug.dart';
|
||||||
|
import '../widgets/driver_earnings_badge.dart'; // ── إضافة ويدجت الأرباح ──
|
||||||
|
|
||||||
class AvailableRidesPage extends StatelessWidget {
|
class AvailableRidesPage extends StatelessWidget {
|
||||||
const AvailableRidesPage({super.key});
|
const AvailableRidesPage({super.key});
|
||||||
@@ -126,7 +127,13 @@ class RideAvailableCard extends StatelessWidget {
|
|||||||
_buildRouteInfo(),
|
_buildRouteInfo(),
|
||||||
const Divider(height: 24, thickness: 0.5),
|
const Divider(height: 24, thickness: 0.5),
|
||||||
_buildRideDetails(),
|
_buildRideDetails(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 12),
|
||||||
|
// ── إضافة شارة الأرباح هنا ──
|
||||||
|
const DriverEarningsBadge(
|
||||||
|
earningsLabel: "أرباحك أعلى هنا مقارنة بـ المنافسين",
|
||||||
|
extraAmount: 2.50, // القيمة هنا ستُسحب من الـ Socket / API قريباً
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
_buildAcceptButton(),
|
_buildAcceptButton(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -237,14 +244,23 @@ class RideAvailableCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildRideDetails() {
|
Widget _buildRideDetails() {
|
||||||
return Row(
|
return Container(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
_infoItem(Icons.social_distance, '${rideInfo['distance']} KM'),
|
color: Theme.of(Get.context!).colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
_infoItem(Icons.access_time, '${rideInfo['duration']} Min'),
|
borderRadius: BorderRadius.circular(12),
|
||||||
_infoItem(Icons.star, '${rideInfo['passengerRate'] ?? 5.0}',
|
border: Border.all(color: Theme.of(Get.context!).dividerColor.withOpacity(0.5)),
|
||||||
iconColor: Colors.amber),
|
),
|
||||||
],
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -392,6 +392,7 @@ class AppLink {
|
|||||||
|
|
||||||
// Endpoint لجلب التسعيرة من السيرفر (Server-Side Pricing)
|
// Endpoint لجلب التسعيرة من السيرفر (Server-Side Pricing)
|
||||||
static String get getPrices => "$server/ride/pricing/get.php";
|
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 addRateToDriver => "$server/ride/rate/addRateToDriver.php";
|
||||||
static String get getDriverRate => "$server/ride/rate/getDriverRate.php";
|
static String get getDriverRate => "$server/ride/rate/getDriverRate.php";
|
||||||
|
|||||||
@@ -199,7 +199,8 @@ class LoginController extends GetxController {
|
|||||||
final Map<String, dynamic> payload = jsonDecode(decodedPayload);
|
final Map<String, dynamic> payload = jsonDecode(decodedPayload);
|
||||||
|
|
||||||
if (payload['token_type'] == 'registration') {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +289,8 @@ class LoginController extends GetxController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (res == 'token_expired' || res == 'failure' || res == 'error') {
|
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();
|
box.erase();
|
||||||
storage.deleteAll();
|
storage.deleteAll();
|
||||||
Get.offAll(() => PhoneNumberScreen());
|
Get.offAll(() => PhoneNumberScreen());
|
||||||
@@ -355,7 +357,8 @@ class LoginController extends GetxController {
|
|||||||
final localFP = (await DeviceHelper.getDeviceFingerprint()).toString();
|
final localFP = (await DeviceHelper.getDeviceFingerprint()).toString();
|
||||||
await storage.write(key: BoxName.fingerPrint, value: localFP);
|
await storage.write(key: BoxName.fingerPrint, value: localFP);
|
||||||
await box.write(BoxName.firstTimeLoadKey, 'false');
|
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 ──────────────────────
|
// ── 5. المقارنة: FCM token + fingerprint ──────────────────────
|
||||||
if (email != '962798583052@intaleqapp.com') {
|
if (email != '962798583052@intaleqapp.com') {
|
||||||
@@ -364,7 +367,8 @@ class LoginController extends GetxController {
|
|||||||
tokenResp != 'failure' &&
|
tokenResp != 'failure' &&
|
||||||
tokenResp != 'error' &&
|
tokenResp != 'error' &&
|
||||||
tokenResp != 'token_expired') {
|
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'];
|
final serverData = tokenJson['data'] ?? tokenJson['message'];
|
||||||
if (serverData is Map) {
|
if (serverData is Map) {
|
||||||
serverFCM = serverData['token']?.toString() ?? '';
|
serverFCM = serverData['token']?.toString() ?? '';
|
||||||
@@ -484,6 +488,7 @@ class LoginController extends GetxController {
|
|||||||
|
|
||||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||||
var jsonDecoeded = jsonDecode(response.body);
|
var jsonDecoeded = jsonDecode(response.body);
|
||||||
|
Log.print("Tester Login Response: $jsonDecoeded");
|
||||||
if (jsonDecoeded['status'] == 'success' &&
|
if (jsonDecoeded['status'] == 'success' &&
|
||||||
jsonDecoeded['data'][0]['verified'].toString() == '1') {
|
jsonDecoeded['data'][0]['verified'].toString() == '1') {
|
||||||
var d = jsonDecoeded['data'][0];
|
var d = jsonDecoeded['data'][0];
|
||||||
@@ -507,11 +512,15 @@ class LoginController extends GetxController {
|
|||||||
sendPassengerLocation();
|
sendPassengerLocation();
|
||||||
Get.offAll(() => const MapPagePassenger());
|
Get.offAll(() => const MapPagePassenger());
|
||||||
} else {
|
} else {
|
||||||
|
Log.print(
|
||||||
|
"Tester Login Failed due to condition mismatch: status=${jsonDecoeded['status']}, verified=${jsonDecoeded['data']?[0]?['verified']}");
|
||||||
Get.offAll(() => LoginPage());
|
Get.offAll(() => LoginPage());
|
||||||
isloading = false;
|
isloading = false;
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
Log.print(
|
||||||
|
"Tester Login Failed with status code: ${response.statusCode}, body: ${response.body}");
|
||||||
isloading = false;
|
isloading = false;
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1947,6 +1947,15 @@ class RideLifecycleController extends GetxController {
|
|||||||
|
|
||||||
double costForDriver = 0;
|
double costForDriver = 0;
|
||||||
|
|
||||||
|
// ─── بيانات مقارنة أسعار المنافسين ───
|
||||||
|
bool hasCompetitorData = false;
|
||||||
|
double? competitorAvgPrice;
|
||||||
|
double? savingsPercent;
|
||||||
|
String? savingsLabel;
|
||||||
|
String? topCompetitor;
|
||||||
|
String? driverExtraLabel;
|
||||||
|
double? driverExtraAmount;
|
||||||
|
|
||||||
Future bottomSheet() async {
|
Future bottomSheet() async {
|
||||||
durationToAdd = Duration(seconds: durationToRide);
|
durationToAdd = Duration(seconds: durationToRide);
|
||||||
hours = durationToAdd.inHours;
|
hours = durationToAdd.inHours;
|
||||||
@@ -2019,6 +2028,46 @@ class RideLifecycleController extends GetxController {
|
|||||||
|
|
||||||
update();
|
update();
|
||||||
mapEngine.changeBottomSheetShown(forceValue: true);
|
mapEngine.changeBottomSheetShown(forceValue: true);
|
||||||
|
|
||||||
|
// جلب مقارنة أسعار المنافسين في الخلفية بعد عرض الأسعار
|
||||||
|
fetchCompetitorContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// يجلب مقارنة سعر سيرو مع المنافسين ويحدّث الواجهة بشكل غير مُوقِف
|
||||||
|
Future<void> 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;
|
double tripDurationInMinutes = durationToPassenger / 5;
|
||||||
int loopCount = tripDurationInMinutes.ceil();
|
int loopCount = tripDurationInMinutes.ceil();
|
||||||
for (var i = 0; i < loopCount; i++) {
|
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));
|
await Future.delayed(const Duration(seconds: 5));
|
||||||
|
if (isClosed) return;
|
||||||
if (rideTimerBegin == true || statusRide == 'Apply') {
|
if (rideTimerBegin == true || statusRide == 'Apply') {
|
||||||
await getDriverCarsLocationToPassengerAfterApplied();
|
await getDriverCarsLocationToPassengerAfterApplied();
|
||||||
}
|
}
|
||||||
@@ -2438,7 +2492,12 @@ class RideLifecycleController extends GetxController {
|
|||||||
int loopCount = tripDurationInMinutes.ceil();
|
int loopCount = tripDurationInMinutes.ceil();
|
||||||
mapEngine.clearMarkersExceptStartEndAndDriver();
|
mapEngine.clearMarkersExceptStartEndAndDriver();
|
||||||
for (var i = 0; i < loopCount; i++) {
|
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));
|
await Future.delayed(const Duration(seconds: 4));
|
||||||
|
if (isClosed) return;
|
||||||
await getDriverCarsLocationToPassengerAfterApplied();
|
await getDriverCarsLocationToPassengerAfterApplied();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:get/get.dart';
|
|||||||
import '../../constant/colors.dart';
|
import '../../constant/colors.dart';
|
||||||
import '../../constant/links.dart';
|
import '../../constant/links.dart';
|
||||||
import '../../constant/style.dart';
|
import '../../constant/style.dart';
|
||||||
import '../../controller/home/map_passenger_controller.dart';
|
|
||||||
import '../../controller/rate/rate_conroller.dart';
|
import '../../controller/rate/rate_conroller.dart';
|
||||||
import '../widgets/elevated_btn.dart';
|
import '../widgets/elevated_btn.dart';
|
||||||
import '../widgets/my_scafold.dart';
|
import '../widgets/my_scafold.dart';
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import '../../../constant/info.dart';
|
|||||||
import '../../../controller/functions/tts.dart';
|
import '../../../controller/functions/tts.dart';
|
||||||
import '../../../controller/home/map/ride_lifecycle_controller.dart';
|
import '../../../controller/home/map/ride_lifecycle_controller.dart';
|
||||||
import '../../widgets/mydialoug.dart';
|
import '../../widgets/mydialoug.dart';
|
||||||
|
import '../../widgets/competitor_price_badge.dart';
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// CAR TYPE MODEL
|
// 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 ─────────────────────────────
|
// ── Promo Code & Actions ─────────────────────────────
|
||||||
_buildPromoButton(context, controller),
|
_buildPromoButton(context, controller),
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user