Update: 2026-07-12 18:53:59
This commit is contained in:
@@ -7,24 +7,7 @@ if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* تطبيع رقم الهاتف ليتوافق مع التخزين في قاعدة البيانات
|
||||
*/
|
||||
function normalizePhone($phone) {
|
||||
$clean = preg_replace('/\D+/', '', $phone);
|
||||
// Syria: 099XXXXXXX or 9639XXXXXXX
|
||||
if (strlen($clean) === 10 && strpos($clean, '09') === 0) return '963' . substr($clean, 1);
|
||||
if (strlen($clean) === 12 && strpos($clean, '963') === 0) return $clean;
|
||||
if (strlen($clean) === 9 && strpos($clean, '9') === 0) return '963' . $clean;
|
||||
// Jordan: 079XXXXXXX or 9627XXXXXXX
|
||||
if (strlen($clean) === 10 && strpos($clean, '07') === 0) return '962' . substr($clean, 1);
|
||||
if (strlen($clean) === 12 && strpos($clean, '962') === 0) return $clean;
|
||||
if (strlen($clean) === 9 && strpos($clean, '7') === 0) return '962' . $clean;
|
||||
// Egypt: 010XXXXXXXX or 2010XXXXXXXX
|
||||
if (strlen($clean) === 11 && strpos($clean, '01') === 0) return '20' . substr($clean, 1);
|
||||
if (strlen($clean) === 13 && strpos($clean, '20') === 0) return $clean;
|
||||
return $clean;
|
||||
}
|
||||
// التطبيع عبر normalizePhone() الموحّدة في core/helpers.php (نفس المنطق سابقاً)
|
||||
|
||||
$phone = filterRequest('phone');
|
||||
if (!$phone) {
|
||||
|
||||
@@ -7,24 +7,7 @@ if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* تطبيع رقم الهاتف ليتوافق مع التخزين في قاعدة البيانات
|
||||
*/
|
||||
function normalizePhone($phone) {
|
||||
$clean = preg_replace('/\D+/', '', $phone);
|
||||
// Syria: 099XXXXXXX or 9639XXXXXXX
|
||||
if (strlen($clean) === 10 && strpos($clean, '09') === 0) return '963' . substr($clean, 1);
|
||||
if (strlen($clean) === 12 && strpos($clean, '963') === 0) return $clean;
|
||||
if (strlen($clean) === 9 && strpos($clean, '9') === 0) return '963' . $clean;
|
||||
// Jordan: 079XXXXXXX or 9627XXXXXXX
|
||||
if (strlen($clean) === 10 && strpos($clean, '07') === 0) return '962' . substr($clean, 1);
|
||||
if (strlen($clean) === 12 && strpos($clean, '962') === 0) return $clean;
|
||||
if (strlen($clean) === 9 && strpos($clean, '7') === 0) return '962' . $clean;
|
||||
// Egypt: 010XXXXXXXX or 2010XXXXXXXX
|
||||
if (strlen($clean) === 11 && strpos($clean, '01') === 0) return '20' . substr($clean, 1);
|
||||
if (strlen($clean) === 13 && strpos($clean, '20') === 0) return $clean;
|
||||
return $clean;
|
||||
}
|
||||
// التطبيع عبر normalizePhone() الموحّدة في core/helpers.php (نفس المنطق سابقاً)
|
||||
|
||||
// 1. تسجيل بداية الطلب
|
||||
$phone = filterRequest("phone");
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
// Admin/transit/org/update.php — تعديل بيانات مؤسسة + إدارة حالة العقد
|
||||
// POST: org_id, [contract_status], [city], [contact_email], [website], [trial_ends_at]
|
||||
//
|
||||
// عند التعليق (suspended) أو الإنهاء (terminated):
|
||||
// يُبطل جميع جلسات مشرفي المؤسسة فوراً (Redis + MySQL)
|
||||
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Admin access required', 403);
|
||||
}
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
require_once __DIR__ . '/../../../transit/functions.php';
|
||||
|
||||
$orgId = filterRequest('org_id', 'int');
|
||||
if (!$orgId) jsonError('org_id is required', 400);
|
||||
|
||||
$st = $transit_con->prepare("SELECT id, contract_status FROM transit_orgs WHERE id=? LIMIT 1");
|
||||
$st->execute([$orgId]);
|
||||
$org = $st->fetch();
|
||||
if (!$org) jsonError('Organization not found', 404);
|
||||
|
||||
$updates = [];
|
||||
$params = [];
|
||||
|
||||
// حقول يمكن تحديثها
|
||||
if (($v = filterRequest('city')) !== null) { $updates[] = 'city=?'; $params[] = $v; }
|
||||
if (($v = filterRequest('contact_email')) !== null) { $updates[] = 'contact_email=?'; $params[] = $v; }
|
||||
if (($v = filterRequest('website')) !== null) { $updates[] = 'website=?'; $params[] = $v; }
|
||||
if (($v = filterRequest('trial_ends_at')) !== null) { $updates[] = 'trial_ends_at=?'; $params[] = $v; }
|
||||
|
||||
$newStatus = null;
|
||||
if (($v = filterRequest('contract_status')) !== null) {
|
||||
$allowed = ['active', 'trial', 'suspended', 'terminated'];
|
||||
if (!in_array($v, $allowed)) {
|
||||
jsonError('Invalid contract_status. Allowed: ' . implode(', ', $allowed), 400);
|
||||
}
|
||||
$newStatus = $v;
|
||||
$updates[] = 'contract_status=?';
|
||||
$params[] = $v;
|
||||
}
|
||||
|
||||
if (empty($updates)) jsonError('No fields to update', 400);
|
||||
|
||||
$updates[] = 'updated_at=NOW()';
|
||||
$params[] = $orgId;
|
||||
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_orgs SET " . implode(', ', $updates) . " WHERE id=?"
|
||||
)->execute($params);
|
||||
|
||||
// إبطال جلسات المشرفين عند التعليق أو الإنهاء
|
||||
if ($newStatus && in_array($newStatus, ['suspended', 'terminated'])) {
|
||||
$admins = $transit_con->prepare("SELECT id FROM transit_org_admins WHERE org_id=?");
|
||||
$admins->execute([$orgId]);
|
||||
|
||||
foreach ($admins->fetchAll(PDO::FETCH_COLUMN) as $adminId) {
|
||||
$sessions = $transit_con->prepare("SELECT token_hash FROM transit_sessions WHERE admin_id=?");
|
||||
$sessions->execute([$adminId]);
|
||||
foreach ($sessions->fetchAll(PDO::FETCH_COLUMN) as $hash) {
|
||||
if ($redis) $redis->del("transit:session:{$hash}");
|
||||
}
|
||||
$transit_con->prepare("DELETE FROM transit_sessions WHERE admin_id=?")->execute([$adminId]);
|
||||
}
|
||||
|
||||
appLog("[ADMIN][TRANSIT][ORG][update] org={$orgId} contract_status={$newStatus} — all admin sessions invalidated");
|
||||
} else {
|
||||
appLog("[ADMIN][TRANSIT][ORG][update] org={$orgId} updated=" . implode(',', $updates));
|
||||
}
|
||||
|
||||
jsonSuccess(['org_id' => $orgId, 'contract_status' => $newStatus ?? $org['contract_status']]);
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
// Admin/transit/route/approve.php — فريق سيرو يعتمد أو يوقف خطاً
|
||||
// POST: route_id, action (approve|suspend|reject)
|
||||
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Admin access required', 403);
|
||||
}
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
require_once __DIR__ . '/../../../transit/functions.php';
|
||||
|
||||
$routeId = filterRequest('route_id', 'int');
|
||||
$action = filterRequest('action');
|
||||
|
||||
if (!$routeId) jsonError('route_id is required', 400);
|
||||
|
||||
$allowed = ['approve', 'suspend', 'reject'];
|
||||
if (!in_array($action, $allowed)) jsonError('Invalid action. Allowed: ' . implode(', ', $allowed), 400);
|
||||
|
||||
$st = $transit_con->prepare("SELECT id, org_id, name_ar, status FROM transit_routes WHERE id=? LIMIT 1");
|
||||
$st->execute([$routeId]);
|
||||
$route = $st->fetch();
|
||||
if (!$route) jsonError('Route not found', 404);
|
||||
|
||||
$statusMap = [
|
||||
'approve' => 'active',
|
||||
'suspend' => 'suspended',
|
||||
'reject' => 'rejected',
|
||||
];
|
||||
$newStatus = $statusMap[$action];
|
||||
|
||||
if ($route['status'] === $newStatus) {
|
||||
jsonError("Route is already in status: {$newStatus}", 409);
|
||||
}
|
||||
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_routes
|
||||
SET status=?, approved_by=?, approved_at=NOW(), updated_at=NOW()
|
||||
WHERE id=?"
|
||||
)->execute([$newStatus, (string)$user_id, $routeId]);
|
||||
|
||||
appLog("[TRANSIT][ROUTE] route #{$routeId} org#{$route['org_id']} → {$newStatus} by admin #{$user_id}", 'INFO');
|
||||
|
||||
// كتابة في Redis للسوكيت: transit:route_org:{routeId} → org_id
|
||||
// يُستخدم في passenger_socket لتحقق العضوية
|
||||
if (isset($redisLocation) && $redisLocation) {
|
||||
$redisLocation->set("transit:route_org:{$routeId}", (string)$route['org_id']);
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'route_id' => $routeId,
|
||||
'route_name' => $route['name_ar'],
|
||||
'new_status' => $newStatus,
|
||||
], "Route {$action}d successfully");
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
// Admin/transit/route/pending.php — قائمة الخطوط المسودة بانتظار الاعتماد
|
||||
// POST: — (اختياري: org_id للفلترة)
|
||||
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Admin access required', 403);
|
||||
}
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
require_once __DIR__ . '/../../../transit/functions.php';
|
||||
|
||||
$orgIdFilter = filterRequest('org_id', 'int');
|
||||
|
||||
$sql = "SELECT r.id, r.org_id, r.name_ar, r.name_en, r.direction, r.distance_km,
|
||||
r.duration_min, r.status, r.created_at,
|
||||
o.name_ar AS org_name, o.type AS org_type, o.country,
|
||||
(SELECT COUNT(*) FROM transit_stops s WHERE s.route_id = r.id) AS stops_count
|
||||
FROM transit_routes r
|
||||
JOIN transit_orgs o ON o.id = r.org_id
|
||||
WHERE r.status = 'draft'";
|
||||
|
||||
$params = [];
|
||||
if ($orgIdFilter) {
|
||||
$sql .= " AND r.org_id = ?";
|
||||
$params[] = $orgIdFilter;
|
||||
}
|
||||
$sql .= " ORDER BY r.created_at DESC LIMIT 100";
|
||||
|
||||
$st = $transit_con->prepare($sql);
|
||||
$st->execute($params);
|
||||
$routes = $st->fetchAll();
|
||||
|
||||
// جلب محطات كل خط للمعاينة
|
||||
$stStops = $transit_con->prepare(
|
||||
"SELECT id, sequence, name_ar, latitude, longitude, is_major
|
||||
FROM transit_stops WHERE route_id=? ORDER BY sequence ASC"
|
||||
);
|
||||
foreach ($routes as &$r) {
|
||||
$stStops->execute([$r['id']]);
|
||||
$r['stops'] = $stStops->fetchAll();
|
||||
}
|
||||
unset($r);
|
||||
|
||||
jsonSuccess(['routes' => $routes, 'total' => count($routes)]);
|
||||
@@ -1,40 +0,0 @@
|
||||
import os
|
||||
|
||||
# Configuration
|
||||
PROJECT_DIR = '.'
|
||||
OUTPUT_FILE = 'siro_v1_secure_latest.md'
|
||||
EXCLUDED_DIRS = {'.git', 'vendor', 'node_modules', '.gemini'}
|
||||
EXCLUDED_FILES = {OUTPUT_FILE, 'aggregate_files.py'}
|
||||
|
||||
def aggregate_files():
|
||||
with open(OUTPUT_FILE, 'w', encoding='utf-8') as outfile:
|
||||
outfile.write(f'# Siro V1 - Secure Latest Version\n\n')
|
||||
|
||||
for root, dirs, files in os.walk(PROJECT_DIR):
|
||||
# Prune excluded directories
|
||||
dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS]
|
||||
|
||||
for file in files:
|
||||
if file in EXCLUDED_FILES:
|
||||
continue
|
||||
|
||||
filepath = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(filepath, PROJECT_DIR)
|
||||
|
||||
# We mainly want to include code files
|
||||
if any(file.endswith(ext) for ext in ['.php', '.sql', '.ini', '.json', '.md', '.txt', '.py', '.sh']):
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8', errors='ignore') as infile:
|
||||
content = infile.read()
|
||||
|
||||
outfile.write(f'## File: {rel_path}\n')
|
||||
outfile.write(f'```\n')
|
||||
outfile.write(content)
|
||||
outfile.write(f'\n```\n\n')
|
||||
print(f"Added: {rel_path}")
|
||||
except Exception as e:
|
||||
print(f"Could not read {rel_path}: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
aggregate_files()
|
||||
print(f"\nDone! File created: {OUTPUT_FILE}")
|
||||
@@ -51,23 +51,7 @@ if (empty($rawPhone)) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// تطبيع رقم الهاتف حسب الدولة (بدون +، بدون أصفار زائدة)
|
||||
// حتى يتطابق مع التخزين في قاعدة البيانات (مثال: 9639XXXXXXX)
|
||||
function normalizePhone($phone) {
|
||||
$clean = preg_replace('/\D+/', '', $phone);
|
||||
// Syria: 099XXXXXXX → 9639XXXXXXX
|
||||
if (strlen($clean) === 10 && strpos($clean, '09') === 0) return '963' . substr($clean, 1);
|
||||
if (strlen($clean) === 12 && strpos($clean, '963') === 0) return $clean;
|
||||
if (strlen($clean) === 9 && strpos($clean, '9') === 0) return '963' . $clean;
|
||||
// Jordan: 079XXXXXXX → 9627XXXXXXX
|
||||
if (strlen($clean) === 10 && strpos($clean, '07') === 0) return '962' . substr($clean, 1);
|
||||
if (strlen($clean) === 12 && strpos($clean, '962') === 0) return $clean;
|
||||
if (strlen($clean) === 9 && strpos($clean, '7') === 0) return '962' . $clean;
|
||||
// Egypt: 010XXXXXXXX → 2010XXXXXXXX
|
||||
if (strlen($clean) === 11 && strpos($clean, '01') === 0) return '20' . substr($clean, 1);
|
||||
if (strlen($clean) === 13 && strpos($clean, '20') === 0) return $clean;
|
||||
return $clean;
|
||||
}
|
||||
// التطبيع عبر normalizePhone() الموحّدة في core/helpers.php (نفس المنطق سابقاً)
|
||||
$phone = normalizePhone($rawPhone);
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
// Rate limiting — حد صارم على طلبات OTP
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'api');
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
@@ -13,7 +17,8 @@ if (!$rawPhone) jsonError('Phone is required');
|
||||
$phone = normalizePhone($rawPhone);
|
||||
|
||||
if (transitIsOtpLocked($phone)) {
|
||||
jsonError('Too many attempts. Try again in 30 minutes.', 429);
|
||||
// نفس رسالة النجاح — لا نكشف أن الحساب مقفل
|
||||
jsonSuccess(null, 'OTP sent successfully');
|
||||
}
|
||||
|
||||
$phoneEnc = $encryptionHelper->encryptData($phone);
|
||||
@@ -26,8 +31,10 @@ $st = $transit_con->prepare(
|
||||
$st->execute([$phoneEnc]);
|
||||
$admin = $st->fetch();
|
||||
|
||||
if (!$admin) jsonError('Invalid credentials', 401);
|
||||
if ($admin['contract_status'] === 'terminated') jsonError('Account suspended', 403);
|
||||
// لا نكشف إن كان الهاتف موجوداً أم لا — نفس الرد دائماً
|
||||
if (!$admin || $admin['contract_status'] === 'terminated') {
|
||||
jsonSuccess(null, 'OTP sent successfully');
|
||||
}
|
||||
|
||||
transitSendAdminOtp($phone);
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
// Rate limiting
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'api');
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
// transit/admin/logout.php — تسجيل خروج مشرف المؤسسة (حذف الجلسة)
|
||||
// POST: — (التوكن في الهيدر Authorization: Bearer)
|
||||
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { jsonError('Transit service unavailable', 503); }
|
||||
|
||||
// استخرج التوكن من الهيدر
|
||||
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
$token = '';
|
||||
if (preg_match('/Bearer\s+(\S+)/i', $authHeader, $m)) {
|
||||
$token = $m[1];
|
||||
} else {
|
||||
$token = filterRequest('token') ?? '';
|
||||
}
|
||||
|
||||
if (!$token) jsonError('No session token provided', 400);
|
||||
|
||||
$hash = hash('sha256', $token);
|
||||
|
||||
// حذف من Redis
|
||||
if ($redis) $redis->del("siro:transit:session:{$hash}");
|
||||
|
||||
// حذف من MySQL
|
||||
try {
|
||||
$transit_con->prepare("DELETE FROM transit_sessions WHERE token_hash=?")->execute([$hash]);
|
||||
} catch (Throwable $e) {
|
||||
// لا بأس — الجلسة انتهت بالـ Redis
|
||||
}
|
||||
|
||||
jsonSuccess(null, 'Logged out successfully');
|
||||
@@ -18,6 +18,10 @@ if (in_array($origin, $adminOrigins)) {
|
||||
}
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
||||
|
||||
// Rate limiting على جلسات لوحة المشرف
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'api');
|
||||
|
||||
// اتصال Transit DB
|
||||
try {
|
||||
$transit_con = Database::get('transit');
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
/**
|
||||
* transit/cron_sync_members.php
|
||||
* ─────────────────────────────────────────────────────────────────────
|
||||
* يعيد بناء مجموعات transit:org_members:{orgId} في Redis (location server)
|
||||
* من قاعدة البيانات MySQL — للتشغيل اليدوي أو الكرون اليومي.
|
||||
*
|
||||
* يُستخدَم بعد:
|
||||
* • هجرة قاعدة البيانات (ALTER/INSERT جماعي)
|
||||
* • انقطاع Redis (مسح البيانات بعد إعادة التشغيل)
|
||||
* • مزامنة دورية للتأكد من عدم الانزلاق
|
||||
*
|
||||
* crontab مقترح:
|
||||
* 0 3 * * * php /home/intaleq-rides/backend/transit/cron_sync_members.php >> /var/log/transit_sync_members.log 2>&1
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Starting transit:org_members sync...\n";
|
||||
|
||||
if (!$redisLocation) {
|
||||
echo "[ERROR] \$redisLocation is not available. Check REDIS_LOCATION_HOST in .env\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
$transit_con = Database::get('transit');
|
||||
} catch (Exception $e) {
|
||||
echo "[ERROR] Cannot connect to siroTransitDb: " . $e->getMessage() . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// جلب كل العضويات النشطة مع org_id
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT org_id, passenger_id
|
||||
FROM transit_enrollments
|
||||
WHERE status = 'active'
|
||||
AND passenger_id IS NOT NULL"
|
||||
);
|
||||
$st->execute();
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
echo "[INFO] Found " . count($rows) . " active enrollments to sync\n";
|
||||
|
||||
// تجميع per org
|
||||
$byOrg = [];
|
||||
foreach ($rows as $row) {
|
||||
$byOrg[(int)$row['org_id']][] = (string)$row['passenger_id'];
|
||||
}
|
||||
|
||||
$synced = 0;
|
||||
foreach ($byOrg as $orgId => $passengerIds) {
|
||||
$key = "transit:org_members:{$orgId}";
|
||||
|
||||
// بناء كامل: احذف المجموعة القديمة وأعد بناءها بدفعة واحدة
|
||||
$redisLocation->del($key);
|
||||
if (!empty($passengerIds)) {
|
||||
// sAdd يقبل array ابتداءً من phpredis 5.x
|
||||
$redisLocation->sAdd($key, ...$passengerIds);
|
||||
$redisLocation->expire($key, 7 * 86400);
|
||||
}
|
||||
|
||||
echo "[SYNC] org_id={$orgId} → {$key} ({" . count($passengerIds) . "} members)\n";
|
||||
$synced++;
|
||||
}
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Done. Synced {$synced} orgs, " . count($rows) . " total enrollments.\n";
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
// transit/driver/activate_by_app.php — السائق يفعّل حسابه بتوكن الدعوة (عبر JWT)
|
||||
// POST: invite_token
|
||||
// المصادقة: JWT السائق الرئيسي من connect_app.php
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
|
||||
|
||||
$inviteToken = filterRequest('invite_token');
|
||||
if (!$inviteToken) jsonError('invite_token is required', 400);
|
||||
|
||||
// البحث بالتوكن فقط — لا نثق بأي معرّف آخر من العميل
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT d.id, d.org_id, d.name, d.status, o.name_ar AS org_name
|
||||
FROM transit_drivers d
|
||||
JOIN transit_orgs o ON o.id = d.org_id
|
||||
WHERE d.invite_token=? LIMIT 1"
|
||||
);
|
||||
$st->execute([$inviteToken]);
|
||||
$driver = $st->fetch();
|
||||
|
||||
if (!$driver) jsonError('Invalid invite token', 404);
|
||||
if ($driver['status'] === 'suspended') jsonError('Account suspended', 403);
|
||||
|
||||
// تحقق أن main_driver_id ليس مضبوطاً لحساب آخر
|
||||
if ($driver['status'] === 'active') {
|
||||
$chk = $transit_con->prepare(
|
||||
"SELECT main_driver_id FROM transit_drivers WHERE id=? LIMIT 1"
|
||||
);
|
||||
$chk->execute([$driver['id']]);
|
||||
$row = $chk->fetch();
|
||||
if ($row && $row['main_driver_id'] && (string)$row['main_driver_id'] !== (string)$transit_user_id) {
|
||||
jsonError('This invite token is already linked to another account', 409);
|
||||
}
|
||||
// إذا كان نفس الحساب — رد نجاح مباشرة
|
||||
if ($row && (string)$row['main_driver_id'] === (string)$transit_user_id) {
|
||||
jsonSuccess([
|
||||
'driver_id' => (int)$driver['id'],
|
||||
'org_id' => (int)$driver['org_id'],
|
||||
'org_name' => $driver['org_name'],
|
||||
'name' => $driver['name'],
|
||||
], 'Already activated');
|
||||
}
|
||||
}
|
||||
|
||||
// ضبط main_driver_id وتفعيل الحساب
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_drivers
|
||||
SET main_driver_id=?, status='active', activated_at=NOW(), invite_token=NULL, updated_at=NOW()
|
||||
WHERE id=?"
|
||||
)->execute([(string)$transit_user_id, $driver['id']]);
|
||||
|
||||
appLog("[TRANSIT][DRIVER] activated driver #{$driver['id']} (org#{$driver['org_id']}) linked to main_driver_id={$transit_user_id}", 'INFO');
|
||||
|
||||
jsonSuccess([
|
||||
'driver_id' => (int)$driver['id'],
|
||||
'org_id' => (int)$driver['org_id'],
|
||||
'org_name' => $driver['org_name'],
|
||||
'name' => $driver['name'],
|
||||
], 'Account activated successfully. Welcome to مواصلاتي!');
|
||||
@@ -7,7 +7,7 @@ $status = filterRequest('status') ?: 'active';
|
||||
$allowed = ['all','invited','active','suspended'];
|
||||
if (!in_array($status, $allowed)) $status = 'active';
|
||||
|
||||
$sql = "SELECT id, name, status, activated_at, created_at FROM transit_drivers WHERE org_id=?";
|
||||
$sql = "SELECT id, name, phone, status, activated_at, created_at FROM transit_drivers WHERE org_id=?";
|
||||
$params = [$transit_org_id];
|
||||
if ($status !== 'all') { $sql .= " AND status=?"; $params[] = $status; }
|
||||
$sql .= " ORDER BY name ASC";
|
||||
|
||||
@@ -44,6 +44,8 @@ if ($rosterRow) {
|
||||
WHERE id=?"
|
||||
)->execute([$transit_user_id, $rosterRow['id']]);
|
||||
|
||||
transitCacheEnrollment($orgId, (string)$transit_user_id);
|
||||
|
||||
jsonSuccess([
|
||||
'enrollment_id' => (int)$rosterRow['id'],
|
||||
'org_name' => $org['name_ar'],
|
||||
|
||||
@@ -27,13 +27,18 @@ $transit_con->prepare(
|
||||
WHERE id=?"
|
||||
)->execute([$newStatus, $expiresAt, $enrollId]);
|
||||
|
||||
if ($action === 'approve' && $enroll['passenger_id']) {
|
||||
if ($enroll['passenger_id']) {
|
||||
if ($action === 'approve') {
|
||||
transitCacheEnrollment($transit_org_id, (string)$enroll['passenger_id']);
|
||||
transitSendTopicNotification(
|
||||
'passenger_' . $enroll['passenger_id'],
|
||||
'تم تفعيل عضويتك',
|
||||
'يمكنك الآن متابعة باصات جامعتك عبر مواصلاتي',
|
||||
['type' => 'transit_enrollment_approved']
|
||||
);
|
||||
} else {
|
||||
transitDropEnrollment($transit_org_id, (string)$enroll['passenger_id']);
|
||||
}
|
||||
}
|
||||
|
||||
jsonSuccess(['enrollment_id' => $enrollId, 'status' => $newStatus]);
|
||||
|
||||
@@ -196,6 +196,29 @@ function transitClearTripOwner(int $tripId): void
|
||||
$redisLocation->del("transit:trip:{$tripId}:owner");
|
||||
}
|
||||
|
||||
// ── عضوية الراكب في Redis (location Redis — بدون بادئة siro:) ──────
|
||||
// يستخدمها passenger_socket.php للتحقق قبل قبول subscribe_transit_route.
|
||||
// تُكتب عند: تفعيل عضوية برقم الكشف، أو موافقة المشرف على الطلب اليدوي.
|
||||
// تُحذف عند: رفض أو تعليق العضوية.
|
||||
|
||||
function transitCacheEnrollment(int $orgId, string $passengerId): void
|
||||
{
|
||||
global $redisLocation;
|
||||
if (!$redisLocation || !$passengerId) return;
|
||||
|
||||
$key = "transit:org_members:{$orgId}";
|
||||
$redisLocation->sAdd($key, $passengerId);
|
||||
$redisLocation->expire($key, 7 * 86400); // تجديد TTL سبعة أيام
|
||||
}
|
||||
|
||||
function transitDropEnrollment(int $orgId, string $passengerId): void
|
||||
{
|
||||
global $redisLocation;
|
||||
if (!$redisLocation || !$passengerId) return;
|
||||
|
||||
$redisLocation->sRem("transit:org_members:{$orgId}", $passengerId);
|
||||
}
|
||||
|
||||
// ── days_mask helpers ────────────────────────────────────────
|
||||
// bit0=Sun … bit6=Sat | 62 = 0b0111110 = الأحد–الخميس
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
$internalKey = getenv('SOCKET_INTERNAL_KEY') ?: '';
|
||||
$internalKey = getInternalSocketKey(); // نفس مفتاح النظام الموحّد INTERNAL_SOCKET_KEY
|
||||
$requestKey = $_SERVER['HTTP_X_INTERNAL_KEY'] ?? filterRequest('internal_key') ?? '';
|
||||
if (!$internalKey || !hash_equals($internalKey, $requestKey)) jsonError('Forbidden', 403);
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ if ($driverId) {
|
||||
|
||||
$transit_con->prepare(
|
||||
"INSERT INTO transit_schedules
|
||||
(route_id, org_id, vehicle_id, driver_id, days_mask, departure_time, valid_from, valid_until, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)"
|
||||
)->execute([$routeId, $transit_org_id, $vehicleId, $driverId, $daysMask, $departureTime, $validFrom, $validUntil, $transit_admin_id]);
|
||||
(route_id, org_id, vehicle_id, driver_id, days_mask, departure_time, valid_from, valid_until)
|
||||
VALUES (?,?,?,?,?,?,?,?)"
|
||||
)->execute([$routeId, $transit_org_id, $vehicleId, $driverId, $daysMask, $departureTime, $validFrom, $validUntil]);
|
||||
|
||||
jsonSuccess(['schedule_id' => (int)$transit_con->lastInsertId()], 'Schedule added');
|
||||
|
||||
@@ -198,7 +198,7 @@ CREATE TABLE IF NOT EXISTS `transit_trips` (
|
||||
`route_id` INT UNSIGNED NOT NULL,
|
||||
`org_id` INT UNSIGNED NOT NULL,
|
||||
`driver_id` INT UNSIGNED NOT NULL,
|
||||
`vehicle_id` INT UNSIGNED NOT NULL,
|
||||
`vehicle_id` INT UNSIGNED DEFAULT NULL COMMENT 'NULL إن كان الجدول بلا باص مخصص',
|
||||
`trip_date` DATE NOT NULL,
|
||||
`status` ENUM('scheduled','started','completed','cancelled','no_show') NOT NULL DEFAULT 'scheduled',
|
||||
`started_at` TIMESTAMP DEFAULT NULL,
|
||||
@@ -221,7 +221,7 @@ CREATE TABLE IF NOT EXISTS `transit_trips` (
|
||||
CREATE TABLE IF NOT EXISTS `transit_enrollments` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`org_id` INT UNSIGNED NOT NULL,
|
||||
`passenger_id` VARCHAR(100) NOT NULL COMMENT 'معرّف في جدول passengers الرئيسي',
|
||||
`passenger_id` VARCHAR(100) DEFAULT NULL COMMENT 'معرّف في جدول passengers الرئيسي — NULL لصفوف الكشف قبل تفعيل الطالب',
|
||||
`student_id` VARCHAR(50) NOT NULL COMMENT 'الرقم الجامعي/الوظيفي (encrypted)',
|
||||
`member_name` VARCHAR(120) DEFAULT NULL COMMENT 'الاسم من كشف الجامعة',
|
||||
`preferred_stop_id` INT UNSIGNED DEFAULT NULL COMMENT 'محطتي المفضلة',
|
||||
@@ -331,6 +331,14 @@ CREATE TABLE IF NOT EXISTS `transit_otp_log` (
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- ترحيل (Migration) لقاعدة أُنشئت قبل 2026-07-12 — نفّذ مرة واحدة:
|
||||
-- ALTER TABLE transit_enrollments MODIFY passenger_id VARCHAR(100) DEFAULT NULL
|
||||
-- COMMENT 'معرّف في جدول passengers الرئيسي — NULL لصفوف الكشف قبل تفعيل الطالب';
|
||||
-- ALTER TABLE transit_trips MODIFY vehicle_id INT UNSIGNED DEFAULT NULL
|
||||
-- COMMENT 'NULL إن كان الجدول بلا باص مخصص';
|
||||
-- -----------------------------------------------------------------
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- ملاحظات الـ Redis (keys تُكتب من PHP — ليست في SQL)
|
||||
-- transit:trip:{trip_id}:pos → آخر موقع GPS للباص (Hash: lat,lng,ts)
|
||||
|
||||
@@ -3,12 +3,21 @@
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
requireTransitFields(['trip_id', 'driver_transit_id', 'delay_minutes']);
|
||||
if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
|
||||
|
||||
$tripId = filterRequest('trip_id', 'int');
|
||||
$driverId = filterRequest('driver_transit_id', 'int');
|
||||
$delay = min((int)filterRequest('delay_minutes'), 120);
|
||||
$reason = filterRequest('reason');
|
||||
if (!$tripId || $delay <= 0) jsonError('trip_id and delay_minutes are required', 400);
|
||||
|
||||
// استخرج سجل السائق من JWT
|
||||
$driverRow = $transit_con->prepare(
|
||||
"SELECT id FROM transit_drivers WHERE main_driver_id=? AND status='active' LIMIT 1"
|
||||
);
|
||||
$driverRow->execute([(string)$transit_user_id]);
|
||||
$driverRec = $driverRow->fetch();
|
||||
if (!$driverRec) jsonError('Driver not found or not active', 403);
|
||||
$driverId = (int)$driverRec['id'];
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT t.id, t.route_id, r.name_ar AS route_name
|
||||
|
||||
@@ -3,10 +3,19 @@
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
requireTransitFields(['trip_id', 'driver_transit_id']);
|
||||
if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
|
||||
|
||||
$tripId = filterRequest('trip_id', 'int');
|
||||
$driverId = filterRequest('driver_transit_id', 'int');
|
||||
if (!$tripId) jsonError('trip_id is required', 400);
|
||||
|
||||
// استخرج سجل السائق من JWT
|
||||
$driverRow = $transit_con->prepare(
|
||||
"SELECT id FROM transit_drivers WHERE main_driver_id=? AND status='active' LIMIT 1"
|
||||
);
|
||||
$driverRow->execute([(string)$transit_user_id]);
|
||||
$driverRec = $driverRow->fetch();
|
||||
if (!$driverRec) jsonError('Driver not found or not active', 403);
|
||||
$driverId = (int)$driverRec['id'];
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT id, route_id, status FROM transit_trips WHERE id=? AND driver_id=? LIMIT 1"
|
||||
|
||||
@@ -19,7 +19,7 @@ if (!$tripId) jsonError('No active trip found', 404);
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT t.id, t.org_id, t.status, t.delay_minutes, t.current_stop_seq, t.started_at,
|
||||
r.name_ar AS route_name, sc.departure_time, d.name AS driver_name
|
||||
r.name_ar AS route_name, r.polyline AS route_polyline, sc.departure_time, d.name AS driver_name
|
||||
FROM transit_trips t
|
||||
JOIN transit_routes r ON r.id = t.route_id
|
||||
JOIN transit_schedules sc ON sc.id = t.schedule_id
|
||||
|
||||
@@ -3,18 +3,26 @@
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
requireTransitFields(['trip_id', 'driver_transit_id', 'lat', 'lng']);
|
||||
if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
|
||||
|
||||
requireTransitFields(['trip_id', 'lat', 'lng']);
|
||||
$tripId = filterRequest('trip_id', 'int');
|
||||
$driverId = filterRequest('driver_transit_id', 'int');
|
||||
$lat = (float)filterRequest('lat');
|
||||
$lng = (float)filterRequest('lng');
|
||||
|
||||
// استخرج سجل السائق من JWT — تحقق أن الرحلة تخصه
|
||||
$driverRow = $transit_con->prepare(
|
||||
"SELECT id FROM transit_drivers WHERE main_driver_id=? AND status='active' LIMIT 1"
|
||||
);
|
||||
$driverRow->execute([(string)$transit_user_id]);
|
||||
$driverRec = $driverRow->fetch();
|
||||
if (!$driverRec) jsonError('Driver not found or not active', 403);
|
||||
$driverId = (int)$driverRec['id'];
|
||||
|
||||
$st = $transit_con->prepare(
|
||||
"SELECT t.id, t.route_id, t.status, r.name_ar AS route_name, d.main_driver_id
|
||||
"SELECT t.id, t.route_id, t.status, r.name_ar AS route_name
|
||||
FROM transit_trips t
|
||||
JOIN transit_routes r ON r.id = t.route_id
|
||||
JOIN transit_drivers d ON d.id = t.driver_id
|
||||
WHERE t.id=? AND t.driver_id=? LIMIT 1"
|
||||
);
|
||||
$st->execute([$tripId, $driverId]);
|
||||
@@ -24,7 +32,6 @@ if (!$trip) jsonError('Trip not found', 404);
|
||||
if ($trip['status'] === 'started') jsonError('Trip already started', 409);
|
||||
if ($trip['status'] === 'completed') jsonError('Trip already completed', 409);
|
||||
if ($trip['status'] === 'cancelled') jsonError('Trip was cancelled', 409);
|
||||
if (!$trip['main_driver_id']) jsonError('Driver has no linked main account — cannot start live tracking', 422);
|
||||
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_trips SET status='started', started_at=NOW(), current_stop_seq=1 WHERE id=?"
|
||||
@@ -33,7 +40,8 @@ $transit_con->prepare(
|
||||
transitUpdateBusPosition($tripId, $lat, $lng);
|
||||
|
||||
// تخزين ملكية الرحلة — يتحقق منها driver_socket قبل أي بثّ حي للراكبين
|
||||
transitSetTripOwner($tripId, (string)$trip['main_driver_id'], (int)$trip['route_id']);
|
||||
// transit_user_id هو main_driver_id بالفعل (يُستخرج من JWT السائق)
|
||||
transitSetTripOwner($tripId, (string)$transit_user_id, (int)$trip['route_id']);
|
||||
|
||||
global $redis;
|
||||
if ($redis) {
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
|
||||
require_once __DIR__ . '/../../transit/connect_app.php';
|
||||
|
||||
$driverTransitId = filterRequest('driver_transit_id', 'int');
|
||||
if (!$driverTransitId) jsonError('driver_transit_id is required', 400);
|
||||
if ($transit_user_role !== 'driver') jsonError('Only drivers can access this endpoint', 403);
|
||||
|
||||
// استخرج سجل السائق من JWT — لا نثق بـ driver_transit_id من العميل
|
||||
$chk = $transit_con->prepare(
|
||||
"SELECT id, org_id FROM transit_drivers WHERE id=? AND status='active' LIMIT 1"
|
||||
"SELECT id, org_id FROM transit_drivers WHERE main_driver_id=? AND status='active' LIMIT 1"
|
||||
);
|
||||
$chk->execute([$driverTransitId]);
|
||||
$chk->execute([(string)$transit_user_id]);
|
||||
$driver = $chk->fetch();
|
||||
if (!$driver) jsonError('Driver not found or not active', 403);
|
||||
if (!$driver) jsonError('Driver not found or not active — activate your account first', 403);
|
||||
$driverTransitId = (int)$driver['id'];
|
||||
|
||||
$today = date('Y-m-d');
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
// transit/trip/update_position.php
|
||||
// ⚠️ مهجور (fallback فقط): المسار الأساسي لموقع الباص هو WebSocket:
|
||||
// سائق الباص يبعث socket.emit('update_bus_location', {...}) إلى driver_socket:2020
|
||||
// الذي يخزّن الموقع ويبثّه حياً لكل ركاب الخط عبر passenger_socket.
|
||||
// هذا الـ endpoint يكتب الموقع في Redis فقط ولا يبثّه للركاب — لا تستخدمه
|
||||
// إلا كخطة بديلة عند تعذّر السوكت. الموقع يُخزَّن على Redis سيرفر الموقع.
|
||||
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
try { $transit_con = Database::get('transit'); }
|
||||
catch (Exception $e) { http_response_code(503); exit; }
|
||||
|
||||
$tripId = filterRequest('trip_id', 'int');
|
||||
$driverId = filterRequest('driver_transit_id', 'int');
|
||||
$lat = (float)filterRequest('lat');
|
||||
$lng = (float)filterRequest('lng');
|
||||
$stopSeq = filterRequest('current_stop_seq', 'int');
|
||||
|
||||
if (!$tripId || !$driverId || !$lat || !$lng) {
|
||||
jsonError('Missing required fields', 400);
|
||||
}
|
||||
|
||||
transitUpdateBusPosition($tripId, $lat, $lng);
|
||||
|
||||
if ($stopSeq !== null) {
|
||||
$transit_con->prepare(
|
||||
"UPDATE transit_trips SET current_stop_seq=?, updated_at=NOW() WHERE id=? AND driver_id=?"
|
||||
)->execute([$stopSeq, $tripId, $driverId]);
|
||||
|
||||
global $redis;
|
||||
if ($redis) {
|
||||
$redis->publish("transit:trip:{$tripId}:stop", json_encode(['seq' => $stopSeq, 'ts' => time()]));
|
||||
}
|
||||
}
|
||||
|
||||
jsonSuccess(['ok' => true]);
|
||||
@@ -0,0 +1,181 @@
|
||||
# مراجعة شاملة لنظام مواصلاتي — 2026-07-12
|
||||
|
||||
دراسة كاملة للنظام عبر: الباك اند (`backend/transit/` + `backend/Admin/transit/`)، سيرفرات السوكيت (`loction_server/driver_socket.php` + `passenger_server/passenger_socket.php`)، والتطبيقات الثلاثة (siro_rider / siro_driver / siro_admin).
|
||||
|
||||
كل مهمة في القسم الأخير مكتوبة كأمر مستقل جاهز للتنفيذ على نموذج آخر — تحتوي المسارات والسياق الكافي.
|
||||
|
||||
---
|
||||
|
||||
## 1. جرد ما هو مبني وشغّال
|
||||
|
||||
### الباك اند — `backend/transit/` (بوابتان)
|
||||
|
||||
| البوابة | المصادقة | الملفات |
|
||||
|---|---|---|
|
||||
| `connect_app.php` | JWT الرئيسي (راكب/سائق) + RateLimiter | trip/*, enrollment/activate + my_enrollments, route/for_org, org/browse, driver/me, driver/activate, notification/subscribe |
|
||||
| `connect_admin.php` | Session token (هاتف + OTP) + CORS | admin/*, vehicle/*, driver/invite + list, route/add + get + list, schedule/add, enrollment/list + approve + import_roster, broadcast/send |
|
||||
|
||||
### الباك اند — `backend/Admin/transit/` (فريق سيرو، JWT admin/super_admin)
|
||||
`org/create` · `org/list` · `org/details` · `org/admins_list` · `org/admin_add` · `org/admin_toggle`
|
||||
|
||||
### سيرفرات السوكيت (إضافات فقط، لم يُمس منطق الرحلات)
|
||||
- سائق الباص: `update_bus_location` مع **تحقق ملكية الرحلة** من `transit:trip:{id}:owner` (يُكتب عند trip/start، يُحذف عند trip/end) — و`route_id` يُؤخذ حصرياً من الكاش الموثوق لا من العميل
|
||||
- الراكب: `subscribe_transit_route` / `unsubscribe_transit_route` + بثّ `bus_location_update` لغرفة `transit_route_{id}`
|
||||
- HTTP داخلي: `get_bus_position` (driver_socket) + `broadcast_bus_location` (passenger_socket)
|
||||
|
||||
### التطبيقات
|
||||
- **الراكب**: 4 شاشات (عضوياتي → تصفح مؤسسات وتفعيل بالرقم الجامعي → خطوط → خريطة حية بالسوكيت) + مدخل بالقائمة الجانبية + ترجمات ar_jo/ar_sy/ar_eg
|
||||
- **السائق**: شاشة رحلات اليوم (بدء/إنهاء/تأخير) + "وضع الباص" داخل LocationController (يحوّل البث لـ update_bus_location ويخرج من حوض الرحلات) + مدخل بالـ Drawer
|
||||
- **سيرو آدمن**: قائمة مؤسسات + إنشاء مؤسسة + تفاصيل وتحليلات + إدارة مشرفين (إضافة/تعليق مع إبطال جلسات فوري)
|
||||
|
||||
---
|
||||
|
||||
## 2. أخطاء حرجة أُصلحت في هذه الجلسة (2026-07-12)
|
||||
|
||||
| # | الخطأ | الملفات | الأثر قبل الإصلاح |
|
||||
|---|---|---|---|
|
||||
| 1 | تضارب تعريف `normalizePhone()` — fatal error | `Admin/rides/admin_get_rides_by_phone.php`, `Admin/rides/monitorRide.php`, `nabeh/resolve_user.php` | **ثلاثة endpoints إنتاجية معطلة تماماً** (منها تكامل نابه مع سيرفر الدفع) — حُذفت التعريفات المحلية لصالح الموحّدة في helpers.php |
|
||||
| 2 | `schedule/add.php` يُدرج عمود `created_by` غير موجود في `transit_schedules` | `transit/schedule/add.php` | إضافة أي جدول زمني = SQL error دائم |
|
||||
| 3 | `transit_enrollments.passenger_id NOT NULL` بينما استيراد الكشف يُدرج NULL | `schema_transit.sql` | استيراد كشف الطلاب معطّل بالكامل (+ ALTER للقاعدة القائمة بآخر الملف) |
|
||||
| 4 | `transit_trips.vehicle_id NOT NULL` بينما الجداول تسمح بجدول بلا باص | `schema_transit.sql` | إنشاء رحلات اليوم يفشل لأي جدول بدون مركبة (+ ALTER) |
|
||||
| 5 | `driver/list.php` يفك تشفير عمود `phone` غير مُحدد في SELECT | `transit/driver/list.php` | الهاتف لا يظهر أبداً في قائمة السائقين |
|
||||
| 6 | `org/register.php` يقرأ `SOCKET_INTERNAL_KEY` (اسم خاطئ) بدل `INTERNAL_SOCKET_KEY` | `transit/org/register.php` | التسجيل الداخلي يرفض دائماً 403 — الآن عبر `getInternalSocketKey()` |
|
||||
|
||||
نتائج فحص إضافية مطمئنة: `encryptData` **حتمي** (CBC بـ IV ثابت) فمطابقة الهاتف/الرقم الجامعي المشفّر تعمل. مفاتيح Redis متسقة (بادئة `siro:` للجلسات على المثيلين). مسارات AppLink بالتطبيقات الثلاثة تطابق مسارات الباك اند.
|
||||
|
||||
---
|
||||
|
||||
## 3. الثغرات الأمنية المتبقية (بالترتيب)
|
||||
|
||||
### أ. حرجة — IDOR في endpoints رحلات السائق
|
||||
`trip/today.php` و`trip/start.php` و`trip/end.php` و`trip/delay.php` تستقبل `driver_transit_id` من العميل **دون التحقق أن JWT المتصل هو صاحب هذا المعرّف**. أي مستخدم مسجّل (حتى راكب) يستطيع بدء/إنهاء/تأخير رحلات أي سائق باص بتخمين أرقام تسلسلية. السوكيت محمي بملكية الرحلة، لكن REST مكشوف.
|
||||
**الإصلاح**: في الملفات الأربعة — اشترط `$transit_user_role === 'driver'` ثم اجلب سجل السائق بـ `WHERE main_driver_id = $transit_user_id` وتجاهل `driver_transit_id` القادم من العميل نهائياً (أو تحقق من تطابقه).
|
||||
|
||||
### ب. حرجة وظيفياً — `main_driver_id` لا يُضبط أبداً
|
||||
`driver/invite.php` يقبله كحقل اختياري يدخله مشرف المؤسسة (لا يعرفه عملياً)، و`driver/activate.php` لا يضبطه. النتيجة: `driver/me.php` يرجع `is_bus_driver=false` للجميع، و`trip/start.php` يرفض بـ 422 — **مسار السائق كامل غير قابل للاستخدام end-to-end**.
|
||||
**الإصلاح المقترح**: نقطة تفعيل جديدة تعمل بـ JWT السائق (`connect_app.php` + role=driver): يرسل `invite_token` فقط، والباك اند يطابق التوكن ويضبط `main_driver_id = $transit_user_id` تلقائياً. هذا يحل (أ) و(ب) معاً ويلغي الحاجة لـ OTP في التفعيل.
|
||||
|
||||
### ج. عالية — التفعيل الحالي `driver/activate.php` بلا مصدر OTP
|
||||
يتحقق بـ `transitVerifyAdminOtp` لكن لا يوجد endpoint يرسل OTP لهاتف السائق (login_request يشترط وجوده في `transit_org_admins`). المسار ميت — يُستبدل بحل النقطة (ب).
|
||||
|
||||
### د. عالية — اشتراك سوكيت الراكب بلا تحقق عضوية
|
||||
`subscribe_transit_route` في passenger_socket يضم أي راكب مصادَق لأي غرفة خط دون التحقق من عضويته النشطة — تسريب مواقع الباصات لغير المشتركين (REST محمي، السوكيت لا).
|
||||
**الإصلاح**: عند التفعيل/الموافقة يكتب الباك اند عضوية الراكب في Redis (مثال `transit:route_members:{org_id}` Set على Redis الموقع)، والسوكيت يتحقق منها قبل `join`.
|
||||
|
||||
### هـ. متوسطة — لا Rate limiting على مسارات OTP والجلسات
|
||||
`admin/login_request.php` و`login_verify.php` و`connect_admin.php` تُحمّل bootstrap مباشرة بدون `RateLimiter->enforce()` — إغراق OTP (سبام واتساب) وتعداد هواتف المشرفين (رسالة 401 مميزة) ممكنان. مع OTP من 3 خانات (900 احتمال، قفل بعد 3 محاولات) يصبح الحد الإلزامي أهم.
|
||||
**الإصلاح**: أضف RateLimiter في الملفين + `connect_admin.php`، ووحّد رد login_request لغير الموجود (نفس رسالة النجاح دون إرسال).
|
||||
|
||||
### و. متوسطة — `trip/update_position.php` بلا أي مصادقة
|
||||
موثّق كـ fallback مهجور لكنه منشور ويكتب مواقع في Redis لأي طارق. **احذفه أو ضعه خلف JWT**.
|
||||
|
||||
### ز. منخفضة
|
||||
- جلسات `transit_sessions` المنتهية لا تُنظّف من MySQL (أضف لتنظيف الكرون الموجود)
|
||||
- لا endpoint لتسجيل خروج مشرف المؤسسة (حذف الجلسة)
|
||||
- رسائل أخطاء 4xx من `jsonError` لا تصل للتطبيقات — دوال CRUD في التطبيقات الثلاثة ترجع `'failure'` لأي 4xx (عدا 401) دون قراءة الرسالة → المستخدم يرى خطأ عام بدل "Enrollment already exists". قرار معماري: إما إرجاع 200 مع `status=failure` بمسارات transit، أو تعديل `_makeRequest` ليقرأ جسم 4xx
|
||||
|
||||
---
|
||||
|
||||
## 4. الفجوات الوظيفية لكل طرف
|
||||
|
||||
### مشرف المؤسسة (الأكبر — لا واجهة إطلاقاً)
|
||||
الـ API موجود لكن **لا توجد لوحة ويب**. راجع القسم 5 للمواصفات الكاملة. فجوات API نفسها:
|
||||
- **لا يوجد endpoint لاعتماد/تفعيل خط**: `route/add` ينشئ `status='draft'` والمخطط يقول الاعتماد لفريق سيرو (`approved_by`) — لكن لا endpoint في `Admin/transit/` ولا في `transit/` يحوّل draft→active. **بدونها الراكب لا يرى أي خط أبداً**
|
||||
- لا تعديل/حذف: route/update، stop إدارة مستقلة (مجلد `transit/stop/` فارغ)، schedule/list+delete، vehicle/update+delete، driver/suspend، org/profile update
|
||||
- لا إلغاء رحلة (enum فيه cancelled/no_show بلا endpoint)
|
||||
- لا قائمة كشوف مستوردة (rosters list) ولا سجل إعلانات (broadcasts list — موجود جزئياً في dashboard آخر 5)
|
||||
|
||||
### فريق سيرو (siro_admin)
|
||||
- اعتماد الخطوط (النقطة أعلاه) — endpoint + شاشة
|
||||
- إدارة حالة العقد (trial/active/suspended/terminated) — لا endpoint ولا UI
|
||||
- تعديل بيانات مؤسسة قائمة
|
||||
|
||||
### الراكب (siro_rider)
|
||||
- **اشتراك FCM Topic فعلي غائب**: التطبيق لا يستدعي `subscribeToTopic('transit_route_{id}')` ولا `transit_org_{id}` — إشعارات الانطلاق/التأخير/الإعلانات لا تصل لأحد
|
||||
- إشعار الموافقة على العضوية يُرسل لموضوع `passenger_{id}` والتطبيق يشترك فقط بموضوع `"passengers"` العام — لا يصل. الحل: اشتراك التطبيق بموضوعه الشخصي عند تسجيل الدخول، أو تغيير آلية الإرسال
|
||||
- الخريطة الحية لا ترسم polyline الخط (البيانات موجودة في `trip/live.php`؛ متوفر `PolylineUtils.decode` في intaleq_maps) ولا أيقونة باص مخصصة (ماركر hue فقط)
|
||||
- زر "فاتك الباص؟ اطلب سيارة" — الميزة التسويقية الأساسية من الرؤية، غير مبنية
|
||||
|
||||
### السائق (siro_driver)
|
||||
- **كشف الوصول للمحطات (geofence) غير مبني**: لا منطق يكتشف دخول نطاق محطة ويرسل `current_stop_seq` — تتبع تقدّم الباص على المحطات لا يعمل
|
||||
- خدمة الخلفية (`background_service.dart`) لها سوكيت مستقل غير واعٍ لوضع الباص — عند طيّ التطبيق أثناء رحلة باص قد يعود البث العادي (تلوث حوض الرحلات) ويتوقف بث الباص. تحتاج مراجعة وتمرير حالة وضع الباص
|
||||
- لا معالج deep link لرابط الدعوة `siromove.com/driver/transit-activate?token=` (يُحل ضمن مسار التفعيل الجديد بالنقطة 3-ب)
|
||||
- `trip/start` قد يرسل lat/lng = 0,0 إن لم يُلتقط الموقع بعد — أضف انتظار/تحقق
|
||||
|
||||
### النظام
|
||||
- كرون مقترح: تنظيف `transit_sessions` المنتهية + إنشاء رحلات الغد مسبقاً (اختياري — حالياً تُنشأ عند أول فتح للسائق) + تقارير أسبوعية للمؤسسات (مؤجل باتفاق)
|
||||
- `TRANSIT_STUDENT_ID_KEY` في `.env.example` لم يعد مستخدماً (نستخدم `$encryptionHelper` العام) — احذفه منعاً للالتباس
|
||||
|
||||
---
|
||||
|
||||
## 5. لوحة مشرف المؤسسة — المواصفات الكاملة
|
||||
|
||||
الأسئلة المطروحة: كيف يدخل المشرف؟ كيف يرسم الخطوط بتفاصيلها؟ كيف يديرها؟ هذه الإجابة الكاملة، مبنية على الـ API القائم:
|
||||
|
||||
**التقنية المقترحة**: ويب SPA (تُنشر على `transit.siromove.com` — CORS جاهز في `connect_admin.php`). كل الطلبات POST مع هيدر `Authorization: Bearer {session_token}`. الردود: `{status:'success', message:{...}}`.
|
||||
|
||||
### تدفق الدخول
|
||||
1. شاشة هاتف → `transit/admin/login_request.php` {phone} → OTP واتساب (3 خانات)
|
||||
2. شاشة رمز → `transit/admin/login_verify.php` {phone, otp} → `{token, expires_in, admin{}, org{}}` — يُخزن التوكن (صالح 24 ساعة)
|
||||
|
||||
### الشاشات (بترتيب البناء)
|
||||
1. **اليوم (Dashboard)** — `admin/dashboard.php`: رحلات اليوم بحالاتها + موقع حي لكل باص started + عدادات + آخر 5 إعلانات. تحديث كل 15 ثانية
|
||||
2. **الأسطول** — vehicles: قائمة `vehicle/list` + نموذج `vehicle/add` (plate, make, model, year, color, capacity, vehicle_type)
|
||||
3. **السائقون** — `driver/list` + دعوة `driver/invite` {name, phone, license_number} → واتساب تلقائي برابط تفعيل. حالات invited/active/suspended
|
||||
4. **الخطوط (الأهم)** — `route/list` + إنشاء بخريطة تفاعلية:
|
||||
- خريطة (Leaflet/MapLibre) ينقر عليها المشرف لإضافة المحطات بالترتيب؛ لكل محطة: name_ar، name_en، نصف قطر الجيوفينس (افتراضي 150م)، eta_offset_min، is_major
|
||||
- يرسم المسار (polyline مشفّر) بين المحطات — إما يدوياً أو عبر routes-osm القائم
|
||||
- حفظ → `route/add` {name_ar, name_en, direction, polyline, distance_km, duration_min, stops:JSON} → يُنشأ **draft** بانتظار اعتماد فريق سيرو
|
||||
- تفاصيل خط: `route/get` (محطات + جداول)
|
||||
5. **الجداول** — من شاشة الخط: `schedule/add` {route_id, departure_time, days_mask, driver_id?, vehicle_id?, valid_from, valid_until} — واجهة اختيار أيام أسبوع تبني الـ bitmask (bit0=أحد … bit6=سبت، 62=أحد–خميس)
|
||||
6. **الطلاب** — `enrollment/list` (فلترة بالحالة + ترقيم) + رفع كشف CSV `enrollment/import_roster` (أعمدة student_id, name) + موافقة/رفض `enrollment/approve`
|
||||
7. **الإعلانات** — `broadcast/send` {body_ar, title_ar, target_type: all|route, target_id}
|
||||
|
||||
### ما يجب إضافته للـ API قبل/أثناء بناء اللوحة
|
||||
route/update + route/submit (طلب اعتماد) · schedule/delete · vehicle/toggle · driver/suspend · trip/cancel · logout · rosters/list
|
||||
|
||||
---
|
||||
|
||||
## 6. تعليمات التنفيذ للنموذج الآخر — مهام مرتبة بالأولوية
|
||||
|
||||
كل بند أدناه Prompt مستقل. نفّذها بالترتيب؛ المهام 1–4 شرط لأي إطلاق تجريبي.
|
||||
|
||||
### المهمة 1 — إغلاق IDOR وربط حساب السائق (حرجة، باك اند + فلاتر)
|
||||
> في مشروع Siro: أصلح ثغرة IDOR في `backend/transit/trip/today.php` و`start.php` و`end.php` و`delay.php` — جميعها تثق بـ `driver_transit_id` من العميل. المطلوب: (1) في كل ملف اشترط `$transit_user_role === 'driver'` ثم استخرج سجل السائق بـ `SELECT id, org_id FROM transit_drivers WHERE main_driver_id = ? AND status='active'` باستخدام `$transit_user_id` من `connect_app.php`، وتجاهل `driver_transit_id` القادم من العميل. (2) أنشئ `backend/transit/driver/activate_by_app.php` يعمل عبر `connect_app.php` بدور driver: يستقبل `invite_token` فقط، يطابقه في `transit_drivers`، يضبط `main_driver_id = $transit_user_id` و`status='active'` و`activated_at=NOW()` ويصفّر التوكن — هذا يحل مشكلة أن `main_driver_id` لا يُضبط أبداً حالياً. (3) في `siro_driver/lib/controller/transit/transit_driver_service.dart` أضف دالة `activateByInviteToken` وأنشئ شاشة إدخال رمز الدعوة تُعرض داخل `transit_driver_home_page.dart` عندما `is_bus_driver=false`، مع معالجة deep link للمسار `siromove.com/driver/transit-activate?token=`. (4) احذف `backend/transit/driver/activate.php` القديم (مساره ميت — لا مصدر OTP له) وحدّث نص رسالة الواتساب في `driver/invite.php` ليوجّه لفتح تطبيق السائق. اتبع أنماط النظام: `filterRequest`, `jsonSuccess/jsonError`, `appLog`. لينت PHP وDart بعد كل تعديل.
|
||||
|
||||
### المهمة 2 — اعتماد الخطوط draft→active (حرجة، باك اند + سيرو آدمن)
|
||||
> في مشروع Siro: لا يوجد أي مسار يحوّل خط مواصلاتي من draft إلى active، فالراكب لا يرى الخطوط أبداً. المطلوب: (1) أنشئ `backend/Admin/transit/route/approve.php` بنمط ملفات `backend/Admin/transit/org/` (فحص `$role` admin/super_admin، `Database::get('transit')`): يستقبل route_id و action (approve|suspend|reject)، يحدّث `transit_routes.status` و`approved_by` (من `$user_id` بالـ JWT) و`approved_at`. (2) أنشئ `backend/Admin/transit/route/pending.php` يرجع كل خطوط draft عبر المؤسسات مع اسم المؤسسة وعدد المحطات. (3) في siro_admin أضف شاشة "اعتماد الخطوط" (`lib/views/transit/route_approval_page.dart` بنفس ثيم `org_list_page.dart` الداكن) تعرض المسودات مع محطاتها على خريطة مصغرة إن أمكن وزرّي اعتماد/رفض، واربطها من `admin_home_page.dart` فئة مواصلاتي، ووسّع `transit_admin_service.dart` و`transit_admin_controller.dart`. لينت كل شيء.
|
||||
|
||||
### المهمة 3 — تحقق العضوية في اشتراك السوكيت (حرجة، باك اند + سوكيت)
|
||||
> في مشروع Siro: `subscribe_transit_route` في `passenger_server/passenger_socket.php` يضم أي راكب لأي غرفة خط دون تحقق. المطلوب: (1) في `backend/transit/functions.php` أضف `transitCacheEnrollment(int $orgId, string $passengerId)` تكتب `SADD transit:org_members:{orgId}` على `$redisLocation` (بدون بادئة) مع TTL تجديدي 7 أيام، و`transitDropEnrollment` للحذف. استدعِ الإضافة عند تفعيل العضوية في `enrollment/activate.php` (الحالتان) وعند الموافقة في `enrollment/approve.php`، والحذف عند الرفض/التعليق. (2) أنشئ سكربت `backend/transit/cron_sync_members.php` يعيد بناء المجموعات من MySQL (للتشغيل اليدوي والكرون اليومي). (3) في `passenger_socket.php` عند `subscribe_transit_route` استعلم Redis المحلي: خذ org_id للخط من هاش جديد `transit:route_org:{routeId}` (اكتبه من `route/add.php` وapprove في المهمة 2) ثم `SISMEMBER transit:org_members:{orgId} passengerId` — ارفض الانضمام إن لم يكن عضواً وسجّل بالـ log. انتبه: سوكيت الراكب يتصل بـ Redis؟ إن لم يكن فيه اتصال Redis أضف واحداً بنمط `getRedis()` من `loction_server/driver_socket.php`. لينت PHP.
|
||||
|
||||
### المهمة 4 — Rate limiting ومصادقة المسارات المكشوفة (عالية، باك اند)
|
||||
> في مشروع Siro: (1) أضف `RateLimiter($redis)->enforce(RateLimiter::identifier(), 'api')` (كما في `backend/transit/connect_app.php`) إلى: `backend/transit/admin/login_request.php`، `login_verify.php`، و`backend/transit/connect_admin.php`. (2) في `login_request.php` وحّد الرد: عند هاتف غير موجود أعد نفس رسالة النجاح دون إرسال OTP (منع تعداد المشرفين). (3) احذف `backend/transit/trip/update_position.php` نهائياً (fallback مهجور بلا مصادقة — المسار الفعلي عبر السوكيت). (4) أنشئ `backend/transit/admin/logout.php` يحذف الجلسة من Redis (`siro:transit:session:{hash}`) وMySQL. (5) أضف تنظيف `DELETE FROM transit_sessions WHERE expires_at < NOW()` إلى سكربت كرون التنظيف القائم في الباك اند (ابحث عن كرونات التنظيف الموجودة واتبع نمطها). لينت.
|
||||
|
||||
### المهمة 5 — إشعارات FCM فعلية (عالية، فلاتر رايدر + باك اند)
|
||||
> في مشروع Siro: الإشعارات لا تصل لأن الاشتراك بالمواضيع غير مبني. المطلوب: (1) في `siro_rider/lib/controller/firebase/firbase_messge.dart` يوجد `subscribeToTopic("passengers")` — أضف بعده اشتراكاً بالموضوع الشخصي `passenger_{id}` من `box.read(BoxName.passengerID)`. (2) في `siro_rider/lib/controller/transit/transit_controller.dart`: عند نجاح `activateEnrollment` اشترك بـ `transit_org_{orgId}`، وفي `openLiveRoute`/زر جديد "تنبيهات الخط" اشترك بـ `transit_route_{routeId}` عبر `FirebaseMessaging.instance.subscribeToTopic` مع استدعاء `backend/transit/notification/subscribe.php` القائم لتسجيل المحطة المفضلة، ومع إلغاء الاشتراك المقابل. (3) تحقق أن معالج الرسائل الحالي يعرض إشعارات data type=transit_* عندما يكون التطبيق بالمقدمة. لينت Dart.
|
||||
|
||||
### المهمة 6 — لوحة مشرف المؤسسة الويب (كبيرة — قسّمها على جلسات)
|
||||
> في مشروع Siro: ابنِ لوحة ويب لمشرف المؤسسة حسب المواصفات في `docs/mawasalati_full_system_review.md` قسم 5. أنشئها في مجلد جديد `transit_dashboard/` بجذر المشروع (Vue 3 أو React + Vite، عربي RTL أساسي مع تبديل فاتح/داكن). الدخول: `POST {SERVER}/transit/admin/login_request.php` ثم `login_verify.php`، التوكن في `Authorization: Bearer`. ابدأ بجلسة أولى: هيكل المشروع + الدخول + Dashboard اليوم + قائمة المركبات والسائقين. الجلسة الثانية: منشئ الخطوط بالخريطة (MapLibre + نقر لإضافة محطات + حفظ route/add) والجداول (days_mask bitmask: bit0=أحد…bit6=سبت). الجلسة الثالثة: الطلاب (قائمة/كشف CSV/موافقات) والإعلانات. أضف أثناء ذلك endpoints الناقصة الموثقة نهاية قسم 5 بنفس أنماط `backend/transit/` (connect_admin, filterRequest, jsonSuccess).
|
||||
|
||||
### المهمة 7 — جيوفينس المحطات في تطبيق السائق (متوسطة)
|
||||
> في مشروع Siro: سائق الباص لا يكتشف وصوله للمحطات. في `siro_driver/lib/controller/transit/transit_driver_controller.dart` أضف منطقاً أثناء الرحلة النشطة: قارن موقع `LocationController.myLocation` (استمع عبر نفس تدفق التحديث) مع محطات `activeTrip.stops` — عند دخول نطاق `geofence_radius` لمحطة تسلسلها أعلى من الحالي، مرّر `currentStopSeq` الجديد إلى `emitBusLocationToSocket` (المعامل موجود جاهز في `location_controller.dart`) وحدّث الواجهة بشريط تقدم المحطات في `transit_driver_home_page.dart`. استخدم `geo.Geolocator.distanceBetween` الموجود. لا تستدعِ REST — السوكيت يخزّن التسلسل في Redis ويبثه.
|
||||
|
||||
### المهمة 8 — مراجعة خدمة الخلفية لوضع الباص (متوسطة، سائق)
|
||||
> في مشروع Siro: افحص `siro_driver/lib/controller/functions/background_service.dart` بالكامل: هل ينشئ سوكيتاً خاصاً ويبث `update_location` عندما يكون التطبيق بالخلفية؟ إن كان كذلك فعند وضع الباص (`LocationController.isBusMode`) سيلوث حوض الرحلات ويوقف بث الباص. مرّر حالة وضع الباص وبيانات الرحلة (trip_id/route_id — خزّنها في GetStorage box عند `setBusMode`) إلى الخدمة الخلفية وبدّل الحدث إلى `update_bus_location` بنفس الحمولة المستخدمة في `emitBusLocationToSocket`. اختبر السيناريو: بدء رحلة باص → طي التطبيق → التأكد من استمرار البث الصحيح.
|
||||
|
||||
### المهمة 9 — تحسينات خريطة الراكب (منخفضة)
|
||||
> في مشروع Siro، ملف `siro_rider/lib/views/transit/transit_live_map_page.dart`: (1) ارسم polyline الخط — الحقل `polyline` يأتي ضمن `trip/live.php`؟ تحقق؛ إن لم يكن أضفه للاستعلام في `backend/transit/trip/live.php` (من `transit_routes.polyline`)، ثم فكّه بـ `PolylineUtils.decode` من حزمة intaleq_maps وأضف `Polyline` للخريطة بلون `AppColor.accentColor`. (2) استخدم أيقونة باص مخصصة للماركر عبر `InlqBitmap.fromAsset` (أضف أصل `assets/images/bus.png` — انظر pubspec). (3) أضف بطاقة سفلية بمعلومات الرحلة (السائق، الانطلاق، المحطة الحالية باسمها بدل الرقم). (4) زر "فاتك الباص؟ اطلب سيارة الآن" يوجّه لطلب رحلة عادية من موقع المستخدم — نقطة التحويل التجارية الأساسية.
|
||||
|
||||
### المهمة 10 — إدارة العقود وتعديل المؤسسات (منخفضة، سيرو آدمن)
|
||||
> في مشروع Siro: أنشئ `backend/Admin/transit/org/update.php` (تعديل بيانات مؤسسة + `contract_status` مع تحقق enum) بنمط `org/create.php`، وعند suspended/terminated أبطل كل جلسات مشرفي المؤسسة (انظر منطق `org/admin_toggle.php`). في siro_admin أضف للشاشة `org_details_page.dart` قائمة إجراءات (تعليق/تفعيل/إنهاء عقد + تعديل الحقول) عبر `transit_admin_service.dart`. لينت.
|
||||
|
||||
---
|
||||
|
||||
## 7. تشيك ليست النشر (قبل أول تجربة حقيقية)
|
||||
|
||||
1. تنفيذ ALTERs الترحيل (نهاية `schema_transit.sql`) على `siroTransitDb` القائمة
|
||||
2. `.env` الإنتاج: قيم `DB_TRANSIT_*` + تأكيد `REDIS_LOCATION_HOST` مضبوط (وإلا لا يظهر موقع الباص إطلاقاً — نفس شرط كثافة السائقين)
|
||||
3. إعادة تشغيل `driver_socket.php` و`passenger_socket.php` بالنسخ المحدثة ومراقبة اللوج: رسائل `🚫 update_bus_location rejected` تعني مشكلة ملكية؛ `[TRANSIT]` بسوكيت الراكب تؤكد الاشتراكات
|
||||
4. جدولة كرون: تنظيف الجلسات + مزامنة أعضاء Redis (المهمة 3)
|
||||
5. اختبار دخاني كامل: إنشاء مؤسسة من سيرو آدمن → دخول مشرف OTP → مركبة + سائق (دعوة) → تفعيل السائق من تطبيقه (المهمة 1) → خط + محطات + جدول → اعتماد الخط من سيرو آدمن (المهمة 2) → كشف طلاب → تفعيل طالب بالرقم الجامعي → رؤية الخط → بدء الرحلة من السائق → الموقع الحي يصل للراكب → إنهاء الرحلة
|
||||
@@ -58,6 +58,30 @@ function getInternalSocketKey(): string {
|
||||
|
||||
$INTERNAL_KEY = getInternalSocketKey();
|
||||
|
||||
// ── Redis location server (للتحقق من عضوية الراكب في المسار) ──
|
||||
// نفس خادم Redis الذي يكتب عليه driver_socket وtransit/functions.php
|
||||
$_locationRedis = null;
|
||||
|
||||
function getLocationRedis(): ?\Redis {
|
||||
global $_locationRedis;
|
||||
if ($_locationRedis !== null) {
|
||||
try { $_locationRedis->ping(); return $_locationRedis; }
|
||||
catch (\Exception $_) { $_locationRedis = null; }
|
||||
}
|
||||
try {
|
||||
$passFile = '/home/location/.reds_pass_key';
|
||||
$redisPass = file_exists($passFile) ? trim((string)@file_get_contents($passFile)) : '';
|
||||
$r = new \Redis();
|
||||
$r->connect('127.0.0.1', 6379, 1.5);
|
||||
if ($redisPass) $r->auth($redisPass);
|
||||
$_locationRedis = $r;
|
||||
return $r;
|
||||
} catch (\Exception $e) {
|
||||
socket_log("[REDIS_ERROR] Location Redis unavailable: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getJwtSecret(): string {
|
||||
$keyPath = getenv('JWT_SECRET_KEY_PATH');
|
||||
if ($keyPath && file_exists($keyPath)) {
|
||||
@@ -207,11 +231,33 @@ $io->on('connection', function ($socket) {
|
||||
|
||||
// 🚌 اشتراك الراكب في بثّ موقع باص خط (مواصلاتي)
|
||||
// يُستدعى عند فتح الراكب لخط في قائمة مواصلاتي
|
||||
// يُرفض إن لم يكن الراكب عضواً نشطاً في المؤسسة المالكة للخط
|
||||
$socket->on('subscribe_transit_route', function ($data) use ($socket, $passengerId) {
|
||||
$data = (array) $data;
|
||||
$routeId = (int)($data['route_id'] ?? 0);
|
||||
if ($routeId <= 0) return;
|
||||
|
||||
// ── فحص العضوية عبر Redis ──────────────────────────────
|
||||
// transit:route_org:{routeId} → org_id (كُتب في route/approve.php)
|
||||
// transit:org_members:{orgId} → SET من passenger_ids (كُتب في transitCacheEnrollment)
|
||||
$redis = getLocationRedis();
|
||||
if ($redis) {
|
||||
$orgId = $redis->get("transit:route_org:{$routeId}");
|
||||
if ($orgId === false || $orgId === null) {
|
||||
socket_log("[TRANSIT_BLOCKED] Route #{$routeId} not in Redis — passenger #{$passengerId}");
|
||||
$socket->emit('transit_error', ['code' => 'ROUTE_NOT_FOUND', 'route_id' => $routeId]);
|
||||
return;
|
||||
}
|
||||
$isMember = $redis->sIsMember("transit:org_members:{$orgId}", (string)$passengerId);
|
||||
if (!$isMember) {
|
||||
socket_log("[TRANSIT_BLOCKED] Passenger #{$passengerId} not enrolled in org #{$orgId} (route #{$routeId})");
|
||||
$socket->emit('transit_error', ['code' => 'NOT_ENROLLED', 'route_id' => $routeId]);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
socket_log("[TRANSIT_WARN] Redis unavailable — enrollment check skipped (passenger #{$passengerId} route #{$routeId})");
|
||||
}
|
||||
|
||||
$socket->join('transit_route_' . $routeId);
|
||||
socket_log("[TRANSIT] Passenger #$passengerId subscribed to route #$routeId");
|
||||
});
|
||||
|
||||
@@ -131,4 +131,52 @@ class TransitAdminController extends GetxController {
|
||||
Get.snackbar('مواصلاتي', res.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── إدارة العقد ───────────────────────────────────────────────
|
||||
bool isUpdatingContract = false;
|
||||
|
||||
Future<bool> updateContractStatus(int orgId, String contractStatus) async {
|
||||
isUpdatingContract = true;
|
||||
update();
|
||||
final res = await TransitAdminService.updateContractStatus(
|
||||
orgId: orgId, contractStatus: contractStatus);
|
||||
isUpdatingContract = false;
|
||||
if (res.success) {
|
||||
await fetchOrgs();
|
||||
return true;
|
||||
}
|
||||
Get.snackbar('مواصلاتي', res.message);
|
||||
update();
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── اعتماد الخطوط ──────────────────────────────────────────────
|
||||
bool isLoadingPendingRoutes = false;
|
||||
List<Map<String, dynamic>> pendingRoutes = [];
|
||||
bool isApprovingRoute = false;
|
||||
|
||||
Future<void> fetchPendingRoutes({int? orgId}) async {
|
||||
isLoadingPendingRoutes = true;
|
||||
update();
|
||||
final res = await TransitAdminService.listPendingRoutes(orgId: orgId);
|
||||
if (res.success) pendingRoutes = res.data ?? [];
|
||||
isLoadingPendingRoutes = false;
|
||||
update();
|
||||
}
|
||||
|
||||
Future<bool> approveRoute(int routeId, String action) async {
|
||||
isApprovingRoute = true;
|
||||
update();
|
||||
final res = await TransitAdminService.approveRoute(
|
||||
routeId: routeId, action: action);
|
||||
isApprovingRoute = false;
|
||||
if (res.success) {
|
||||
pendingRoutes.removeWhere((r) => int.tryParse(r['id'].toString()) == routeId);
|
||||
update();
|
||||
return true;
|
||||
}
|
||||
Get.snackbar('مواصلاتي', res.message);
|
||||
update();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,4 +136,56 @@ class TransitAdminService {
|
||||
if (res is Map && res['status'] == 'success') return TransitApiResult(true, null, 'ok');
|
||||
return TransitApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
// ── اعتماد الخطوط ──────────────────────────────────────────────
|
||||
|
||||
static Future<TransitApiResult<List<Map<String, dynamic>>>> listPendingRoutes({
|
||||
int? orgId,
|
||||
}) async {
|
||||
final payload = <String, dynamic>{};
|
||||
if (orgId != null) payload['org_id'] = orgId.toString();
|
||||
|
||||
final res =
|
||||
await CRUD().post(link: '$_base/route/pending.php', payload: payload);
|
||||
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
|
||||
final msg = res['message'] as Map;
|
||||
final list = (msg['routes'] is List)
|
||||
? (msg['routes'] as List)
|
||||
.map((r) => Map<String, dynamic>.from(r))
|
||||
.toList()
|
||||
: <Map<String, dynamic>>[];
|
||||
return TransitApiResult(true, list, 'ok');
|
||||
}
|
||||
return TransitApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static Future<TransitApiResult<Map<String, dynamic>>> updateContractStatus({
|
||||
required int orgId,
|
||||
required String contractStatus, // active | trial | suspended | terminated
|
||||
}) async {
|
||||
final res = await CRUD().post(link: '$_base/org/update.php', payload: {
|
||||
'org_id': orgId.toString(),
|
||||
'contract_status': contractStatus,
|
||||
});
|
||||
if (res is Map && res['status'] == 'success') {
|
||||
return TransitApiResult(
|
||||
true, Map<String, dynamic>.from(res['message'] ?? {}), 'ok');
|
||||
}
|
||||
return TransitApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static Future<TransitApiResult<Map<String, dynamic>>> approveRoute({
|
||||
required int routeId,
|
||||
required String action, // approve | suspend | reject
|
||||
}) async {
|
||||
final res = await CRUD().post(link: '$_base/route/approve.php', payload: {
|
||||
'route_id': routeId.toString(),
|
||||
'action': action,
|
||||
});
|
||||
if (res is Map && res['status'] == 'success') {
|
||||
return TransitApiResult(
|
||||
true, Map<String, dynamic>.from(res['message'] ?? {}), 'ok');
|
||||
}
|
||||
return TransitApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,17 @@ class _TransitOrgDetailsPageState extends State<TransitOrgDetailsPage> {
|
||||
() => TransitOrgAdminsPage(orgId: widget.orgId, orgName: widget.orgName),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, color: AppColor.textSecondary),
|
||||
color: AppColor.surface,
|
||||
onSelected: (action) => _handleContractAction(action),
|
||||
itemBuilder: (_) => [
|
||||
_menuItem('active', 'تفعيل العقد', Icons.check_circle_outline, AppColor.success),
|
||||
_menuItem('trial', 'وضع تجريبي', Icons.science_outlined, AppColor.info),
|
||||
_menuItem('suspended', 'تعليق العقد', Icons.pause_circle_outline, AppColor.warning),
|
||||
_menuItem('terminated', 'إنهاء العقد', Icons.cancel_outlined, AppColor.danger),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: c.isLoadingDetails
|
||||
@@ -53,6 +64,74 @@ class _TransitOrgDetailsPageState extends State<TransitOrgDetailsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
PopupMenuItem<String> _menuItem(String value, String label, IconData icon, Color color) {
|
||||
return PopupMenuItem<String>(
|
||||
value: value,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 18),
|
||||
const SizedBox(width: 10),
|
||||
Text(label, style: TextStyle(color: color, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleContractAction(String contractStatus) async {
|
||||
final labels = {
|
||||
'active': 'تفعيل العقد',
|
||||
'trial': 'وضع تجريبي',
|
||||
'suspended': 'تعليق العقد',
|
||||
'terminated': 'إنهاء العقد',
|
||||
};
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
backgroundColor: AppColor.surface,
|
||||
title: Text(labels[contractStatus] ?? contractStatus,
|
||||
style: const TextStyle(color: AppColor.textPrimary)),
|
||||
content: Text(
|
||||
contractStatus == 'terminated'
|
||||
? 'سيتم إنهاء العقد وإبطال جميع جلسات المشرفين. هذا الإجراء خطير.'
|
||||
: 'هل أنت متأكد من تغيير حالة العقد إلى "${labels[contractStatus]}"؟',
|
||||
style: const TextStyle(color: AppColor.textSecondary),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('إلغاء', style: TextStyle(color: AppColor.textSecondary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(
|
||||
labels[contractStatus] ?? contractStatus,
|
||||
style: TextStyle(
|
||||
color: contractStatus == 'terminated' ? AppColor.danger : AppColor.accent,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm != true) return;
|
||||
|
||||
final c = Get.find<TransitAdminController>();
|
||||
final ok = await c.updateContractStatus(widget.orgId, contractStatus);
|
||||
if (ok && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('تم تحديث حالة العقد إلى: ${labels[contractStatus]}'),
|
||||
backgroundColor: contractStatus == 'terminated'
|
||||
? AppColor.danger
|
||||
: contractStatus == 'suspended'
|
||||
? AppColor.warning
|
||||
: AppColor.success,
|
||||
));
|
||||
c.loadOrgDetails(widget.orgId);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _content(TransitAdminController c) {
|
||||
final d = c.selectedOrgDetails!;
|
||||
final counts = d.counts;
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../controller/transit/transit_admin_controller.dart';
|
||||
import '../../controller/transit/transit_admin_models.dart';
|
||||
import 'org_create_page.dart';
|
||||
import 'org_details_page.dart';
|
||||
import 'route_approval_page.dart';
|
||||
|
||||
class TransitOrgListPage extends StatefulWidget {
|
||||
const TransitOrgListPage({super.key});
|
||||
@@ -34,6 +35,12 @@ class _TransitOrgListPageState extends State<TransitOrgListPage> {
|
||||
elevation: 0,
|
||||
title: const Text('مواصلاتي — المؤسسات', style: TextStyle(color: AppColor.textPrimary)),
|
||||
actions: [
|
||||
// زر اعتماد الخطوط
|
||||
IconButton(
|
||||
icon: const Icon(Icons.route_rounded, color: AppColor.warning),
|
||||
tooltip: 'اعتماد الخطوط',
|
||||
onPressed: () => Get.to(() => const RouteApprovalPage()),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add, color: AppColor.accent),
|
||||
onPressed: () async {
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
// route_approval_page.dart — اعتماد خطوط مواصلاتي المسودة (لوحة سيرو آدمن)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../constant/colors.dart';
|
||||
import '../../controller/transit/transit_admin_controller.dart';
|
||||
|
||||
class RouteApprovalPage extends StatefulWidget {
|
||||
const RouteApprovalPage({super.key});
|
||||
|
||||
@override
|
||||
State<RouteApprovalPage> createState() => _RouteApprovalPageState();
|
||||
}
|
||||
|
||||
class _RouteApprovalPageState extends State<RouteApprovalPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Get.find<TransitAdminController>().fetchPendingRoutes();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColor.bg,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColor.bg,
|
||||
elevation: 0,
|
||||
title: const Text(
|
||||
'مواصلاتي — اعتماد الخطوط',
|
||||
style: TextStyle(color: AppColor.textPrimary, fontWeight: FontWeight.bold),
|
||||
),
|
||||
iconTheme: const IconThemeData(color: AppColor.textPrimary),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh_rounded, color: AppColor.accent),
|
||||
onPressed: () =>
|
||||
Get.find<TransitAdminController>().fetchPendingRoutes(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: GetBuilder<TransitAdminController>(
|
||||
builder: (c) {
|
||||
if (c.isLoadingPendingRoutes) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColor.accent));
|
||||
}
|
||||
|
||||
if (c.pendingRoutes.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline_rounded,
|
||||
size: 64, color: AppColor.success.withOpacity(0.6)),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'لا توجد خطوط تنتظر الاعتماد',
|
||||
style: TextStyle(
|
||||
color: AppColor.textSecondary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: c.pendingRoutes.length,
|
||||
itemBuilder: (_, i) => _RouteCard(
|
||||
route: c.pendingRoutes[i],
|
||||
onApprove: (action) async {
|
||||
final routeId =
|
||||
int.tryParse(c.pendingRoutes[i]['id'].toString()) ?? 0;
|
||||
if (routeId == 0) return;
|
||||
final ok = await c.approveRoute(routeId, action);
|
||||
if (ok && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(action == 'approve'
|
||||
? 'تم اعتماد الخط ✓'
|
||||
: 'تم رفض الخط'),
|
||||
backgroundColor: action == 'approve'
|
||||
? AppColor.success
|
||||
: AppColor.danger,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteCard extends StatelessWidget {
|
||||
final Map<String, dynamic> route;
|
||||
final void Function(String action) onApprove;
|
||||
|
||||
const _RouteCard({required this.route, required this.onApprove});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stops = (route['stops'] is List) ? route['stops'] as List : [];
|
||||
final stopsCount =
|
||||
int.tryParse(route['stops_count']?.toString() ?? '0') ?? stops.length;
|
||||
final distKm = route['distance_km']?.toString() ?? '—';
|
||||
final durMin = route['duration_min']?.toString() ?? '—';
|
||||
final direction = route['direction']?.toString() ?? '';
|
||||
final orgName = route['org_name']?.toString() ?? '';
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColor.divider),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── هيدر الكارد ──────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.accentSoft,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(Icons.route_rounded,
|
||||
color: AppColor.accent, size: 24),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
route['name_ar']?.toString() ?? '',
|
||||
style: const TextStyle(
|
||||
color: AppColor.textPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15),
|
||||
),
|
||||
if (orgName.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(orgName,
|
||||
style: const TextStyle(
|
||||
color: AppColor.accent,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
_TagChip(
|
||||
label: direction == 'outbound' ? 'ذهاب' : 'إياب',
|
||||
color: direction == 'outbound'
|
||||
? AppColor.info
|
||||
: AppColor.warning,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── إحصائيات ─────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
_StatChip(
|
||||
icon: Icons.location_on_rounded,
|
||||
label: '$stopsCount محطة'),
|
||||
const SizedBox(width: 8),
|
||||
if (distKm != '—') _StatChip(icon: Icons.straighten_rounded, label: '$distKm كم'),
|
||||
const SizedBox(width: 8),
|
||||
if (durMin != '—') _StatChip(icon: Icons.timer_rounded, label: '$durMin د'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── قائمة المحطات ──────────────────────────────
|
||||
if (stops.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text('المحطات:',
|
||||
style: TextStyle(
|
||||
color: AppColor.textSecondary.withOpacity(0.8),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600)),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
itemCount: stops.length,
|
||||
separatorBuilder: (_, __) => const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Icon(Icons.arrow_forward_ios_rounded,
|
||||
size: 10, color: AppColor.textMuted),
|
||||
),
|
||||
itemBuilder: (_, i) => Text(
|
||||
stops[i]['name_ar']?.toString() ?? '•',
|
||||
style: const TextStyle(
|
||||
color: AppColor.textSecondary, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// ── أزرار الإجراءات ──────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => onApprove('reject'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: AppColor.danger),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
child: const Text('رفض',
|
||||
style: TextStyle(color: AppColor.danger)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => onApprove('approve'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColor.success,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
child: const Text('اعتماد الخط',
|
||||
style: TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TagChip extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
const _TagChip({required this.label, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(label,
|
||||
style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.bold)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
const _StatChip({required this.icon, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.surfaceElevated,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 12, color: AppColor.textMuted),
|
||||
const SizedBox(width: 4),
|
||||
Text(label,
|
||||
style: const TextStyle(color: AppColor.textSecondary, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -123,4 +123,10 @@ class BoxName {
|
||||
static const String registrationDate = "registrationDate";
|
||||
static const String recentLocations = 'recentLocations';
|
||||
static const String tripData = 'tripData';
|
||||
|
||||
// 🚌 مواصلاتي — وضع الباص (يُكتب من LocationController.setBusMode)
|
||||
// تقرأه background_service لتمييز أحداث السوكيت في الخلفية
|
||||
static const String isBusMode = 'isBusMode';
|
||||
static const String busModeTripId = 'busModeTripId';
|
||||
static const String busModeRouteId = 'busModeRouteId';
|
||||
}
|
||||
|
||||
@@ -78,6 +78,14 @@ Future<bool> onStart(ServiceInstance service) async {
|
||||
// 🔥 قراءة حالة التطبيق مباشرة قبل العرض
|
||||
await GetStorage.init(); // تأكد من تحديث البيانات
|
||||
final box = GetStorage();
|
||||
|
||||
// 🚌 في وضع الباص لا نعرض طلبات الرحلات — السائق في مهمة باص نشطة
|
||||
final bool inBusMode = box.read(BoxName.isBusMode) ?? false;
|
||||
if (inBusMode) {
|
||||
print("🚌 Bus mode active — ignoring new_ride_request in background.");
|
||||
return;
|
||||
}
|
||||
|
||||
bool isAppInForeground = box.read(BoxName.isAppInForeground) ?? false;
|
||||
|
||||
// 🔥 Check إضافي: هل الـ Overlay مفتوح بالفعل؟ (للأندرويد فقط)
|
||||
|
||||
@@ -22,6 +22,7 @@ import '../home/payment/captain_wallet_controller.dart';
|
||||
import '../home/navigation/navigation_controller.dart';
|
||||
import 'background_service.dart';
|
||||
import 'crud.dart';
|
||||
import '../transit/transit_driver_controller.dart';
|
||||
|
||||
class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
// ===================================================================
|
||||
@@ -56,6 +57,10 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
isBusMode = enabled;
|
||||
activeBusTripId = enabled ? tripId : null;
|
||||
activeBusRouteId = enabled ? routeId : null;
|
||||
// نحفظ الحالة في GetStorage ليقرأها background_service في الـ isolate المستقل
|
||||
box.write(BoxName.isBusMode, enabled);
|
||||
box.write(BoxName.busModeTripId, enabled ? tripId : null);
|
||||
box.write(BoxName.busModeRouteId, enabled ? routeId : null);
|
||||
Log.print('🚌 Bus mode ${enabled ? "enabled" : "disabled"} (trip: $tripId, route: $routeId)');
|
||||
}
|
||||
|
||||
@@ -572,6 +577,10 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
||||
// 🚌 وضع الباص: بثّ موقع الباص فقط — لا يدخل حوض الرحلات العادي
|
||||
if (isBusMode) {
|
||||
emitBusLocationToSocket(pos, heading, speed);
|
||||
// كشف الوصول للمحطات (جيوفينس)
|
||||
if (Get.isRegistered<TransitDriverController>()) {
|
||||
Get.find<TransitDriverController>().onLocationUpdate(pos);
|
||||
}
|
||||
await _saveBehaviorIfMoved(pos, now, currentSpeed: speed);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -741,33 +741,20 @@ class HomeCaptainController extends GetxController {
|
||||
"📍 [HomeCaptain] Location updated: ${myLocation.latitude}, ${myLocation.longitude}");
|
||||
|
||||
if (!CountryPolygons.isServiceSupported(myLocation)) {
|
||||
Get.bottomSheet(
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.location_off,
|
||||
size: 60, color: Colors.redAccent),
|
||||
const SizedBox(height: 15),
|
||||
Text(
|
||||
if (!Get.isSnackbarOpen) {
|
||||
Get.snackbar(
|
||||
'Alert'.tr,
|
||||
'service_unavailable_area'.tr,
|
||||
style: const TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
isDismissible: false,
|
||||
enableDrag: false,
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
backgroundColor: Colors.redAccent,
|
||||
colorText: Colors.white,
|
||||
icon: const Icon(Icons.location_off, color: Colors.white),
|
||||
margin: const EdgeInsets.all(15),
|
||||
duration: const Duration(seconds: 5),
|
||||
isDismissible: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print(
|
||||
"⚠️ [HomeCaptain] Could not get current location, using default.");
|
||||
|
||||
@@ -10,6 +10,11 @@ final Map<String, String> ar_eg = {
|
||||
"Report Delay": "الإبلاغ عن تأخير",
|
||||
"Number of minutes": "عدد الدقائق",
|
||||
"Send": "إرسال",
|
||||
"Activate Bus Driver Account": "تفعيل حساب سائق الباص",
|
||||
"Invite Token": "رمز الدعوة",
|
||||
"Paste the token from the WhatsApp message": "الصق الرمز من رسالة الواتساب",
|
||||
"Activate": "تفعيل",
|
||||
"Now at": "الآن في",
|
||||
" \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
||||
" \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
||||
" and acknowledge our Privacy Policy.": "وأوافق على سياسة الخصوصية.",
|
||||
|
||||
@@ -10,6 +10,11 @@ final Map<String, String> ar_jo = {
|
||||
"Report Delay": "الإبلاغ عن تأخير",
|
||||
"Number of minutes": "عدد الدقائق",
|
||||
"Send": "إرسال",
|
||||
"Activate Bus Driver Account": "تفعيل حساب سائق الباص",
|
||||
"Invite Token": "رمز الدعوة",
|
||||
"Paste the token from the WhatsApp message": "الصق الرمز من رسالة الواتساب",
|
||||
"Activate": "تفعيل",
|
||||
"Now at": "الآن في",
|
||||
" \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
||||
" \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
||||
" and acknowledge our Privacy Policy.": "وأوافق على سياسة الخصوصية الخاصة بنا.",
|
||||
|
||||
@@ -10,6 +10,11 @@ final Map<String, String> ar_sy = {
|
||||
"Report Delay": "الإبلاغ عن تأخير",
|
||||
"Number of minutes": "عدد الدقائق",
|
||||
"Send": "إرسال",
|
||||
"Activate Bus Driver Account": "تفعيل حساب سائق الباص",
|
||||
"Invite Token": "رمز الدعوة",
|
||||
"Paste the token from the WhatsApp message": "الصق الرمز من رسالة الواتساب",
|
||||
"Activate": "تفعيل",
|
||||
"Now at": "الآن في",
|
||||
" \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
||||
" \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}": " \\\${durationController.jsonData1['message'][0]['day'].toString().split('-')[1]}",
|
||||
" and acknowledge our Privacy Policy.": "وبوافق على سياسة الخصوصية.",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// transit_driver_controller.dart — تحكم وضع الباص (جهة السائق)
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart' show LatLng;
|
||||
|
||||
@@ -18,6 +19,13 @@ class TransitDriverController extends GetxController {
|
||||
TransitDriverTrip? activeTrip;
|
||||
bool isActionInProgress = false;
|
||||
|
||||
// ── حالة التفعيل ──────────────────────────────────────────────
|
||||
bool isActivating = false;
|
||||
String activationError = '';
|
||||
|
||||
// ── جيوفينس المحطات — آخر محطة تم الإعلان عنها ──────────────
|
||||
int _lastAnnouncedStopSeq = 0;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
@@ -41,18 +49,35 @@ class TransitDriverController extends GetxController {
|
||||
update();
|
||||
}
|
||||
|
||||
/// تفعيل الحساب بتوكن الدعوة
|
||||
Future<void> activateWithToken(String token) async {
|
||||
if (token.trim().isEmpty) return;
|
||||
isActivating = true;
|
||||
activationError = '';
|
||||
update();
|
||||
|
||||
final res = await TransitDriverService.activateByInviteToken(token.trim());
|
||||
if (res.success) {
|
||||
// أعد فحص الحالة بعد التفعيل
|
||||
await checkBusDriverStatus();
|
||||
} else {
|
||||
activationError = res.message;
|
||||
}
|
||||
isActivating = false;
|
||||
update();
|
||||
}
|
||||
|
||||
Future<void> fetchTodayTrips() async {
|
||||
if (driverTransitId == null) return;
|
||||
isLoadingTrips = true;
|
||||
update();
|
||||
|
||||
final res = await TransitDriverService.getTodayTrips(driverTransitId!);
|
||||
final res = await TransitDriverService.getTodayTrips();
|
||||
if (res.success) {
|
||||
todayTrips = res.data ?? [];
|
||||
final started = todayTrips.where((t) => t.status == 'started');
|
||||
activeTrip = started.isNotEmpty ? started.first : null;
|
||||
|
||||
// إن كانت هناك رحلة قيد التشغيل بالفعل (مثلاً بعد إعادة فتح التطبيق)، فعّل وضع الباص
|
||||
// إن كانت هناك رحلة قيد التشغيل بالفعل (إعادة فتح التطبيق)
|
||||
if (activeTrip != null && Get.isRegistered<LocationController>()) {
|
||||
Get.find<LocationController>().setBusMode(
|
||||
enabled: true,
|
||||
@@ -66,7 +91,7 @@ class TransitDriverController extends GetxController {
|
||||
}
|
||||
|
||||
Future<bool> startTrip(TransitDriverTrip trip) async {
|
||||
if (driverTransitId == null || isActionInProgress) return false;
|
||||
if (isActionInProgress) return false;
|
||||
isActionInProgress = true;
|
||||
update();
|
||||
|
||||
@@ -77,12 +102,12 @@ class TransitDriverController extends GetxController {
|
||||
|
||||
final res = await TransitDriverService.startTrip(
|
||||
tripId: trip.id,
|
||||
driverTransitId: driverTransitId!,
|
||||
lat: pos.latitude,
|
||||
lng: pos.longitude,
|
||||
);
|
||||
|
||||
if (res.success) {
|
||||
_lastAnnouncedStopSeq = 0;
|
||||
if (Get.isRegistered<LocationController>()) {
|
||||
Get.find<LocationController>().setBusMode(
|
||||
enabled: true,
|
||||
@@ -101,16 +126,14 @@ class TransitDriverController extends GetxController {
|
||||
}
|
||||
|
||||
Future<bool> endTrip(TransitDriverTrip trip) async {
|
||||
if (driverTransitId == null || isActionInProgress) return false;
|
||||
if (isActionInProgress) return false;
|
||||
isActionInProgress = true;
|
||||
update();
|
||||
|
||||
final res = await TransitDriverService.endTrip(
|
||||
tripId: trip.id,
|
||||
driverTransitId: driverTransitId!,
|
||||
);
|
||||
final res = await TransitDriverService.endTrip(tripId: trip.id);
|
||||
|
||||
if (res.success) {
|
||||
_lastAnnouncedStopSeq = 0;
|
||||
if (Get.isRegistered<LocationController>()) {
|
||||
Get.find<LocationController>().setBusMode(enabled: false);
|
||||
}
|
||||
@@ -124,15 +147,57 @@ class TransitDriverController extends GetxController {
|
||||
return res.success;
|
||||
}
|
||||
|
||||
Future<bool> reportDelay(TransitDriverTrip trip, int minutes, {String? reason}) async {
|
||||
if (driverTransitId == null) return false;
|
||||
Future<bool> reportDelay(TransitDriverTrip trip, int minutes,
|
||||
{String? reason}) async {
|
||||
final res = await TransitDriverService.reportDelay(
|
||||
tripId: trip.id,
|
||||
driverTransitId: driverTransitId!,
|
||||
delayMinutes: minutes,
|
||||
reason: reason,
|
||||
);
|
||||
if (!res.success) Get.snackbar('مواصلاتي', res.message);
|
||||
return res.success;
|
||||
}
|
||||
|
||||
// ── كشف الوصول للمحطات (جيوفينس) ────────────────────────────
|
||||
// تُستدعى من LocationController عند كل تحديث للموقع أثناء وضع الباص
|
||||
void onLocationUpdate(LatLng pos) {
|
||||
if (activeTrip == null || activeTrip!.stops.isEmpty) return;
|
||||
|
||||
final stops = activeTrip!.stops;
|
||||
int? nextSeq;
|
||||
|
||||
// الاتجاه للمحطة التالية بعد آخر معلنة
|
||||
for (final stop in stops) {
|
||||
if (stop.sequence <= _lastAnnouncedStopSeq) continue;
|
||||
final dist = Geolocator.distanceBetween(
|
||||
pos.latitude, pos.longitude,
|
||||
stop.latitude, stop.longitude,
|
||||
);
|
||||
if (dist <= stop.geofenceRadius) {
|
||||
nextSeq = stop.sequence;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextSeq != null && nextSeq > _lastAnnouncedStopSeq) {
|
||||
_lastAnnouncedStopSeq = nextSeq;
|
||||
// أرسل التسلسل الحالي عبر السوكيت
|
||||
if (Get.isRegistered<LocationController>()) {
|
||||
final lc = Get.find<LocationController>();
|
||||
lc.emitBusLocationToSocket(pos, 0, 0, currentStopSeq: nextSeq);
|
||||
}
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
// عودة بالمحطة الحالية للعرض
|
||||
TransitStop? get currentStop {
|
||||
if (activeTrip == null || _lastAnnouncedStopSeq == 0) return null;
|
||||
try {
|
||||
return activeTrip!.stops
|
||||
.firstWhere((s) => s.sequence == _lastAnnouncedStopSeq);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,29 +3,35 @@
|
||||
class TransitStopInfo {
|
||||
final int sequence;
|
||||
final String nameAr;
|
||||
final double lat;
|
||||
final double lng;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final double geofenceRadius; // متر
|
||||
final int? etaOffsetMin;
|
||||
|
||||
TransitStopInfo({
|
||||
required this.sequence,
|
||||
required this.nameAr,
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
this.geofenceRadius = 150,
|
||||
this.etaOffsetMin,
|
||||
});
|
||||
|
||||
factory TransitStopInfo.fromJson(Map<String, dynamic> j) => TransitStopInfo(
|
||||
sequence: int.tryParse(j['sequence'].toString()) ?? 0,
|
||||
nameAr: j['name_ar']?.toString() ?? '',
|
||||
lat: double.tryParse(j['latitude']?.toString() ?? '0') ?? 0,
|
||||
lng: double.tryParse(j['longitude']?.toString() ?? '0') ?? 0,
|
||||
latitude: double.tryParse(j['latitude']?.toString() ?? '0') ?? 0,
|
||||
longitude: double.tryParse(j['longitude']?.toString() ?? '0') ?? 0,
|
||||
geofenceRadius: double.tryParse(j['geofence_radius']?.toString() ?? '150') ?? 150,
|
||||
etaOffsetMin: j['eta_offset_min'] == null
|
||||
? null
|
||||
: int.tryParse(j['eta_offset_min'].toString()),
|
||||
);
|
||||
}
|
||||
|
||||
// alias للتوافق مع كود الجيوفينس
|
||||
typedef TransitStop = TransitStopInfo;
|
||||
|
||||
class TransitDriverTrip {
|
||||
final int id;
|
||||
final int routeId;
|
||||
|
||||
@@ -29,13 +29,23 @@ class TransitDriverService {
|
||||
return TransitApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
/// رحلات اليوم (تُنشأ تلقائياً من الجداول عند أول استدعاء)
|
||||
static Future<TransitApiResult<List<TransitDriverTrip>>> getTodayTrips(
|
||||
int driverTransitId) async {
|
||||
/// تفعيل حساب السائق بتوكن الدعوة (بعد IDOR fix لا نحتاج driver_transit_id)
|
||||
static Future<TransitApiResult<Map<String, dynamic>>> activateByInviteToken(
|
||||
String inviteToken) async {
|
||||
final res = await CRUD().post(
|
||||
link: '$_base/trip/today.php',
|
||||
payload: {'driver_transit_id': driverTransitId.toString()},
|
||||
link: '$_base/driver/activate_by_app.php',
|
||||
payload: {'invite_token': inviteToken},
|
||||
);
|
||||
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
|
||||
return TransitApiResult(
|
||||
true, Map<String, dynamic>.from(res['message']), 'ok');
|
||||
}
|
||||
return TransitApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
/// رحلات اليوم — الخادم يجلب السائق من JWT، لا نمرر driver_transit_id
|
||||
static Future<TransitApiResult<List<TransitDriverTrip>>> getTodayTrips() async {
|
||||
final res = await CRUD().post(link: '$_base/trip/today.php');
|
||||
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
|
||||
final msg = res['message'] as Map;
|
||||
final list = (msg['trips'] is List)
|
||||
@@ -50,7 +60,6 @@ class TransitDriverService {
|
||||
|
||||
static Future<TransitApiResult<Map<String, dynamic>>> startTrip({
|
||||
required int tripId,
|
||||
required int driverTransitId,
|
||||
required double lat,
|
||||
required double lng,
|
||||
}) async {
|
||||
@@ -58,7 +67,6 @@ class TransitDriverService {
|
||||
link: '$_base/trip/start.php',
|
||||
payload: {
|
||||
'trip_id': tripId.toString(),
|
||||
'driver_transit_id': driverTransitId.toString(),
|
||||
'lat': lat.toString(),
|
||||
'lng': lng.toString(),
|
||||
},
|
||||
@@ -71,14 +79,10 @@ class TransitDriverService {
|
||||
|
||||
static Future<TransitApiResult<Map<String, dynamic>>> endTrip({
|
||||
required int tripId,
|
||||
required int driverTransitId,
|
||||
}) async {
|
||||
final res = await CRUD().post(
|
||||
link: '$_base/trip/end.php',
|
||||
payload: {
|
||||
'trip_id': tripId.toString(),
|
||||
'driver_transit_id': driverTransitId.toString(),
|
||||
},
|
||||
payload: {'trip_id': tripId.toString()},
|
||||
);
|
||||
if (res is Map && res['status'] == 'success') {
|
||||
return TransitApiResult(true, Map<String, dynamic>.from(res['message'] ?? {}), 'ok');
|
||||
@@ -88,7 +92,6 @@ class TransitDriverService {
|
||||
|
||||
static Future<TransitApiResult<void>> reportDelay({
|
||||
required int tripId,
|
||||
required int driverTransitId,
|
||||
required int delayMinutes,
|
||||
String? reason,
|
||||
}) async {
|
||||
@@ -96,7 +99,6 @@ class TransitDriverService {
|
||||
link: '$_base/trip/delay.php',
|
||||
payload: {
|
||||
'trip_id': tripId.toString(),
|
||||
'driver_transit_id': driverTransitId.toString(),
|
||||
'delay_minutes': delayMinutes.toString(),
|
||||
if (reason != null) 'reason': reason,
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ class TransitDriverHomePage extends StatelessWidget {
|
||||
|
||||
return GetBuilder<TransitDriverController>(
|
||||
builder: (c) {
|
||||
// ── حالة التحميل ─────────────────────────────────────────
|
||||
if (c.isCheckingBusDriver) {
|
||||
return MyScafolld(
|
||||
title: 'Mawasalati'.tr,
|
||||
@@ -26,37 +27,22 @@ class TransitDriverHomePage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// ── حساب غير مفعّل — شاشة إدخال التوكن ─────────────────
|
||||
if (!c.isBusDriver) {
|
||||
return MyScafolld(
|
||||
title: 'Mawasalati'.tr,
|
||||
isleading: true,
|
||||
body: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'This account is not registered as a bus driver in any institution'.tr,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
return _ActivationPage(controller: c);
|
||||
}
|
||||
|
||||
// ── رحلات اليوم ──────────────────────────────────────────
|
||||
return MyScafolld(
|
||||
title: c.orgName,
|
||||
isleading: true,
|
||||
body: [
|
||||
Column(
|
||||
children: [
|
||||
// بانر المحطة الحالية إن كانت هناك رحلة نشطة
|
||||
if (c.activeTrip != null && c.currentStop != null)
|
||||
_CurrentStopBanner(stop: c.currentStop!),
|
||||
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: c.fetchTodayTrips,
|
||||
@@ -96,7 +82,8 @@ class TransitDriverHomePage extends StatelessWidget {
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: BorderSide(color: isStarted ? AppColor.greenColor : AppColor.borderColor,
|
||||
side: BorderSide(
|
||||
color: isStarted ? AppColor.greenColor : AppColor.borderColor,
|
||||
width: isStarted ? 1.5 : 1),
|
||||
),
|
||||
child: Padding(
|
||||
@@ -121,7 +108,8 @@ class TransitDriverHomePage extends StatelessWidget {
|
||||
if (trip.delayMinutes > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text('${'Delayed by'.tr} ${trip.delayMinutes} ${'min'.tr}',
|
||||
child: Text(
|
||||
'${'Delayed by'.tr} ${trip.delayMinutes} ${'min'.tr}',
|
||||
style: AppStyle.subtitle.copyWith(color: AppColor.yellowColor)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -178,3 +166,136 @@ class TransitDriverHomePage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── شاشة التفعيل بتوكن الدعوة ──────────────────────────────────────────────
|
||||
|
||||
class _ActivationPage extends StatelessWidget {
|
||||
final TransitDriverController controller;
|
||||
const _ActivationPage({required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokenCtrl = TextEditingController();
|
||||
|
||||
return MyScafolld(
|
||||
title: 'Mawasalati'.tr,
|
||||
isleading: true,
|
||||
body: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 40),
|
||||
Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.accentColor.withOpacity(0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.directions_bus_rounded,
|
||||
size: 46, color: AppColor.accentColor),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'This account is not registered as a bus driver in any institution'.tr,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.title,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Activate Bus Driver Account'.tr,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.subtitle,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
controller: tokenCtrl,
|
||||
textAlign: TextAlign.center,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Invite Token'.tr,
|
||||
hintText: 'Paste the token from the WhatsApp message'.tr,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 14),
|
||||
),
|
||||
),
|
||||
if (controller.activationError.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
controller.activationError,
|
||||
style: AppStyle.subtitle.copyWith(color: AppColor.redColor),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: controller.isActivating
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColor.accentColor,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: () =>
|
||||
controller.activateWithToken(tokenCtrl.text),
|
||||
child: Text(
|
||||
'Activate'.tr,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: controller.checkBusDriverStatus,
|
||||
child: Text('Refresh'.tr,
|
||||
style: AppStyle.subtitle.copyWith(color: AppColor.accentColor)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── بانر المحطة الحالية ──────────────────────────────────────────────────────
|
||||
|
||||
class _CurrentStopBanner extends StatelessWidget {
|
||||
final TransitStopInfo stop;
|
||||
const _CurrentStopBanner({required this.stop});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
color: AppColor.greenColor.withOpacity(0.12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.location_on_rounded, color: AppColor.greenColor, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${'Now at'.tr}: ${stop.nameAr}',
|
||||
style: AppStyle.subtitle.copyWith(
|
||||
color: AppColor.greenColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,11 +74,17 @@ class FirebaseMessagesController extends GetxController {
|
||||
// Log.print('fcmToken: ${token}');
|
||||
box.write(BoxName.tokenFCM, (token.toString()));
|
||||
});
|
||||
// 🔹 الاشتراك في topic
|
||||
await fcmToken
|
||||
.subscribeToTopic("passengers"); // أو "users" حسب نوع المستخدم
|
||||
// 🔹 الاشتراك في topic العام
|
||||
await fcmToken.subscribeToTopic("passengers");
|
||||
Log.print("Subscribed to 'passengers' topic ✅");
|
||||
|
||||
// اشتراك شخصي بـ passenger_{id} لاستقبال إشعارات العضوية في مواصلاتي
|
||||
final passengerId = box.read(BoxName.passengerID);
|
||||
if (passengerId != null && passengerId.toString().isNotEmpty) {
|
||||
await fcmToken.subscribeToTopic('passenger_${passengerId}');
|
||||
Log.print("Subscribed to 'passenger_${passengerId}' topic ✅");
|
||||
}
|
||||
|
||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
||||
// If the app is in the background or terminated, show a system tray message
|
||||
RemoteNotification? notification = message.notification;
|
||||
|
||||
@@ -297,31 +297,20 @@ class LocationSearchController extends GetxController {
|
||||
newMyLocation = passengerLocation;
|
||||
|
||||
if (!CountryPolygons.isServiceSupported(passengerLocation)) {
|
||||
Get.bottomSheet(
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.location_off, size: 60, color: Colors.redAccent),
|
||||
const SizedBox(height: 15),
|
||||
Text(
|
||||
if (!Get.isSnackbarOpen) {
|
||||
Get.snackbar(
|
||||
'Alert'.tr,
|
||||
'service_unavailable_area'.tr,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
isDismissible: false,
|
||||
enableDrag: false,
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
backgroundColor: Colors.redAccent,
|
||||
colorText: Colors.white,
|
||||
icon: const Icon(Icons.location_off, color: Colors.white),
|
||||
margin: const EdgeInsets.all(15),
|
||||
duration: const Duration(seconds: 5),
|
||||
isDismissible: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Get.isRegistered<RideLifecycleController>()) {
|
||||
final rideLifecycle = Get.find<RideLifecycleController>();
|
||||
|
||||
@@ -24,6 +24,7 @@ class MapSocketController extends GetxController {
|
||||
// 🚌 مواصلاتي — الخط الحالي المشترَك فيه + مستمع تحديث موقع الباص
|
||||
int? _subscribedTransitRouteId;
|
||||
void Function(Map<String, dynamic> data)? onBusLocationUpdate;
|
||||
void Function(String errorCode)? onTransitError;
|
||||
|
||||
DateTime? get lastDriverLocationTime => _lastSocketLocationTime;
|
||||
int get socketLocationUpdatesCount => _socketLocationUpdatesCount;
|
||||
@@ -154,6 +155,18 @@ class MapSocketController extends GetxController {
|
||||
Log.print('Error parsing bus_location_update: $e');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('transit_error', (data) {
|
||||
if (data == null) return;
|
||||
try {
|
||||
final map = Map<String, dynamic>.from(data as Map);
|
||||
final code = map['code']?.toString() ?? '';
|
||||
Log.print('⚠️ transit_error: $code');
|
||||
onTransitError?.call(code);
|
||||
} catch (e) {
|
||||
Log.print('Error parsing transit_error: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── مواصلاتي: اشتراك/إلغاء اشتراك ببثّ موقع خط ─────────────
|
||||
|
||||
@@ -21,6 +21,7 @@ final Map<String, String> ar_eg = {
|
||||
"Stops": "محطات",
|
||||
"No bus running on this route right now": "لا يوجد باص يعمل على هذا الخط الآن",
|
||||
"Bus is delayed": "الباص متأخر",
|
||||
"Missed the bus? Book a ride now": "فاتك الأتوبيس؟ احجز سيارة دلوقتي",
|
||||
" ')[0]).toString()}.\\n\${' I am using": " ')[0]).toString()}.\\n\${' I am using",
|
||||
" I am currently located at ": " أنا حالياً في ",
|
||||
" I am using": " أنا استخدم",
|
||||
|
||||
@@ -21,6 +21,7 @@ final Map<String, String> ar_jo = {
|
||||
"Stops": "محطات",
|
||||
"No bus running on this route right now": "لا يوجد باص يعمل على هذا الخط الآن",
|
||||
"Bus is delayed": "الباص متأخر",
|
||||
"Missed the bus? Book a ride now": "فاتك الباص؟ اطلب سيارة الآن",
|
||||
" ')[0]).toString()}.\\n\${' I am using": " ')[0]).toString()}.\\n\${' I am using",
|
||||
" I am currently located at ": " أنا حالياً في ",
|
||||
" I am using": " أنا استخدم",
|
||||
|
||||
@@ -21,6 +21,7 @@ final Map<String, String> ar_sy = {
|
||||
"Stops": "محطات",
|
||||
"No bus running on this route right now": "لا يوجد باص يعمل على هذا الخط الآن",
|
||||
"Bus is delayed": "الباص متأخر",
|
||||
"Missed the bus? Book a ride now": "فاتك الباص؟ اطلب سيارة هلق",
|
||||
" ')[0]).toString()}.\\n\${' I am using": " ')[0]).toString()}.\\n\${' I am using",
|
||||
" I am currently located at ": " أنا حالياً في ",
|
||||
" I am using": " أنا استخدم",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// transit_controller.dart — تحكم شاشات مواصلاتي (جهة الراكب)
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:get/get.dart';
|
||||
import '../home/map/map_socket_controller.dart';
|
||||
import '../../print.dart';
|
||||
@@ -55,12 +56,31 @@ class TransitController extends GetxController {
|
||||
);
|
||||
if (res.success) {
|
||||
await fetchMyEnrollments();
|
||||
// اشترك في topic المؤسسة لاستقبال إشعارات الانطلاق والتأخير
|
||||
try {
|
||||
await FirebaseMessaging.instance.subscribeToTopic('transit_org_$orgId');
|
||||
Log.print('Subscribed to transit_org_$orgId ✅');
|
||||
} catch (_) {}
|
||||
return true;
|
||||
}
|
||||
Get.snackbar('مواصلاتي', res.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
// اشتراك/إلغاء اشتراك في topic الخط المحدد
|
||||
Future<void> subscribeToRouteTopic(int routeId) async {
|
||||
try {
|
||||
await FirebaseMessaging.instance.subscribeToTopic('transit_route_$routeId');
|
||||
Log.print('Subscribed to transit_route_$routeId ✅');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> unsubscribeFromRouteTopic(int routeId) async {
|
||||
try {
|
||||
await FirebaseMessaging.instance.unsubscribeFromTopic('transit_route_$routeId');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> openOrgRoutes(int orgId) async {
|
||||
currentOrgId = orgId;
|
||||
isLoadingRoutes = true;
|
||||
@@ -101,6 +121,7 @@ class TransitController extends GetxController {
|
||||
if (Get.isRegistered<MapSocketController>()) {
|
||||
final ms = Get.find<MapSocketController>();
|
||||
ms.onBusLocationUpdate = _handleLiveBusUpdate;
|
||||
ms.onTransitError = _handleTransitError;
|
||||
ms.subscribeToTransitRoute(routeId);
|
||||
}
|
||||
}
|
||||
@@ -122,11 +143,23 @@ class TransitController extends GetxController {
|
||||
update();
|
||||
}
|
||||
|
||||
void _handleTransitError(String errorCode) {
|
||||
if (errorCode == 'NOT_ENROLLED') {
|
||||
liveError = 'غير مشترك في هذا الخط. فعّل عضويتك أولاً.';
|
||||
} else if (errorCode == 'ROUTE_NOT_FOUND') {
|
||||
liveError = 'الخط غير متاح حالياً.';
|
||||
} else {
|
||||
liveError = 'خطأ في الاتصال بالخط: $errorCode';
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
void closeLiveRoute() {
|
||||
if (liveRouteId != null && Get.isRegistered<MapSocketController>()) {
|
||||
final ms = Get.find<MapSocketController>();
|
||||
ms.unsubscribeFromTransitRoute(liveRouteId!);
|
||||
ms.onBusLocationUpdate = null;
|
||||
ms.onTransitError = null;
|
||||
}
|
||||
liveRouteId = null;
|
||||
liveTripId = null;
|
||||
|
||||
@@ -1,152 +1,3 @@
|
||||
// transit_live_map_page.dart — تتبع الباص الحي على الخريطة
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
|
||||
import '../../constant/colors.dart';
|
||||
import '../../constant/style.dart';
|
||||
import '../../env/env.dart';
|
||||
import '../../controller/transit/transit_controller.dart';
|
||||
import '../widgets/my_scafold.dart';
|
||||
|
||||
class TransitLiveMapPage extends StatefulWidget {
|
||||
final int routeId;
|
||||
final String routeName;
|
||||
const TransitLiveMapPage({super.key, required this.routeId, required this.routeName});
|
||||
|
||||
@override
|
||||
State<TransitLiveMapPage> createState() => _TransitLiveMapPageState();
|
||||
}
|
||||
|
||||
class _TransitLiveMapPageState extends State<TransitLiveMapPage> {
|
||||
IntaleqMapController? _mapController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Get.find<TransitController>().openLiveRoute(widget.routeId);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
Get.find<TransitController>().closeLiveRoute();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<TransitController>(
|
||||
builder: (c) {
|
||||
final stops = (c.liveTripData?['stops'] is List)
|
||||
? List<Map>.from(c.liveTripData!['stops'])
|
||||
: <Map>[];
|
||||
|
||||
final markers = <Marker>{};
|
||||
|
||||
for (final s in stops) {
|
||||
final lat = double.tryParse(s['latitude']?.toString() ?? '') ?? 0;
|
||||
final lng = double.tryParse(s['longitude']?.toString() ?? '') ?? 0;
|
||||
if (lat == 0 && lng == 0) continue;
|
||||
markers.add(Marker(
|
||||
markerId: MarkerId('stop_${s['id']}'),
|
||||
position: LatLng(lat, lng),
|
||||
infoWindow: InfoWindow(title: s['name_ar']?.toString() ?? ''),
|
||||
icon: InlqBitmap.defaultMarkerWithHue(
|
||||
(s['is_major']?.toString() ?? '0') == '1' ? 30 : 200),
|
||||
));
|
||||
}
|
||||
|
||||
LatLng? busLatLng;
|
||||
if (c.liveBusPosition != null) {
|
||||
busLatLng = LatLng(c.liveBusPosition!.lat, c.liveBusPosition!.lng);
|
||||
markers.add(Marker(
|
||||
markerId: const MarkerId('bus'),
|
||||
position: busLatLng,
|
||||
rotation: c.liveBusPosition!.heading,
|
||||
anchor: const Offset(0.5, 0.5),
|
||||
icon: InlqBitmap.defaultMarkerWithHue(120),
|
||||
));
|
||||
if (_mapController != null) {
|
||||
_mapController!.animateCamera(CameraUpdate.newLatLng(busLatLng));
|
||||
}
|
||||
}
|
||||
|
||||
final initialTarget = busLatLng ??
|
||||
(markers.isNotEmpty ? markers.first.position : const LatLng(31.95, 35.93));
|
||||
|
||||
return MyScafolld(
|
||||
title: widget.routeName,
|
||||
isleading: true,
|
||||
body: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
IntaleqMap(
|
||||
apiKey: Env.mapSaasKey,
|
||||
initialCameraPosition: CameraPosition(target: initialTarget, zoom: 14),
|
||||
markers: markers,
|
||||
onMapCreated: (ctrl) => _mapController = ctrl,
|
||||
),
|
||||
if (c.isLoadingLiveTrip)
|
||||
const Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Colors.black12,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
if (!c.isLoadingLiveTrip && c.liveError.isNotEmpty)
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: _statusBanner(c.liveError, AppColor.redColor),
|
||||
),
|
||||
if (!c.isLoadingLiveTrip &&
|
||||
c.liveError.isEmpty &&
|
||||
c.liveBusPosition == null)
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: _statusBanner(
|
||||
'No bus running on this route right now'.tr, AppColor.yellowColor),
|
||||
),
|
||||
if (c.liveTripData?['trip']?['delay_minutes'] != null &&
|
||||
(int.tryParse(c.liveTripData!['trip']['delay_minutes'].toString()) ?? 0) > 0)
|
||||
Positioned(
|
||||
bottom: 24,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: _statusBanner(
|
||||
'${'Bus is delayed'.tr} ${c.liveTripData!['trip']['delay_minutes']} ${'min'.tr}',
|
||||
AppColor.yellowColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statusBanner(String text, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border(right: BorderSide(color: color, width: 4)),
|
||||
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 6)],
|
||||
),
|
||||
child: Text(text, style: AppStyle.title.copyWith(color: color, fontWeight: FontWeight.bold)),
|
||||
);
|
||||
}
|
||||
}
|
||||
// transit_live_map_page.dart — محوّل إلى transit_map_page الجديدة
|
||||
// احتُفظ بهذا الملف للتوافق مع أي مرجع موجود
|
||||
export 'transit_map_page.dart';
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
// transit_map_page.dart — خريطة الباص الحي (مواصلاتي)
|
||||
// خريطة مستقلة داخل مجلد transit — لا تمس home_captain ولا passenger map
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
|
||||
import '../../constant/colors.dart';
|
||||
import '../../constant/style.dart';
|
||||
import '../../env/env.dart';
|
||||
import '../../controller/transit/transit_controller.dart';
|
||||
|
||||
// ── ألوان الصفحة ──────────────────────────────────────────────────────────────
|
||||
Color get _bg =>
|
||||
Get.isDarkMode ? const Color(0xFF0A0F1E) : const Color(0xFFF0F4FF);
|
||||
Color get _card =>
|
||||
Get.isDarkMode ? const Color(0xFF151C2E) : Colors.white;
|
||||
Color get _text =>
|
||||
Get.isDarkMode ? const Color(0xFFEEF2FF) : const Color(0xFF1E293B);
|
||||
Color get _sub =>
|
||||
Get.isDarkMode ? const Color(0xFF94A3B8) : const Color(0xFF64748B);
|
||||
const Color _accent = Color(0xFF2563EB);
|
||||
|
||||
class TransitMapPage extends StatefulWidget {
|
||||
final int routeId;
|
||||
final String routeName;
|
||||
|
||||
const TransitMapPage({super.key, required this.routeId, required this.routeName});
|
||||
|
||||
@override
|
||||
State<TransitMapPage> createState() => _TransitMapPageState();
|
||||
}
|
||||
|
||||
class _TransitMapPageState extends State<TransitMapPage> {
|
||||
IntaleqMapController? _mapCtrl;
|
||||
InlqBitmap _busIcon = InlqBitmap.defaultMarkerWithHue(120);
|
||||
bool _busIconLoaded = false;
|
||||
bool _cameraLocked = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
// تحميل أيقونة الباص من الأصول (bus.png: 499×499، size يضبط الحجم المرئي ~80dp)
|
||||
try {
|
||||
_busIcon = InlqBitmap.fromAsset(
|
||||
'assets/images/bus.png',
|
||||
size: 0.40,
|
||||
);
|
||||
_busIconLoaded = true;
|
||||
} catch (_) {
|
||||
_busIcon = InlqBitmap.defaultMarkerWithHue(120);
|
||||
}
|
||||
|
||||
// تحميل بيانات الرحلة + اشتراك السوكيت
|
||||
await Get.find<TransitController>().openLiveRoute(widget.routeId);
|
||||
// اشتراك FCM لتنبيهات الخط
|
||||
Get.find<TransitController>().subscribeToRouteTopic(widget.routeId);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
Get.find<TransitController>().closeLiveRoute();
|
||||
Get.find<TransitController>().unsubscribeFromRouteTopic(widget.routeId);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomPad = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: _bg,
|
||||
body: GetBuilder<TransitController>(
|
||||
builder: (c) {
|
||||
// ── بناء ماركرات المحطات والباص ─────────────────────────
|
||||
final stops = (c.liveTripData?['stops'] is List)
|
||||
? List<Map>.from(c.liveTripData!['stops'])
|
||||
: <Map>[];
|
||||
|
||||
final markers = <Marker>{};
|
||||
|
||||
// ماركر المحطات
|
||||
for (final s in stops) {
|
||||
final lat = double.tryParse(s['latitude']?.toString() ?? '') ?? 0;
|
||||
final lng = double.tryParse(s['longitude']?.toString() ?? '') ?? 0;
|
||||
if (lat == 0 && lng == 0) continue;
|
||||
final isMajor = (s['is_major']?.toString() ?? '0') == '1';
|
||||
markers.add(Marker(
|
||||
markerId: MarkerId('stop_${s['id']}'),
|
||||
position: LatLng(lat, lng),
|
||||
infoWindow: InfoWindow(title: s['name_ar']?.toString() ?? ''),
|
||||
icon: InlqBitmap.defaultMarkerWithHue(isMajor ? 30 : 200),
|
||||
zIndex: 1,
|
||||
));
|
||||
}
|
||||
|
||||
// ماركر الباص — أيقونة مخصصة 80×80 بدون خلفية
|
||||
LatLng? busPos;
|
||||
if (c.liveBusPosition != null) {
|
||||
busPos = LatLng(c.liveBusPosition!.lat, c.liveBusPosition!.lng);
|
||||
markers.add(Marker(
|
||||
markerId: const MarkerId('transit_bus'),
|
||||
position: busPos,
|
||||
rotation: c.liveBusPosition!.heading,
|
||||
anchor: const Offset(0.5, 0.5),
|
||||
flat: true,
|
||||
zIndex: 3,
|
||||
icon: _busIconLoaded ? _busIcon : InlqBitmap.defaultMarkerWithHue(120),
|
||||
));
|
||||
|
||||
// تتبع الكاميرا للباص
|
||||
if (_cameraLocked && _mapCtrl != null) {
|
||||
_mapCtrl!.animateCamera(CameraUpdate.newLatLng(busPos));
|
||||
}
|
||||
}
|
||||
|
||||
// ── polyline الخط ──────────────────────────────────────
|
||||
final polylines = <Polyline>{};
|
||||
final encodedPolyline =
|
||||
c.liveTripData?['trip']?['route_polyline']?.toString() ?? '';
|
||||
if (encodedPolyline.isNotEmpty) {
|
||||
final coords = PolylineUtils.decode(encodedPolyline);
|
||||
if (coords.isNotEmpty) {
|
||||
polylines.add(Polyline(
|
||||
polylineId: const PolylineId('transit_route'),
|
||||
points: coords,
|
||||
color: _accent.withOpacity(0.7),
|
||||
width: 4,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
final initialTarget = busPos ??
|
||||
(markers.isNotEmpty
|
||||
? markers.first.position
|
||||
: const LatLng(31.95, 35.93));
|
||||
|
||||
// ── معلومات البطاقة السفلية ────────────────────────────
|
||||
final trip = c.liveTripData?['trip'];
|
||||
final driverName = trip?['driver_name']?.toString() ?? '';
|
||||
final departure = trip?['departure_time']?.toString() ?? '';
|
||||
final delayMin =
|
||||
int.tryParse(trip?['delay_minutes']?.toString() ?? '0') ?? 0;
|
||||
final currentStopSeq = c.liveBusPosition?.currentStopSeq;
|
||||
final currentStopName = currentStopSeq != null && stops.isNotEmpty
|
||||
? stops
|
||||
.where((s) =>
|
||||
int.tryParse(s['sequence']?.toString() ?? '') ==
|
||||
currentStopSeq)
|
||||
.map((s) => s['name_ar']?.toString() ?? '')
|
||||
.firstOrNull
|
||||
: null;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// ── 1. الخريطة ─────────────────────────────────────
|
||||
Listener(
|
||||
onPointerDown: (_) {
|
||||
if (_cameraLocked) setState(() => _cameraLocked = false);
|
||||
},
|
||||
child: IntaleqMap(
|
||||
apiKey: Env.mapSaasKey,
|
||||
initialCameraPosition:
|
||||
CameraPosition(target: initialTarget, zoom: 14.5),
|
||||
markers: markers,
|
||||
polylines: polylines,
|
||||
onMapCreated: (ctrl) => _mapCtrl = ctrl,
|
||||
mapType: Get.isDarkMode
|
||||
? IntaleqMapType.normal
|
||||
: IntaleqMapType.light,
|
||||
myLocationEnabled: true,
|
||||
),
|
||||
),
|
||||
|
||||
// ── 2. شريط العنوان ────────────────────────────────
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 8,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: Row(
|
||||
children: [
|
||||
// زر الرجوع
|
||||
GestureDetector(
|
||||
onTap: () => Get.back(),
|
||||
child: Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: _card,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.12),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3))
|
||||
],
|
||||
),
|
||||
child: Icon(Icons.arrow_back_ios_rounded,
|
||||
color: _text, size: 18),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// اسم الخط
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _card,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3))
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: c.liveBusPosition != null
|
||||
? Colors.greenAccent.shade400
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.routeName,
|
||||
style: AppStyle.title.copyWith(
|
||||
fontSize: 14,
|
||||
color: _text,
|
||||
fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// زر إعادة تثبيت الكاميرا
|
||||
if (!_cameraLocked)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() => _cameraLocked = true);
|
||||
if (busPos != null && _mapCtrl != null) {
|
||||
_mapCtrl!.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(busPos!, 15));
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: _accent,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _accent.withOpacity(0.4),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3))
|
||||
],
|
||||
),
|
||||
child: const Icon(Icons.my_location_rounded,
|
||||
color: Colors.white, size: 20),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── 3. بانر التأخير ────────────────────────────────
|
||||
if (delayMin > 0)
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 66,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: _statusBanner(
|
||||
'🕐 ${'Bus is delayed'.tr} $delayMin ${'min'.tr}',
|
||||
const Color(0xFFF59E0B),
|
||||
),
|
||||
),
|
||||
|
||||
// ── 4. حالة التحميل أو الخطأ ─────────────────────
|
||||
if (c.isLoadingLiveTrip)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Colors.black26,
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
|
||||
if (!c.isLoadingLiveTrip && c.liveError.isNotEmpty)
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 70,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: _statusBanner(c.liveError, Colors.redAccent),
|
||||
),
|
||||
|
||||
if (!c.isLoadingLiveTrip &&
|
||||
c.liveError.isEmpty &&
|
||||
c.liveBusPosition == null &&
|
||||
!c.isLoadingLiveTrip)
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 70,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: _statusBanner(
|
||||
'No bus running on this route right now'.tr,
|
||||
const Color(0xFFF59E0B),
|
||||
),
|
||||
),
|
||||
|
||||
// ── 5. بطاقة المعلومات السفلية ─────────────────────
|
||||
if (!c.isLoadingLiveTrip && trip != null)
|
||||
Positioned(
|
||||
bottom: bottomPad + 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
child: _InfoCard(
|
||||
driverName: driverName,
|
||||
departure: departure,
|
||||
currentStopName: currentStopName,
|
||||
totalStops: stops.length,
|
||||
currentStopSeq: currentStopSeq,
|
||||
onMissedBus: () {
|
||||
// CTA: فاتك الباص؟ اطلب رحلة
|
||||
Get.back();
|
||||
Get.toNamed('/home');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statusBanner(String text, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _card,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border(right: BorderSide(color: color, width: 4)),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withOpacity(0.08), blurRadius: 8)
|
||||
],
|
||||
),
|
||||
child: Text(text,
|
||||
style: AppStyle.subtitle.copyWith(
|
||||
color: color, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── بطاقة معلومات الرحلة ────────────────────────────────────────────────────
|
||||
|
||||
class _InfoCard extends StatelessWidget {
|
||||
final String driverName;
|
||||
final String departure;
|
||||
final String? currentStopName;
|
||||
final int totalStops;
|
||||
final int? currentStopSeq;
|
||||
final VoidCallback onMissedBus;
|
||||
|
||||
const _InfoCard({
|
||||
required this.driverName,
|
||||
required this.departure,
|
||||
required this.currentStopName,
|
||||
required this.totalStops,
|
||||
required this.currentStopSeq,
|
||||
required this.onMissedBus,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _card,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.12),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, -4))
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ── صف المعلومات ─────────────────────────────
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: _accent.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(Icons.directions_bus_rounded,
|
||||
color: _accent, size: 26),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (driverName.isNotEmpty)
|
||||
Text(driverName,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: _text)),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
if (departure.isNotEmpty) ...[
|
||||
Icon(Icons.schedule_rounded, size: 13, color: _sub),
|
||||
const SizedBox(width: 3),
|
||||
Text(departure,
|
||||
style: TextStyle(fontSize: 12, color: _sub)),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
if (currentStopSeq != null) ...[
|
||||
Icon(Icons.location_on_rounded,
|
||||
size: 13, color: Colors.greenAccent.shade400),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
currentStopName ??
|
||||
'${'Stop'.tr} $currentStopSeq',
|
||||
style:
|
||||
TextStyle(fontSize: 12, color: _sub),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// عداد المحطات
|
||||
if (totalStops > 0 && currentStopSeq != null)
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
'$currentStopSeq/$totalStops',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: _accent),
|
||||
),
|
||||
Text('Stops'.tr,
|
||||
style: TextStyle(fontSize: 10, color: _sub)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// ── شريط التقدم بين المحطات ─────────────────
|
||||
if (totalStops > 0 && currentStopSeq != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: currentStopSeq! / totalStops,
|
||||
backgroundColor: _accent.withOpacity(0.12),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(_accent),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── زر "فاتك الباص؟" ─────────────────────────
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onMissedBus,
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: _accent.withOpacity(0.5)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
),
|
||||
icon: const Icon(Icons.local_taxi_rounded, size: 18),
|
||||
label: Text(
|
||||
'Missed the bus? Book a ride now'.tr,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import '../../constant/colors.dart';
|
||||
import '../../constant/style.dart';
|
||||
import '../../controller/transit/transit_controller.dart';
|
||||
import '../widgets/my_scafold.dart';
|
||||
import 'transit_live_map_page.dart';
|
||||
import 'transit_map_page.dart';
|
||||
|
||||
class TransitRoutesPage extends StatefulWidget {
|
||||
final int orgId;
|
||||
@@ -64,7 +64,7 @@ class _TransitRoutesPageState extends State<TransitRoutesPage> {
|
||||
subtitle: Text('${r.stopCount} ${'Stops'.tr}', style: AppStyle.subtitle),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: () => Get.to(
|
||||
() => TransitLiveMapPage(routeId: r.id, routeName: r.nameAr),
|
||||
() => TransitMapPage(routeId: r.id, routeName: r.nameAr),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user