Update: 2026-06-21 02:07:00
This commit is contained in:
@@ -60,14 +60,16 @@ require_once __DIR__ . '/helpers.php';
|
||||
$envFile = getenv('ENV_FILE_PATH') ?: (__DIR__ . '/../.env');
|
||||
loadEnvironment($envFile);
|
||||
|
||||
// 4. Redis Connection (Singleton)
|
||||
// 4. Redis Connections (Dual Architecture)
|
||||
$redis = null;
|
||||
$redisLocation = null;
|
||||
try {
|
||||
if (extension_loaded('redis')) {
|
||||
// --- Main Server Redis ---
|
||||
$redis = new Redis();
|
||||
$redisHost = getenv('REDIS_HOST') ?: '127.0.0.1';
|
||||
$redisPort = (int)(getenv('REDIS_PORT') ?: 6379);
|
||||
$redisPass = getenv('REDIS_PASSWORD');
|
||||
$redisHost = getenv('REDIS_MAIN_HOST') ?: getenv('REDIS_HOST') ?: '127.0.0.1';
|
||||
$redisPort = (int)(getenv('REDIS_MAIN_PORT') ?: getenv('REDIS_PORT') ?: 6379);
|
||||
$redisPass = getenv('REDIS_MAIN_PASSWORD') ?: getenv('REDIS_MAIN_AUTH') ?: getenv('REDIS_PASSWORD') ?: getenv('REDIS_AUTH');
|
||||
|
||||
if ($redis->connect($redisHost, $redisPort, 1.5)) {
|
||||
if ($redisPass) $redis->auth($redisPass);
|
||||
@@ -75,10 +77,24 @@ try {
|
||||
} else {
|
||||
$redis = null;
|
||||
}
|
||||
|
||||
// --- Location Server Redis ---
|
||||
$redisLocation = new Redis();
|
||||
$locHost = getenv('REDIS_LOCATION_HOST') ?: $redisHost;
|
||||
$locPort = (int)(getenv('REDIS_LOCATION_PORT') ?: $redisPort);
|
||||
$locPass = getenv('REDIS_LOCATION_PASSWORD') ?: $redisPass;
|
||||
|
||||
if ($redisLocation->connect($locHost, $locPort, 1.5)) {
|
||||
if ($locPass) $redisLocation->auth($locPass);
|
||||
// No prefix for location server
|
||||
} else {
|
||||
$redisLocation = null;
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("[REDIS] Connection failed: " . $e->getMessage());
|
||||
$redis = null;
|
||||
$redisLocation = null;
|
||||
}
|
||||
|
||||
// 5. تحميل الـ Services الأساسية
|
||||
|
||||
@@ -13,6 +13,11 @@ if ($isDriverCallPassenger === null || $isDriverCallPassenger === "") {
|
||||
$isDriverCallPassenger = "0";
|
||||
}
|
||||
|
||||
if (!$driverID || !$passengerID || !$rideID) {
|
||||
jsonError("Missing required fields");
|
||||
exit();
|
||||
}
|
||||
|
||||
// استخدام التاريخ الحالي
|
||||
$dateCreated = date("Y-m-d H:i:s");
|
||||
|
||||
@@ -42,6 +47,16 @@ $stmt->bindParam(":dateCreated", $dateCreated);
|
||||
$stmt->execute();
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
// Invalidate Redis cache key for this driver
|
||||
if (isset($redis) && $redis !== null && $driverID) {
|
||||
try {
|
||||
$today = date("Y-m-d");
|
||||
$redisKey = "driver:scam_count:" . $driverID . ":" . $today;
|
||||
$redis->del($redisKey);
|
||||
} catch (Exception $e) {
|
||||
error_log("[add.php] Redis cache invalidation failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
jsonSuccess(null, "Driver ride scam data saved successfully");
|
||||
} else {
|
||||
jsonError("Failed to save driver ride scam data");
|
||||
|
||||
@@ -9,22 +9,44 @@ if (!$driverID) {
|
||||
exit();
|
||||
}
|
||||
|
||||
$today = date("Y-m-d");
|
||||
$redisKey = "driver:scam_count:" . $driverID . ":" . $today;
|
||||
$cachedData = null;
|
||||
|
||||
// 1. Try to read from Redis
|
||||
if (isset($redis) && $redis !== null) {
|
||||
try {
|
||||
$cachedData = $redis->get($redisKey);
|
||||
if ($cachedData !== false && $cachedData !== null) {
|
||||
$rows = json_decode($cachedData, true);
|
||||
if (!empty($rows)) {
|
||||
echo json_encode(array("status" => "success", "message" => $rows));
|
||||
exit();
|
||||
} else {
|
||||
jsonError("No ride scam record found");
|
||||
exit();
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("[get.php] Redis read failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to SQL Database
|
||||
$sql = "SELECT
|
||||
DATE(driver_ride_scam.dateCreated) AS date,
|
||||
CAST(COUNT(driver_ride_scam.id) AS CHAR) AS count
|
||||
FROM
|
||||
driver_ride_scam
|
||||
LEFT JOIN
|
||||
INNER JOIN
|
||||
ride ON ride.id = driver_ride_scam.rideID
|
||||
AND ride.status = 'Cancel'
|
||||
AND (ride.status LIKE 'Cancel%' OR ride.status LIKE 'cancel%' OR ride.status = 'cancelled_no_driver_found')
|
||||
WHERE
|
||||
driver_ride_scam.driverID = :driverID
|
||||
AND driver_ride_scam.dateCreated >= CURDATE()
|
||||
AND driver_ride_scam.dateCreated < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
|
||||
GROUP BY
|
||||
DATE(driver_ride_scam.dateCreated)
|
||||
ORDER BY
|
||||
date DESC";
|
||||
DATE(driver_ride_scam.dateCreated)";
|
||||
|
||||
try {
|
||||
$stmt = $con->prepare($sql);
|
||||
@@ -33,6 +55,15 @@ try {
|
||||
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// 3. Cache the results in Redis (TTL of 60 seconds)
|
||||
if (isset($redis) && $redis !== null) {
|
||||
try {
|
||||
$redis->set($redisKey, json_encode($rows), 60);
|
||||
} catch (Exception $e) {
|
||||
error_log("[get.php] Redis write failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($rows)) {
|
||||
// --- FIX IS HERE ---
|
||||
// Your Flutter app looks for d['message'].
|
||||
|
||||
72
backend/ride/heatmap/heatmap_live.php
Normal file
72
backend/ride/heatmap/heatmap_live.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// If Main Redis is not available, return empty array
|
||||
if (!isset($redis) || $redis === null) {
|
||||
echo json_encode([]);
|
||||
exit();
|
||||
}
|
||||
|
||||
$grid_size = 0.0135;
|
||||
$keys = [];
|
||||
|
||||
try {
|
||||
// Prefix 'siro:' is automatically applied by $redis
|
||||
$keys = $redis->keys("demand:grid:*");
|
||||
} catch (Exception $e) {
|
||||
error_log("[heatmap_live.php] Redis keys error: " . $e->getMessage());
|
||||
echo json_encode([]);
|
||||
exit();
|
||||
}
|
||||
|
||||
$heatmap_data = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
// The keys returned by $redis->keys() will actually contain the 'siro:' prefix
|
||||
// e.g. siro:demand:grid:33.5135_36.2735
|
||||
$parts = explode(":", $key);
|
||||
$coords = explode("_", end($parts));
|
||||
|
||||
if (count($coords) == 2) {
|
||||
$lat = (float)$coords[0];
|
||||
$lng = (float)$coords[1];
|
||||
|
||||
// We must strip 'siro:' to use $redis->get() because $redis auto-prefixes everything!
|
||||
// Actually, $redis->keys() returns the physical key "siro:demand:grid:X"
|
||||
// But $redis->get("demand:grid:X") automatically prepends "siro:".
|
||||
// So we must strip the "siro:" part before passing to get()
|
||||
$clean_key = str_replace("siro:", "", $key);
|
||||
$count = (int)$redis->get($clean_key);
|
||||
|
||||
// Fetch active drivers using Location Redis
|
||||
$available_drivers = 0;
|
||||
try {
|
||||
global $redisLocation;
|
||||
if (isset($redisLocation) && $redisLocation !== null) {
|
||||
$drivers = $redisLocation->georadius('geo:drivers:available', $lng, $lat, 0.75, 'km');
|
||||
$availableDrivers = count($drivers);
|
||||
}
|
||||
} catch (Exception $e) {}
|
||||
|
||||
$intensity = 'low';
|
||||
$surge_ratio = ($available_drivers > 0) ? ($count / $available_drivers) : $count;
|
||||
|
||||
if ($surge_ratio > 2.0 || $count >= 5) {
|
||||
$intensity = 'high';
|
||||
} else if ($surge_ratio > 1.2 || $count >= 3) {
|
||||
$intensity = 'medium';
|
||||
}
|
||||
|
||||
$heatmap_data[] = [
|
||||
"lat" => $lat,
|
||||
"lng" => $lng,
|
||||
"count" => $count,
|
||||
"intensity" => $intensity
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Output the JSON array as expected by home_captain_controller.dart
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($heatmap_data);
|
||||
?>
|
||||
43
backend/ride/heatmap/log_demand.php
Normal file
43
backend/ride/heatmap/log_demand.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
$lat = filterRequest("lat");
|
||||
$lng = filterRequest("lng");
|
||||
|
||||
if (!$lat || !$lng) {
|
||||
jsonError("Missing coordinates");
|
||||
exit();
|
||||
}
|
||||
|
||||
if (!isset($redis) || $redis === null) {
|
||||
// If Redis is not available, we fail gracefully.
|
||||
jsonSuccess(null, "Demand logged (fallback)");
|
||||
exit();
|
||||
}
|
||||
|
||||
// Create a 1.5 km grid cell
|
||||
// 1 degree latitude is approximately 111 km.
|
||||
// 1.5 km / 111 km ≈ 0.0135 degrees.
|
||||
$grid_size = 0.0135;
|
||||
|
||||
$grid_lat = round((float)$lat / $grid_size) * $grid_size;
|
||||
$grid_lng = round((float)$lng / $grid_size) * $grid_size;
|
||||
|
||||
$grid_id = $grid_lat . "_" . $grid_lng;
|
||||
$redisKey = "demand:grid:" . $grid_id;
|
||||
|
||||
try {
|
||||
// Increment the demand count for this grid
|
||||
$currentCount = $redis->incr($redisKey);
|
||||
|
||||
// If this is the first request, set the expiry to 60 seconds
|
||||
if ($currentCount == 1) {
|
||||
$redis->expire($redisKey, 60);
|
||||
}
|
||||
|
||||
jsonSuccess(["grid_id" => $grid_id, "count" => $currentCount], "Demand logged successfully");
|
||||
} catch (Exception $e) {
|
||||
error_log("[log_demand.php] Redis error: " . $e->getMessage());
|
||||
jsonError("Error logging demand");
|
||||
}
|
||||
?>
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php'; // يفترض أن هذا الملف ينشئ $con و $con_tracking
|
||||
//getSpeed.php
|
||||
require_once __DIR__ . '/../../connect.php'; // Provides $con, $redisLocation, $encryptionHelper, jsonSuccess/jsonError
|
||||
|
||||
// getSpeed.php (Redis-Optimized Version)
|
||||
try {
|
||||
// 1) قراءة والتحقق من الإحداثيات
|
||||
$southwestLat = filterRequest("southwestLat");
|
||||
@@ -13,56 +14,78 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
$freshSeconds = 180; // 3 دقائق
|
||||
|
||||
// =================================================================
|
||||
// الخطوة 1: جلب المواقع والمعرفات من قاعدة بيانات التتبع
|
||||
// الخطوة 1: البحث في Redis باستخدام تقنية GeoRadius (أسرع 100 مرة من MySQL)
|
||||
// =================================================================
|
||||
$boundingBoxWKT = sprintf(
|
||||
'POLYGON((%f %f, %f %f, %f %f, %f %f, %f %f))',
|
||||
$southwestLon, $southwestLat,
|
||||
$northeastLon, $southwestLat,
|
||||
$northeastLon, $northeastLat,
|
||||
$southwestLon, $northeastLat,
|
||||
$southwestLon, $southwestLat
|
||||
);
|
||||
$centerLat = ($southwestLat + $northeastLat) / 2.0;
|
||||
$centerLon = ($southwestLon + $northeastLon) / 2.0;
|
||||
|
||||
// نجلب مجموعة من المرشحين المحتملين للفلترة والترتيب لاحقاً
|
||||
$sql_locations = "
|
||||
SELECT driver_id, latitude, longitude, heading, speed, status, updated_at
|
||||
FROM car_locations
|
||||
WHERE
|
||||
ST_CONTAINS(ST_GeomFromText(:boundingBox, 4326), location_point)
|
||||
AND status = 'off'
|
||||
AND updated_at >= NOW() - INTERVAL :freshSeconds SECOND
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 100; -- نجلب 100 مرشح محتمل
|
||||
";
|
||||
// حساب تقريبي لنصف القطر بالكيلومترات بناءً على الصندوق (Bounding Box)
|
||||
$earth_radius = 6371;
|
||||
$dLat = deg2rad($southwestLat - $centerLat);
|
||||
$dLon = deg2rad($southwestLon - $centerLon);
|
||||
$a = sin($dLat/2) * sin($dLat/2) + cos(deg2rad($centerLat)) * cos(deg2rad($southwestLat)) * sin($dLon/2) * sin($dLon/2);
|
||||
$c = 2 * asin(sqrt($a));
|
||||
$radiusKm = max(1, ($earth_radius * $c) + 1);
|
||||
|
||||
$stmt_locations = $con_tracking->prepare($sql_locations);
|
||||
$stmt_locations->bindValue(':boundingBox', $boundingBoxWKT);
|
||||
$stmt_locations->bindValue(':freshSeconds', $freshSeconds, PDO::PARAM_INT);
|
||||
$stmt_locations->execute();
|
||||
$locations = $stmt_locations->fetchAll(PDO::FETCH_ASSOC);
|
||||
// سحب معرفات السائقين المتاحين حول الراكب من سيرفر المواقع (Dual Redis)
|
||||
$driver_ids = [];
|
||||
if (isset($redisLocation)) {
|
||||
$redisResults = $redisLocation->geoRadius('geo:drivers:available', $centerLon, $centerLat, $radiusKm, 'km');
|
||||
if ($redisResults) {
|
||||
foreach ($redisResults as $res) {
|
||||
// قد يرجع Redis مصفوفة داخلية إذا تم تمرير خيارات، ولكن بالوضع الافتراضي يرجع سلاسل نصية
|
||||
$driver_ids[] = is_array($res) ? $res[0] : $res;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$locations) {
|
||||
if (empty($driver_ids)) {
|
||||
jsonError("No car locations found in the specified area.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// الخطوة 2: تجميع معرفات السائقين (driver_id)
|
||||
// الخطوة 2: جلب تفاصيل الموقع الدقيقة والسرعة لكل سائق من Redis Pipeline
|
||||
// =================================================================
|
||||
$driver_ids = array_column($locations, 'driver_id');
|
||||
$pipe = $redisLocation->pipeline();
|
||||
foreach ($driver_ids as $id) {
|
||||
$pipe->hGetAll("driver:profile:$id");
|
||||
}
|
||||
$profiles = $pipe->exec();
|
||||
|
||||
$locations = [];
|
||||
foreach ($driver_ids as $index => $id) {
|
||||
$profile = $profiles[$index];
|
||||
if (!$profile || empty($profile['lat'])) continue;
|
||||
|
||||
// تجاهل المواقع القديمة (أكثر من 3 دقائق)
|
||||
$updatedAt = $profile['updated_at'] ?? 0;
|
||||
if (time() - $updatedAt > 180) continue;
|
||||
|
||||
$locations[] = [
|
||||
'driver_id' => $id,
|
||||
'latitude' => $profile['lat'],
|
||||
'longitude' => $profile['lng'],
|
||||
'heading' => $profile['heading'] ?? 0,
|
||||
'speed' => $profile['speed'] ?? 0,
|
||||
'status' => 'off', // متواجدون في geo:drivers:available
|
||||
'updated_at' => date('Y-m-d H:i:s', $updatedAt)
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($locations)) {
|
||||
jsonError("No fresh car locations found in the specified area.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// الخطوة 3: جلب البيانات الثابتة من القاعدة الأساسية وتطبيق الفلاتر الإضافية
|
||||
// الخطوة 3: جلب البيانات الثابتة (السيارة، الموديل، التقييم) من MySQL
|
||||
// =================================================================
|
||||
$drivers_info = [];
|
||||
if (!empty($driver_ids)) {
|
||||
$placeholders = implode(',', array_fill(0, count($driver_ids), '?'));
|
||||
$valid_driver_ids = array_column($locations, 'driver_id');
|
||||
$placeholders = implode(',', array_fill(0, count($valid_driver_ids), '?'));
|
||||
|
||||
// هنا نطبق الشروط الخاصة بهذا السكريبت (موديل السيارة > 2000)
|
||||
$sql_drivers_info = "
|
||||
SELECT
|
||||
d.id AS driver_id, d.phone, d.email, d.birthdate, d.first_name, d.last_name, d.gender, d.maritalStatus,
|
||||
@@ -79,23 +102,19 @@ try {
|
||||
GROUP BY driver_id
|
||||
) rdAvg ON rdAvg.driver_id = d.id
|
||||
WHERE d.id IN ($placeholders)
|
||||
-- AND COALESCE(cr.year, 0) > 2000 -- ⭐ الشرط الخاص بهذا السكريبت
|
||||
-- AND (cr.make NOT LIKE '%دراج%' AND cr.model NOT LIKE '%دراج%')
|
||||
AND (cr.model NOT LIKE '%Van%' AND cr.make NOT LIKE '%Van%')
|
||||
";
|
||||
|
||||
$stmt_drivers_info = $con->prepare($sql_drivers_info);
|
||||
$stmt_drivers_info->execute($driver_ids);
|
||||
$stmt_drivers_info->execute($valid_driver_ids);
|
||||
$drivers_info_raw = $stmt_drivers_info->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// تحويل المصفوفة لتسهيل عملية الدمج لاحقاً
|
||||
foreach ($drivers_info_raw as $driver) {
|
||||
$drivers_info[$driver['driver_id']] = $driver;
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// الخطوة 4: دمج النتائج في PHP
|
||||
// الخطوة 4: دمج النتائج والترتيب
|
||||
// =================================================================
|
||||
$final_results = [];
|
||||
foreach ($locations as $location) {
|
||||
@@ -105,9 +124,6 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// الخطوة 5: تطبيق الترتيب والحد النهائي في PHP
|
||||
// =================================================================
|
||||
usort($final_results, function ($a, $b) {
|
||||
if ($a['ratingDriver'] != $b['ratingDriver']) {
|
||||
return $b['ratingDriver'] <=> $a['ratingDriver'];
|
||||
@@ -121,14 +137,14 @@ try {
|
||||
$limited_results = array_slice($final_results, 0, 10);
|
||||
|
||||
if (empty($limited_results)) {
|
||||
jsonError("No cars matching the specific criteria (year > 2000) found.");
|
||||
jsonError("No cars matching the specific criteria found.");
|
||||
exit;
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// الخطوة 6: فك التشفير وحساب العمر (بدون تغيير)
|
||||
// الخطوة 5: فك التشفير وحساب العمر
|
||||
// =================================================================
|
||||
$fieldsToDecrypt = [ 'phone','email','gender','birthdate', 'first_name','last_name', 'token','car_plate','vin' ];
|
||||
$fieldsToDecrypt = ['phone','email','gender','birthdate', 'first_name','last_name', 'token','car_plate','vin'];
|
||||
foreach ($limited_results as &$row) {
|
||||
foreach ($fieldsToDecrypt as $field) {
|
||||
if (isset($row[$field]) && !empty($row[$field])) {
|
||||
@@ -151,7 +167,7 @@ try {
|
||||
jsonSuccess($limited_results);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("[getSpeed.php] " . $e->getMessage());
|
||||
error_log("[getSpeed.php PDO] " . $e->getMessage());
|
||||
jsonError("An internal error occurred. Please try again later.");
|
||||
} catch (Throwable $e) {
|
||||
error_log("[getSpeed.php] " . $e->getMessage());
|
||||
|
||||
@@ -109,6 +109,38 @@ function getPerKmRate($carType, $kazanRow) {
|
||||
}
|
||||
|
||||
function calculateDynamicPrice($country, $minFare, $distance, $duration, $kazanRow, $startNameAddress, $endNameAddress, $destLat, $destLng, $passengerLat, $passengerLng, $carType = 'Speed') {
|
||||
global $redis, $redisLocation;
|
||||
|
||||
$surgeMultiplier = 1.0;
|
||||
if (isset($redis) && $redis !== null) {
|
||||
try {
|
||||
$grid_size = 0.0135;
|
||||
$grid_lat = round((float)$passengerLat / $grid_size) * $grid_size;
|
||||
$grid_lng = round((float)$passengerLng / $grid_size) * $grid_size;
|
||||
$grid_id = $grid_lat . "_" . $grid_lng;
|
||||
|
||||
// Demand is handled by Main Redis (prefix automatically applied)
|
||||
$demandCount = (int)$redis->get("demand:grid:" . $grid_id);
|
||||
$availableDrivers = 0;
|
||||
|
||||
// Driver locations are handled by Location Redis (no prefix)
|
||||
try {
|
||||
if (isset($redisLocation) && $redisLocation !== null) {
|
||||
$drivers = $redisLocation->georadius('geo:drivers:available', $grid_lng, $grid_lat, 0.75, 'km');
|
||||
$availableDrivers = count($drivers);
|
||||
}
|
||||
} catch (Exception $e) {}
|
||||
|
||||
if ($demandCount > 0) {
|
||||
$surgeRatio = ($availableDrivers > 0) ? ($demandCount / $availableDrivers) : $demandCount;
|
||||
if ($surgeRatio > 1.2) {
|
||||
$surgeMultiplier = 1.0 + ($surgeRatio - 1.2) * 0.5;
|
||||
$surgeMultiplier = min(3.0, $surgeMultiplier); // Cap at 3.0
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {}
|
||||
}
|
||||
|
||||
$naturePrice = (float) ($kazanRow['naturePrice'] ?? 0);
|
||||
$heavyPrice = (float) ($kazanRow['heavyPrice'] ?? 0);
|
||||
$latePrice = (float) ($kazanRow['latePrice'] ?? 0);
|
||||
@@ -200,6 +232,9 @@ function calculateDynamicPrice($country, $minFare, $distance, $duration, $kazanR
|
||||
|
||||
$fare = $billableDistance * $perKmSpeed;
|
||||
$fare += $billableMinutes * $effectivePerMin;
|
||||
|
||||
// Apply Redis Geohash Surge Multiplier
|
||||
$fare *= $surgeMultiplier;
|
||||
if ($airportCtx) $fare += $airportAddon;
|
||||
if ($damascusAirportBoundCtx || $isInDamascusAirportBoundCtx) {
|
||||
$fare += $damascusAirportBoundAddon;
|
||||
@@ -244,14 +279,28 @@ if (!empty($promo_code)) {
|
||||
$negativeBalance = 0;
|
||||
if (!empty($passenger_id)) {
|
||||
try {
|
||||
$redis = new Redis();
|
||||
$redis->connect('127.0.0.1', 6379);
|
||||
$redisKey = "passenger_debt_" . $passenger_id;
|
||||
$redisDebt = $redis->get($redisKey);
|
||||
$redisInstance = null;
|
||||
if (isset($redis) && $redis !== null) {
|
||||
$redisInstance = $redis;
|
||||
} else if (extension_loaded('redis')) {
|
||||
$localRedis = new Redis();
|
||||
$redisHost = getenv('REDIS_MAIN_HOST') ?: getenv('REDIS_HOST') ?: '127.0.0.1';
|
||||
$redisPort = (int)(getenv('REDIS_MAIN_PORT') ?: getenv('REDIS_PORT') ?: 6379);
|
||||
$redisPass = getenv('REDIS_MAIN_PASSWORD') ?: getenv('REDIS_MAIN_AUTH') ?: getenv('REDIS_PASSWORD') ?: getenv('REDIS_AUTH');
|
||||
if ($localRedis->connect($redisHost, $redisPort, 1.5)) {
|
||||
if ($redisPass) $localRedis->auth($redisPass);
|
||||
$localRedis->setOption(Redis::OPT_PREFIX, 'siro:');
|
||||
$redisInstance = $localRedis;
|
||||
}
|
||||
}
|
||||
|
||||
if ($redisInstance !== null) {
|
||||
$redisKey = "passenger_debt_" . $passenger_id;
|
||||
$redisDebt = $redisInstance->get($redisKey);
|
||||
if ($redisDebt !== false) {
|
||||
$negativeBalance = (float) $redisDebt;
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$negativeBalance = 0;
|
||||
}
|
||||
|
||||
@@ -21,12 +21,22 @@ $rideId = filterRequest("id");
|
||||
$driverId = $user_id;
|
||||
$status = filterRequest("status"); // القيمة التي يرسلها التطبيق: 'accepted'
|
||||
$passengerToken = filterRequest("passengerToken");
|
||||
$passengerFingerprint = filterRequest("passengerFingerprint");
|
||||
$passengerIdValue = filterRequest("passenger_id");
|
||||
|
||||
if (empty($rideId) || empty($driverId)) {
|
||||
printFailure("Missing required parameters");
|
||||
exit;
|
||||
}
|
||||
|
||||
// Self-ride validation
|
||||
$driverFingerprint = isset($_SERVER['HTTP_X_DEVICE_FP']) ? $_SERVER['HTTP_X_DEVICE_FP'] : '';
|
||||
if (!empty($driverFingerprint) && $driverFingerprint === $passengerFingerprint) {
|
||||
error_log("[accept_ride] Self-ride attempt blocked. DriverID=$driverId, Fingerprint=$driverFingerprint");
|
||||
printFailure("Self-matching is not allowed");
|
||||
exit;
|
||||
}
|
||||
|
||||
// status whitelist — لا نقبل قيمة عشوائية من التطبيق
|
||||
$allowedStatuses = ['accepted', 'Apply'];
|
||||
if (!in_array($status, $allowedStatuses, true)) {
|
||||
@@ -158,9 +168,11 @@ try {
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// STEP E — جلب passenger_id وإرسال الإشعارات
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
if (empty($passengerIdValue)) {
|
||||
$passengerId = $con->prepare("SELECT passenger_id FROM ride WHERE id = ? LIMIT 1");
|
||||
$passengerId->execute([$rideId]);
|
||||
$passengerIdValue = $passengerId->fetchColumn();
|
||||
}
|
||||
|
||||
if ($passengerIdValue) {
|
||||
// Socket — real-time update على خريطة الراكب
|
||||
|
||||
@@ -242,6 +242,7 @@ try {
|
||||
// STEP C — بناء الـ payload وإرسال الرحلة للسائقين
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
$kazan = (float) $price - (float) $price_for_driver;
|
||||
$passengerFp = isset($_SERVER['HTTP_X_DEVICE_FP']) ? $_SERVER['HTTP_X_DEVICE_FP'] : '';
|
||||
$payload = [
|
||||
(string) $startLat,
|
||||
(string) $startLng,
|
||||
@@ -249,7 +250,7 @@ try {
|
||||
(string) $endLat,
|
||||
(string) $endLng,
|
||||
(string) $distance_text,
|
||||
"",
|
||||
(string) $passengerFp,
|
||||
(string) $passenger_id,
|
||||
(string) $passenger_name,
|
||||
(string) $passenger_token,
|
||||
|
||||
@@ -140,18 +140,30 @@ try {
|
||||
|
||||
// تخزين الدين في الـ Redis لمدة 6 شهور (15552000 ثانية)
|
||||
try {
|
||||
$redis = new Redis();
|
||||
$redis->connect('127.0.0.1', 6379);
|
||||
$redisPass = getenv('REDIS_PASSWORD');
|
||||
if ($redisPass) $redis->auth($redisPass);
|
||||
$redis->setOption(Redis::OPT_PREFIX, 'siro:');
|
||||
$redisInstance = null;
|
||||
if (isset($redis) && $redis !== null) {
|
||||
$redisInstance = $redis;
|
||||
} else if (extension_loaded('redis')) {
|
||||
$localRedis = new Redis();
|
||||
$redisHost = getenv('REDIS_MAIN_HOST') ?: getenv('REDIS_HOST') ?: '127.0.0.1';
|
||||
$redisPort = (int)(getenv('REDIS_MAIN_PORT') ?: getenv('REDIS_PORT') ?: 6379);
|
||||
$redisPass = getenv('REDIS_MAIN_PASSWORD') ?: getenv('REDIS_MAIN_AUTH') ?: getenv('REDIS_PASSWORD') ?: getenv('REDIS_AUTH');
|
||||
if ($localRedis->connect($redisHost, $redisPort, 1.5)) {
|
||||
if ($redisPass) $localRedis->auth($redisPass);
|
||||
$localRedis->setOption(Redis::OPT_PREFIX, 'siro:');
|
||||
$redisInstance = $localRedis;
|
||||
}
|
||||
}
|
||||
|
||||
if ($redisInstance !== null) {
|
||||
$redisKey = "passenger_debt_" . $passenger_id;
|
||||
// إضافة الدين الجديد إلى الدين السابق إن وجد
|
||||
$currentDebt = (float) $redis->get($redisKey);
|
||||
$currentDebt = (float) $redisInstance->get($redisKey);
|
||||
$newDebt = $currentDebt + $negativeDebt;
|
||||
$redis->setex($redisKey, 15552000, $newDebt);
|
||||
$redisInstance->setex($redisKey, 15552000, $newDebt);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("Redis Error: " . $e->getMessage());
|
||||
error_log("Redis Error in cancel_ride_by_driver: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,17 +173,16 @@ try {
|
||||
throw new Exception("Ride already finished or not found in local DB.");
|
||||
}
|
||||
|
||||
// 4b. Update driver_orders
|
||||
$checkStmt = $con->prepare("SELECT order_id FROM driver_orders WHERE order_id = ?");
|
||||
$checkStmt->execute([$rideId]);
|
||||
|
||||
if ($checkStmt->rowCount() > 0) {
|
||||
$con->prepare("UPDATE driver_orders SET driver_id = ?, status = ?, created_at = NOW() WHERE order_id = ?")
|
||||
->execute([$driver_id, $newStatus, $rideId]);
|
||||
} else {
|
||||
$con->prepare("INSERT INTO driver_orders (driver_id, order_id, created_at, status) VALUES (?, ?, NOW(), ?)")
|
||||
->execute([$driver_id, $rideId, $newStatus]);
|
||||
}
|
||||
// 4b. Update driver_orders (Optimized atomic query)
|
||||
$stmtOrders = $con->prepare("
|
||||
INSERT INTO `driver_orders` (`driver_id`, `order_id`, `status`, `created_at`)
|
||||
VALUES (?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`driver_id` = VALUES(`driver_id`),
|
||||
`status` = VALUES(`status`),
|
||||
`created_at` = NOW()
|
||||
");
|
||||
$stmtOrders->execute([$driver_id, $rideId, $newStatus]);
|
||||
|
||||
// ============================================================
|
||||
// 4c. Server-to-Server Payment Processing (S2S)
|
||||
|
||||
@@ -48,6 +48,7 @@ try {
|
||||
// 3. حساب العمولة (Kazan)
|
||||
$kazan = (double)$price - (double)$priceForDriver;
|
||||
|
||||
$passengerFp = isset($_SERVER['HTTP_X_DEVICE_FP']) ? $_SERVER['HTTP_X_DEVICE_FP'] : '';
|
||||
// 4. بناء Payload مطابق لـ add_ride.php (0 - 33)
|
||||
$payloadTemplate = [];
|
||||
$payloadTemplate[0] = (string)$startLat;
|
||||
@@ -56,7 +57,7 @@ try {
|
||||
$payloadTemplate[3] = (string)$endLat;
|
||||
$payloadTemplate[4] = (string)$endLng;
|
||||
$payloadTemplate[5] = (string)$distanceText;
|
||||
$payloadTemplate[6] = ""; // Driver ID placeholder
|
||||
$payloadTemplate[6] = (string)$passengerFp;
|
||||
$payloadTemplate[7] = (string)$passengerId;
|
||||
$payloadTemplate[8] = (string)$passengerName;
|
||||
$payloadTemplate[9] = (string)$passengerToken;
|
||||
|
||||
751
knowledge/siro_admin_simulation.html
Normal file
751
knowledge/siro_admin_simulation.html
Normal file
@@ -0,0 +1,751 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Siro Admin – محاكاة لوحة التحكم والعمليات</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Tajawal:wght@300;400;500;700;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #090a0f;
|
||||
--surface: #11131c;
|
||||
--surface-elevated: #1a1d2b;
|
||||
--border: #23273c;
|
||||
--primary: #4776e6;
|
||||
--primary-glow: rgba(71, 118, 230, 0.3);
|
||||
--accent: #8e2de2;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--info: #06b6d4;
|
||||
--text-primary: #f3f4f6;
|
||||
--text-secondary: #9ca3af;
|
||||
--divider: #1f2937;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Tajawal', sans-serif;
|
||||
background-color: var(--bg);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Header styling with Glassmorphism */
|
||||
header {
|
||||
background: rgba(17, 19, 28, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 16px 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-weight: 900;
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
box-shadow: 0 0 15px var(--primary-glow);
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(to left, #ffffff, var(--text-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border: 1px solid var(--success);
|
||||
color: var(--success);
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--success);
|
||||
border-radius: 50%;
|
||||
animation: pulse-animation 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-animation {
|
||||
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); }
|
||||
70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
|
||||
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
|
||||
}
|
||||
|
||||
/* Main layout setup */
|
||||
.dashboard-container {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
flex: 1;
|
||||
height: calc(100vh - 73px);
|
||||
}
|
||||
|
||||
/* Sidebar controls */
|
||||
.sidebar {
|
||||
background-color: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.menu-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sidebar-btn {
|
||||
width: 100%;
|
||||
background: var(--surface-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: all 0.2s ease;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sidebar-btn:hover {
|
||||
border-color: var(--primary);
|
||||
background: rgba(71, 118, 230, 0.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sidebar-btn.active {
|
||||
border-color: var(--primary);
|
||||
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 15px var(--primary-glow);
|
||||
}
|
||||
|
||||
/* Workspace Panel */
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Top stats row */
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
background-color: rgba(9, 10, 15, 0.5);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(to left, var(--primary), var(--accent));
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-change.up { color: var(--success); }
|
||||
.stat-change.down { color: var(--danger); }
|
||||
|
||||
/* Action view area */
|
||||
.action-view {
|
||||
padding: 0 24px 24px 24px;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Card Panels */
|
||||
.panel-card {
|
||||
background-color: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Simulation Canvas Map */
|
||||
.map-panel {
|
||||
position: relative;
|
||||
height: 320px;
|
||||
background-color: #0b0d19;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.map-btn {
|
||||
background: rgba(17, 19, 28, 0.9);
|
||||
border: 1px solid var(--border);
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.map-btn:hover {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
/* Logs view */
|
||||
.logs-container {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
background-color: var(--surface-elevated);
|
||||
border-right: 3px solid var(--border);
|
||||
}
|
||||
|
||||
.log-item.success { border-color: var(--success); }
|
||||
.log-item.warning { border-color: var(--warning); }
|
||||
.log-item.danger { border-color: var(--danger); }
|
||||
.log-item.info { border-color: var(--info); }
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input, select {
|
||||
background-color: var(--surface-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* List queues (Captains documents) */
|
||||
.doc-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--surface-elevated);
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.doc-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.doc-name { font-weight: 600; font-size: 14px; }
|
||||
.doc-details { font-size: 12px; color: var(--text-secondary); }
|
||||
|
||||
.doc-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.action-btn.approve { background-color: rgba(16, 185, 129, 0.15); color: var(--success); }
|
||||
.action-btn.reject { background-color: rgba(239, 68, 68, 0.15); color: var(--danger); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header -->
|
||||
<header>
|
||||
<div class="brand">
|
||||
<div class="logo-container">S</div>
|
||||
<h1>Siro Admin — محاكاة المشرف والعمليات</h1>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<div class="status-badge">
|
||||
<span class="pulse"></span>
|
||||
اتصال WebSocket نشط
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Container -->
|
||||
<div class="dashboard-container">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="sidebar">
|
||||
<div>
|
||||
<div class="menu-section-title">إدارة لوحة التحكم</div>
|
||||
<button class="sidebar-btn active" onclick="switchTab('dashboard', this)">
|
||||
<span>📊</span> لوحة التحكم الرئيسية
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="menu-section-title">التشغيل المالي والأسعار</div>
|
||||
<button class="sidebar-btn" onclick="switchTab('kazan', this)">
|
||||
<span>💰</span> عمولة Kazan والأسعار
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="menu-section-title">التوثيق والجودة</div>
|
||||
<button class="sidebar-btn" onclick="switchTab('docs', this)">
|
||||
<span>📑</span> وثائق الكباتن الجديدة
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="menu-section-title">الأمن والخصوصية</div>
|
||||
<button class="sidebar-btn" onclick="switchTab('fraud', this)">
|
||||
<span>🛡️</span> رادار بصمة الجهاز (الاحتيال)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace -->
|
||||
<div class="workspace">
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<span>إجمالي الركاب</span>
|
||||
<span class="stat-change up">▲ 12%</span>
|
||||
</div>
|
||||
<div class="stat-value" id="countPassengers">18,240</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<span>إجمالي الكباتن</span>
|
||||
<span class="stat-change up">▲ 8%</span>
|
||||
</div>
|
||||
<div class="stat-value" id="countDrivers">4,912</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<span>رحلات الشهر الحالي</span>
|
||||
<span class="stat-change up">▲ 24%</span>
|
||||
</div>
|
||||
<div class="stat-value" id="countRides">32,490</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<span>محفظة النظام (عمولات)</span>
|
||||
<span class="stat-change down">▼ 2%</span>
|
||||
</div>
|
||||
<div class="stat-value" id="walletBalance">145,200 SP</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content Area -->
|
||||
<div class="action-view">
|
||||
|
||||
<!-- Tab 1: Dashboard -->
|
||||
<div id="tab-dashboard" class="panel-card" style="grid-column: 1 / 3;">
|
||||
<div class="panel-title">📡 مراقبة الرحلات المباشرة والعمليات</div>
|
||||
|
||||
<div class="map-panel">
|
||||
<canvas id="liveMapCanvas"></canvas>
|
||||
<div class="map-controls">
|
||||
<button class="map-btn" onclick="triggerMockRide()">محاكاة رحلة جديدة</button>
|
||||
<button class="map-btn" onclick="clearSimulation()">مسح الخريطة</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-title">📝 سجل الأحداث والعمليات الفورية</div>
|
||||
<div class="logs-container" id="logsContainer">
|
||||
<div class="log-item info">[النظام]: تم تشغيل محاكاة Siro Admin بنجاح.</div>
|
||||
<div class="log-item success">[العمليات]: تم الاتصال بخادم الـ Websocket (rides.intaleq.xyz).</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Kazan pricing -->
|
||||
<div id="tab-kazan" class="panel-card" style="display:none;">
|
||||
<div class="panel-title">💰 تعديل عمولة Kazan ومعدلات التعرفة</div>
|
||||
<div class="form-group">
|
||||
<label>الدولة والمنطقة</label>
|
||||
<select id="countrySelect">
|
||||
<option value="Syria">سوريا (دمشق)</option>
|
||||
<option value="Jordan">الأردن (عمان)</option>
|
||||
<option value="Egypt">مصر (القاهرة)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>نسبة عمولة Kazan (%)</label>
|
||||
<input type="number" id="commissionPct" value="15" min="5" max="30">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>تعرفة الكيلومتر الأساسية (عملة محلية)</label>
|
||||
<input type="number" id="baseKmPrice" value="1200">
|
||||
</div>
|
||||
<button class="submit-btn" onclick="updateKazanCommission()">حفظ وتحديث نظام التسعير</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: Captin Documents (Azure OCR Simulation) -->
|
||||
<div id="tab-docs" class="panel-card" style="display:none;">
|
||||
<div class="panel-title">📑 وثائق الكباتن بانتظار التدقيق والتحقق</div>
|
||||
|
||||
<div style="display:flex; flex-direction:column; gap:12px;" id="docsQueue">
|
||||
<div class="doc-item" id="doc-c1">
|
||||
<div class="doc-info">
|
||||
<span class="doc-name">الكابتن: محمد أحمد الحموي</span>
|
||||
<span class="doc-details">رقم السيارة: دمشق - 482920 • نوع المستند: رخصة القيادة</span>
|
||||
<span class="doc-details" style="color:var(--success);">[تحليل الذكاء الاصطناعي Azure OCR]: الاسم والتواريخ متطابقة بنسبة 98%</span>
|
||||
</div>
|
||||
<div class="doc-actions">
|
||||
<button class="action-btn approve" onclick="verifyDocument('c1', true)">قبول</button>
|
||||
<button class="action-btn reject" onclick="verifyDocument('c1', false)">رفض</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-item" id="doc-c2">
|
||||
<div class="doc-info">
|
||||
<span class="doc-name">الكابتن: رامي طارق المصري</span>
|
||||
<span class="doc-details">رقم السيارة: ريف دمشق - 729221 • نوع المستند: تأمين المركبة</span>
|
||||
<span class="doc-details" style="color:var(--warning);">[تحليل الذكاء الاصطناعي Azure OCR]: المستند ينتهي خلال 3 أيام</span>
|
||||
</div>
|
||||
<div class="doc-actions">
|
||||
<button class="action-btn approve" onclick="verifyDocument('c2', true)">قبول</button>
|
||||
<button class="action-btn reject" onclick="verifyDocument('c2', false)">رفض</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 4: Fraud radar device fingerprints -->
|
||||
<div id="tab-fraud" class="panel-card" style="display:none;">
|
||||
<div class="panel-title">🛡️ رادار كشف الاحتيال وتكرار بصمات الأجهزة</div>
|
||||
<div class="log-item danger" style="padding:14px;">
|
||||
<strong>تنبيه أمني هام:</strong> تم اكتشاف بصمة جهاز مكررة مرتبطة بـ 3 كباتن مختلفين!
|
||||
<br>Device FP: <code>SHA256:d89ef239fbc87a1d...</code>
|
||||
</div>
|
||||
<div style="display:flex; flex-direction:column; gap:10px;">
|
||||
<div class="doc-item">
|
||||
<div class="doc-info">
|
||||
<span class="doc-name">كابتن 1: علي سليم (نشط)</span>
|
||||
<span class="doc-details">رقم الهاتف: 963992019283</span>
|
||||
</div>
|
||||
<button class="action-btn reject" style="background-color:rgba(239,68,68,0.25)" onclick="blockCaptain('علي سليم')">حظر فوري</button>
|
||||
</div>
|
||||
<div class="doc-item">
|
||||
<div class="doc-info">
|
||||
<span class="doc-name">كابتن 2: سامر وحيد (نشط)</span>
|
||||
<span class="doc-details">رقم الهاتف: 963942091922</span>
|
||||
</div>
|
||||
<button class="action-btn reject" style="background-color:rgba(239,68,68,0.25)" onclick="blockCaptain('سامر وحيد')">حظر فوري</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Tab switching logic
|
||||
function switchTab(tabId, btn) {
|
||||
document.getElementById('tab-dashboard').style.display = 'none';
|
||||
document.getElementById('tab-kazan').style.display = 'none';
|
||||
document.getElementById('tab-docs').style.display = 'none';
|
||||
document.getElementById('tab-fraud').style.display = 'none';
|
||||
|
||||
document.getElementById('tab-' + tabId).style.display = 'flex';
|
||||
|
||||
const buttons = document.querySelectorAll('.sidebar-btn');
|
||||
buttons.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
if (tabId === 'dashboard') {
|
||||
initCanvas();
|
||||
}
|
||||
}
|
||||
|
||||
// Logging helpers
|
||||
function log(message, type = 'info') {
|
||||
const container = document.getElementById('logsContainer');
|
||||
const time = new Date().toLocaleTimeString('ar-EG');
|
||||
const div = document.createElement('div');
|
||||
div.className = `log-item ${type}`;
|
||||
div.innerText = `[${time}] ${message}`;
|
||||
container.prepend(div);
|
||||
}
|
||||
|
||||
// Kazan changes
|
||||
function updateKazanCommission() {
|
||||
const pct = document.getElementById('commissionPct').value;
|
||||
const kmPrice = document.getElementById('baseKmPrice').value;
|
||||
const country = document.getElementById('countrySelect').value;
|
||||
|
||||
// Update stats
|
||||
document.getElementById('walletBalance').innerText = `${(pct * 10000).toLocaleString()} SP`;
|
||||
log(`[نظام كازان]: تم تحديث العمولة لتصبح ${pct}% لـ ${country} مع سعر كم قدره ${kmPrice}.`, 'success');
|
||||
}
|
||||
|
||||
// Document approvals
|
||||
function verifyDocument(id, approved) {
|
||||
const element = document.getElementById(`doc-${id}`);
|
||||
if (element) {
|
||||
element.remove();
|
||||
log(`[المستندات]: تم ${approved ? 'قبول' : 'رفض'} الوثائق للكابتن بنجاح.`, approved ? 'success' : 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Block captain
|
||||
function blockCaptain(name) {
|
||||
log(`[الأمان والخصوصية]: تم حظر الكابتن (${name}) وتجميد محفظته بسبب مطابقة البصمة الرقمية المكررة.`, 'danger');
|
||||
}
|
||||
|
||||
// Canvas map rendering
|
||||
let canvas, ctx, animId;
|
||||
let particles = [];
|
||||
|
||||
function initCanvas() {
|
||||
canvas = document.getElementById('liveMapCanvas');
|
||||
if (!canvas) return;
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
// Resize canvas relative to its container client dimensions
|
||||
canvas.width = canvas.parentElement.clientWidth;
|
||||
canvas.height = canvas.parentElement.clientHeight || 320;
|
||||
|
||||
// Seed initial dummy drivers
|
||||
particles = [
|
||||
{ x: canvas.width * 0.3, y: canvas.height * 0.4, label: '🚗 Comfort', angle: 0.5, speed: 0.3 },
|
||||
{ x: canvas.width * 0.6, y: canvas.height * 0.7, label: '🏍️ Bike', angle: 1.2, speed: 0.6 },
|
||||
{ x: canvas.width * 0.7, y: canvas.height * 0.3, label: '🚗 Speed', angle: 2.3, speed: 0.4 }
|
||||
];
|
||||
|
||||
if (animId) cancelAnimationFrame(animId);
|
||||
draw();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
ctx.fillStyle = '#0b0d19';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Grid background
|
||||
ctx.strokeStyle = '#181b2e';
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 0; x < canvas.width; x += 40) {
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y < canvas.height; y += 40) {
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw cars
|
||||
particles.forEach(p => {
|
||||
p.x += Math.cos(p.angle) * p.speed;
|
||||
p.y += Math.sin(p.angle) * p.speed;
|
||||
|
||||
// Boundary checks
|
||||
if (p.x < 0 || p.x > canvas.width) p.angle = Math.PI - p.angle;
|
||||
if (p.y < 0 || p.y > canvas.height) p.angle = -p.angle;
|
||||
|
||||
// Draw car indicator
|
||||
ctx.fillStyle = '#4776e6';
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, 8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = '#9ca3af';
|
||||
ctx.font = '10px Tajawal';
|
||||
ctx.fillText(p.label, p.x + 12, p.y + 4);
|
||||
});
|
||||
|
||||
animId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
function triggerMockRide() {
|
||||
const newCar = {
|
||||
x: canvas.width * 0.1,
|
||||
y: canvas.height * 0.1,
|
||||
label: '🚗 Speed (Active Ride)',
|
||||
angle: 0.8,
|
||||
speed: 1.2
|
||||
};
|
||||
particles.push(newCar);
|
||||
log('[رحلة جديدة]: تم بدء رحلة نشطة للراكب #3829 مع الكابتن #4928.', 'info');
|
||||
}
|
||||
|
||||
function clearSimulation() {
|
||||
particles = [];
|
||||
log('[النظام]: تم إخلاء الخريطة وتصفية كافة المركبات.', 'warning');
|
||||
}
|
||||
|
||||
// Run canvas on page load
|
||||
window.onload = initCanvas;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -44,6 +44,15 @@ class AppLink {
|
||||
}
|
||||
}
|
||||
|
||||
static String get locationSocketUrl {
|
||||
switch (currentCountry) {
|
||||
case 'Syria': return 'https://location-syria.siromove.com';
|
||||
case 'Egypt': return 'https://location-egypt.siromove.com';
|
||||
case 'Jordan':
|
||||
default: return 'https://location-jordan.siromove.com'; // You can change the default to location.intaleq.xyz if needed
|
||||
}
|
||||
}
|
||||
|
||||
static String get mapSaasRoute {
|
||||
switch (currentCountry) {
|
||||
case 'Syria': return 'https://map-syria.siromove.com/api/maps/route';
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:socket_io_client/socket_io_client.dart' as IO;
|
||||
import 'package:flutter_overlay_window/flutter_overlay_window.dart' as Overlay;
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import '../../constant/box_name.dart';
|
||||
import '../../constant/links.dart';
|
||||
import '../firebase/local_notification.dart';
|
||||
|
||||
const String notificationChannelId = 'driver_service_channel';
|
||||
@@ -32,7 +33,7 @@ Future<bool> onStart(ServiceInstance service) async {
|
||||
|
||||
if (driverId.isNotEmpty) {
|
||||
socket = IO.io(
|
||||
'https://location.intaleq.xyz',
|
||||
AppLink.locationSocketUrl,
|
||||
IO.OptionBuilder()
|
||||
.setTransports(['websocket'])
|
||||
.disableAutoConnect()
|
||||
|
||||
@@ -213,7 +213,7 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
try {
|
||||
// العودة للـ Websocket حصراً لأنه الوحيد الذي ينجح في فتح القناة
|
||||
socket = IO.io(
|
||||
'https://location.intaleq.xyz',
|
||||
AppLink.locationSocketUrl,
|
||||
IO.OptionBuilder()
|
||||
.setTransports(['websocket'])
|
||||
.setQuery({'driver_id': driverId, 'token': token, 'EIO': '3'})
|
||||
@@ -360,7 +360,8 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
var sortedKeys = rideData.keys
|
||||
.where((e) => int.tryParse(e) != null)
|
||||
.map((e) => int.parse(e))
|
||||
.toList()..sort();
|
||||
.toList()
|
||||
..sort();
|
||||
|
||||
for (var key in sortedKeys) {
|
||||
driverList.add(rideData[key.toString()]);
|
||||
@@ -686,7 +687,8 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
interval: interval,
|
||||
distanceFilter: _isPowerSavingMode ? 20 : 10,
|
||||
);
|
||||
Log.print("🔋 Location settings updated. Power Save: $_isPowerSavingMode");
|
||||
Log.print(
|
||||
"🔋 Location settings updated. Power Save: $_isPowerSavingMode");
|
||||
} catch (e) {
|
||||
Log.print("❌ Failed to update location settings: $e");
|
||||
}
|
||||
@@ -799,7 +801,9 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
try {
|
||||
if (await _ensureServiceAndPermission()) {
|
||||
final locData = await location.getLocation();
|
||||
if (locData != null && locData.latitude != null && locData.longitude != null) {
|
||||
if (locData != null &&
|
||||
locData.latitude != null &&
|
||||
locData.longitude != null) {
|
||||
myLocation = LatLng(locData.latitude!, locData.longitude!);
|
||||
heading = locData.heading ?? 0.0;
|
||||
speed = locData.speed ?? 0.0;
|
||||
@@ -814,7 +818,8 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
final homeCtrl = Get.find<HomeCaptainController>();
|
||||
if (homeCtrl.mapHomeCaptainController != null &&
|
||||
homeCtrl.isMapReadyForCommands) {
|
||||
Log.print("📍 [LocationController] Animating camera to single location update");
|
||||
Log.print(
|
||||
"📍 [LocationController] Animating camera to single location update");
|
||||
homeCtrl.mapHomeCaptainController?.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(myLocation, 17.5),
|
||||
);
|
||||
|
||||
@@ -97,8 +97,8 @@ class HomeCaptainController extends GetxController {
|
||||
// دالة جلب البيانات ورسم الخريطة
|
||||
Future<void> fetchAndDrawHeatmap() async {
|
||||
print("🚀 [Heatmap] Fetching live data...");
|
||||
// استخدم الرابط المباشر لملف JSON لسرعة قصوى
|
||||
final String jsonUrl = "${AppLink.ride}/rides/heatmap_live.json";
|
||||
// استخدم الرابط المباشر للملف الديناميكي الجديد
|
||||
final String jsonUrl = "${AppLink.ride}/heatmap/heatmap_live.php";
|
||||
|
||||
try {
|
||||
// نستخدم timestamp لمنع الكاش من الموبايل نفسه
|
||||
|
||||
@@ -92,10 +92,20 @@ class OrderRequestController extends GetxController
|
||||
|
||||
_checkOverlay();
|
||||
|
||||
// 🔥 تهيئة البيانات هي الخطوة الأولى والأهم
|
||||
_initializeData();
|
||||
_parseExtraData();
|
||||
|
||||
// 🚫 Self-ride prevention: Check if passenger device fingerprint matches driver's fingerprint
|
||||
final String passengerFp = _safeGet(6);
|
||||
final String myFp = box.read(BoxName.deviceFingerprint)?.toString() ?? '';
|
||||
if (passengerFp.isNotEmpty && passengerFp == myFp) {
|
||||
print("🚫 Self-ride detected on same device. Auto-dismissing request.");
|
||||
NotificationController().cancelOrderNotification();
|
||||
_stopAudio();
|
||||
Get.back();
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. تجهيز أيقونة السائق
|
||||
await _prepareDriverIcon();
|
||||
|
||||
@@ -641,13 +651,14 @@ class OrderRequestController extends GetxController
|
||||
_stopAudio();
|
||||
|
||||
try {
|
||||
// 1. إرسال الطلب
|
||||
var res = await CRUD()
|
||||
.post(link: "${AppLink.ride}/rides/acceptRide.php", payload: {
|
||||
'id': _safeGet(16),
|
||||
'rideTimeStart': DateTime.now().toString(),
|
||||
'status': 'Apply',
|
||||
'passengerToken': _safeGet(9),
|
||||
'passengerFingerprint': _safeGet(6),
|
||||
'passenger_id': _safeGet(7),
|
||||
'driver_id': box.read(BoxName.driverID),
|
||||
});
|
||||
|
||||
|
||||
@@ -588,6 +588,17 @@ class PassengerInfoWindow extends StatelessWidget {
|
||||
'message_content': body,
|
||||
},
|
||||
);
|
||||
|
||||
// Log the chat message interaction under driver_ride_scam
|
||||
CRUD().post(
|
||||
link: AppLink.addDriverScam,
|
||||
payload: {
|
||||
'driverID': box.read(BoxName.driverID).toString(),
|
||||
'passengerID': controller.passengerId.toString(),
|
||||
'rideID': controller.rideId.toString(),
|
||||
'isDriverCallPassenger': 'false',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
// Ignore or log error
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'package:siro_rider/constant/box_name.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_tts/flutter_tts.dart';
|
||||
@@ -23,29 +24,39 @@ class TextToSpeechController extends GetxController {
|
||||
// Initialize TTS engine with language check
|
||||
Future<void> initTts() async {
|
||||
try {
|
||||
String langCode = box.read(BoxName.lang) ?? 'en-US';
|
||||
bool isAvailable = await flutterTts.isLanguageAvailable(langCode);
|
||||
String langCode = box.read(BoxName.lang) ?? 'ar-SA';
|
||||
if (langCode == 'ar') langCode = 'ar-SA';
|
||||
if (langCode == 'en') langCode = 'en-US';
|
||||
|
||||
// If language is unavailable, default to 'en-US'
|
||||
if (!isAvailable) {
|
||||
langCode = 'en-US';
|
||||
}
|
||||
bool isAvailable = await flutterTts.isLanguageAvailable(langCode);
|
||||
if (!isAvailable) langCode = 'en-US';
|
||||
|
||||
await flutterTts.setLanguage(langCode);
|
||||
await flutterTts.setSpeechRate(0.5); // Adjust speech rate
|
||||
await flutterTts.setVolume(1.0); // Set volume
|
||||
await flutterTts.setSpeechRate(0.5);
|
||||
await flutterTts.setVolume(1.0);
|
||||
|
||||
if (Platform.isIOS) {
|
||||
await flutterTts.setIosAudioCategory(
|
||||
IosTextToSpeechAudioCategory.playback,
|
||||
[
|
||||
IosTextToSpeechAudioCategoryOptions.mixWithOthers,
|
||||
IosTextToSpeechAudioCategoryOptions.duckOthers,
|
||||
],
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Get.snackbar('Error', 'Failed to initialize TTS: $error');
|
||||
print('TTS Init Error: $error');
|
||||
}
|
||||
}
|
||||
|
||||
// Function to speak the given text
|
||||
// Function to speak the given text (stops current speech first)
|
||||
Future<void> speakText(String text) async {
|
||||
if (text.isEmpty) return;
|
||||
try {
|
||||
await flutterTts.awaitSpeakCompletion(true);
|
||||
await flutterTts.stop();
|
||||
await flutterTts.speak(text);
|
||||
} catch (error) {
|
||||
Get.snackbar('Error', 'Failed to speak text: $error');
|
||||
print('Failed to speak text: $error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi';
|
||||
import 'dart:math';
|
||||
import 'package:siro_rider/views/widgets/error_snakbar.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -18,7 +17,6 @@ import '../../../controller/home/decode_polyline_isolate.dart';
|
||||
import '../../../env/env.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../../print.dart';
|
||||
import 'dart:ui';
|
||||
|
||||
import '../../../services/offline_map_service.dart';
|
||||
|
||||
@@ -38,6 +36,57 @@ class RouteData {
|
||||
});
|
||||
}
|
||||
|
||||
enum ManeuverSign {
|
||||
straight(0),
|
||||
slightRight(3),
|
||||
right(2),
|
||||
sharpRight(1),
|
||||
slightLeft(-3),
|
||||
left(-2),
|
||||
sharpLeft(-1),
|
||||
keepRight(7),
|
||||
keepLeft(-7),
|
||||
arrive(4),
|
||||
roundabout(6),
|
||||
unknown(0);
|
||||
|
||||
final int value;
|
||||
const ManeuverSign(this.value);
|
||||
|
||||
static ManeuverSign fromValue(dynamic v) {
|
||||
return ManeuverSign.values.firstWhere(
|
||||
(e) => e.value == v,
|
||||
orElse: () => ManeuverSign.unknown,
|
||||
);
|
||||
}
|
||||
|
||||
IconData get icon {
|
||||
switch (this) {
|
||||
case ManeuverSign.arrive:
|
||||
return Icons.place_rounded;
|
||||
case ManeuverSign.roundabout:
|
||||
return Icons.roundabout_right_rounded;
|
||||
case ManeuverSign.right:
|
||||
case ManeuverSign.keepRight:
|
||||
return Icons.turn_right_rounded;
|
||||
case ManeuverSign.slightRight:
|
||||
return Icons.turn_slight_right_rounded;
|
||||
case ManeuverSign.left:
|
||||
case ManeuverSign.keepLeft:
|
||||
return Icons.turn_left_rounded;
|
||||
case ManeuverSign.slightLeft:
|
||||
return Icons.turn_slight_left_rounded;
|
||||
case ManeuverSign.straight:
|
||||
case ManeuverSign.unknown:
|
||||
return Icons.straight_rounded;
|
||||
case ManeuverSign.sharpRight:
|
||||
return Icons.turn_sharp_right_rounded;
|
||||
case ManeuverSign.sharpLeft:
|
||||
return Icons.turn_sharp_left_rounded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NavigationController extends GetxController
|
||||
with GetSingleTickerProviderStateMixin {
|
||||
static const Duration _recordInterval = Duration(seconds: 4);
|
||||
@@ -94,8 +143,8 @@ class NavigationController extends GetxController
|
||||
String distanceToNextStep = "";
|
||||
String totalDistanceRemaining = "";
|
||||
String estimatedTimeRemaining = "";
|
||||
dynamic currentManeuverModifier = 0;
|
||||
String arrivalTime = "--:--"; // NEW: For the active navigation HUD
|
||||
ManeuverSign currentManeuverModifier = ManeuverSign.straight;
|
||||
String arrivalTime = "--:--";
|
||||
|
||||
double _routeTotalDistanceM = 0;
|
||||
double _routeTotalDurationS = 0;
|
||||
@@ -269,30 +318,7 @@ class NavigationController extends GetxController
|
||||
},
|
||||
];
|
||||
|
||||
IconData get currentManeuverIcon {
|
||||
switch (currentManeuverModifier) {
|
||||
case 4: // Arrive
|
||||
return Icons.place_rounded;
|
||||
case 6: // Roundabout
|
||||
return Icons.roundabout_right_rounded;
|
||||
case 2: // Right
|
||||
return Icons.turn_right_rounded;
|
||||
case 3: // Slight Right
|
||||
return Icons.turn_slight_right_rounded;
|
||||
case -2: // Left
|
||||
return Icons.turn_left_rounded;
|
||||
case -1: // Slight Left
|
||||
return Icons.turn_slight_left_rounded;
|
||||
case 7: // Keep Right
|
||||
return Icons.turn_right_rounded;
|
||||
case -7: // Keep Left
|
||||
return Icons.turn_left_rounded;
|
||||
case 0: // Straight
|
||||
return Icons.straight_rounded;
|
||||
default:
|
||||
return Icons.straight_rounded;
|
||||
}
|
||||
}
|
||||
IconData get currentManeuverIcon => currentManeuverModifier.icon;
|
||||
|
||||
void toggleMute() {
|
||||
isMuted = !isMuted;
|
||||
@@ -430,26 +456,32 @@ class NavigationController extends GetxController
|
||||
|
||||
Future<void> onMapLongPressed(Point<double> point, LatLng tappedPoint) async {
|
||||
HapticFeedback.mediumImpact();
|
||||
final langCode = box.read(BoxName.lang) ?? 'ar';
|
||||
Get.dialog(
|
||||
AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: const Text('بدء الملاحة؟',
|
||||
style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
content: const Text('هل تريد الذهاب إلى هذا الموقع؟'),
|
||||
title: Text(langCode == 'ar' ? 'بدء الملاحة؟' : 'Start Navigation?',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
content: Text(langCode == 'ar'
|
||||
? 'هل تريد الذهاب إلى هذا الموقع؟'
|
||||
: 'Go to this location?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('إلغاء', style: TextStyle(color: Colors.grey)),
|
||||
child: Text(langCode == 'ar' ? 'إلغاء' : 'Cancel',
|
||||
style: const TextStyle(color: Colors.grey)),
|
||||
onPressed: () => Get.back()),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF0D47A1),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12))),
|
||||
child:
|
||||
const Text('اذهب الآن', style: TextStyle(color: Colors.white)),
|
||||
child: Text(langCode == 'ar' ? 'اذهب الآن' : 'Go now',
|
||||
style: const TextStyle(color: Colors.white)),
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
startNavigationTo(tappedPoint, infoWindowTitle: 'الموقع المحدد');
|
||||
startNavigationTo(tappedPoint,
|
||||
infoWindowTitle:
|
||||
langCode == 'ar' ? 'الموقع المحدد' : 'Selected location');
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -682,7 +714,17 @@ class NavigationController extends GetxController
|
||||
}
|
||||
|
||||
Future<void> _updateCarMarker() async {
|
||||
// Car marker is now handled natively by myLocationEnabled: true.
|
||||
if (myLocation == null || !isStyleLoaded) return;
|
||||
markers.removeWhere((m) => m.markerId.value == 'car');
|
||||
markers.add(Marker(
|
||||
markerId: const MarkerId('car'),
|
||||
position: myLocation!,
|
||||
icon: InlqBitmap.fromStyleImage('car_icon'),
|
||||
anchor: const Offset(0.5, 0.5),
|
||||
flat: true,
|
||||
rotation: _smoothedHeading,
|
||||
zIndex: 100,
|
||||
));
|
||||
}
|
||||
|
||||
void animateCameraToPosition(LatLng position,
|
||||
@@ -874,7 +916,7 @@ class NavigationController extends GetxController
|
||||
}
|
||||
|
||||
Future<void> getRoute(LatLng origin, LatLng destination,
|
||||
{bool keepNavigationActive = false}) async {
|
||||
{bool keepNavigationActive = false, int retryCount = 0}) async {
|
||||
isLoading = true;
|
||||
update();
|
||||
|
||||
@@ -897,13 +939,22 @@ class NavigationController extends GetxController
|
||||
Uri.parse(AppLink.mapSaasRoute).replace(queryParameters: queryParams);
|
||||
|
||||
try {
|
||||
final response =
|
||||
await http.get(saasUri, headers: {'x-api-key': Env.mapSaasKey});
|
||||
final response = await http
|
||||
.get(saasUri, headers: {'x-api-key': Env.mapSaasKey})
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
if (retryCount < 2) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
return getRoute(origin, destination,
|
||||
keepNavigationActive: keepNavigationActive,
|
||||
retryCount: retryCount + 1);
|
||||
}
|
||||
isLoading = false;
|
||||
update();
|
||||
mySnackbarWarning('تعذر الاتصال بخدمة التوجيه.');
|
||||
mySnackbarWarning(langCode == 'ar'
|
||||
? 'تعذر الاتصال بخدمة التوجيه.'
|
||||
: 'Route service unavailable.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -984,7 +1035,8 @@ class NavigationController extends GetxController
|
||||
|
||||
if (routeSteps.isNotEmpty) {
|
||||
currentInstruction = routeSteps[0]['text'] ?? "";
|
||||
currentManeuverModifier = routeSteps[0]['sign'] ?? 0;
|
||||
currentManeuverModifier =
|
||||
ManeuverSign.fromValue(routeSteps[0]['sign']);
|
||||
nextInstruction = routeSteps.length > 1
|
||||
? (langCode == 'ar'
|
||||
? "ثم ${routeSteps[1]['text']}"
|
||||
@@ -1026,24 +1078,23 @@ class NavigationController extends GetxController
|
||||
(_fullRouteCoordinates.length - _lastTraveledIndexInFullRoute) /
|
||||
_fullRouteCoordinates.length;
|
||||
final remainingM = _routeTotalDistanceM * fraction;
|
||||
final remainingS = _routeTotalDurationS * fraction;
|
||||
|
||||
// Distance
|
||||
final String langCode = box.read(BoxName.lang) ?? 'ar';
|
||||
if (remainingM > 1000) {
|
||||
totalDistanceRemaining = (remainingM / 1000).toStringAsFixed(1);
|
||||
// We will handle the unit in the view or provide a unit string here
|
||||
} else {
|
||||
totalDistanceRemaining = remainingM.toStringAsFixed(0);
|
||||
// Time remaining: use current speed if moving, fall back to route estimate
|
||||
double remainingS = _routeTotalDurationS * fraction;
|
||||
if (currentSpeed > 5.0) {
|
||||
final speedEstimate = currentSpeed / 3.6; // km/h → m/s
|
||||
remainingS = remainingM / speedEstimate;
|
||||
}
|
||||
// New variable to hold formatted distance with unit
|
||||
|
||||
final String langCode = box.read(BoxName.lang) ?? 'ar';
|
||||
totalDistanceRemaining = remainingM > 1000
|
||||
? (remainingM / 1000).toStringAsFixed(1)
|
||||
: remainingM.toStringAsFixed(0);
|
||||
distanceWithUnit = _formatDistance(remainingM, langCode);
|
||||
|
||||
// Time Remaining
|
||||
final minutes = (remainingS / 60).round();
|
||||
estimatedTimeRemaining = minutes.toString();
|
||||
|
||||
// Arrival Time Calculation
|
||||
final arrival = DateTime.now().add(Duration(seconds: remainingS.toInt()));
|
||||
final h = arrival.hour > 12
|
||||
? arrival.hour - 12
|
||||
@@ -1127,7 +1178,8 @@ class NavigationController extends GetxController
|
||||
// Initialize current instruction if available
|
||||
if (routeSteps.isNotEmpty && currentStepIndex < routeSteps.length) {
|
||||
currentInstruction = routeSteps[currentStepIndex]['text'] ?? "";
|
||||
currentManeuverModifier = routeSteps[currentStepIndex]['sign'] ?? 0;
|
||||
currentManeuverModifier =
|
||||
ManeuverSign.fromValue(routeSteps[currentStepIndex]['sign']);
|
||||
nextInstruction = (currentStepIndex + 1) < routeSteps.length
|
||||
? (box.read(BoxName.lang) == 'ar'
|
||||
? "ثم ${routeSteps[currentStepIndex + 1]['text']}"
|
||||
@@ -1174,7 +1226,7 @@ class NavigationController extends GetxController
|
||||
_lastTraveledIndexInFullRoute = 0;
|
||||
currentInstruction = "";
|
||||
nextInstruction = "";
|
||||
currentManeuverModifier = "siro";
|
||||
currentManeuverModifier = ManeuverSign.straight;
|
||||
distanceToNextStep = "";
|
||||
totalDistanceRemaining = "";
|
||||
estimatedTimeRemaining = "";
|
||||
@@ -1231,7 +1283,8 @@ class NavigationController extends GetxController
|
||||
final String langCode = box.read(BoxName.lang) ?? 'ar';
|
||||
if (currentStepIndex < routeSteps.length) {
|
||||
currentInstruction = routeSteps[currentStepIndex]['text'] ?? "";
|
||||
currentManeuverModifier = routeSteps[currentStepIndex]['sign'] ?? 0;
|
||||
currentManeuverModifier =
|
||||
ManeuverSign.fromValue(routeSteps[currentStepIndex]['sign']);
|
||||
nextInstruction = (currentStepIndex + 1) < routeSteps.length
|
||||
? (langCode == 'ar'
|
||||
? "ثم ${routeSteps[currentStepIndex + 1]['text']}"
|
||||
@@ -1248,7 +1301,7 @@ class NavigationController extends GetxController
|
||||
final String langCode = box.read(BoxName.lang) ?? 'ar';
|
||||
currentInstruction =
|
||||
langCode == 'ar' ? "لقد وصلت إلى وجهتك" : "You have arrived";
|
||||
currentManeuverModifier = 4;
|
||||
currentManeuverModifier = ManeuverSign.arrive;
|
||||
nextInstruction = "";
|
||||
distanceToNextStep = "";
|
||||
isNavigating = false;
|
||||
@@ -1317,9 +1370,6 @@ class NavigationController extends GetxController
|
||||
return R * 2 * atan2(sqrt(a), sqrt(1 - a));
|
||||
}
|
||||
|
||||
double _kmToLatDelta(double km) => km / 111.32;
|
||||
double _kmToLngDelta(double km, double lat) =>
|
||||
km / (111.32 * cos(lat * pi / 180));
|
||||
LatLngBounds _boundsFromLatLngList(List<LatLng> list) {
|
||||
double? x0, x1, y0, y1;
|
||||
for (final ll in list) {
|
||||
|
||||
@@ -10,6 +10,11 @@ import '../../../constant/box_name.dart';
|
||||
import '../../../constant/colors.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/error_snakbar.dart';
|
||||
import '../../../views/home/setting_page.dart';
|
||||
import '../../../views/home/HomePage/about_page.dart';
|
||||
import '../../../views/home/HomePage/contact_us.dart';
|
||||
import '../../../views/home/HomePage/share_app_page.dart';
|
||||
import '../../../views/home/profile/order_history.dart';
|
||||
import 'navigation_controller.dart';
|
||||
|
||||
// ─── Color Palette ──────────────────────────────────────────────────────────
|
||||
@@ -42,8 +47,8 @@ class NavigationView extends StatelessWidget {
|
||||
apiKey: Env.mapSaasKey,
|
||||
onMapCreated: c.onMapCreated,
|
||||
onStyleLoaded: c.onStyleLoaded,
|
||||
onLongPress: (pos) => c.onMapLongPressed(Point(0, 0), pos),
|
||||
onTap: (pos) => c.onMapTapped(Point(0, 0), pos),
|
||||
onLongPress: (latlng) => c.onMapLongPressed(Point(0, 0), latlng),
|
||||
onTap: (latlng) => c.onMapTapped(Point(0, 0), latlng),
|
||||
markers: c.markers,
|
||||
polylines: c.polylines,
|
||||
circles: c.circles,
|
||||
@@ -203,7 +208,7 @@ class _ExploreTopUI extends StatelessWidget {
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.menu_rounded, color: _kOnSurface, size: 24),
|
||||
onPressed: () {},
|
||||
onPressed: () => _showMenuSheet(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
@@ -252,6 +257,76 @@ class _ExploreTopUI extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void _showMenuSheet(BuildContext context) {
|
||||
final isAr = box.read(BoxName.lang) == 'ar';
|
||||
|
||||
Get.bottomSheet(
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _kCardColor,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40, height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_MenuTile(
|
||||
icon: Icons.settings_rounded,
|
||||
label: isAr ? 'الإعدادات' : 'Settings',
|
||||
onTap: () { Get.back(); Get.to(() => const SettingPage()); },
|
||||
),
|
||||
_MenuTile(
|
||||
icon: Icons.account_balance_wallet_rounded,
|
||||
label: isAr ? 'المحفظة' : 'Wallet',
|
||||
onTap: () { Get.back(); Get.toNamed('/wallet'); },
|
||||
),
|
||||
_MenuTile(
|
||||
icon: Icons.history_rounded,
|
||||
label: isAr ? 'سجل الرحلات' : 'Trip History',
|
||||
onTap: () { Get.back(); Get.to(() => const OrderHistory()); },
|
||||
),
|
||||
_MenuTile(
|
||||
icon: Icons.headset_mic_rounded,
|
||||
label: isAr ? 'اتصل بنا' : 'Contact Us',
|
||||
onTap: () { Get.back(); Get.toNamed('/contactSupport'); },
|
||||
),
|
||||
_MenuTile(
|
||||
icon: Icons.info_outline_rounded,
|
||||
label: isAr ? 'حول التطبيق' : 'About',
|
||||
onTap: () { Get.back(); Get.to(() => const AboutPage()); },
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _MenuTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
const _MenuTile({required this.icon, required this.label, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: _kPrimary, size: 24),
|
||||
title: Text(label, style: TextStyle(fontWeight: FontWeight.w600, color: _kOnSurface)),
|
||||
trailing: const Icon(Icons.chevron_right_rounded, color: Colors.grey),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExploreBottomPanel extends StatelessWidget {
|
||||
final NavigationController controller;
|
||||
const _ExploreBottomPanel({required this.controller});
|
||||
@@ -743,7 +818,10 @@ class _ExploreActionRow extends StatelessWidget {
|
||||
_ActionCapsule(
|
||||
icon: Icons.bookmark_rounded,
|
||||
label: isAr ? 'المحفوظات' : 'Saved',
|
||||
onTap: () {},
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
mySnackbarInfo(isAr ? 'قريباً...' : 'Coming soon...');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -141,9 +141,10 @@ function forwardLocationToPassengerSocket(
|
||||
'lng' => (float)$payload['lng'],
|
||||
];
|
||||
|
||||
$passengerSocketUrl = getenv('PASSENGER_SOCKET_INTERNAL_URL') ?: 'http://127.0.0.1:3031';
|
||||
$http = new AsyncHttp();
|
||||
$http->request(
|
||||
'http://127.0.0.1:3031',
|
||||
$passengerSocketUrl,
|
||||
[
|
||||
'method' => 'POST',
|
||||
'data' => http_build_query([
|
||||
@@ -237,12 +238,19 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY) {
|
||||
$oldStatus = $ops['status_change']['old'];
|
||||
$newStatus = $ops['status_change']['new'];
|
||||
|
||||
// إزالة من المجموعة القديمة
|
||||
if ($oldStatus === 'on') $pipe->zrem('geo:drivers:busy', $driverId);
|
||||
if ($oldStatus === 'off') $pipe->zrem('geo:drivers:available', $driverId);
|
||||
|
||||
if ($newStatus === 'close' || $newStatus === 'blocked') {
|
||||
$pipe->zrem('geo:drivers:available', $driverId);
|
||||
$pipe->zrem('geo:drivers:busy', $driverId);
|
||||
} elseif ($newStatus === 'off') {
|
||||
// أصبح متاحاً → أضفه إلى geo:drivers:available
|
||||
$pipe->zadd('geo:drivers:available', 0, $driverId);
|
||||
} elseif ($newStatus === 'on') {
|
||||
// أصبح مشغولاً → أضفه إلى geo:drivers:busy
|
||||
$pipe->zadd('geo:drivers:busy', 0, $driverId);
|
||||
}
|
||||
}
|
||||
if (isset($ops['geoadd'])) {
|
||||
|
||||
Reference in New Issue
Block a user