From 653d73422f7f3c9b4f9fdcb7015a2696f14dd338 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sun, 12 Jul 2026 18:53:59 +0300 Subject: [PATCH] Update: 2026-07-12 18:53:59 --- .../Admin/rides/admin_get_rides_by_phone.php | 19 +- backend/Admin/rides/monitorRide.php | 19 +- backend/Admin/transit/org/update.php | 75 +++ backend/Admin/transit/route/approve.php | 58 ++ backend/Admin/transit/route/pending.php | 48 ++ backend/aggregate_files.py | 40 -- backend/nabeh/resolve_user.php | 18 +- backend/transit/admin/login_request.php | 13 +- backend/transit/admin/login_verify.php | 4 + backend/transit/admin/logout.php | 34 ++ backend/transit/connect_admin.php | 4 + backend/transit/cron_sync_members.php | 67 +++ backend/transit/driver/activate_by_app.php | 61 +++ backend/transit/driver/list.php | 2 +- backend/transit/enrollment/activate.php | 2 + backend/transit/enrollment/approve.php | 19 +- backend/transit/functions.php | 23 + backend/transit/org/register.php | 2 +- backend/transit/schedule/add.php | 6 +- backend/transit/schema_transit.sql | 12 +- backend/transit/trip/delay.php | 19 +- backend/transit/trip/end.php | 15 +- backend/transit/trip/live.php | 2 +- backend/transit/trip/start.php | 28 +- backend/transit/trip/today.php | 11 +- backend/transit/trip/update_position.php | 38 -- docs/mawasalati_full_system_review.md | 181 +++++++ passenger_server/passenger_socket.php | 46 ++ .../transit/transit_admin_controller.dart | 48 ++ .../transit/transit_admin_service.dart | 52 ++ .../lib/views/transit/org_details_page.dart | 79 +++ .../lib/views/transit/org_list_page.dart | 7 + .../views/transit/route_approval_page.dart | 310 +++++++++++ siro_driver/lib/constant/box_name.dart | 6 + .../functions/background_service.dart | 8 + .../functions/location_controller.dart | 9 + .../home/captin/home_captain_controller.dart | 39 +- siro_driver/lib/controller/local/ar_eg.dart | 5 + siro_driver/lib/controller/local/ar_jo.dart | 5 + siro_driver/lib/controller/local/ar_sy.dart | 5 + .../transit/transit_driver_controller.dart | 91 +++- .../transit/transit_driver_models.dart | 18 +- .../transit/transit_driver_service.dart | 30 +- .../transit/transit_driver_home_page.dart | 169 +++++- .../controller/firebase/firbase_messge.dart | 12 +- .../home/map/location_search_controller.dart | 37 +- .../home/map/map_socket_controller.dart | 13 + siro_rider/lib/controller/local/ar_eg.dart | 1 + siro_rider/lib/controller/local/ar_jo.dart | 1 + siro_rider/lib/controller/local/ar_sy.dart | 1 + .../transit/transit_controller.dart | 33 ++ .../views/transit/transit_live_map_page.dart | 155 +----- .../lib/views/transit/transit_map_page.dart | 510 ++++++++++++++++++ .../views/transit/transit_routes_page.dart | 4 +- 54 files changed, 2078 insertions(+), 436 deletions(-) create mode 100644 backend/Admin/transit/org/update.php create mode 100644 backend/Admin/transit/route/approve.php create mode 100644 backend/Admin/transit/route/pending.php delete mode 100644 backend/aggregate_files.py create mode 100644 backend/transit/admin/logout.php create mode 100644 backend/transit/cron_sync_members.php create mode 100644 backend/transit/driver/activate_by_app.php delete mode 100644 backend/transit/trip/update_position.php create mode 100644 docs/mawasalati_full_system_review.md create mode 100644 siro_admin/lib/views/transit/route_approval_page.dart create mode 100644 siro_rider/lib/views/transit/transit_map_page.dart diff --git a/backend/Admin/rides/admin_get_rides_by_phone.php b/backend/Admin/rides/admin_get_rides_by_phone.php index 121f2401..3b106dc3 100644 --- a/backend/Admin/rides/admin_get_rides_by_phone.php +++ b/backend/Admin/rides/admin_get_rides_by_phone.php @@ -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) { diff --git a/backend/Admin/rides/monitorRide.php b/backend/Admin/rides/monitorRide.php index faf49558..f2f599a4 100644 --- a/backend/Admin/rides/monitorRide.php +++ b/backend/Admin/rides/monitorRide.php @@ -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"); diff --git a/backend/Admin/transit/org/update.php b/backend/Admin/transit/org/update.php new file mode 100644 index 00000000..728aa32a --- /dev/null +++ b/backend/Admin/transit/org/update.php @@ -0,0 +1,75 @@ +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']]); diff --git a/backend/Admin/transit/route/approve.php b/backend/Admin/transit/route/approve.php new file mode 100644 index 00000000..653ea7ba --- /dev/null +++ b/backend/Admin/transit/route/approve.php @@ -0,0 +1,58 @@ +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"); diff --git a/backend/Admin/transit/route/pending.php b/backend/Admin/transit/route/pending.php new file mode 100644 index 00000000..026c216a --- /dev/null +++ b/backend/Admin/transit/route/pending.php @@ -0,0 +1,48 @@ +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)]); diff --git a/backend/aggregate_files.py b/backend/aggregate_files.py deleted file mode 100644 index ccba4d13..00000000 --- a/backend/aggregate_files.py +++ /dev/null @@ -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}") diff --git a/backend/nabeh/resolve_user.php b/backend/nabeh/resolve_user.php index fe2d67ca..3f38c7a5 100644 --- a/backend/nabeh/resolve_user.php +++ b/backend/nabeh/resolve_user.php @@ -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 { diff --git a/backend/transit/admin/login_request.php b/backend/transit/admin/login_request.php index 3e54fa56..0eb48d79 100644 --- a/backend/transit/admin/login_request.php +++ b/backend/transit/admin/login_request.php @@ -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); diff --git a/backend/transit/admin/login_verify.php b/backend/transit/admin/login_verify.php index b4ed1920..e2ec609a 100644 --- a/backend/transit/admin/login_verify.php +++ b/backend/transit/admin/login_verify.php @@ -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); } diff --git a/backend/transit/admin/logout.php b/backend/transit/admin/logout.php new file mode 100644 index 00000000..10898a8a --- /dev/null +++ b/backend/transit/admin/logout.php @@ -0,0 +1,34 @@ +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'); diff --git a/backend/transit/connect_admin.php b/backend/transit/connect_admin.php index 1aeb0edd..c08276a3 100644 --- a/backend/transit/connect_admin.php +++ b/backend/transit/connect_admin.php @@ -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'); diff --git a/backend/transit/cron_sync_members.php b/backend/transit/cron_sync_members.php new file mode 100644 index 00000000..e9f7f827 --- /dev/null +++ b/backend/transit/cron_sync_members.php @@ -0,0 +1,67 @@ +> /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"; diff --git a/backend/transit/driver/activate_by_app.php b/backend/transit/driver/activate_by_app.php new file mode 100644 index 00000000..74b38057 --- /dev/null +++ b/backend/transit/driver/activate_by_app.php @@ -0,0 +1,61 @@ +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 مواصلاتي!'); diff --git a/backend/transit/driver/list.php b/backend/transit/driver/list.php index bd4292ba..48ee7707 100644 --- a/backend/transit/driver/list.php +++ b/backend/transit/driver/list.php @@ -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"; diff --git a/backend/transit/enrollment/activate.php b/backend/transit/enrollment/activate.php index 1cddc89f..93d46cb5 100644 --- a/backend/transit/enrollment/activate.php +++ b/backend/transit/enrollment/activate.php @@ -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'], diff --git a/backend/transit/enrollment/approve.php b/backend/transit/enrollment/approve.php index aa3716fd..10b6082e 100644 --- a/backend/transit/enrollment/approve.php +++ b/backend/transit/enrollment/approve.php @@ -27,13 +27,18 @@ $transit_con->prepare( WHERE id=?" )->execute([$newStatus, $expiresAt, $enrollId]); -if ($action === 'approve' && $enroll['passenger_id']) { - transitSendTopicNotification( - 'passenger_' . $enroll['passenger_id'], - 'تم تفعيل عضويتك', - 'يمكنك الآن متابعة باصات جامعتك عبر مواصلاتي', - ['type' => 'transit_enrollment_approved'] - ); +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]); diff --git a/backend/transit/functions.php b/backend/transit/functions.php index a7d5d102..bbfd3c75 100644 --- a/backend/transit/functions.php +++ b/backend/transit/functions.php @@ -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 = الأحد–الخميس diff --git a/backend/transit/org/register.php b/backend/transit/org/register.php index 658cf77c..095e4f4d 100644 --- a/backend/transit/org/register.php +++ b/backend/transit/org/register.php @@ -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); diff --git a/backend/transit/schedule/add.php b/backend/transit/schedule/add.php index 141c91bf..c3221c1d 100644 --- a/backend/transit/schedule/add.php +++ b/backend/transit/schedule/add.php @@ -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'); diff --git a/backend/transit/schema_transit.sql b/backend/transit/schema_transit.sql index bfdbfdc1..6b3f2e73 100644 --- a/backend/transit/schema_transit.sql +++ b/backend/transit/schema_transit.sql @@ -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) diff --git a/backend/transit/trip/delay.php b/backend/transit/trip/delay.php index a4e89a56..e21596fa 100644 --- a/backend/transit/trip/delay.php +++ b/backend/transit/trip/delay.php @@ -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'); +$tripId = filterRequest('trip_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 diff --git a/backend/transit/trip/end.php b/backend/transit/trip/end.php index 8656c9be..305bca83 100644 --- a/backend/transit/trip/end.php +++ b/backend/transit/trip/end.php @@ -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'); +$tripId = filterRequest('trip_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" diff --git a/backend/transit/trip/live.php b/backend/transit/trip/live.php index aacadbd9..b6b48589 100644 --- a/backend/transit/trip/live.php +++ b/backend/transit/trip/live.php @@ -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 diff --git a/backend/transit/trip/start.php b/backend/transit/trip/start.php index 9bc771c9..ad95fa48 100644 --- a/backend/transit/trip/start.php +++ b/backend/transit/trip/start.php @@ -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); -$tripId = filterRequest('trip_id', 'int'); -$driverId = filterRequest('driver_transit_id', 'int'); -$lat = (float)filterRequest('lat'); -$lng = (float)filterRequest('lng'); +requireTransitFields(['trip_id', 'lat', 'lng']); +$tripId = filterRequest('trip_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 + JOIN transit_routes r ON r.id = t.route_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) { diff --git a/backend/transit/trip/today.php b/backend/transit/trip/today.php index aeca02d3..95f59233 100644 --- a/backend/transit/trip/today.php +++ b/backend/transit/trip/today.php @@ -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'); diff --git a/backend/transit/trip/update_position.php b/backend/transit/trip/update_position.php deleted file mode 100644 index faa0b92b..00000000 --- a/backend/transit/trip/update_position.php +++ /dev/null @@ -1,38 +0,0 @@ -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]); diff --git a/docs/mawasalati_full_system_review.md b/docs/mawasalati_full_system_review.md new file mode 100644 index 00000000..00150856 --- /dev/null +++ b/docs/mawasalati_full_system_review.md @@ -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) → كشف طلاب → تفعيل طالب بالرقم الجامعي → رؤية الخط → بدء الرحلة من السائق → الموقع الحي يصل للراكب → إنهاء الرحلة diff --git a/passenger_server/passenger_socket.php b/passenger_server/passenger_socket.php index c12d3109..79414cf0 100644 --- a/passenger_server/passenger_socket.php +++ b/passenger_server/passenger_socket.php @@ -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"); }); diff --git a/siro_admin/lib/controller/transit/transit_admin_controller.dart b/siro_admin/lib/controller/transit/transit_admin_controller.dart index 113b7fd6..0c8de351 100644 --- a/siro_admin/lib/controller/transit/transit_admin_controller.dart +++ b/siro_admin/lib/controller/transit/transit_admin_controller.dart @@ -131,4 +131,52 @@ class TransitAdminController extends GetxController { Get.snackbar('مواصلاتي', res.message); } } + + // ── إدارة العقد ─────────────────────────────────────────────── + bool isUpdatingContract = false; + + Future 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> pendingRoutes = []; + bool isApprovingRoute = false; + + Future fetchPendingRoutes({int? orgId}) async { + isLoadingPendingRoutes = true; + update(); + final res = await TransitAdminService.listPendingRoutes(orgId: orgId); + if (res.success) pendingRoutes = res.data ?? []; + isLoadingPendingRoutes = false; + update(); + } + + Future 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; + } } diff --git a/siro_admin/lib/controller/transit/transit_admin_service.dart b/siro_admin/lib/controller/transit/transit_admin_service.dart index fd54ddd9..546e8c47 100644 --- a/siro_admin/lib/controller/transit/transit_admin_service.dart +++ b/siro_admin/lib/controller/transit/transit_admin_service.dart @@ -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>>> listPendingRoutes({ + int? orgId, + }) async { + final payload = {}; + 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.from(r)) + .toList() + : >[]; + return TransitApiResult(true, list, 'ok'); + } + return TransitApiResult(false, null, _errMsg(res)); + } + + static Future>> 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.from(res['message'] ?? {}), 'ok'); + } + return TransitApiResult(false, null, _errMsg(res)); + } + + static Future>> 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.from(res['message'] ?? {}), 'ok'); + } + return TransitApiResult(false, null, _errMsg(res)); + } } diff --git a/siro_admin/lib/views/transit/org_details_page.dart b/siro_admin/lib/views/transit/org_details_page.dart index 5d9db7e9..b38deb73 100644 --- a/siro_admin/lib/views/transit/org_details_page.dart +++ b/siro_admin/lib/views/transit/org_details_page.dart @@ -41,6 +41,17 @@ class _TransitOrgDetailsPageState extends State { () => TransitOrgAdminsPage(orgId: widget.orgId, orgName: widget.orgName), ), ), + PopupMenuButton( + 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 { ); } + PopupMenuItem _menuItem(String value, String label, IconData icon, Color color) { + return PopupMenuItem( + value: value, + child: Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 10), + Text(label, style: TextStyle(color: color, fontSize: 13)), + ], + ), + ); + } + + Future _handleContractAction(String contractStatus) async { + final labels = { + 'active': 'تفعيل العقد', + 'trial': 'وضع تجريبي', + 'suspended': 'تعليق العقد', + 'terminated': 'إنهاء العقد', + }; + final confirm = await showDialog( + 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(); + 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; diff --git a/siro_admin/lib/views/transit/org_list_page.dart b/siro_admin/lib/views/transit/org_list_page.dart index 11cf057f..2a8dc9d1 100644 --- a/siro_admin/lib/views/transit/org_list_page.dart +++ b/siro_admin/lib/views/transit/org_list_page.dart @@ -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 { 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 { diff --git a/siro_admin/lib/views/transit/route_approval_page.dart b/siro_admin/lib/views/transit/route_approval_page.dart new file mode 100644 index 00000000..72310703 --- /dev/null +++ b/siro_admin/lib/views/transit/route_approval_page.dart @@ -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 createState() => _RouteApprovalPageState(); +} + +class _RouteApprovalPageState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + Get.find().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().fetchPendingRoutes(), + ), + ], + ), + body: GetBuilder( + 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 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)), + ], + ), + ); + } +} diff --git a/siro_driver/lib/constant/box_name.dart b/siro_driver/lib/constant/box_name.dart index c9697289..6a6d25cd 100755 --- a/siro_driver/lib/constant/box_name.dart +++ b/siro_driver/lib/constant/box_name.dart @@ -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'; } diff --git a/siro_driver/lib/controller/functions/background_service.dart b/siro_driver/lib/controller/functions/background_service.dart index bc9286fd..513dd44f 100644 --- a/siro_driver/lib/controller/functions/background_service.dart +++ b/siro_driver/lib/controller/functions/background_service.dart @@ -78,6 +78,14 @@ Future 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 مفتوح بالفعل؟ (للأندرويد فقط) diff --git a/siro_driver/lib/controller/functions/location_controller.dart b/siro_driver/lib/controller/functions/location_controller.dart index 970bcb13..4fceb78a 100755 --- a/siro_driver/lib/controller/functions/location_controller.dart +++ b/siro_driver/lib/controller/functions/location_controller.dart @@ -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()) { + Get.find().onLocationUpdate(pos); + } await _saveBehaviorIfMoved(pos, now, currentSpeed: speed); return; } diff --git a/siro_driver/lib/controller/home/captin/home_captain_controller.dart b/siro_driver/lib/controller/home/captin/home_captain_controller.dart index bcff0b0e..c0c37ea7 100755 --- a/siro_driver/lib/controller/home/captin/home_captain_controller.dart +++ b/siro_driver/lib/controller/home/captin/home_captain_controller.dart @@ -741,32 +741,19 @@ 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( - 'service_unavailable_area'.tr, - style: const TextStyle( - fontSize: 18, fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - ], - ), - ), - isDismissible: false, - enableDrag: false, - ); + if (!Get.isSnackbarOpen) { + Get.snackbar( + 'Alert'.tr, + 'service_unavailable_area'.tr, + 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( diff --git a/siro_driver/lib/controller/local/ar_eg.dart b/siro_driver/lib/controller/local/ar_eg.dart index 5f87921e..ad486e61 100644 --- a/siro_driver/lib/controller/local/ar_eg.dart +++ b/siro_driver/lib/controller/local/ar_eg.dart @@ -10,6 +10,11 @@ final Map 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.": "وأوافق على سياسة الخصوصية.", diff --git a/siro_driver/lib/controller/local/ar_jo.dart b/siro_driver/lib/controller/local/ar_jo.dart index f4140a27..1e8d0c38 100644 --- a/siro_driver/lib/controller/local/ar_jo.dart +++ b/siro_driver/lib/controller/local/ar_jo.dart @@ -10,6 +10,11 @@ final Map 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.": "وأوافق على سياسة الخصوصية الخاصة بنا.", diff --git a/siro_driver/lib/controller/local/ar_sy.dart b/siro_driver/lib/controller/local/ar_sy.dart index e0a218b5..119c21a1 100644 --- a/siro_driver/lib/controller/local/ar_sy.dart +++ b/siro_driver/lib/controller/local/ar_sy.dart @@ -10,6 +10,11 @@ final Map 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.": "وبوافق على سياسة الخصوصية.", diff --git a/siro_driver/lib/controller/transit/transit_driver_controller.dart b/siro_driver/lib/controller/transit/transit_driver_controller.dart index 79cd3c63..5d2a1174 100644 --- a/siro_driver/lib/controller/transit/transit_driver_controller.dart +++ b/siro_driver/lib/controller/transit/transit_driver_controller.dart @@ -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 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 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()) { Get.find().setBusMode( enabled: true, @@ -66,7 +91,7 @@ class TransitDriverController extends GetxController { } Future 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()) { Get.find().setBusMode( enabled: true, @@ -101,16 +126,14 @@ class TransitDriverController extends GetxController { } Future 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()) { Get.find().setBusMode(enabled: false); } @@ -124,15 +147,57 @@ class TransitDriverController extends GetxController { return res.success; } - Future reportDelay(TransitDriverTrip trip, int minutes, {String? reason}) async { - if (driverTransitId == null) return false; + Future 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()) { + final lc = Get.find(); + 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; + } + } } diff --git a/siro_driver/lib/controller/transit/transit_driver_models.dart b/siro_driver/lib/controller/transit/transit_driver_models.dart index a875040e..9914d358 100644 --- a/siro_driver/lib/controller/transit/transit_driver_models.dart +++ b/siro_driver/lib/controller/transit/transit_driver_models.dart @@ -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 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; diff --git a/siro_driver/lib/controller/transit/transit_driver_service.dart b/siro_driver/lib/controller/transit/transit_driver_service.dart index 755d8e5c..754c017c 100644 --- a/siro_driver/lib/controller/transit/transit_driver_service.dart +++ b/siro_driver/lib/controller/transit/transit_driver_service.dart @@ -29,13 +29,23 @@ class TransitDriverService { return TransitApiResult(false, null, _errMsg(res)); } - /// رحلات اليوم (تُنشأ تلقائياً من الجداول عند أول استدعاء) - static Future>> getTodayTrips( - int driverTransitId) async { + /// تفعيل حساب السائق بتوكن الدعوة (بعد IDOR fix لا نحتاج driver_transit_id) + static Future>> 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.from(res['message']), 'ok'); + } + return TransitApiResult(false, null, _errMsg(res)); + } + + /// رحلات اليوم — الخادم يجلب السائق من JWT، لا نمرر driver_transit_id + static Future>> 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>> 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>> 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.from(res['message'] ?? {}), 'ok'); @@ -88,7 +92,6 @@ class TransitDriverService { static Future> 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, }, diff --git a/siro_driver/lib/views/transit/transit_driver_home_page.dart b/siro_driver/lib/views/transit/transit_driver_home_page.dart index cae54b14..8b056cd9 100644 --- a/siro_driver/lib/views/transit/transit_driver_home_page.dart +++ b/siro_driver/lib/views/transit/transit_driver_home_page.dart @@ -18,6 +18,7 @@ class TransitDriverHomePage extends StatelessWidget { return GetBuilder( 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), + ), + ], + ), + ); + } +} diff --git a/siro_rider/lib/controller/firebase/firbase_messge.dart b/siro_rider/lib/controller/firebase/firbase_messge.dart index 416c3964..6e8b2fe2 100644 --- a/siro_rider/lib/controller/firebase/firbase_messge.dart +++ b/siro_rider/lib/controller/firebase/firbase_messge.dart @@ -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; diff --git a/siro_rider/lib/controller/home/map/location_search_controller.dart b/siro_rider/lib/controller/home/map/location_search_controller.dart index 82281a85..abdf10f7 100644 --- a/siro_rider/lib/controller/home/map/location_search_controller.dart +++ b/siro_rider/lib/controller/home/map/location_search_controller.dart @@ -297,30 +297,19 @@ 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( - 'service_unavailable_area'.tr, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - ], - ), - ), - isDismissible: false, - enableDrag: false, - ); + if (!Get.isSnackbarOpen) { + Get.snackbar( + 'Alert'.tr, + 'service_unavailable_area'.tr, + 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()) { diff --git a/siro_rider/lib/controller/home/map/map_socket_controller.dart b/siro_rider/lib/controller/home/map/map_socket_controller.dart index 2bbcf7ec..bf8f9b22 100644 --- a/siro_rider/lib/controller/home/map/map_socket_controller.dart +++ b/siro_rider/lib/controller/home/map/map_socket_controller.dart @@ -24,6 +24,7 @@ class MapSocketController extends GetxController { // 🚌 مواصلاتي — الخط الحالي المشترَك فيه + مستمع تحديث موقع الباص int? _subscribedTransitRouteId; void Function(Map 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.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'); + } + }); } // ── مواصلاتي: اشتراك/إلغاء اشتراك ببثّ موقع خط ───────────── diff --git a/siro_rider/lib/controller/local/ar_eg.dart b/siro_rider/lib/controller/local/ar_eg.dart index 045b61f9..169cf32e 100644 --- a/siro_rider/lib/controller/local/ar_eg.dart +++ b/siro_rider/lib/controller/local/ar_eg.dart @@ -21,6 +21,7 @@ final Map 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": " أنا استخدم", diff --git a/siro_rider/lib/controller/local/ar_jo.dart b/siro_rider/lib/controller/local/ar_jo.dart index 8abd0e15..345277f8 100644 --- a/siro_rider/lib/controller/local/ar_jo.dart +++ b/siro_rider/lib/controller/local/ar_jo.dart @@ -21,6 +21,7 @@ final Map 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": " أنا استخدم", diff --git a/siro_rider/lib/controller/local/ar_sy.dart b/siro_rider/lib/controller/local/ar_sy.dart index a40a6bdd..a48dca7f 100644 --- a/siro_rider/lib/controller/local/ar_sy.dart +++ b/siro_rider/lib/controller/local/ar_sy.dart @@ -21,6 +21,7 @@ final Map 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": " أنا استخدم", diff --git a/siro_rider/lib/controller/transit/transit_controller.dart b/siro_rider/lib/controller/transit/transit_controller.dart index d80b4eb3..c70d0c62 100644 --- a/siro_rider/lib/controller/transit/transit_controller.dart +++ b/siro_rider/lib/controller/transit/transit_controller.dart @@ -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 subscribeToRouteTopic(int routeId) async { + try { + await FirebaseMessaging.instance.subscribeToTopic('transit_route_$routeId'); + Log.print('Subscribed to transit_route_$routeId ✅'); + } catch (_) {} + } + + Future unsubscribeFromRouteTopic(int routeId) async { + try { + await FirebaseMessaging.instance.unsubscribeFromTopic('transit_route_$routeId'); + } catch (_) {} + } + Future openOrgRoutes(int orgId) async { currentOrgId = orgId; isLoadingRoutes = true; @@ -101,6 +121,7 @@ class TransitController extends GetxController { if (Get.isRegistered()) { final ms = Get.find(); 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()) { final ms = Get.find(); ms.unsubscribeFromTransitRoute(liveRouteId!); ms.onBusLocationUpdate = null; + ms.onTransitError = null; } liveRouteId = null; liveTripId = null; diff --git a/siro_rider/lib/views/transit/transit_live_map_page.dart b/siro_rider/lib/views/transit/transit_live_map_page.dart index bbd75f26..1ade1c1b 100644 --- a/siro_rider/lib/views/transit/transit_live_map_page.dart +++ b/siro_rider/lib/views/transit/transit_live_map_page.dart @@ -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 createState() => _TransitLiveMapPageState(); -} - -class _TransitLiveMapPageState extends State { - IntaleqMapController? _mapController; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - Get.find().openLiveRoute(widget.routeId); - }); - } - - @override - void dispose() { - Get.find().closeLiveRoute(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return GetBuilder( - builder: (c) { - final stops = (c.liveTripData?['stops'] is List) - ? List.from(c.liveTripData!['stops']) - : []; - - final markers = {}; - - 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'; diff --git a/siro_rider/lib/views/transit/transit_map_page.dart b/siro_rider/lib/views/transit/transit_map_page.dart new file mode 100644 index 00000000..cfe5b1a4 --- /dev/null +++ b/siro_rider/lib/views/transit/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 createState() => _TransitMapPageState(); +} + +class _TransitMapPageState extends State { + 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().openLiveRoute(widget.routeId); + // اشتراك FCM لتنبيهات الخط + Get.find().subscribeToRouteTopic(widget.routeId); + }); + } + + @override + void dispose() { + Get.find().closeLiveRoute(); + Get.find().unsubscribeFromRouteTopic(widget.routeId); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bottomPad = MediaQuery.of(context).padding.bottom; + + return Scaffold( + backgroundColor: _bg, + body: GetBuilder( + builder: (c) { + // ── بناء ماركرات المحطات والباص ───────────────────────── + final stops = (c.liveTripData?['stops'] is List) + ? List.from(c.liveTripData!['stops']) + : []; + + final markers = {}; + + // ماركر المحطات + 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 = {}; + 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(_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), + ), + ), + ), + ], + ), + ); + } +} diff --git a/siro_rider/lib/views/transit/transit_routes_page.dart b/siro_rider/lib/views/transit/transit_routes_page.dart index d42e2e44..15ec9fdb 100644 --- a/siro_rider/lib/views/transit/transit_routes_page.dart +++ b/siro_rider/lib/views/transit/transit_routes_page.dart @@ -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 { 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), ), ), );