Update: 2026-07-04 04:05:08
This commit is contained in:
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* getPredictiveDemandZones.php
|
||||||
|
* ─────────────────────────────────────────────────────────────
|
||||||
|
* API endpoint: يُعيد للسائق قائمة بأفضل المناطق المتوقع
|
||||||
|
* ارتفاع الطلب فيها خلال الساعة القادمة.
|
||||||
|
*
|
||||||
|
* يقرأ من Redis أولاً (cache يُجدَّد كل ساعة بالـ cron)،
|
||||||
|
* ثم يُصفّي أقرب المناطق لموقع السائق الحالي.
|
||||||
|
*
|
||||||
|
* Request (GET/POST):
|
||||||
|
* lat — خط العرض الحالي للسائق
|
||||||
|
* lng — خط الطول الحالي للسائق
|
||||||
|
* radius — نطاق البحث بالكيلومترات (اختياري، افتراضي: 10)
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* { success: true, zones: [...], predicted_hour: X, last_updated: "..." }
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../../functions.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// ── Auth ──────────────────────────────────────────────────────
|
||||||
|
// يستخدم نفس آلية JWT الموجودة في النظام
|
||||||
|
$driverId = getAuthDriverId(); // دالة موجودة في bootstrap/functions
|
||||||
|
if (!$driverId) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['success' => false, 'message' => 'Unauthorized']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parameters ────────────────────────────────────────────────
|
||||||
|
$driverLat = (float)filterRequest('lat');
|
||||||
|
$driverLng = (float)filterRequest('lng');
|
||||||
|
$radiusKm = (float)(filterRequest('radius') ?? 10);
|
||||||
|
|
||||||
|
// تحويل km إلى درجات تقريباً (1° ≈ 111km)
|
||||||
|
$radiusDeg = $radiusKm / 111.0;
|
||||||
|
|
||||||
|
// ── Redis Cache ───────────────────────────────────────────────
|
||||||
|
try {
|
||||||
|
$redis = getRedisConnection();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$redis = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheRaw = $redis ? $redis->get('siro:cache:predictive_demand') : null;
|
||||||
|
|
||||||
|
if ($cacheRaw) {
|
||||||
|
$cacheData = json_decode($cacheRaw, true);
|
||||||
|
$allZones = $cacheData['zones'] ?? [];
|
||||||
|
|
||||||
|
// فلتر: فقط المناطق ضمن نطاق السائق
|
||||||
|
$nearbyZones = array_filter($allZones, function ($z) use ($driverLat, $driverLng, $radiusDeg) {
|
||||||
|
if ($driverLat == 0 || $driverLng == 0) return true; // لو ما أرسل موقعه، أرجع الكل
|
||||||
|
return abs($z['lat'] - $driverLat) <= $radiusDeg
|
||||||
|
&& abs($z['lng'] - $driverLng) <= $radiusDeg;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ترتيب: الأقرب للسائق أولاً
|
||||||
|
if ($driverLat != 0) {
|
||||||
|
usort($nearbyZones, function ($a, $b) use ($driverLat, $driverLng) {
|
||||||
|
$da = abs($a['lat'] - $driverLat) + abs($a['lng'] - $driverLng);
|
||||||
|
$db = abs($b['lat'] - $driverLat) + abs($b['lng'] - $driverLng);
|
||||||
|
return $da <=> $db;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'source' => 'cache',
|
||||||
|
'predicted_hour' => $cacheData['predicted_hour'] ?? null,
|
||||||
|
'last_updated' => $cacheData['last_updated'] ?? null,
|
||||||
|
'zones' => array_values($nearbyZones),
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fallback: حساب مباشر من DB إذا لم يتوفر cache ────────────
|
||||||
|
try {
|
||||||
|
$con = Database::get('main');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo json_encode(['success' => false, 'message' => 'DB error']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextHour = ((int)date('H') + 1) % 24;
|
||||||
|
$dow = (int)date('N');
|
||||||
|
$gridSize = 0.01;
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
ROUND(pickup_lat / :g) * :g AS lat,
|
||||||
|
ROUND(pickup_lng / :g) * :g AS lng,
|
||||||
|
COUNT(*) AS demand_score,
|
||||||
|
country_code
|
||||||
|
FROM rides
|
||||||
|
WHERE
|
||||||
|
status IN ('completed', 'cancelled_by_driver', 'timeout')
|
||||||
|
AND HOUR(created_at) = :hour
|
||||||
|
AND DAYOFWEEK(created_at) = :dow
|
||||||
|
AND created_at >= DATE_SUB(NOW(), INTERVAL 4 WEEK)
|
||||||
|
AND pickup_lat BETWEEN (:dlat - :r) AND (:dlat + :r)
|
||||||
|
AND pickup_lng BETWEEN (:dlng - :r) AND (:dlng + :r)
|
||||||
|
GROUP BY lat, lng, country_code
|
||||||
|
HAVING demand_score >= 2
|
||||||
|
ORDER BY demand_score DESC
|
||||||
|
LIMIT 10
|
||||||
|
";
|
||||||
|
|
||||||
|
$stmt = $con->prepare($sql);
|
||||||
|
$stmt->execute([
|
||||||
|
':g' => $gridSize,
|
||||||
|
':hour' => $nextHour,
|
||||||
|
':dow' => $dow,
|
||||||
|
':dlat' => $driverLat ?: 31.95, // fallback عمّان
|
||||||
|
':dlng' => $driverLng ?: 35.93,
|
||||||
|
':r' => $radiusDeg,
|
||||||
|
]);
|
||||||
|
$zones = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// تسمية المناطق
|
||||||
|
foreach ($zones as &$z) {
|
||||||
|
$stmtN = $con->prepare("
|
||||||
|
SELECT zone_name FROM geofence_zones
|
||||||
|
WHERE is_active = 1
|
||||||
|
AND ABS(latitude - :lat) < 0.05
|
||||||
|
AND ABS(longitude - :lng) < 0.05
|
||||||
|
ORDER BY ABS(latitude - :lat) + ABS(longitude - :lng) ASC
|
||||||
|
LIMIT 1
|
||||||
|
");
|
||||||
|
$stmtN->execute([':lat' => $z['lat'], ':lng' => $z['lng']]);
|
||||||
|
$nameRow = $stmtN->fetch(PDO::FETCH_ASSOC);
|
||||||
|
$z['zone_name'] = $nameRow['zone_name'] ?? 'منطقة قريبة';
|
||||||
|
$z['lat'] = (float)$z['lat'];
|
||||||
|
$z['lng'] = (float)$z['lng'];
|
||||||
|
$z['demand_score'] = (int)$z['demand_score'];
|
||||||
|
}
|
||||||
|
unset($z);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'source' => 'realtime',
|
||||||
|
'predicted_hour' => $nextHour,
|
||||||
|
'last_updated' => date('Y-m-d H:i:s'),
|
||||||
|
'zones' => $zones,
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
?>
|
||||||
@@ -20,7 +20,8 @@ require_once __DIR__ . '/../core/Security/EncryptionHelper.php';
|
|||||||
set_time_limit(0);
|
set_time_limit(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$con = Database::get('main');
|
$con = Database::get('main');
|
||||||
|
$redis = getRedisConnection();
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
die("Database connection failed: " . $e->getMessage() . "\n");
|
die("Database connection failed: " . $e->getMessage() . "\n");
|
||||||
}
|
}
|
||||||
@@ -101,22 +102,49 @@ foreach ($passengersByCountry as $countryCode => $passengers) {
|
|||||||
|
|
||||||
// 4. إرسال الـ Push Notification لكل راكب
|
// 4. إرسال الـ Push Notification لكل راكب
|
||||||
foreach ($passengers as $p) {
|
foreach ($passengers as $p) {
|
||||||
|
$passengerId = $p['passenger_id'];
|
||||||
$encryptedToken = $p['token'];
|
$encryptedToken = $p['token'];
|
||||||
$decryptedToken = $encryptionHelper->decryptData($encryptedToken);
|
$decryptedToken = $encryptionHelper->decryptData($encryptedToken);
|
||||||
|
|
||||||
if ($decryptedToken) {
|
if (!$decryptedToken) continue;
|
||||||
$dataPayload = [
|
|
||||||
'type' => 'marketing_promo',
|
|
||||||
'promo_code' => $promoCode
|
|
||||||
];
|
|
||||||
|
|
||||||
// استدعاء دالة الإرسال (يفترض وجودها في نظام FCM الخاص بك، مثل sendFCM)
|
// ── Anti-spam: لا يُرسَل لنفس الراكب أكثر من مرة/24 ساعة ──
|
||||||
// sendFCM($decryptedToken, $title, $body, $dataPayload);
|
$redisKey = "marketing_push:sent:{$passengerId}";
|
||||||
// سنطبع للـ Log فقط في هذا السكربت لتجنب إرسال رسائل حقيقية دون تفعيل FCM الفعلي
|
if ($redis && $redis->exists($redisKey)) {
|
||||||
|
continue;
|
||||||
error_log("Sending Push to passenger {$p['passenger_id']}: $title");
|
|
||||||
$totalPushes++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$dataPayload = [
|
||||||
|
'type' => 'marketing_promo',
|
||||||
|
'promo_code' => $promoCode,
|
||||||
|
'screen' => 'home',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── إرسال FCM الفعلي ──────────────────────────────────────
|
||||||
|
sendFCM_Internal(
|
||||||
|
$decryptedToken,
|
||||||
|
$title,
|
||||||
|
$body,
|
||||||
|
$dataPayload,
|
||||||
|
'Marketing',
|
||||||
|
false,
|
||||||
|
'ding'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── تسجيل anti-spam في Redis (TTL 24 ساعة) ───────────────
|
||||||
|
if ($redis) {
|
||||||
|
$redis->setex($redisKey, 86400, '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── حفظ في جدول notifications الداخلي ────────────────────
|
||||||
|
try {
|
||||||
|
$ins = $con->prepare(
|
||||||
|
"INSERT INTO notifications (title, body, passenger_id) VALUES (:t, :b, :pid)"
|
||||||
|
);
|
||||||
|
$ins->execute([':t' => $title, ':b' => $body, ':pid' => $passengerId]);
|
||||||
|
} catch (Exception $ignored) {}
|
||||||
|
|
||||||
|
$totalPushes++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* cron_passenger_reengagement.php
|
||||||
|
* ─────────────────────────────────────────────────────────────
|
||||||
|
* Re-engagement cron: يرسل إشعار تسويقي مخصص (عبر Gemini AI)
|
||||||
|
* للركاب الذين لم يفتحوا التطبيق منذ 3 أيام أو أكثر.
|
||||||
|
*
|
||||||
|
* المصدر: جدول passenger_opening_locations (آخر نشاط مسجّل)
|
||||||
|
* Anti-spam: Redis — لا يُرسَل لنفس الراكب أكثر من مرة كل 72 ساعة
|
||||||
|
*
|
||||||
|
* جدولة مقترحة (crontab):
|
||||||
|
* 0 10 * * * php /var/www/backend/bot/cron_passenger_reengagement.php
|
||||||
|
* (يعمل يومياً الساعة 10 صباحاً)
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../core/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../core/Services/SiroGeminiService.php';
|
||||||
|
require_once __DIR__ . '/../core/Security/EncryptionHelper.php';
|
||||||
|
require_once __DIR__ . '/../functions.php';
|
||||||
|
|
||||||
|
set_time_limit(0);
|
||||||
|
ini_set('memory_limit', '256M');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$con = Database::get('main');
|
||||||
|
$redis = getRedisConnection();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
die("[ReEngagement] Connection failed: " . $e->getMessage() . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[ReEngagement] Starting Passenger Re-engagement Engine...\n";
|
||||||
|
|
||||||
|
$REDIS_TTL = 72 * 3600; // anti-spam: 72 ساعة
|
||||||
|
$INACTIVE_DAYS = 3; // عدد أيام الخمول
|
||||||
|
$BATCH_LIMIT = 500; // أقصى عدد ركاب لكل تشغيل
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// 1. جلب الركاب الخاملين (آخر نشاطهم منذ 3 أيام أو أكثر)
|
||||||
|
// نعتمد على passenger_opening_locations كمصدر لآخر نشاط
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
p.id AS passenger_id,
|
||||||
|
p.country_code,
|
||||||
|
t.token,
|
||||||
|
MAX(pol.created_at) AS last_active
|
||||||
|
FROM passengers p
|
||||||
|
JOIN tokens t ON t.passengerID = p.id
|
||||||
|
LEFT JOIN passenger_opening_locations pol ON pol.passenger_id = p.id
|
||||||
|
WHERE p.status = 'notDeleted'
|
||||||
|
GROUP BY p.id, p.country_code, t.token
|
||||||
|
HAVING
|
||||||
|
last_active IS NULL
|
||||||
|
OR last_active < DATE_SUB(NOW(), INTERVAL :days DAY)
|
||||||
|
ORDER BY last_active ASC
|
||||||
|
LIMIT :limit
|
||||||
|
";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $con->prepare($sql);
|
||||||
|
$stmt->bindValue(':days', $INACTIVE_DAYS, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':limit', $BATCH_LIMIT, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
$inactivePassengers = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
// Fallback: جدول passenger_opening_locations قد لا يملك passenger_id عمود — نستخدم start_location
|
||||||
|
$sqlFallback = "
|
||||||
|
SELECT
|
||||||
|
p.id AS passenger_id,
|
||||||
|
p.country_code,
|
||||||
|
t.token,
|
||||||
|
MAX(pol.date) AS last_active
|
||||||
|
FROM passengers p
|
||||||
|
JOIN tokens t ON t.passengerID = p.id
|
||||||
|
LEFT JOIN passenger_opening_locations pol ON pol.start_location = p.id
|
||||||
|
WHERE p.status = 'notDeleted'
|
||||||
|
GROUP BY p.id, p.country_code, t.token
|
||||||
|
HAVING
|
||||||
|
last_active IS NULL
|
||||||
|
OR last_active < DATE_SUB(NOW(), INTERVAL :days DAY)
|
||||||
|
ORDER BY last_active ASC
|
||||||
|
LIMIT :limit
|
||||||
|
";
|
||||||
|
$stmt = $con->prepare($sqlFallback);
|
||||||
|
$stmt->bindValue(':days', $INACTIVE_DAYS, PDO::PARAM_INT);
|
||||||
|
$stmt->bindValue(':limit', $BATCH_LIMIT, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
$inactivePassengers = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($inactivePassengers)) {
|
||||||
|
echo "[ReEngagement] No inactive passengers found. All good!\n";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[ReEngagement] Found " . count($inactivePassengers) . " inactive passengers (≥{$INACTIVE_DAYS} days).\n";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// 2. تجميع حسب الدولة لتقليل استدعاءات Gemini
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
$byCountry = [];
|
||||||
|
foreach ($inactivePassengers as $p) {
|
||||||
|
$cc = strtoupper($p['country_code'] ?? 'SY');
|
||||||
|
$byCountry[$cc][] = $p;
|
||||||
|
}
|
||||||
|
|
||||||
|
$geminiService = new SiroGeminiService();
|
||||||
|
$encryptionHelper = new EncryptionHelper();
|
||||||
|
|
||||||
|
$sentCount = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
|
||||||
|
foreach ($byCountry as $countryCode => $passengers) {
|
||||||
|
echo "[ReEngagement] Country: $countryCode — " . count($passengers) . " passengers.\n";
|
||||||
|
|
||||||
|
// ─── رسالة Gemini مخصصة لدولة واحدة ───────────────────
|
||||||
|
$regionName = match($countryCode) {
|
||||||
|
'JO' => 'Amman',
|
||||||
|
'SY' => 'Damascus',
|
||||||
|
'EG' => 'Cairo',
|
||||||
|
'IQ' => 'Baghdad',
|
||||||
|
default => 'your city'
|
||||||
|
};
|
||||||
|
|
||||||
|
// سحب أسعار المنافسين كمرجع للرسالة
|
||||||
|
$stmtComp = $con->prepare(
|
||||||
|
"SELECT competitor_name, base_fare FROM competitor_secret_formulas
|
||||||
|
WHERE country_code = :cc ORDER BY last_updated DESC LIMIT 3"
|
||||||
|
);
|
||||||
|
$stmtComp->execute([':cc' => $countryCode]);
|
||||||
|
$competitors = $stmtComp->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
if (empty($competitors)) {
|
||||||
|
$competitors = [['competitor_name' => 'Market Average', 'base_fare' => 1.0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$aiCampaign = $geminiService->analyzeMarketAndDraftCampaign(
|
||||||
|
$competitors,
|
||||||
|
1.0,
|
||||||
|
$regionName,
|
||||||
|
$countryCode
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fallback إذا Gemini ما أعطى فرصة
|
||||||
|
if (!$aiCampaign || ($aiCampaign['opportunity_detected'] ?? false) !== true) {
|
||||||
|
$countryEmoji = match($countryCode) { 'JO' => '🇯🇴', 'SY' => '🇸🇾', 'EG' => '🇪🇬', default => '🌍' };
|
||||||
|
$title = "وينك؟ نفتقدك! {$countryEmoji}";
|
||||||
|
$body = "مش شايفينك من {$INACTIVE_DAYS} أيام. سيارتك جاهزة وبأرخص سعر — اطلب الآن! 🚗";
|
||||||
|
} else {
|
||||||
|
$title = $aiCampaign['push_title'] ?? '🚗 سيرو يفتقدك!';
|
||||||
|
$body = $aiCampaign['push_body'] ?? "لم تطلب منذ {$INACTIVE_DAYS} أيام — العروض لا تنتظر!";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[ReEngagement] Message: [{$title}] {$body}\n";
|
||||||
|
|
||||||
|
// ─── إرسال لكل راكب مع anti-spam ───────────────────────
|
||||||
|
foreach ($passengers as $p) {
|
||||||
|
$passengerId = $p['passenger_id'];
|
||||||
|
$redisKey = "reengagement:sent:{$passengerId}";
|
||||||
|
|
||||||
|
// Anti-spam: تخطّى إذا أرسلنا له خلال 72 ساعة
|
||||||
|
if ($redis && $redis->exists($redisKey)) {
|
||||||
|
$skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decryptedToken = $encryptionHelper->decryptData($p['token'] ?? '');
|
||||||
|
if (!$decryptedToken) continue;
|
||||||
|
|
||||||
|
$result = sendFCM_Internal(
|
||||||
|
$decryptedToken,
|
||||||
|
$title,
|
||||||
|
$body,
|
||||||
|
[
|
||||||
|
'type' => 're_engagement',
|
||||||
|
'days_inactive' => $INACTIVE_DAYS,
|
||||||
|
'screen' => 'home',
|
||||||
|
],
|
||||||
|
'Marketing',
|
||||||
|
false,
|
||||||
|
'ding'
|
||||||
|
);
|
||||||
|
|
||||||
|
// حفظ في Redis لمنع التكرار
|
||||||
|
if ($redis) {
|
||||||
|
$redis->setex($redisKey, $REDIS_TTL, '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
// حفظ في جدول notifications الداخلي (يظهر في صفحة الإشعارات)
|
||||||
|
try {
|
||||||
|
$ins = $con->prepare(
|
||||||
|
"INSERT INTO notifications (title, body, passenger_id)
|
||||||
|
VALUES (:title, :body, :pid)"
|
||||||
|
);
|
||||||
|
$ins->execute([':title' => $title, ':body' => $body, ':pid' => $passengerId]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log("[ReEngagement] DB insert error: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$sentCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[ReEngagement] Done ✅ — Sent: {$sentCount}, Skipped (anti-spam): {$skippedCount}\n";
|
||||||
|
?>
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* cron_predictive_demand.php
|
||||||
|
* ─────────────────────────────────────────────────────────────
|
||||||
|
* التنبؤ الذكي بالطلب: يحلل بيانات الرحلات التاريخية ويرسل
|
||||||
|
* إشعارات للسائقين المتاحين يخبرهم بالمناطق المتوقع ارتفاع
|
||||||
|
* الطلب فيها خلال الـ 30 دقيقة القادمة.
|
||||||
|
*
|
||||||
|
* المنطق:
|
||||||
|
* 1. يحسب متوسط عدد الطلبات لكل خلية (grid 0.01°×0.01°) في
|
||||||
|
* نفس اليوم + نفس الساعة من آخر 4 أسابيع
|
||||||
|
* 2. يرتب الخلايا تنازلياً ويختار أفضل 10
|
||||||
|
* 3. يجد السائقين المتاحين (is_online=1) داخل 5km من كل خلية
|
||||||
|
* 4. يرسل FCM مخصص لكل سائق (مرة كل ساعتين anti-spam)
|
||||||
|
* 5. يحفظ النتائج في Redis للـ API
|
||||||
|
*
|
||||||
|
* جدولة مقترحة (crontab):
|
||||||
|
* 0 * * * * php /var/www/backend/bot/cron_predictive_demand.php
|
||||||
|
* (كل ساعة)
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../core/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../functions.php';
|
||||||
|
require_once __DIR__ . '/../core/Security/EncryptionHelper.php';
|
||||||
|
|
||||||
|
set_time_limit(120);
|
||||||
|
ini_set('memory_limit', '256M');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$con = Database::get('main');
|
||||||
|
$redis = getRedisConnection();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
die("[PredictiveDemand] Connection failed: " . $e->getMessage() . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[PredictiveDemand] Starting...\n";
|
||||||
|
|
||||||
|
$GRID_SIZE = 0.01; // حجم الخلية (~1.1km)
|
||||||
|
$RADIUS_DEGREES = 0.045; // ~5km
|
||||||
|
$ANTI_SPAM_TTL = 7200; // 2 ساعة
|
||||||
|
$TOP_ZONES = 10; // أفضل 10 مناطق
|
||||||
|
$WEEKS_HISTORY = 4; // تحليل آخر 4 أسابيع
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// 1. تحليل الطلبات التاريخية (نفس الساعة + نفس اليوم من الأسبوع)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
$currentHour = (int)date('H');
|
||||||
|
$currentDow = (int)date('N'); // 1=Mon ... 7=Sun
|
||||||
|
$targetHourNext = ($currentHour + 1) % 24; // الساعة القادمة (المتوقع)
|
||||||
|
|
||||||
|
$sqlHistory = "
|
||||||
|
SELECT
|
||||||
|
ROUND(pickup_lat / :grid) * :grid AS cell_lat,
|
||||||
|
ROUND(pickup_lng / :grid) * :grid AS cell_lng,
|
||||||
|
country_code,
|
||||||
|
COUNT(*) AS demand_score
|
||||||
|
FROM rides
|
||||||
|
WHERE
|
||||||
|
status IN ('completed', 'cancelled_by_driver', 'timeout')
|
||||||
|
AND HOUR(created_at) = :next_hour
|
||||||
|
AND DAYOFWEEK(created_at) = :dow
|
||||||
|
AND created_at >= DATE_SUB(NOW(), INTERVAL :weeks WEEK)
|
||||||
|
AND pickup_lat != 0
|
||||||
|
AND pickup_lng != 0
|
||||||
|
GROUP BY cell_lat, cell_lng, country_code
|
||||||
|
HAVING demand_score >= 2
|
||||||
|
ORDER BY demand_score DESC
|
||||||
|
LIMIT :top
|
||||||
|
";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmtH = $con->prepare($sqlHistory);
|
||||||
|
$stmtH->bindValue(':grid', $GRID_SIZE, PDO::PARAM_STR);
|
||||||
|
$stmtH->bindValue(':next_hour', $targetHourNext, PDO::PARAM_INT);
|
||||||
|
$stmtH->bindValue(':dow', $currentDow, PDO::PARAM_INT);
|
||||||
|
$stmtH->bindValue(':weeks', $WEEKS_HISTORY, PDO::PARAM_INT);
|
||||||
|
$stmtH->bindValue(':top', $TOP_ZONES * 3, PDO::PARAM_INT); // نأخذ أكثر لنصفيها لاحقاً
|
||||||
|
$stmtH->execute();
|
||||||
|
$hotZones = $stmtH->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
die("[PredictiveDemand] Query error: " . $e->getMessage() . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($hotZones)) {
|
||||||
|
echo "[PredictiveDemand] Not enough historical data for this hour. Exiting.\n";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[PredictiveDemand] Found " . count($hotZones) . " hot zones.\n";
|
||||||
|
|
||||||
|
// خذ أفضل N منطقة
|
||||||
|
$hotZones = array_slice($hotZones, 0, $TOP_ZONES);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// 2. تسمية المناطق باستخدام geofence_zones الموجودة أو اسم
|
||||||
|
// عام بناءً على الإحداثيات
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function getZoneName($con, $lat, $lng, $radius)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stmt = $con->prepare("
|
||||||
|
SELECT zone_name
|
||||||
|
FROM geofence_zones
|
||||||
|
WHERE is_active = 1
|
||||||
|
AND ABS(latitude - :lat) < :r
|
||||||
|
AND ABS(longitude - :lng) < :r
|
||||||
|
ORDER BY ABS(latitude - :lat) + ABS(longitude - :lng) ASC
|
||||||
|
LIMIT 1
|
||||||
|
");
|
||||||
|
$stmt->execute([':lat' => $lat, ':lng' => $lng, ':r' => $radius]);
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
return $row ? $row['zone_name'] : null;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// 3. حفظ النتائج في Redis للـ API
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
$redisData = [];
|
||||||
|
foreach ($hotZones as &$zone) {
|
||||||
|
$zoneName = getZoneName($con, $zone['cell_lat'], $zone['cell_lng'], $RADIUS_DEGREES);
|
||||||
|
$zone['zone_name'] = $zoneName ?? "منطقة ({$zone['cell_lat']}, {$zone['cell_lng']})";
|
||||||
|
$redisData[] = [
|
||||||
|
'lat' => (float)$zone['cell_lat'],
|
||||||
|
'lng' => (float)$zone['cell_lng'],
|
||||||
|
'zone_name' => $zone['zone_name'],
|
||||||
|
'demand_score' => (int)$zone['demand_score'],
|
||||||
|
'predicted_hour' => $targetHourNext,
|
||||||
|
'country_code' => $zone['country_code'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
unset($zone);
|
||||||
|
|
||||||
|
if ($redis) {
|
||||||
|
$redis->setex(
|
||||||
|
'siro:cache:predictive_demand',
|
||||||
|
3600, // TTL ساعة واحدة
|
||||||
|
json_encode([
|
||||||
|
'last_updated' => date('Y-m-d H:i:s'),
|
||||||
|
'predicted_hour' => $targetHourNext,
|
||||||
|
'zones' => $redisData,
|
||||||
|
], JSON_UNESCAPED_UNICODE)
|
||||||
|
);
|
||||||
|
echo "[PredictiveDemand] Zones cached in Redis.\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// 4. إيجاد السائقين المتاحين قرب كل منطقة وإرسال إشعار
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
$encryptionHelper = new EncryptionHelper();
|
||||||
|
$totalSent = 0;
|
||||||
|
$totalSkipped = 0;
|
||||||
|
$notifiedDrivers = []; // لتجنب إرسال نفس السائق أكثر من إشعار
|
||||||
|
|
||||||
|
foreach ($hotZones as $zone) {
|
||||||
|
$cellLat = (float)$zone['cell_lat'];
|
||||||
|
$cellLng = (float)$zone['cell_lng'];
|
||||||
|
$zoneName = $zone['zone_name'];
|
||||||
|
$score = (int)$zone['demand_score'];
|
||||||
|
$minutes = 30; // التوقع دائماً 30 دقيقة للأمام
|
||||||
|
|
||||||
|
// استخرج رسالة مناسبة بناءً على حجم الطلب
|
||||||
|
if ($score >= 10) {
|
||||||
|
$intensity = 'مرتفع جداً 🔥🔥';
|
||||||
|
} elseif ($score >= 5) {
|
||||||
|
$intensity = 'مرتفع 🔥';
|
||||||
|
} else {
|
||||||
|
$intensity = 'متوسط 📈';
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = "🔮 توقع ذكي من سيرو";
|
||||||
|
$body = "نتوقع طلب {$intensity} من منطقة «{$zoneName}» خلال {$minutes} دقيقة. اتجه الآن! 🚗";
|
||||||
|
|
||||||
|
// ── جلب السائقين المتاحين في نطاق 5km ──────────────────
|
||||||
|
$sqlDrivers = "
|
||||||
|
SELECT
|
||||||
|
d.id AS driver_id,
|
||||||
|
t.token,
|
||||||
|
dl.lat,
|
||||||
|
dl.lng
|
||||||
|
FROM drivers d
|
||||||
|
JOIN driver_live_location dl ON dl.driver_id = d.id
|
||||||
|
JOIN tokens t ON t.driverID = d.id
|
||||||
|
WHERE d.status_driver = 'free'
|
||||||
|
AND d.verified = 1
|
||||||
|
AND dl.updated_at >= DATE_SUB(NOW(), INTERVAL 15 MINUTE)
|
||||||
|
AND ABS(dl.lat - :lat) < :radius
|
||||||
|
AND ABS(dl.lng - :lng) < :radius
|
||||||
|
LIMIT 50
|
||||||
|
";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmtD = $con->prepare($sqlDrivers);
|
||||||
|
$stmtD->execute([
|
||||||
|
':lat' => $cellLat,
|
||||||
|
':lng' => $cellLng,
|
||||||
|
':radius' => $RADIUS_DEGREES,
|
||||||
|
]);
|
||||||
|
$nearbyDrivers = $stmtD->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
// جدول driver_live_location قد يختلف اسمه
|
||||||
|
error_log("[PredictiveDemand] Driver query error: " . $e->getMessage());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[PredictiveDemand] Zone [{$zoneName}] score={$score} → " . count($nearbyDrivers) . " nearby drivers.\n";
|
||||||
|
|
||||||
|
foreach ($nearbyDrivers as $driver) {
|
||||||
|
$driverId = $driver['driver_id'];
|
||||||
|
|
||||||
|
// تخطّى إذا أرسلنا لهذا السائق سابقاً في نفس الـ cron run
|
||||||
|
if (isset($notifiedDrivers[$driverId])) {
|
||||||
|
$totalSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anti-spam: مرة كل ساعتين لكل سائق
|
||||||
|
$redisKey = "predictive:sent:{$driverId}";
|
||||||
|
if ($redis && $redis->exists($redisKey)) {
|
||||||
|
$totalSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decryptedToken = $encryptionHelper->decryptData($driver['token'] ?? '');
|
||||||
|
if (!$decryptedToken) continue;
|
||||||
|
|
||||||
|
sendFCM_Internal(
|
||||||
|
$decryptedToken,
|
||||||
|
$title,
|
||||||
|
$body,
|
||||||
|
[
|
||||||
|
'type' => 'predictive_demand',
|
||||||
|
'zone_name' => $zoneName,
|
||||||
|
'zone_lat' => $cellLat,
|
||||||
|
'zone_lng' => $cellLng,
|
||||||
|
'demand_score' => $score,
|
||||||
|
'predicted_hour' => $targetHourNext,
|
||||||
|
],
|
||||||
|
'System',
|
||||||
|
false,
|
||||||
|
'ding'
|
||||||
|
);
|
||||||
|
|
||||||
|
// حفظ في Redis anti-spam
|
||||||
|
if ($redis) {
|
||||||
|
$redis->setex($redisKey, $ANTI_SPAM_TTL, '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
$notifiedDrivers[$driverId] = true;
|
||||||
|
$totalSent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[PredictiveDemand] Done ✅ — Sent: {$totalSent}, Skipped: {$totalSkipped}\n";
|
||||||
|
?>
|
||||||
@@ -16,8 +16,8 @@ $sql = "
|
|||||||
SELECT p.id as passenger_id, t.token
|
SELECT p.id as passenger_id, t.token
|
||||||
FROM passengers p
|
FROM passengers p
|
||||||
JOIN tokens t ON p.id = t.passengerID
|
JOIN tokens t ON p.id = t.passengerID
|
||||||
LEFT JOIN passenger_opening_locations pol ON p.id = pol.start_location
|
LEFT JOIN passenger_opening_locations pol ON p.id = pol.passenger_id
|
||||||
WHERE (pol.date IS NULL OR pol.date < DATE_SUB(NOW(), INTERVAL 24 HOUR))
|
WHERE (pol.created_at IS NULL OR pol.created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR))
|
||||||
GROUP BY p.id, t.token
|
GROUP BY p.id, t.token
|
||||||
LIMIT 500 -- Batch size to avoid overloading the server or getting rate-limited
|
LIMIT 500 -- Batch size to avoid overloading the server or getting rate-limited
|
||||||
";
|
";
|
||||||
|
|||||||
@@ -145,6 +145,9 @@ class AppLink {
|
|||||||
static String get getSurgeHeatmap =>
|
static String get getSurgeHeatmap =>
|
||||||
"$server/ride/heatmap/get_surge_heatmap.php";
|
"$server/ride/heatmap/get_surge_heatmap.php";
|
||||||
|
|
||||||
|
static String get getPredictiveDemand =>
|
||||||
|
"$server/api/demand/getPredictiveDemandZones.php";
|
||||||
|
|
||||||
///mapOSM = 'https://routesy.intaleq.xyz'
|
///mapOSM = 'https://routesy.intaleq.xyz'
|
||||||
static String get mapOSM {
|
static String get mapOSM {
|
||||||
switch (currentCountry) {
|
switch (currentCountry) {
|
||||||
|
|||||||
@@ -61,8 +61,13 @@ class HomeCaptainController extends GetxController {
|
|||||||
double widthMapTypeAndTraffic = 50;
|
double widthMapTypeAndTraffic = 50;
|
||||||
// === متغيرات الهيت ماب الجديدة ===
|
// === متغيرات الهيت ماب الجديدة ===
|
||||||
bool isHeatmapVisible = false;
|
bool isHeatmapVisible = false;
|
||||||
Set<Polygon> heatmapPolygons =
|
Set<Polygon> heatmapPolygons = {};
|
||||||
{}; // سنستخدم Polygon لرسم المربعات على جوجل مابس
|
|
||||||
|
// === متغيرات التنبؤ الذكي بالطلب ===
|
||||||
|
bool isPredictiveVisible = false;
|
||||||
|
Set<Polygon> predictivePolygons = {};
|
||||||
|
List<Map<String, dynamic>> predictiveZones = [];
|
||||||
|
Timer? _predictiveTimer;
|
||||||
|
|
||||||
// Inject the LocationController class
|
// Inject the LocationController class
|
||||||
// final locationController = Get.put(LocationController());
|
// final locationController = Get.put(LocationController());
|
||||||
@@ -198,6 +203,97 @@ class HomeCaptainController extends GetxController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// 🔮 التنبؤ الذكي بالطلب
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// تبديل عرض/إخفاء طبقة التنبؤ الذكي
|
||||||
|
void togglePredictiveDemand() {
|
||||||
|
isPredictiveVisible = !isPredictiveVisible;
|
||||||
|
if (isPredictiveVisible) {
|
||||||
|
_startPredictiveCycle();
|
||||||
|
} else {
|
||||||
|
_predictiveTimer?.cancel();
|
||||||
|
predictivePolygons.clear();
|
||||||
|
predictiveZones.clear();
|
||||||
|
}
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startPredictiveCycle() {
|
||||||
|
_predictiveTimer?.cancel();
|
||||||
|
fetchAndDrawPredictiveDemand();
|
||||||
|
// تحديث كل 30 دقيقة (يتوافق مع دورة الـ cron)
|
||||||
|
_predictiveTimer = Timer.periodic(const Duration(minutes: 30), (_) {
|
||||||
|
if (isPredictiveVisible) fetchAndDrawPredictiveDemand();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchAndDrawPredictiveDemand() async {
|
||||||
|
print("🔮 [Predictive] Fetching demand zones...");
|
||||||
|
try {
|
||||||
|
final myLat = locationController.myLocation.latitude;
|
||||||
|
final myLng = locationController.myLocation.longitude;
|
||||||
|
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse(
|
||||||
|
"${AppLink.getPredictiveDemand}?lat=$myLat&lng=$myLng&radius=10"
|
||||||
|
"&t=${DateTime.now().millisecondsSinceEpoch}",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final body = json.decode(response.body);
|
||||||
|
if (body['success'] == true) {
|
||||||
|
final zones = List<Map<String, dynamic>>.from(body['zones'] ?? []);
|
||||||
|
predictiveZones = zones;
|
||||||
|
_drawPredictivePolygons(zones);
|
||||||
|
print("✅ [Predictive] ${zones.length} zones loaded.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print("❌ [Predictive] Error: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawPredictivePolygons(List<Map<String, dynamic>> zones) {
|
||||||
|
const double offset = 0.008; // أكبر قليلاً من الهيتماب (0.005) ليكون مميزاً
|
||||||
|
final Set<Polygon> temp = {};
|
||||||
|
|
||||||
|
for (final zone in zones) {
|
||||||
|
final double lat = (zone['lat'] as num).toDouble();
|
||||||
|
final double lng = (zone['lng'] as num).toDouble();
|
||||||
|
final int score = (zone['demand_score'] as num).toInt();
|
||||||
|
|
||||||
|
// ألوان التنبؤ: أزرق/سماوي (مختلف تماماً عن الهيتماب)
|
||||||
|
final Color fill = score >= 8
|
||||||
|
? const Color(0xFF0EA5E9).withOpacity(0.35) // أزرق قوي
|
||||||
|
: score >= 4
|
||||||
|
? const Color(0xFF38BDF8).withOpacity(0.30) // أزرق فاتح
|
||||||
|
: const Color(0xFF7DD3FC).withOpacity(0.25); // سماوي خفيف
|
||||||
|
|
||||||
|
final Color stroke = score >= 8
|
||||||
|
? const Color(0xFF0369A1).withOpacity(0.9)
|
||||||
|
: const Color(0xFF0EA5E9).withOpacity(0.8);
|
||||||
|
|
||||||
|
temp.add(Polygon(
|
||||||
|
polygonId: PolygonId("pred_${lat}_$lng"),
|
||||||
|
points: [
|
||||||
|
LatLng(lat - offset, lng - offset),
|
||||||
|
LatLng(lat + offset, lng - offset),
|
||||||
|
LatLng(lat + offset, lng + offset),
|
||||||
|
LatLng(lat - offset, lng + offset),
|
||||||
|
],
|
||||||
|
fillColor: fill,
|
||||||
|
strokeColor: stroke,
|
||||||
|
strokeWidth: 2,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
predictivePolygons = temp;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
void goToWalletFromConnect() {
|
void goToWalletFromConnect() {
|
||||||
Get.back();
|
Get.back();
|
||||||
Get.back();
|
Get.back();
|
||||||
|
|||||||
@@ -803,6 +803,7 @@ final Map<String, String> ar_eg = {
|
|||||||
"Heading your way now. Please be ready.": "قاعد يجيك دلوقتي. من فضلك استعد.",
|
"Heading your way now. Please be ready.": "قاعد يجيك دلوقتي. من فضلك استعد.",
|
||||||
"Health Insurance": "التأمين الصحي",
|
"Health Insurance": "التأمين الصحي",
|
||||||
"Heatmap": "خريطة الحرارة",
|
"Heatmap": "خريطة الحرارة",
|
||||||
|
"Predictive Demand": "🔮 توقعات الطلب",
|
||||||
"Height:": "الطول:",
|
"Height:": "الطول:",
|
||||||
"Hello": "أهلاً",
|
"Hello": "أهلاً",
|
||||||
"Hello this is Captain": "أهلاً، ده الكابتن",
|
"Hello this is Captain": "أهلاً، ده الكابتن",
|
||||||
|
|||||||
@@ -803,6 +803,7 @@ final Map<String, String> ar_jo = {
|
|||||||
"Heading your way now. Please be ready.": "في طريقه إليك الآن. يرجى الاستعداد.",
|
"Heading your way now. Please be ready.": "في طريقه إليك الآن. يرجى الاستعداد.",
|
||||||
"Health Insurance": "التأمين الصحي",
|
"Health Insurance": "التأمين الصحي",
|
||||||
"Heatmap": "خريطة الحرارة",
|
"Heatmap": "خريطة الحرارة",
|
||||||
|
"Predictive Demand": "🔮 توقعات الطلب",
|
||||||
"Height:": "الطول:",
|
"Height:": "الطول:",
|
||||||
"Hello": "مرحباً",
|
"Hello": "مرحباً",
|
||||||
"Hello this is Captain": "مرحباً، هذا الكابتن",
|
"Hello this is Captain": "مرحباً، هذا الكابتن",
|
||||||
|
|||||||
@@ -803,6 +803,7 @@ final Map<String, String> ar_sy = {
|
|||||||
"Heading your way now. Please be ready.": "عم ييجي لعندك هلق. تفضّل جهّز حالك.",
|
"Heading your way now. Please be ready.": "عم ييجي لعندك هلق. تفضّل جهّز حالك.",
|
||||||
"Health Insurance": "التأمين الصحي",
|
"Health Insurance": "التأمين الصحي",
|
||||||
"Heatmap": "خريطة حرارية",
|
"Heatmap": "خريطة حرارية",
|
||||||
|
"Predictive Demand": "🔮 توقعات الطلب",
|
||||||
"Height:": "الطول:",
|
"Height:": "الطول:",
|
||||||
"Hello": "أهلاً",
|
"Hello": "أهلاً",
|
||||||
"Hello this is Captain": "أهلاً، أنا الكابتن",
|
"Hello this is Captain": "أهلاً، أنا الكابتن",
|
||||||
|
|||||||
@@ -803,6 +803,7 @@ final Map<String, String> en = {
|
|||||||
"Heading your way now. Please be ready.": "Heading your way now. Please be ready.",
|
"Heading your way now. Please be ready.": "Heading your way now. Please be ready.",
|
||||||
"Health Insurance": "Health Insurance",
|
"Health Insurance": "Health Insurance",
|
||||||
"Heatmap": "Heatmap",
|
"Heatmap": "Heatmap",
|
||||||
|
"Predictive Demand": "Predictive Demand",
|
||||||
"Height:": "Height:",
|
"Height:": "Height:",
|
||||||
"Hello": "Hello",
|
"Hello": "Hello",
|
||||||
"Hello this is Captain": "Hello this is Captain",
|
"Hello this is Captain": "Hello this is Captain",
|
||||||
|
|||||||
@@ -326,6 +326,42 @@ class _AppBarControls extends StatelessWidget {
|
|||||||
onTap: c.toggleHeatmap,
|
onTap: c.toggleHeatmap,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Predictive Demand
|
||||||
|
GetBuilder<HomeCaptainController>(
|
||||||
|
builder: (c) => Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
_IconBtn(
|
||||||
|
icon: Icons.auto_awesome_rounded,
|
||||||
|
color: c.isPredictiveVisible
|
||||||
|
? const Color(0xFF0EA5E9)
|
||||||
|
: Colors.grey.shade600,
|
||||||
|
tooltip: 'Predictive Demand'.tr,
|
||||||
|
onTap: c.togglePredictiveDemand,
|
||||||
|
),
|
||||||
|
if (c.isPredictiveVisible && c.predictiveZones.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
top: -4,
|
||||||
|
right: -4,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(3),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF0EA5E9),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${c.predictiveZones.length}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
// Center on me
|
// Center on me
|
||||||
_IconBtn(
|
_IconBtn(
|
||||||
icon: Icons.my_location_rounded,
|
icon: Icons.my_location_rounded,
|
||||||
@@ -398,7 +434,10 @@ class _MapView extends StatelessWidget {
|
|||||||
mapType: s.isMapDarkMode
|
mapType: s.isMapDarkMode
|
||||||
? IntaleqMapType.normal
|
? IntaleqMapType.normal
|
||||||
: IntaleqMapType.light,
|
: IntaleqMapType.light,
|
||||||
polygons: ctrl.heatmapPolygons,
|
polygons: {
|
||||||
|
...ctrl.heatmapPolygons,
|
||||||
|
...ctrl.predictivePolygons,
|
||||||
|
},
|
||||||
markers: {
|
markers: {
|
||||||
Marker(
|
Marker(
|
||||||
markerId: MarkerId('MyLocation'.tr),
|
markerId: MarkerId('MyLocation'.tr),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: siro_driver
|
|||||||
description: "A new Flutter project."
|
description: "A new Flutter project."
|
||||||
publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||||
|
|
||||||
version: 1.0.0+2
|
version: 1.0.0+3
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.0.5 <4.0.0"
|
sdk: ">=3.0.5 <4.0.0"
|
||||||
|
|||||||
@@ -1297,10 +1297,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.18"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1313,10 +1313,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1885,10 +1885,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.9"
|
version: "0.7.11"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user