From c7bd644b88a71a4e11997c6e57246e27b1d1b06a Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 4 Jul 2026 04:05:08 +0300 Subject: [PATCH] Update: 2026-07-04 04:05:08 --- .../api/demand/getPredictiveDemandZones.php | 150 ++++++++++ backend/bot/cron_auto_marketing_pusher.php | 56 +++- backend/bot/cron_passenger_reengagement.php | 204 ++++++++++++++ backend/bot/cron_predictive_demand.php | 257 ++++++++++++++++++ backend/bot/cron_silent_push_inactive.php | 4 +- siro_driver/lib/constant/links.dart | 3 + .../home/captin/home_captain_controller.dart | 100 ++++++- siro_driver/lib/controller/local/ar_eg.dart | 1 + siro_driver/lib/controller/local/ar_jo.dart | 1 + siro_driver/lib/controller/local/ar_sy.dart | 1 + siro_driver/lib/controller/local/en.dart | 1 + .../home/Captin/home_captain/home_captin.dart | 41 ++- siro_driver/pubspec.yaml | 2 +- siro_rider/pubspec.lock | 12 +- 14 files changed, 807 insertions(+), 26 deletions(-) create mode 100644 backend/api/demand/getPredictiveDemandZones.php create mode 100644 backend/bot/cron_passenger_reengagement.php create mode 100644 backend/bot/cron_predictive_demand.php diff --git a/backend/api/demand/getPredictiveDemandZones.php b/backend/api/demand/getPredictiveDemandZones.php new file mode 100644 index 00000000..adf30b2a --- /dev/null +++ b/backend/api/demand/getPredictiveDemandZones.php @@ -0,0 +1,150 @@ + 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); +?> diff --git a/backend/bot/cron_auto_marketing_pusher.php b/backend/bot/cron_auto_marketing_pusher.php index cbab5b3b..bf2ccbc0 100644 --- a/backend/bot/cron_auto_marketing_pusher.php +++ b/backend/bot/cron_auto_marketing_pusher.php @@ -20,7 +20,8 @@ require_once __DIR__ . '/../core/Security/EncryptionHelper.php'; set_time_limit(0); try { - $con = Database::get('main'); + $con = Database::get('main'); + $redis = getRedisConnection(); } catch (Exception $e) { die("Database connection failed: " . $e->getMessage() . "\n"); } @@ -101,22 +102,49 @@ foreach ($passengersByCountry as $countryCode => $passengers) { // 4. إرسال الـ Push Notification لكل راكب foreach ($passengers as $p) { + $passengerId = $p['passenger_id']; $encryptedToken = $p['token']; $decryptedToken = $encryptionHelper->decryptData($encryptedToken); - - if ($decryptedToken) { - $dataPayload = [ - 'type' => 'marketing_promo', - 'promo_code' => $promoCode - ]; - - // استدعاء دالة الإرسال (يفترض وجودها في نظام FCM الخاص بك، مثل sendFCM) - // sendFCM($decryptedToken, $title, $body, $dataPayload); - // سنطبع للـ Log فقط في هذا السكربت لتجنب إرسال رسائل حقيقية دون تفعيل FCM الفعلي - - error_log("Sending Push to passenger {$p['passenger_id']}: $title"); - $totalPushes++; + + if (!$decryptedToken) continue; + + // ── Anti-spam: لا يُرسَل لنفس الراكب أكثر من مرة/24 ساعة ── + $redisKey = "marketing_push:sent:{$passengerId}"; + if ($redis && $redis->exists($redisKey)) { + continue; } + + $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++; } } diff --git a/backend/bot/cron_passenger_reengagement.php b/backend/bot/cron_passenger_reengagement.php new file mode 100644 index 00000000..f18052f2 --- /dev/null +++ b/backend/bot/cron_passenger_reengagement.php @@ -0,0 +1,204 @@ +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"; +?> diff --git a/backend/bot/cron_predictive_demand.php b/backend/bot/cron_predictive_demand.php new file mode 100644 index 00000000..aecef6a1 --- /dev/null +++ b/backend/bot/cron_predictive_demand.php @@ -0,0 +1,257 @@ +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"; +?> diff --git a/backend/bot/cron_silent_push_inactive.php b/backend/bot/cron_silent_push_inactive.php index d987bf4d..ff04ce44 100644 --- a/backend/bot/cron_silent_push_inactive.php +++ b/backend/bot/cron_silent_push_inactive.php @@ -16,8 +16,8 @@ $sql = " SELECT p.id as passenger_id, t.token FROM passengers p JOIN tokens t ON p.id = t.passengerID - LEFT JOIN passenger_opening_locations pol ON p.id = pol.start_location - WHERE (pol.date IS NULL OR pol.date < DATE_SUB(NOW(), INTERVAL 24 HOUR)) + LEFT JOIN passenger_opening_locations pol ON p.id = pol.passenger_id + WHERE (pol.created_at IS NULL OR pol.created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)) GROUP BY p.id, t.token LIMIT 500 -- Batch size to avoid overloading the server or getting rate-limited "; diff --git a/siro_driver/lib/constant/links.dart b/siro_driver/lib/constant/links.dart index b2000418..e55a4663 100755 --- a/siro_driver/lib/constant/links.dart +++ b/siro_driver/lib/constant/links.dart @@ -145,6 +145,9 @@ class AppLink { static String get getSurgeHeatmap => "$server/ride/heatmap/get_surge_heatmap.php"; + static String get getPredictiveDemand => + "$server/api/demand/getPredictiveDemandZones.php"; + ///mapOSM = 'https://routesy.intaleq.xyz' static String get mapOSM { switch (currentCountry) { diff --git a/siro_driver/lib/controller/home/captin/home_captain_controller.dart b/siro_driver/lib/controller/home/captin/home_captain_controller.dart index c1a5b1e8..2bff4645 100755 --- a/siro_driver/lib/controller/home/captin/home_captain_controller.dart +++ b/siro_driver/lib/controller/home/captin/home_captain_controller.dart @@ -61,8 +61,13 @@ class HomeCaptainController extends GetxController { double widthMapTypeAndTraffic = 50; // === متغيرات الهيت ماب الجديدة === bool isHeatmapVisible = false; - Set heatmapPolygons = - {}; // سنستخدم Polygon لرسم المربعات على جوجل مابس + Set heatmapPolygons = {}; + + // === متغيرات التنبؤ الذكي بالطلب === + bool isPredictiveVisible = false; + Set predictivePolygons = {}; + List> predictiveZones = []; + Timer? _predictiveTimer; // Inject the LocationController class // 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 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>.from(body['zones'] ?? []); + predictiveZones = zones; + _drawPredictivePolygons(zones); + print("✅ [Predictive] ${zones.length} zones loaded."); + } + } + } catch (e) { + print("❌ [Predictive] Error: $e"); + } + } + + void _drawPredictivePolygons(List> zones) { + const double offset = 0.008; // أكبر قليلاً من الهيتماب (0.005) ليكون مميزاً + final Set 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() { Get.back(); Get.back(); diff --git a/siro_driver/lib/controller/local/ar_eg.dart b/siro_driver/lib/controller/local/ar_eg.dart index 6e2fac02..65b302af 100644 --- a/siro_driver/lib/controller/local/ar_eg.dart +++ b/siro_driver/lib/controller/local/ar_eg.dart @@ -803,6 +803,7 @@ final Map ar_eg = { "Heading your way now. Please be ready.": "قاعد يجيك دلوقتي. من فضلك استعد.", "Health Insurance": "التأمين الصحي", "Heatmap": "خريطة الحرارة", + "Predictive Demand": "🔮 توقعات الطلب", "Height:": "الطول:", "Hello": "أهلاً", "Hello this is Captain": "أهلاً، ده الكابتن", diff --git a/siro_driver/lib/controller/local/ar_jo.dart b/siro_driver/lib/controller/local/ar_jo.dart index 3ee1caff..255080cf 100644 --- a/siro_driver/lib/controller/local/ar_jo.dart +++ b/siro_driver/lib/controller/local/ar_jo.dart @@ -803,6 +803,7 @@ final Map ar_jo = { "Heading your way now. Please be ready.": "في طريقه إليك الآن. يرجى الاستعداد.", "Health Insurance": "التأمين الصحي", "Heatmap": "خريطة الحرارة", + "Predictive Demand": "🔮 توقعات الطلب", "Height:": "الطول:", "Hello": "مرحباً", "Hello this is Captain": "مرحباً، هذا الكابتن", diff --git a/siro_driver/lib/controller/local/ar_sy.dart b/siro_driver/lib/controller/local/ar_sy.dart index 867149ef..06d595ee 100644 --- a/siro_driver/lib/controller/local/ar_sy.dart +++ b/siro_driver/lib/controller/local/ar_sy.dart @@ -803,6 +803,7 @@ final Map ar_sy = { "Heading your way now. Please be ready.": "عم ييجي لعندك هلق. تفضّل جهّز حالك.", "Health Insurance": "التأمين الصحي", "Heatmap": "خريطة حرارية", + "Predictive Demand": "🔮 توقعات الطلب", "Height:": "الطول:", "Hello": "أهلاً", "Hello this is Captain": "أهلاً، أنا الكابتن", diff --git a/siro_driver/lib/controller/local/en.dart b/siro_driver/lib/controller/local/en.dart index 250b20a3..6acb9f61 100644 --- a/siro_driver/lib/controller/local/en.dart +++ b/siro_driver/lib/controller/local/en.dart @@ -803,6 +803,7 @@ final Map en = { "Heading your way now. Please be ready.": "Heading your way now. Please be ready.", "Health Insurance": "Health Insurance", "Heatmap": "Heatmap", + "Predictive Demand": "Predictive Demand", "Height:": "Height:", "Hello": "Hello", "Hello this is Captain": "Hello this is Captain", diff --git a/siro_driver/lib/views/home/Captin/home_captain/home_captin.dart b/siro_driver/lib/views/home/Captin/home_captain/home_captin.dart index f30fd2e9..aa73939a 100755 --- a/siro_driver/lib/views/home/Captin/home_captain/home_captin.dart +++ b/siro_driver/lib/views/home/Captin/home_captain/home_captin.dart @@ -326,6 +326,42 @@ class _AppBarControls extends StatelessWidget { onTap: c.toggleHeatmap, ), ), + // Predictive Demand + GetBuilder( + 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 _IconBtn( icon: Icons.my_location_rounded, @@ -398,7 +434,10 @@ class _MapView extends StatelessWidget { mapType: s.isMapDarkMode ? IntaleqMapType.normal : IntaleqMapType.light, - polygons: ctrl.heatmapPolygons, + polygons: { + ...ctrl.heatmapPolygons, + ...ctrl.predictivePolygons, + }, markers: { Marker( markerId: MarkerId('MyLocation'.tr), diff --git a/siro_driver/pubspec.yaml b/siro_driver/pubspec.yaml index 7e738805..82d20553 100644 --- a/siro_driver/pubspec.yaml +++ b/siro_driver/pubspec.yaml @@ -2,7 +2,7 @@ name: siro_driver description: "A new Flutter project." 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: sdk: ">=3.0.5 <4.0.0" diff --git a/siro_rider/pubspec.lock b/siro_rider/pubspec.lock index 2f1e1ab2..0de912cf 100644 --- a/siro_rider/pubspec.lock +++ b/siro_rider/pubspec.lock @@ -1297,10 +1297,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -1313,10 +1313,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: "direct main" description: @@ -1885,10 +1885,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.11" timezone: dependency: transitive description: