From 24fb56f08f1347c02934beba54ad0ddbd822ea6b Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sun, 12 Jul 2026 05:40:28 +0300 Subject: [PATCH] Update: 2026-07-12 05:40:28 --- backend/.env.example | 17 + backend/Admin/transit/org/admin_add.php | 44 +++ backend/Admin/transit/org/admin_toggle.php | 39 ++ backend/Admin/transit/org/admins_list.php | 31 ++ backend/Admin/transit/org/create.php | 72 ++++ backend/Admin/transit/org/details.php | 88 +++++ backend/Admin/transit/org/list.php | 72 ++++ backend/core/Database/Database.php | 6 + backend/core/Services/FcmService.php | 49 +++ backend/core/Services/OtpService.php | 9 +- backend/core/helpers.php | 29 ++ backend/transit/admin/dashboard.php | 65 ++++ backend/transit/admin/login_request.php | 34 ++ backend/transit/admin/login_verify.php | 48 +++ backend/transit/broadcast/send.php | 32 ++ backend/transit/connect_admin.php | 33 ++ backend/transit/connect_app.php | 29 ++ backend/transit/driver/activate.php | 41 +++ backend/transit/driver/invite.php | 35 ++ backend/transit/driver/list.php | 27 ++ backend/transit/driver/me.php | 30 ++ backend/transit/enrollment/activate.php | 66 ++++ backend/transit/enrollment/approve.php | 39 ++ backend/transit/enrollment/import_roster.php | 79 ++++ backend/transit/enrollment/list.php | 36 ++ backend/transit/enrollment/my_enrollments.php | 18 + backend/transit/functions.php | 205 +++++++++++ backend/transit/notification/subscribe.php | 39 ++ backend/transit/org/browse.php | 24 ++ backend/transit/org/register.php | 59 +++ backend/transit/route/add.php | 56 +++ backend/transit/route/for_org.php | 34 ++ backend/transit/route/get.php | 32 ++ backend/transit/route/list.php | 28 ++ backend/transit/schedule/add.php | 38 ++ backend/transit/schema_transit.sql | 342 ++++++++++++++++++ backend/transit/trip/delay.php | 33 ++ backend/transit/trip/end.php | 32 ++ backend/transit/trip/live.php | 53 +++ backend/transit/trip/start.php | 51 +++ backend/transit/trip/today.php | 70 ++++ backend/transit/trip/update_position.php | 38 ++ backend/transit/vehicle/add.php | 27 ++ backend/transit/vehicle/list.php | 12 + loction_server/driver_socket.php | 139 ++++++- passenger_server/passenger_socket.php | 43 +++ .../transit/transit_admin_controller.dart | 134 +++++++ .../transit/transit_admin_models.dart | 70 ++++ .../transit/transit_admin_service.dart | 139 +++++++ .../lib/views/admin/admin_home_page.dart | 8 + .../lib/views/transit/org_admins_page.dart | 185 ++++++++++ .../lib/views/transit/org_create_page.dart | 143 ++++++++ .../lib/views/transit/org_details_page.dart | 170 +++++++++ .../lib/views/transit/org_list_page.dart | 150 ++++++++ .../functions/location_controller.dart | 43 ++- siro_driver/lib/controller/local/ar_eg.dart | 11 + siro_driver/lib/controller/local/ar_jo.dart | 11 + siro_driver/lib/controller/local/ar_sy.dart | 11 + .../transit/transit_driver_controller.dart | 138 +++++++ .../transit/transit_driver_models.dart | 72 ++++ .../transit/transit_driver_service.dart | 109 ++++++ .../Captin/home_captain/drawer_captain.dart | 6 + .../transit/transit_driver_home_page.dart | 172 +++++++++ .../home/map/map_socket_controller.dart | 45 +++ siro_rider/lib/controller/local/ar_eg.dart | 22 ++ siro_rider/lib/controller/local/ar_jo.dart | 22 ++ siro_rider/lib/controller/local/ar_sy.dart | 22 ++ .../transit/transit_controller.dart | 142 ++++++++ .../controller/transit/transit_models.dart | 164 +++++++++ .../controller/transit/transit_service.dart | 130 +++++++ .../home/map_widget.dart/map_menu_widget.dart | 6 + .../lib/views/transit/transit_home_page.dart | 147 ++++++++ .../views/transit/transit_live_map_page.dart | 146 ++++++++ .../transit/transit_org_browse_page.dart | 145 ++++++++ .../views/transit/transit_routes_page.dart | 74 ++++ 75 files changed, 5051 insertions(+), 9 deletions(-) create mode 100644 backend/Admin/transit/org/admin_add.php create mode 100644 backend/Admin/transit/org/admin_toggle.php create mode 100644 backend/Admin/transit/org/admins_list.php create mode 100644 backend/Admin/transit/org/create.php create mode 100644 backend/Admin/transit/org/details.php create mode 100644 backend/Admin/transit/org/list.php create mode 100644 backend/transit/admin/dashboard.php create mode 100644 backend/transit/admin/login_request.php create mode 100644 backend/transit/admin/login_verify.php create mode 100644 backend/transit/broadcast/send.php create mode 100644 backend/transit/connect_admin.php create mode 100644 backend/transit/connect_app.php create mode 100644 backend/transit/driver/activate.php create mode 100644 backend/transit/driver/invite.php create mode 100644 backend/transit/driver/list.php create mode 100644 backend/transit/driver/me.php create mode 100644 backend/transit/enrollment/activate.php create mode 100644 backend/transit/enrollment/approve.php create mode 100644 backend/transit/enrollment/import_roster.php create mode 100644 backend/transit/enrollment/list.php create mode 100644 backend/transit/enrollment/my_enrollments.php create mode 100644 backend/transit/functions.php create mode 100644 backend/transit/notification/subscribe.php create mode 100644 backend/transit/org/browse.php create mode 100644 backend/transit/org/register.php create mode 100644 backend/transit/route/add.php create mode 100644 backend/transit/route/for_org.php create mode 100644 backend/transit/route/get.php create mode 100644 backend/transit/route/list.php create mode 100644 backend/transit/schedule/add.php create mode 100644 backend/transit/schema_transit.sql create mode 100644 backend/transit/trip/delay.php create mode 100644 backend/transit/trip/end.php create mode 100644 backend/transit/trip/live.php create mode 100644 backend/transit/trip/start.php create mode 100644 backend/transit/trip/today.php create mode 100644 backend/transit/trip/update_position.php create mode 100644 backend/transit/vehicle/add.php create mode 100644 backend/transit/vehicle/list.php create mode 100644 siro_admin/lib/controller/transit/transit_admin_controller.dart create mode 100644 siro_admin/lib/controller/transit/transit_admin_models.dart create mode 100644 siro_admin/lib/controller/transit/transit_admin_service.dart create mode 100644 siro_admin/lib/views/transit/org_admins_page.dart create mode 100644 siro_admin/lib/views/transit/org_create_page.dart create mode 100644 siro_admin/lib/views/transit/org_details_page.dart create mode 100644 siro_admin/lib/views/transit/org_list_page.dart create mode 100644 siro_driver/lib/controller/transit/transit_driver_controller.dart create mode 100644 siro_driver/lib/controller/transit/transit_driver_models.dart create mode 100644 siro_driver/lib/controller/transit/transit_driver_service.dart create mode 100644 siro_driver/lib/views/transit/transit_driver_home_page.dart create mode 100644 siro_rider/lib/controller/transit/transit_controller.dart create mode 100644 siro_rider/lib/controller/transit/transit_models.dart create mode 100644 siro_rider/lib/controller/transit/transit_service.dart create mode 100644 siro_rider/lib/views/transit/transit_home_page.dart create mode 100644 siro_rider/lib/views/transit/transit_live_map_page.dart create mode 100644 siro_rider/lib/views/transit/transit_org_browse_page.dart create mode 100644 siro_rider/lib/views/transit/transit_routes_page.dart diff --git a/backend/.env.example b/backend/.env.example index 888f1b7f..c81cefd9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -14,6 +14,23 @@ DB_NAME=siro_main DB_USER=siro_user DB_PASS= +# ============================================================================= +# Database Configuration - TRANSIT DATABASE (مواصلاتي — جامعات/مدارس/فنادق/شركات) +# ============================================================================= +# قاعدة بيانات معزولة تماماً عن main/ride/tracking — ممنوع أي JOIN بينها وبينهم، +# الربط بينها وبين النظام الرئيسي عبر المعرّفات (passenger_id/driver_id) فقط. +DB_TRANSIT_HOST=localhost +DB_TRANSIT_PORT=3306 +DB_TRANSIT_NAME=siroTransitDb +DB_TRANSIT_USER=siroTransitUser +DB_TRANSIT_PASS= +# مفتاح تشفير الرقم الجامعي (32 byte) — منفصل عن ENC_KEY لمزيد من العزل +TRANSIT_STUDENT_ID_KEY= +# Origins مسموحة للوحة الويب (مشرف المؤسسة) +TRANSIT_ADMIN_ORIGINS=https://transit.siromove.com,https://admin.siromove.com +# رابط تفعيل السائق (deep link في تطبيق السائق) +APP_DEEP_LINK_BASE=https://siromove.com/driver/transit-activate + # ============================================================================= # Encryption Configuration - CRITICAL FOR SECURITY # ============================================================================= diff --git a/backend/Admin/transit/org/admin_add.php b/backend/Admin/transit/org/admin_add.php new file mode 100644 index 00000000..066e2d0d --- /dev/null +++ b/backend/Admin/transit/org/admin_add.php @@ -0,0 +1,44 @@ +prepare("SELECT id FROM transit_orgs WHERE id=? LIMIT 1"); +$chkOrg->execute([$orgId]); +if (!$chkOrg->fetch()) jsonError('Organization not found', 404); + +$phoneEnc = $encryptionHelper->encryptData($phone); + +$chkDup = $transit_con->prepare( + "SELECT id FROM transit_org_admins WHERE org_id=? AND phone=? LIMIT 1" +); +$chkDup->execute([$orgId, $phoneEnc]); +if ($chkDup->fetch()) jsonError('An admin with this phone already exists for this organization', 409); + +$transit_con->prepare( + "INSERT INTO transit_org_admins (org_id, name, phone, role, is_active) VALUES (?,?,?,?,1)" +)->execute([$orgId, $name, $phoneEnc, $adminRole]); + +appLog("[ADMIN][TRANSIT][ORG][admin_add] org={$orgId} name={$name} role={$adminRole}"); + +jsonSuccess(['admin_id' => (int)$transit_con->lastInsertId()], 'Admin added successfully'); diff --git a/backend/Admin/transit/org/admin_toggle.php b/backend/Admin/transit/org/admin_toggle.php new file mode 100644 index 00000000..7ca7bbb6 --- /dev/null +++ b/backend/Admin/transit/org/admin_toggle.php @@ -0,0 +1,39 @@ +prepare("SELECT id, org_id FROM transit_org_admins WHERE id=? LIMIT 1"); +$st->execute([$adminId]); +$admin = $st->fetch(); +if (!$admin) jsonError('Admin not found', 404); + +$transit_con->prepare("UPDATE transit_org_admins SET is_active=? WHERE id=?") + ->execute([$isActive ? 1 : 0, $adminId]); + +// إبطال جلساته الحالية فوراً عند التعليق +if (!$isActive) { + $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][admin_toggle] admin={$adminId} is_active={$isActive}"); + +jsonSuccess(['admin_id' => $adminId, 'is_active' => (bool)$isActive]); diff --git a/backend/Admin/transit/org/admins_list.php b/backend/Admin/transit/org/admins_list.php new file mode 100644 index 00000000..e938de6e --- /dev/null +++ b/backend/Admin/transit/org/admins_list.php @@ -0,0 +1,31 @@ +prepare( + "SELECT id, name, phone, role, is_active, created_at + FROM transit_org_admins WHERE org_id=? ORDER BY created_at ASC" +); +$st->execute([$orgId]); +$admins = $st->fetchAll(); + +foreach ($admins as &$a) { + if (!empty($a['phone'])) { + $a['phone'] = $encryptionHelper->decryptData($a['phone']) ?: null; + } +} +unset($a); + +jsonSuccess(['admins' => $admins]); diff --git a/backend/Admin/transit/org/create.php b/backend/Admin/transit/org/create.php new file mode 100644 index 00000000..4a3d5203 --- /dev/null +++ b/backend/Admin/transit/org/create.php @@ -0,0 +1,72 @@ +prepare("SELECT id FROM transit_orgs WHERE name_ar=? AND country=? LIMIT 1"); +$chk->execute([$nameAr, $country]); +if ($chk->fetch()) jsonError('Organization already exists', 409); + +$adminPhoneEnc = $encryptionHelper->encryptData($adminPhone); +$contactPhoneRaw = normalizePhone(filterRequest('contact_phone') ?? ''); +$contactPhoneEnc = $contactPhoneRaw ? $encryptionHelper->encryptData($contactPhoneRaw) : null; + +$transit_con->beginTransaction(); +try { + $transit_con->prepare( + "INSERT INTO transit_orgs + (type, country, city, name_ar, name_en, contact_phone, contact_email, website, contract_status, trial_ends_at) + VALUES (?,?,?,?,?,?,?,?,'active',?)" + )->execute([ + $type, $country, $city, $nameAr, $nameEn, + $contactPhoneEnc, filterRequest('contact_email'), filterRequest('website'), + $trialEndsAt, + ]); + + $orgId = (int)$transit_con->lastInsertId(); + + $transit_con->prepare( + "INSERT INTO transit_org_admins (org_id, name, phone, role) VALUES (?,?,?,?)" + )->execute([$orgId, $adminName, $adminPhoneEnc, $adminRole]); + + $transit_con->commit(); +} catch (Throwable $e) { + $transit_con->rollBack(); + appLog('[ADMIN][TRANSIT][ORG][create] ' . $e->getMessage(), 'ERROR'); + jsonError('Failed to create organization', 500); +} + +jsonSuccess([ + 'org_id' => $orgId, + 'name_ar' => $nameAr, + 'type' => $type, + 'country' => $country, +], 'Organization created successfully'); diff --git a/backend/Admin/transit/org/details.php b/backend/Admin/transit/org/details.php new file mode 100644 index 00000000..9310b078 --- /dev/null +++ b/backend/Admin/transit/org/details.php @@ -0,0 +1,88 @@ +prepare("SELECT * FROM transit_orgs WHERE id=? LIMIT 1"); +$st->execute([$orgId]); +$org = $st->fetch(); +if (!$org) jsonError('Organization not found', 404); + +// فك تشفير هاتف التواصل للعرض الإداري فقط +if (!empty($org['contact_phone'])) { + $org['contact_phone'] = $encryptionHelper->decryptData($org['contact_phone']) ?: null; +} + +// ── العدّادات الأساسية ─────────────────────────────────────── +$counts = [ + 'drivers_total' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_drivers WHERE org_id=$orgId")->fetchColumn(), + 'drivers_active' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_drivers WHERE org_id=$orgId AND status='active'")->fetchColumn(), + 'vehicles_total' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_vehicles WHERE org_id=$orgId")->fetchColumn(), + 'vehicles_active' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_vehicles WHERE org_id=$orgId AND is_active=1")->fetchColumn(), + 'routes_total' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_routes WHERE org_id=$orgId")->fetchColumn(), + 'routes_active' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_routes WHERE org_id=$orgId AND status='active'")->fetchColumn(), + 'enrollments_active' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_enrollments WHERE org_id=$orgId AND status='active'")->fetchColumn(), + 'enrollments_pending' => (int)$transit_con->query("SELECT COUNT(*) FROM transit_enrollments WHERE org_id=$orgId AND status='pending'")->fetchColumn(), +]; + +// ── الرحلات: اليوم / هذا الأسبوع / هذا الشهر / إجمالي ────────── +$today = date('Y-m-d'); +$weekStart = date('Y-m-d', strtotime('monday this week')); +$monthStart = date('Y-m-01'); + +$stTrips = $transit_con->prepare( + "SELECT + SUM(trip_date = ?) AS today_count, + SUM(trip_date >= ?) AS week_count, + SUM(trip_date >= ?) AS month_count, + COUNT(*) AS total_count, + SUM(status='completed') AS completed_count, + SUM(status='cancelled') AS cancelled_count, + SUM(status='no_show') AS no_show_count, + AVG(CASE WHEN status='completed' THEN delay_minutes END) AS avg_delay_minutes, + SUM(CASE WHEN status='completed' AND started_at IS NOT NULL AND completed_at IS NOT NULL + THEN TIMESTAMPDIFF(MINUTE, started_at, completed_at) ELSE 0 END) AS total_minutes_driven + FROM transit_trips WHERE org_id = ?" +); +$stTrips->execute([$today, $weekStart, $monthStart, $orgId]); +$tripStats = $stTrips->fetch(); + +$trips = [ + 'today' => (int)($tripStats['today_count'] ?? 0), + 'this_week' => (int)($tripStats['week_count'] ?? 0), + 'this_month' => (int)($tripStats['month_count'] ?? 0), + 'total' => (int)($tripStats['total_count'] ?? 0), + 'completed' => (int)($tripStats['completed_count'] ?? 0), + 'cancelled' => (int)($tripStats['cancelled_count'] ?? 0), + 'no_show' => (int)($tripStats['no_show_count'] ?? 0), + 'avg_delay_minutes' => round((float)($tripStats['avg_delay_minutes'] ?? 0), 1), + 'total_hours_driven' => round(((int)($tripStats['total_minutes_driven'] ?? 0)) / 60, 1), +]; + +// ── الخطوط مع ملخص لكل خط ───────────────────────────────────── +$stRoutes = $transit_con->prepare( + "SELECT r.id, r.name_ar, r.status, r.distance_km, + (SELECT COUNT(*) FROM transit_stops s WHERE s.route_id = r.id) AS stops_count, + (SELECT COUNT(*) FROM transit_trips t WHERE t.route_id = r.id AND t.status='completed') AS completed_trips + FROM transit_routes r WHERE r.org_id = ? ORDER BY r.name_ar ASC" +); +$stRoutes->execute([$orgId]); +$routes = $stRoutes->fetchAll(); + +jsonSuccess([ + 'org' => $org, + 'counts' => $counts, + 'trips' => $trips, + 'routes' => $routes, +]); diff --git a/backend/Admin/transit/org/list.php b/backend/Admin/transit/org/list.php new file mode 100644 index 00000000..f1b09920 --- /dev/null +++ b/backend/Admin/transit/org/list.php @@ -0,0 +1,72 @@ +prepare("SELECT COUNT(*) FROM transit_orgs WHERE $where"); +$countSt->execute($params); +$total = (int)$countSt->fetchColumn(); + +$params[] = $perPage; +$params[] = $offset; +$st = $transit_con->prepare( + "SELECT id, type, country, city, name_ar, name_en, logo_url, + contract_status, trial_ends_at, created_at + FROM transit_orgs + WHERE $where + ORDER BY created_at DESC + LIMIT ? OFFSET ?" +); +$st->execute($params); +$orgs = $st->fetchAll(); + +if ($orgs) { + $ids = implode(',', array_map('intval', array_column($orgs, 'id'))); + + $drivers = $transit_con->query("SELECT org_id, COUNT(*) c FROM transit_drivers WHERE org_id IN ($ids) GROUP BY org_id") + ->fetchAll(PDO::FETCH_KEY_PAIR); + $vehicles = $transit_con->query("SELECT org_id, COUNT(*) c FROM transit_vehicles WHERE org_id IN ($ids) GROUP BY org_id") + ->fetchAll(PDO::FETCH_KEY_PAIR); + $routes = $transit_con->query("SELECT org_id, COUNT(*) c FROM transit_routes WHERE org_id IN ($ids) AND status='active' GROUP BY org_id") + ->fetchAll(PDO::FETCH_KEY_PAIR); + $enrollments = $transit_con->query("SELECT org_id, COUNT(*) c FROM transit_enrollments WHERE org_id IN ($ids) AND status='active' GROUP BY org_id") + ->fetchAll(PDO::FETCH_KEY_PAIR); + + foreach ($orgs as &$o) { + $id = $o['id']; + $o['drivers_count'] = (int)($drivers[$id] ?? 0); + $o['vehicles_count'] = (int)($vehicles[$id] ?? 0); + $o['active_routes'] = (int)($routes[$id] ?? 0); + $o['active_enrollments'] = (int)($enrollments[$id] ?? 0); + } + unset($o); +} + +jsonSuccess([ + 'orgs' => $orgs, + 'pagination' => ['total' => $total, 'page' => $page, 'per_page' => $perPage], +]); diff --git a/backend/core/Database/Database.php b/backend/core/Database/Database.php index 85647041..7e49abd9 100644 --- a/backend/core/Database/Database.php +++ b/backend/core/Database/Database.php @@ -28,6 +28,12 @@ class Database 'user' => 'DB_RIDE_USER', 'pass' => 'DB_RIDE_PASS', ], + 'transit' => [ + 'name' => 'DB_TRANSIT_NAME', + 'host' => 'DB_TRANSIT_HOST', + 'user' => 'DB_TRANSIT_USER', + 'pass' => 'DB_TRANSIT_PASS', + ], ]; public static function get(string $name = 'main'): PDO diff --git a/backend/core/Services/FcmService.php b/backend/core/Services/FcmService.php index 5ac0740d..f21922d8 100644 --- a/backend/core/Services/FcmService.php +++ b/backend/core/Services/FcmService.php @@ -120,6 +120,55 @@ class FcmService : ['status' => 'error', 'code' => $httpCode, 'response' => $result]; } + // ── إرسال إشعار لـ FCM Topic (قناة المواصلاتي وغيرها) ── + public function sendToTopic( + string $topic, + string $title, + string $body, + array $data = [] + ): array { + $accessToken = $this->getAccessToken(); + if (!$accessToken) return ['status' => 'error', 'message' => 'No access token']; + + if (!file_exists($this->serviceAccountFile)) { + return ['status' => 'error', 'message' => 'Service account file missing']; + } + + $creds = json_decode(file_get_contents($this->serviceAccountFile), true); + $projectId = $creds['project_id']; + $fcmUrl = "https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send"; + + $processedData = array_map( + fn($v) => is_array($v) || is_object($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string)$v, + array_merge($data, ['title' => $title, 'body' => $body]) + ); + + $payload = [ + 'message' => [ + 'topic' => $topic, + 'notification' => ['title' => $title, 'body' => $body], + 'data' => $processedData, + 'android' => ['priority' => 'HIGH'], + ], + ]; + + $ch = curl_init($fcmUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => ["Authorization: Bearer $accessToken", 'Content-Type: application/json; charset=UTF-8'], + CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 5, + ]); + $result = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + return $httpCode === 200 + ? ['status' => 'success'] + : ['status' => 'error', 'code' => $httpCode, 'response' => $result]; + } + // ── Access Token مع Redis Cache ───────────────────────── private function getAccessToken(): ?string { diff --git a/backend/core/Services/OtpService.php b/backend/core/Services/OtpService.php index 0a8104b3..a4ae3972 100644 --- a/backend/core/Services/OtpService.php +++ b/backend/core/Services/OtpService.php @@ -17,15 +17,16 @@ class OtpService } // ── توليد وحفظ OTP ───────────────────────────────────── - public function generate(string $phone): string + // $digits: عدد الأرقام — الافتراضي 6، يمكن تمرير 3 لـ transit (100–999) + public function generate(string $phone, int $digits = 3): string { - // OTP آمن (6 أرقام عشوائية) - $otp = str_pad((string)random_int(100000, 999999), 6, '0', STR_PAD_LEFT); + $min = (int)str_pad('1', $digits, '0'); // digits=3 → 100 | digits=6 → 100000 + $max = (int)str_pad('9', $digits, '9'); // digits=3 → 999 | digits=6 → 999999 + $otp = str_pad((string)random_int($min, $max), $digits, '0', STR_PAD_LEFT); if ($this->redis) { $key = "otp:{$phone}"; $this->redis->setex($key, self::OTP_TTL, password_hash($otp, PASSWORD_BCRYPT)); - // إعادة تعيين عداد المحاولات $this->redis->del("otp:attempts:{$phone}"); } diff --git a/backend/core/helpers.php b/backend/core/helpers.php index 3daae632..91b06f58 100644 --- a/backend/core/helpers.php +++ b/backend/core/helpers.php @@ -234,3 +234,32 @@ function getInternalSocketKey(): string return ''; } +/** + * تطبيع رقم الهاتف إلى الصيغة الدولية بدون + (E.164 بدون +) + * ناتج ثابت لأي مدخل من JO/SY/EG: + * الأردن → 9627XXXXXXXX + * سوريا → 9639XXXXXXX + * مصر → 20XXXXXXXXXX + * يُستخدم دائماً قبل التشفير وقبل الاستعلام. + */ +function normalizePhone(string $phone): string +{ + $d = preg_replace('/\D+/', '', $phone); + + // سوريا: 09X → 963X | 9X (9 أرقام) → 963X | 963... مكتمل + if (strlen($d) === 10 && str_starts_with($d, '09')) return '963' . substr($d, 1); + if (strlen($d) === 9 && str_starts_with($d, '9')) return '963' . $d; + if (strlen($d) === 12 && str_starts_with($d, '963')) return $d; + + // الأردن: 07X → 962X | 7X (9 أرقام) → 962X | 962... مكتمل + if (strlen($d) === 10 && str_starts_with($d, '07')) return '962' . substr($d, 1); + if (strlen($d) === 9 && str_starts_with($d, '7')) return '962' . $d; + if (strlen($d) === 12 && str_starts_with($d, '962')) return $d; + + // مصر: 01X → 20X | 201... مكتمل + if (strlen($d) === 11 && str_starts_with($d, '01')) return '20' . substr($d, 1); + if (strlen($d) === 13 && str_starts_with($d, '20')) return $d; + + return $d; // رقم خارج النطاق — يُعاد كما هو +} + diff --git a/backend/transit/admin/dashboard.php b/backend/transit/admin/dashboard.php new file mode 100644 index 00000000..505fc1b8 --- /dev/null +++ b/backend/transit/admin/dashboard.php @@ -0,0 +1,65 @@ +prepare( + "SELECT t.id, t.status, t.started_at, t.completed_at, t.delay_minutes, t.current_stop_seq, + r.name_ar AS route_name, sc.departure_time, + v.plate AS vehicle_plate, + d.name AS driver_name, d.phone AS driver_phone + FROM transit_trips t + JOIN transit_routes r ON r.id = t.route_id + JOIN transit_schedules sc ON sc.id = t.schedule_id + JOIN transit_vehicles v ON v.id = t.vehicle_id + JOIN transit_drivers d ON d.id = t.driver_id + WHERE t.org_id = ? AND t.trip_date = ? + ORDER BY sc.departure_time ASC" +); +$stTrips->execute([$transit_org_id, $today]); +$trips = $stTrips->fetchAll(); + +foreach ($trips as &$trip) { + $trip['live_position'] = null; + if ($trip['status'] === 'started') { + $pos = transitGetBusPosition((int)$trip['id']); + if ($pos) $trip['live_position'] = ['lat' => (float)$pos['lat'], 'lng' => (float)$pos['lng'], 'ts' => (int)$pos['ts']]; + } +} +unset($trip); + +$stStats = $transit_con->prepare( + "SELECT COUNT(*) total_trips, + SUM(status='started') active_now, + SUM(status='completed') completed_today, + SUM(status='no_show') no_show_today + FROM transit_trips WHERE org_id=? AND trip_date=?" +); +$stStats->execute([$transit_org_id, $today]); +$stats = $stStats->fetch(); + +$stMembers = $transit_con->prepare( + "SELECT COUNT(*) active_members FROM transit_enrollments WHERE org_id=? AND status='active'" +); +$stMembers->execute([$transit_org_id]); + +$stBc = $transit_con->prepare( + "SELECT id, title_ar, body_ar, sent_at, target_type FROM transit_broadcasts + WHERE org_id=? ORDER BY created_at DESC LIMIT 5" +); +$stBc->execute([$transit_org_id]); + +jsonSuccess([ + 'date' => $today, + 'trips' => $trips, + 'stats' => [ + 'total_trips' => (int)$stats['total_trips'], + 'active_now' => (int)$stats['active_now'], + 'completed_today' => (int)$stats['completed_today'], + 'no_show_today' => (int)$stats['no_show_today'], + 'active_members' => (int)$stMembers->fetchColumn(), + ], + 'recent_broadcasts' => $stBc->fetchAll(), +]); diff --git a/backend/transit/admin/login_request.php b/backend/transit/admin/login_request.php new file mode 100644 index 00000000..3e54fa56 --- /dev/null +++ b/backend/transit/admin/login_request.php @@ -0,0 +1,34 @@ +encryptData($phone); +$st = $transit_con->prepare( + "SELECT a.id, o.contract_status + FROM transit_org_admins a + JOIN transit_orgs o ON o.id = a.org_id + WHERE a.phone = ? AND a.is_active = 1 LIMIT 1" +); +$st->execute([$phoneEnc]); +$admin = $st->fetch(); + +if (!$admin) jsonError('Invalid credentials', 401); +if ($admin['contract_status'] === 'terminated') jsonError('Account suspended', 403); + +transitSendAdminOtp($phone); + +jsonSuccess(null, 'OTP sent successfully'); diff --git a/backend/transit/admin/login_verify.php b/backend/transit/admin/login_verify.php new file mode 100644 index 00000000..b4ed1920 --- /dev/null +++ b/backend/transit/admin/login_verify.php @@ -0,0 +1,48 @@ +encryptData($phone); +$st = $transit_con->prepare( + "SELECT a.id, a.org_id, a.name, a.role, a.permissions, + o.name_ar, o.name_en, o.type, o.contract_status, o.logo_url + FROM transit_org_admins a + JOIN transit_orgs o ON o.id = a.org_id + WHERE a.phone = ? AND a.is_active = 1 LIMIT 1" +); +$st->execute([$phoneEnc]); +$admin = $st->fetch(); +if (!$admin) jsonError('Admin not found', 401); + +$token = transitCreateSession((int)$admin['id'], (int)$admin['org_id']); + +jsonSuccess([ + 'token' => $token, + 'expires_in' => 86400, + 'admin' => [ + 'id' => (int)$admin['id'], + 'name' => $admin['name'], + 'role' => $admin['role'], + 'permissions' => json_decode($admin['permissions'] ?? '{}', true), + ], + 'org' => [ + 'id' => (int)$admin['org_id'], + 'name_ar' => $admin['name_ar'], + 'name_en' => $admin['name_en'], + 'type' => $admin['type'], + 'contract_status' => $admin['contract_status'], + 'logo_url' => $admin['logo_url'], + ], +], 'Login successful'); diff --git a/backend/transit/broadcast/send.php b/backend/transit/broadcast/send.php new file mode 100644 index 00000000..ca53000f --- /dev/null +++ b/backend/transit/broadcast/send.php @@ -0,0 +1,32 @@ +prepare( + "INSERT INTO transit_broadcasts + (org_id, sent_by, target_type, target_id, title_ar, body_ar, fcm_topic, sent_at) + VALUES (?,?,?,?,?,?,?,NOW())" +)->execute([$transit_org_id, $transit_admin_id, $targetType, $targetId, $titleAr, $bodyAr, $fcmTopic]); + +$broadcastId = (int)$transit_con->lastInsertId(); + +transitSendTopicNotification( + $fcmTopic, $titleAr, $bodyAr, + ['type' => 'transit_broadcast', 'broadcast_id' => (string)$broadcastId] +); + +jsonSuccess(['broadcast_id' => $broadcastId, 'fcm_topic' => $fcmTopic], 'Broadcast sent'); diff --git a/backend/transit/connect_admin.php b/backend/transit/connect_admin.php new file mode 100644 index 00000000..1aeb0edd --- /dev/null +++ b/backend/transit/connect_admin.php @@ -0,0 +1,33 @@ + 'failure', 'message' => 'Transit service unavailable']); + exit; +} + +// التحقق من الـ session (يُعيد ['admin_id'=>X, 'org_id'=>Y]) +$transit_admin = transitAuthAdmin(); +$transit_admin_id = (int)$transit_admin['admin_id']; +$transit_org_id = (int)$transit_admin['org_id']; diff --git a/backend/transit/connect_app.php b/backend/transit/connect_app.php new file mode 100644 index 00000000..978c2490 --- /dev/null +++ b/backend/transit/connect_app.php @@ -0,0 +1,29 @@ +enforce(RateLimiter::identifier(), 'api'); + +// JWT المعتاد — يُستخرج منه passenger_id أو driver_id +$jwtService = new JwtService($redis); +$decoded = $jwtService->authenticate(); + +$transit_user_id = $decoded->user_id ?? null; +$transit_user_role = $decoded->role ?? 'passenger'; // 'passenger' أو 'driver' + +// اتصال قاعدة بيانات Transit فقط — ممنوع Database::get('main') في ملفات transit/ +try { + $transit_con = Database::get('transit'); +} catch (Exception $e) { + http_response_code(503); + echo json_encode(['status' => 'failure', 'message' => 'Transit service unavailable']); + exit; +} diff --git a/backend/transit/driver/activate.php b/backend/transit/driver/activate.php new file mode 100644 index 00000000..460fd705 --- /dev/null +++ b/backend/transit/driver/activate.php @@ -0,0 +1,41 @@ +encryptData($phone); + +$st = $transit_con->prepare( + "SELECT id, org_id, name, status FROM transit_drivers + WHERE invite_token=? AND phone=? LIMIT 1" +); +$st->execute([$inviteToken, $phoneEnc]); +$driver = $st->fetch(); + +if (!$driver) jsonError('Invalid invite token or phone mismatch', 404); +if ($driver['status'] === 'active') jsonError('Driver already activated', 409); +if ($driver['status'] === 'suspended') jsonError('Account suspended', 403); + +$transit_con->prepare( + "UPDATE transit_drivers SET status='active', activated_at=NOW(), invite_token=NULL WHERE id=?" +)->execute([$driver['id']]); + +jsonSuccess([ + 'driver_id' => (int)$driver['id'], + 'org_id' => (int)$driver['org_id'], + 'name' => $driver['name'], + 'mode' => 'bus', +], 'Driver activated. Welcome to مواصلاتي!'); diff --git a/backend/transit/driver/invite.php b/backend/transit/driver/invite.php new file mode 100644 index 00000000..033076d2 --- /dev/null +++ b/backend/transit/driver/invite.php @@ -0,0 +1,35 @@ +encryptData($phone); + +$chk = $transit_con->prepare( + "SELECT id FROM transit_drivers WHERE phone=? AND org_id=? LIMIT 1" +); +$chk->execute([$phoneEnc, $transit_org_id]); +if ($chk->fetch()) jsonError('Driver phone already registered in this organization', 409); + +$inviteToken = bin2hex(random_bytes(24)); +$mainDriverId = filterRequest('main_driver_id'); + +$transit_con->prepare( + "INSERT INTO transit_drivers + (org_id,name,phone,license_number,main_driver_id,invite_token,invite_sent_at,status) + VALUES (?,?,?,?,?,?,NOW(),'invited')" +)->execute([$transit_org_id, $name, $phoneEnc, $license, $mainDriverId, $inviteToken]); + +$driverId = (int)$transit_con->lastInsertId(); + +$appLink = getenv('APP_DEEP_LINK_BASE') ?: 'https://siromove.com/driver/transit-activate'; +$message = "مرحباً {$name}، تمت إضافتك كسائق في منصة مواصلاتي.\nفعّل حسابك: {$appLink}?token={$inviteToken}"; +sendWhatsAppFromServer($phone, $message); + +jsonSuccess(['driver_id' => $driverId, 'invite_token' => $inviteToken], 'Driver invited successfully'); diff --git a/backend/transit/driver/list.php b/backend/transit/driver/list.php new file mode 100644 index 00000000..bd4292ba --- /dev/null +++ b/backend/transit/driver/list.php @@ -0,0 +1,27 @@ +prepare($sql); +$st->execute($params); +$drivers = $st->fetchAll(); + +// فك تشفير هواتف السائقين للعرض +foreach ($drivers as &$d) { + if (!empty($d['phone'])) { + $d['phone'] = $encryptionHelper->decryptData($d['phone']) ?: '***'; + } +} +unset($d); + +jsonSuccess(['drivers' => $drivers]); diff --git a/backend/transit/driver/me.php b/backend/transit/driver/me.php new file mode 100644 index 00000000..8cc53767 --- /dev/null +++ b/backend/transit/driver/me.php @@ -0,0 +1,30 @@ +prepare( + "SELECT d.id, d.org_id, d.name, d.status, + o.name_ar AS org_name, o.type AS org_type + FROM transit_drivers d + JOIN transit_orgs o ON o.id = d.org_id + WHERE d.main_driver_id = ? LIMIT 1" +); +$st->execute([(string)$transit_user_id]); +$driver = $st->fetch(); + +if (!$driver) { + jsonSuccess(['is_bus_driver' => false]); +} + +jsonSuccess([ + 'is_bus_driver' => true, + 'driver_transit_id' => (int)$driver['id'], + 'org_id' => (int)$driver['org_id'], + 'org_name' => $driver['org_name'], + 'org_type' => $driver['org_type'], + 'status' => $driver['status'], +]); diff --git a/backend/transit/enrollment/activate.php b/backend/transit/enrollment/activate.php new file mode 100644 index 00000000..1cddc89f --- /dev/null +++ b/backend/transit/enrollment/activate.php @@ -0,0 +1,66 @@ +prepare( + "SELECT id, name_ar, contract_status FROM transit_orgs WHERE id=? LIMIT 1" +); +$chkOrg->execute([$orgId]); +$org = $chkOrg->fetch(); +if (!$org) jsonError('Organization not found', 404); +if ($org['contract_status'] === 'terminated') jsonError('Organization account inactive', 403); + +$chkEnroll = $transit_con->prepare( + "SELECT id, status FROM transit_enrollments WHERE org_id=? AND passenger_id=? LIMIT 1" +); +$chkEnroll->execute([$orgId, $transit_user_id]); +$existing = $chkEnroll->fetch(); +if ($existing && in_array($existing['status'], ['active', 'pending'])) { + jsonError('Enrollment already exists', 409); +} + +$encryptedId = $encryptionHelper->encryptData($studentId); + +// مطابقة الكشف: هل الرقم موجود في كشف مستورد بدون passenger_id؟ +$stRoster = $transit_con->prepare( + "SELECT id, member_name FROM transit_enrollments + WHERE org_id=? AND student_id=? AND passenger_id IS NULL LIMIT 1" +); +$stRoster->execute([$orgId, $encryptedId]); +$rosterRow = $stRoster->fetch(); + +if ($rosterRow) { + $transit_con->prepare( + "UPDATE transit_enrollments + SET passenger_id=?, status='active', verified_at=NOW(), verify_method='roster_phone_match' + WHERE id=?" + )->execute([$transit_user_id, $rosterRow['id']]); + + jsonSuccess([ + 'enrollment_id' => (int)$rosterRow['id'], + 'org_name' => $org['name_ar'], + 'status' => 'active', + 'verify_method' => 'roster_phone_match', + ], 'تم التحقق من عضويتك بنجاح!'); +} + +// طلب يدوي ينتظر موافقة المشرف +$transit_con->prepare( + "INSERT INTO transit_enrollments + (org_id, passenger_id, student_id, verify_method, status) + VALUES (?,?,?,'manual_admin','pending')" +)->execute([$orgId, $transit_user_id, $encryptedId]); + +jsonSuccess([ + 'enrollment_id' => (int)$transit_con->lastInsertId(), + 'org_name' => $org['name_ar'], + 'status' => 'pending', +], 'طلبك قيد المراجعة. سيتم إشعارك عند التفعيل.'); diff --git a/backend/transit/enrollment/approve.php b/backend/transit/enrollment/approve.php new file mode 100644 index 00000000..aa3716fd --- /dev/null +++ b/backend/transit/enrollment/approve.php @@ -0,0 +1,39 @@ +prepare( + "SELECT id, passenger_id, status FROM transit_enrollments WHERE id=? AND org_id=? LIMIT 1" +); +$st->execute([$enrollId, $transit_org_id]); +$enroll = $st->fetch(); + +if (!$enroll) jsonError('Enrollment not found', 404); +if ($enroll['status'] !== 'pending') jsonError('Enrollment is not pending', 409); + +$newStatus = ($action === 'approve') ? 'active' : 'suspended'; +$transit_con->prepare( + "UPDATE transit_enrollments + SET status=?, verified_at=NOW(), verify_method='manual_admin', expires_at=? + WHERE id=?" +)->execute([$newStatus, $expiresAt, $enrollId]); + +if ($action === 'approve' && $enroll['passenger_id']) { + transitSendTopicNotification( + 'passenger_' . $enroll['passenger_id'], + 'تم تفعيل عضويتك', + 'يمكنك الآن متابعة باصات جامعتك عبر مواصلاتي', + ['type' => 'transit_enrollment_approved'] + ); +} + +jsonSuccess(['enrollment_id' => $enrollId, 'status' => $newStatus]); diff --git a/backend/transit/enrollment/import_roster.php b/backend/transit/enrollment/import_roster.php new file mode 100644 index 00000000..58427a8d --- /dev/null +++ b/backend/transit/enrollment/import_roster.php @@ -0,0 +1,79 @@ +prepare( + "INSERT INTO transit_rosters (org_id, uploaded_by, filename, total_rows, semester, notes) + VALUES (?,?,?,?,?,?)" +)->execute([$transit_org_id, $transit_admin_id, $_FILES['file']['name'], $totalRows, $semester, $notes]); +$rosterId = (int)$transit_con->lastInsertId(); + +$insertSt = $transit_con->prepare( + "INSERT IGNORE INTO transit_enrollments + (org_id, passenger_id, student_id, member_name, verify_method, status, roster_id) + VALUES (?,NULL,?,?,'roster_manual','pending',?)" +); + +$matchedRows = 0; +$newEnroll = 0; + +foreach ($rows as $row) { + $sid = trim($row['student_id'] ?? $row['id'] ?? ''); + $name = trim($row['name'] ?? $row['full_name'] ?? ''); + if (!$sid) continue; + + $encSid = $encryptionHelper->encryptData($sid); + + $chk = $transit_con->prepare( + "SELECT id, passenger_id FROM transit_enrollments WHERE org_id=? AND student_id=? LIMIT 1" + ); + $chk->execute([$transit_org_id, $encSid]); + $existing = $chk->fetch(); + + if ($existing) { + $transit_con->prepare( + "UPDATE transit_enrollments SET member_name=?, roster_id=? WHERE id=?" + )->execute([$name, $rosterId, $existing['id']]); + if ($existing['passenger_id']) $matchedRows++; + } else { + $insertSt->execute([$transit_org_id, $encSid, $name, $rosterId]); + $newEnroll++; + } +} + +$transit_con->prepare( + "UPDATE transit_rosters SET matched_rows=?, new_enrollments=? WHERE id=?" +)->execute([$matchedRows, $newEnroll, $rosterId]); + +jsonSuccess([ + 'roster_id' => $rosterId, + 'total_rows' => $totalRows, + 'new_enrollments' => $newEnroll, + 'matched' => $matchedRows, +], 'Roster imported successfully'); diff --git a/backend/transit/enrollment/list.php b/backend/transit/enrollment/list.php new file mode 100644 index 00000000..a8517c2a --- /dev/null +++ b/backend/transit/enrollment/list.php @@ -0,0 +1,36 @@ +prepare("SELECT COUNT(*) FROM transit_enrollments WHERE $where"); +$countSt->execute($params); +$total = (int)$countSt->fetchColumn(); + +$params[] = $perPage; +$params[] = $offset; +$st = $transit_con->prepare( + "SELECT id, passenger_id, member_name, status, verify_method, + verified_at, expires_at, created_at + FROM transit_enrollments + WHERE $where ORDER BY created_at DESC LIMIT ? OFFSET ?" +); +$st->execute($params); +$enrollments = $st->fetchAll(); + +jsonSuccess([ + 'enrollments' => $enrollments, + 'pagination' => ['total' => $total, 'page' => $page, 'per_page' => $perPage], +]); diff --git a/backend/transit/enrollment/my_enrollments.php b/backend/transit/enrollment/my_enrollments.php new file mode 100644 index 00000000..c505e6bc --- /dev/null +++ b/backend/transit/enrollment/my_enrollments.php @@ -0,0 +1,18 @@ +prepare( + "SELECT e.id, e.org_id, e.status, e.verify_method, e.expires_at, e.created_at, + o.name_ar AS org_name, o.name_en AS org_name_en, o.type AS org_type, o.logo_url + FROM transit_enrollments e + JOIN transit_orgs o ON o.id = e.org_id + WHERE e.passenger_id = ? + ORDER BY e.created_at DESC" +); +$st->execute([$transit_user_id]); + +jsonSuccess(['enrollments' => $st->fetchAll()]); diff --git a/backend/transit/functions.php b/backend/transit/functions.php new file mode 100644 index 00000000..a7d5d102 --- /dev/null +++ b/backend/transit/functions.php @@ -0,0 +1,205 @@ +prepare( + "INSERT INTO transit_sessions (admin_id, org_id, token_hash, ip, user_agent, expires_at) + VALUES (?,?,?,?,?,?)" + )->execute([$adminId, $orgId, $hash, $ip, $ua, $expiresAt]); + + if ($redis) { + $redis->setEx( + "transit:session:{$hash}", + 86400, + json_encode(['admin_id' => $adminId, 'org_id' => $orgId]) + ); + } + + return $token; +} + +function transitAuthAdmin(): array +{ + global $redis; + + $header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['HTTP_X_TRANSIT_TOKEN'] ?? ''; + $token = str_replace('Bearer ', '', $header); + if (!$token) jsonError('Missing admin session token', 401); + + $hash = hash('sha256', $token); + + if ($redis) { + $val = $redis->get("transit:session:{$hash}"); + if ($val) { + $data = json_decode($val, true); + if ($data) return $data; + } + jsonError('Session expired or invalid', 401); + } + + // Fallback MySQL + $con = Database::get('transit'); + $st = $con->prepare( + "SELECT admin_id, org_id FROM transit_sessions + WHERE token_hash=? AND expires_at > NOW() LIMIT 1" + ); + $st->execute([$hash]); + $row = $st->fetch(); + if (!$row) jsonError('Session expired or invalid', 401); + return $row; +} + +// ── OTP مشرف الويب (يستخدم OtpService الموجود) ───────────── + +function transitSendAdminOtp(string $phone): void +{ + global $redis; + + $otpSvc = new OtpService($redis); + $otp = $otpSvc->generate($phone, 3); // 3 أرقام فقط (100–999) حسب سياسة مواصلاتي + + $message = "رمز تسجيل الدخول لمنصة مواصلاتي: {$otp}\nصالح لمدة 5 دقائق."; + sendWhatsAppFromServer($phone, $message); +} + +function transitVerifyAdminOtp(string $phone, string $otp): bool +{ + global $redis; + $otpSvc = new OtpService($redis); + return $otpSvc->verify($phone, $otp); +} + +function transitIsOtpLocked(string $phone): bool +{ + global $redis; + $otpSvc = new OtpService($redis); + return $otpSvc->isLocked($phone); +} + +// ── تشفير الرقم الجامعي / الوظيفي (يستخدم EncryptionHelper) ─ + +function transitEncryptStudentId(string $raw): string +{ + global $encryptionHelper; + return $encryptionHelper->encryptData($raw); +} + +function transitDecryptStudentId(string $enc): string +{ + global $encryptionHelper; + return (string)$encryptionHelper->decryptData($enc); +} + +// ── إشعارات FCM Topic للخطوط ──────────────────────────────── + +function transitRouteTopic(int $routeId): string +{ + return 'transit_route_' . $routeId; +} + +function transitOrgTopic(int $orgId): string +{ + return 'transit_org_' . $orgId; +} + +function transitSendTopicNotification(string $topic, string $title, string $body, array $data = []): void +{ + global $redis; + + $fcm = new FcmService($redis); + $result = $fcm->sendToTopic($topic, $title, $body, $data); + + if ($result['status'] !== 'success') { + appLog("[TRANSIT][FCM] Topic push failed on '{$topic}': " . json_encode($result), 'WARNING'); + } +} + +// ── موقع الباص في Redis سيرفر الموقع ($redisLocation) ──────── +// موقع الباص يكتبه driver_socket على Redis سيرفر الموقع (بدون بادئة siro:) +// تماماً مثل موقع السائق العادي — لذلك نقرؤه/نكتبه عبر $redisLocation +// وليس $redis الرئيسي (الذي يضيف بادئة siro: ولا يراه السوكت). + +function transitUpdateBusPosition(int $tripId, float $lat, float $lng): void +{ + global $redisLocation; + if (!$redisLocation) return; + + $redisLocation->hMSet("transit:trip:{$tripId}:pos", ['lat' => $lat, 'lng' => $lng, 'ts' => time()]); + $redisLocation->expire("transit:trip:{$tripId}:pos", 86400); +} + +function transitGetBusPosition(int $tripId): ?array +{ + global $redisLocation; + if (!$redisLocation) return null; + + $pos = $redisLocation->hGetAll("transit:trip:{$tripId}:pos"); + return ($pos && isset($pos['lat'])) ? $pos : null; +} + +// ── ملكية الرحلة (Trip Ownership) ───────────────────────────── +// تُكتب عند بدء الرحلة على Redis سيرفر الموقع، ليتحقق منها driver_socket +// قبل قبول أي update_bus_location — يمنع أي سائق آخر من انتحال trip_id +// أو البث لخط لا يملكه. main_driver_id هو نفسه sub في JWT (الحساب في +// جدول driver الرئيسي)، وليس transit_drivers.id. + +function transitSetTripOwner(int $tripId, string $mainDriverId, int $routeId): void +{ + global $redisLocation; + if (!$redisLocation || !$mainDriverId) return; + + $key = "transit:trip:{$tripId}:owner"; + $redisLocation->hMSet($key, ['driver_id' => $mainDriverId, 'route_id' => $routeId]); + $redisLocation->expire($key, 86400); +} + +function transitClearTripOwner(int $tripId): void +{ + global $redisLocation; + if (!$redisLocation) return; + + $redisLocation->del("transit:trip:{$tripId}:owner"); +} + +// ── days_mask helpers ──────────────────────────────────────── +// bit0=Sun … bit6=Sat | 62 = 0b0111110 = الأحد–الخميس + +function transitDayActive(int $mask): bool +{ + return (bool)(($mask >> (int)date('w')) & 1); +} diff --git a/backend/transit/notification/subscribe.php b/backend/transit/notification/subscribe.php new file mode 100644 index 00000000..6e120764 --- /dev/null +++ b/backend/transit/notification/subscribe.php @@ -0,0 +1,39 @@ +prepare( + "SELECT e.id + FROM transit_enrollments e + JOIN transit_routes r ON r.org_id = e.org_id + WHERE r.id=? AND e.passenger_id=? AND e.status='active' LIMIT 1" +); +$chk->execute([$routeId, $transit_user_id]); +if (!$chk->fetch()) jsonError('Active enrollment required to subscribe', 403); + +if ($stopId) { + $transit_con->prepare( + "UPDATE transit_enrollments + SET preferred_stop_id=?, preferred_route_id=? + WHERE passenger_id=? AND org_id=(SELECT org_id FROM transit_routes WHERE id=? LIMIT 1)" + )->execute([$stopId, $routeId, $transit_user_id, $routeId]); +} + +$topic = transitRouteTopic($routeId); + +// اشتراك FCM Topic يتم من التطبيق مباشرة (firebaseMessaging.subscribeToTopic) +// هنا نسجّل فقط المحطة المفضلة ونؤكد الموضوع +appLog("[TRANSIT][NOTIFY] passenger {$transit_user_id} subscribed to {$topic}"); + +jsonSuccess([ + 'topic' => $topic, + 'preferred_stop_id' => $stopId, +], 'Subscribed to route notifications'); diff --git a/backend/transit/org/browse.php b/backend/transit/org/browse.php new file mode 100644 index 00000000..0ef727e2 --- /dev/null +++ b/backend/transit/org/browse.php @@ -0,0 +1,24 @@ +prepare( + "SELECT id, type, country, city, name_ar, name_en, logo_url + FROM transit_orgs WHERE $where ORDER BY name_ar ASC LIMIT 200" +); +$st->execute($params); + +jsonSuccess(['orgs' => $st->fetchAll()]); diff --git a/backend/transit/org/register.php b/backend/transit/org/register.php new file mode 100644 index 00000000..658cf77c --- /dev/null +++ b/backend/transit/org/register.php @@ -0,0 +1,59 @@ +prepare("SELECT id FROM transit_orgs WHERE name_ar=? AND country=? LIMIT 1"); +$chk->execute([$nameAr, $country]); +if ($chk->fetch()) jsonError('Organization already exists', 409); + +$adminPhoneEnc = $encryptionHelper->encryptData($adminPhone); +$contactPhoneRaw = normalizePhone(filterRequest('contact_phone') ?? ''); +$contactPhoneEnc = $contactPhoneRaw ? $encryptionHelper->encryptData($contactPhoneRaw) : null; + +$transit_con->beginTransaction(); +try { + $transit_con->prepare( + "INSERT INTO transit_orgs (type,country,city,name_ar,name_en,contact_phone,contact_email,website,contract_status,trial_ends_at) + VALUES (?,?,?,?,?,?,?,?,'trial',?)" + )->execute([$type,$country,$city,$nameAr,$nameEn,$contactPhoneEnc, + filterRequest('contact_email'),filterRequest('website'),$trialEndsAt]); + + $orgId = (int)$transit_con->lastInsertId(); + + $transit_con->prepare( + "INSERT INTO transit_org_admins (org_id, name, phone, role) VALUES (?,?,?,?)" + )->execute([$orgId, $adminName, $adminPhoneEnc, $adminRole]); + + $transit_con->commit(); +} catch (Throwable $e) { + $transit_con->rollBack(); + appLog('[TRANSIT][ORG][register] ' . $e->getMessage(), 'ERROR'); + jsonError('Failed to create organization', 500); +} + +jsonSuccess(['org_id' => $orgId, 'name_ar' => $nameAr, 'type' => $type, 'country' => $country], 'Organization registered successfully'); diff --git a/backend/transit/route/add.php b/backend/transit/route/add.php new file mode 100644 index 00000000..6decd41f --- /dev/null +++ b/backend/transit/route/add.php @@ -0,0 +1,56 @@ +beginTransaction(); +try { + $transit_con->prepare( + "INSERT INTO transit_routes + (org_id,name_ar,name_en,direction,polyline,distance_km,duration_min,status,created_by) + VALUES (?,?,?,?,?,?,?,'draft',?)" + )->execute([ + $transit_org_id, $nameAr, + filterRequest('name_en'), + $direction, + filterRequest('polyline'), + filterRequest('distance_km','float'), + filterRequest('duration_min','int'), + $transit_admin_id, + ]); + + $routeId = (int)$transit_con->lastInsertId(); + + $insertStop = $transit_con->prepare( + "INSERT INTO transit_stops + (route_id,org_id,sequence,name_ar,name_en,latitude,longitude,geofence_radius,eta_offset_min,is_major) + VALUES (?,?,?,?,?,?,?,?,?,?)" + ); + foreach ($stops as $i => $s) { + if (empty($s['lat']) || empty($s['lng']) || empty($s['name_ar'])) continue; + $insertStop->execute([ + $routeId, $transit_org_id, $i + 1, + $s['name_ar'], $s['name_en'] ?? null, + (float)$s['lat'], (float)$s['lng'], + (int)($s['radius'] ?? 150), + isset($s['eta_offset_min']) ? (int)$s['eta_offset_min'] : null, + (int)($s['is_major'] ?? 0), + ]); + } + + $transit_con->commit(); +} catch (Throwable $e) { + $transit_con->rollBack(); + appLog('[TRANSIT][ROUTE] ' . $e->getMessage(), 'ERROR'); + jsonError('Failed to save route', 500); +} + +jsonSuccess(['route_id' => $routeId, 'stops_added' => count($stops)], 'Route saved as draft'); diff --git a/backend/transit/route/for_org.php b/backend/transit/route/for_org.php new file mode 100644 index 00000000..24d471ae --- /dev/null +++ b/backend/transit/route/for_org.php @@ -0,0 +1,34 @@ +prepare( + "SELECT id FROM transit_enrollments WHERE org_id=? AND passenger_id=? AND status='active' LIMIT 1" +); +$chk->execute([$orgId, $transit_user_id]); +if (!$chk->fetch()) jsonError('Active enrollment required to view routes', 403); + +$st = $transit_con->prepare( + "SELECT id, name_ar, name_en, direction, distance_km, duration_min, status + FROM transit_routes WHERE org_id=? AND status='active' ORDER BY name_ar ASC" +); +$st->execute([$orgId]); +$routes = $st->fetchAll(); + +if ($routes) { + $ids = implode(',', array_map('intval', array_column($routes, 'id'))); + $counts = $transit_con->query( + "SELECT route_id, COUNT(*) c FROM transit_stops WHERE route_id IN ($ids) GROUP BY route_id" + )->fetchAll(PDO::FETCH_KEY_PAIR); + foreach ($routes as &$r) $r['stop_count'] = (int)($counts[$r['id']] ?? 0); + unset($r); +} + +jsonSuccess(['routes' => $routes]); diff --git a/backend/transit/route/get.php b/backend/transit/route/get.php new file mode 100644 index 00000000..0188e475 --- /dev/null +++ b/backend/transit/route/get.php @@ -0,0 +1,32 @@ +prepare("SELECT * FROM transit_routes WHERE id=? AND org_id=? LIMIT 1"); +$st->execute([$routeId, $transit_org_id]); +$route = $st->fetch(); +if (!$route) jsonError('Route not found', 404); + +$stStops = $transit_con->prepare( + "SELECT id,sequence,name_ar,name_en,latitude,longitude,geofence_radius,eta_offset_min,is_major + FROM transit_stops WHERE route_id=? ORDER BY sequence ASC" +); +$stStops->execute([$routeId]); +$route['stops'] = $stStops->fetchAll(); + +$stSch = $transit_con->prepare( + "SELECT s.id, s.departure_time, s.days_mask, s.valid_from, s.valid_until, s.is_active, + d.name AS driver_name, v.plate AS vehicle_plate + FROM transit_schedules s + LEFT JOIN transit_drivers d ON d.id=s.driver_id + LEFT JOIN transit_vehicles v ON v.id=s.vehicle_id + WHERE s.route_id=? AND s.org_id=? ORDER BY s.departure_time ASC" +); +$stSch->execute([$routeId, $transit_org_id]); +$route['schedules'] = $stSch->fetchAll(); + +jsonSuccess(['route' => $route]); diff --git a/backend/transit/route/list.php b/backend/transit/route/list.php new file mode 100644 index 00000000..1ac32059 --- /dev/null +++ b/backend/transit/route/list.php @@ -0,0 +1,28 @@ +prepare($sql); +$st->execute($params); +$routes = $st->fetchAll(); + +if ($routes) { + $ids = implode(',', array_map('intval', array_column($routes, 'id'))); + $cntSt = $transit_con->query( + "SELECT route_id, COUNT(*) cnt FROM transit_stops WHERE route_id IN ($ids) GROUP BY route_id" + ); + $counts = $cntSt ? array_column($cntSt->fetchAll(), 'cnt', 'route_id') : []; + foreach ($routes as &$r) $r['stop_count'] = (int)($counts[$r['id']] ?? 0); + unset($r); +} + +jsonSuccess(['routes' => $routes]); diff --git a/backend/transit/schedule/add.php b/backend/transit/schedule/add.php new file mode 100644 index 00000000..141c91bf --- /dev/null +++ b/backend/transit/schedule/add.php @@ -0,0 +1,38 @@ +prepare("SELECT id FROM transit_routes WHERE id=? AND org_id=? LIMIT 1"); +$chk->execute([$routeId, $transit_org_id]); +if (!$chk->fetch()) jsonError('Route not found or access denied', 404); + +if ($vehicleId) { + $chkV = $transit_con->prepare("SELECT id FROM transit_vehicles WHERE id=? AND org_id=? LIMIT 1"); + $chkV->execute([$vehicleId, $transit_org_id]); + if (!$chkV->fetch()) jsonError('Vehicle not found or access denied', 404); +} + +if ($driverId) { + $chkD = $transit_con->prepare("SELECT id FROM transit_drivers WHERE id=? AND org_id=? AND status='active' LIMIT 1"); + $chkD->execute([$driverId, $transit_org_id]); + if (!$chkD->fetch()) jsonError('Driver not found or not active', 404); +} + +$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]); + +jsonSuccess(['schedule_id' => (int)$transit_con->lastInsertId()], 'Schedule added'); diff --git a/backend/transit/schema_transit.sql b/backend/transit/schema_transit.sql new file mode 100644 index 00000000..bfdbfdc1 --- /dev/null +++ b/backend/transit/schema_transit.sql @@ -0,0 +1,342 @@ +-- ============================================================= +-- schema_transit.sql — قاعدة بيانات منصة مواصلاتي (siroTransitDb) +-- عزل كامل عن main/ride/tracking — ممنوع أي JOIN خارجي +-- الربط بالنظام الرئيسي عبر passenger_id / driver_id فقط +-- ============================================================= + +SET FOREIGN_KEY_CHECKS = 0; +SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; +SET time_zone = "+00:00"; + +-- ----------------------------------------------------------------- +-- 1. transit_orgs — المؤسسات (جامعات، مدارس، فنادق، شركات، ناقلون) +-- ----------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `transit_orgs` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `type` ENUM('university','school','hotel','company','transporter') NOT NULL, + `country` CHAR(2) NOT NULL COMMENT 'JO | SY | EG | ...', + `city` VARCHAR(80) NOT NULL, + `name_ar` VARCHAR(200) NOT NULL, + `name_en` VARCHAR(200) NOT NULL, + `logo_url` VARCHAR(500) DEFAULT NULL, + `campus_polygon` JSON DEFAULT NULL COMMENT 'حدود الحرم — مصفوفة [{lat,lng}]', + `website` VARCHAR(300) DEFAULT NULL, + `contact_phone` VARCHAR(30) DEFAULT NULL, + `contact_email` VARCHAR(150) DEFAULT NULL, + `contract_status` ENUM('trial','active','suspended','terminated') NOT NULL DEFAULT 'trial', + `trial_ends_at` DATE DEFAULT NULL, + `notes` TEXT DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_country_type` (`country`, `type`), + KEY `idx_contract_status` (`contract_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------- +-- 2. transit_org_links — ربط ناقل ↔ مؤسسة (النمط الثاني) +-- جامعة حكومية لا تملك أسطولاً → تُخدَم بناقل مسجّل +-- ----------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `transit_org_links` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `institution_id` INT UNSIGNED NOT NULL COMMENT 'org_id للمؤسسة (جامعة/مدرسة)', + `transporter_id` INT UNSIGNED NOT NULL COMMENT 'org_id للناقل (type=transporter)', + `status` ENUM('active','inactive') NOT NULL DEFAULT 'active', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_link` (`institution_id`, `transporter_id`), + KEY `idx_transporter` (`transporter_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------- +-- 3. transit_org_admins — مشرفو المؤسسة (لوحة تحكم الويب) +-- ----------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `transit_org_admins` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `org_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(120) NOT NULL, + `phone` VARCHAR(25) NOT NULL, + `role` ENUM('owner','transport_manager','dispatcher') NOT NULL DEFAULT 'transport_manager', + `permissions` JSON DEFAULT NULL COMMENT '{"routes":true,"drivers":true,"roster":true,"broadcast":true}', + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_phone_org` (`phone`, `org_id`), + KEY `idx_org` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------- +-- 4. transit_sessions — جلسات تسجيل دخول مشرف الويب (OTP-based) +-- ----------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `transit_sessions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `admin_id` INT UNSIGNED NOT NULL, + `org_id` INT UNSIGNED NOT NULL, + `token_hash` VARCHAR(64) NOT NULL COMMENT 'sha256 للـ session token', + `ip` VARCHAR(45) DEFAULT NULL, + `user_agent` VARCHAR(300) DEFAULT NULL, + `expires_at` TIMESTAMP NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_token` (`token_hash`), + KEY `idx_admin` (`admin_id`), + KEY `idx_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------- +-- 5. transit_vehicles — الباصات والفانات المملوكة للمؤسسة +-- ----------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `transit_vehicles` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `org_id` INT UNSIGNED NOT NULL, + `plate` VARCHAR(30) NOT NULL, + `make` VARCHAR(50) DEFAULT NULL, + `model` VARCHAR(50) DEFAULT NULL, + `year` SMALLINT DEFAULT NULL, + `color` VARCHAR(30) DEFAULT NULL, + `capacity` TINYINT NOT NULL DEFAULT 30, + `vehicle_type` ENUM('bus','minibus','van','other') NOT NULL DEFAULT 'bus', + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `notes` VARCHAR(300) DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_plate` (`plate`), + KEY `idx_org` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------- +-- 6. transit_drivers — سائقو الباصات (يُنشئهم مشرف المؤسسة) +-- ----------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `transit_drivers` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `org_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(120) NOT NULL, + `phone` VARCHAR(25) NOT NULL, + `license_number` VARCHAR(50) DEFAULT NULL, + `license_img_url` VARCHAR(500) DEFAULT NULL, + `main_driver_id` VARCHAR(100) DEFAULT NULL COMMENT 'معرّف في جدول driver الرئيسي إن كان كابتن مشاوير أيضاً', + `invite_token` VARCHAR(64) DEFAULT NULL COMMENT 'رمز الدعوة للتفعيل الأولي', + `invite_sent_at` TIMESTAMP DEFAULT NULL, + `activated_at` TIMESTAMP DEFAULT NULL, + `status` ENUM('invited','active','suspended') NOT NULL DEFAULT 'invited', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_phone_org` (`phone`, `org_id`), + KEY `idx_org` (`org_id`), + KEY `idx_invite_token` (`invite_token`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------- +-- 7. transit_routes — خطوط النقل +-- ----------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `transit_routes` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `org_id` INT UNSIGNED NOT NULL, + `name_ar` VARCHAR(150) NOT NULL, + `name_en` VARCHAR(150) DEFAULT NULL, + `direction` ENUM('outbound','inbound','circular') NOT NULL DEFAULT 'outbound', + `polyline` TEXT DEFAULT NULL COMMENT 'Encoded polyline للمسار', + `distance_km` DECIMAL(7,2) DEFAULT NULL, + `duration_min` SMALLINT DEFAULT NULL, + `status` ENUM('draft','active','suspended') NOT NULL DEFAULT 'draft', + `created_by` INT UNSIGNED DEFAULT NULL COMMENT 'admin_id من أنشأه', + `approved_by` INT UNSIGNED DEFAULT NULL COMMENT 'admin_id من اعتمده (فريق سيرو)', + `approved_at` TIMESTAMP DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_org_status` (`org_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------- +-- 8. transit_stops — محطات الخط مع جيوفينس +-- ----------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `transit_stops` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `route_id` INT UNSIGNED NOT NULL, + `org_id` INT UNSIGNED NOT NULL, + `sequence` TINYINT NOT NULL COMMENT 'ترتيب المحطة في الخط (1، 2، 3…)', + `name_ar` VARCHAR(120) NOT NULL, + `name_en` VARCHAR(120) DEFAULT NULL, + `latitude` DECIMAL(10,7) NOT NULL, + `longitude` DECIMAL(10,7) NOT NULL, + `geofence_radius` SMALLINT NOT NULL DEFAULT 150 COMMENT 'نصف قطر الجيوفينس بالمتر', + `eta_offset_min` SMALLINT DEFAULT NULL COMMENT 'الإزاحة الزمنية عن وقت الانطلاق بالدقائق', + `is_major` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'محطة رئيسية (تظهر بارزة للطالب)', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_route_seq` (`route_id`, `sequence`), + KEY `idx_org` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------- +-- 9. transit_schedules — جداول انطلاق الخطوط +-- ----------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `transit_schedules` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `route_id` INT UNSIGNED NOT NULL, + `org_id` INT UNSIGNED NOT NULL, + `vehicle_id` INT UNSIGNED DEFAULT NULL COMMENT 'الباص المخصص لهذا الجدول (NULL = يُحدد يومياً)', + `driver_id` INT UNSIGNED DEFAULT NULL COMMENT 'السائق المخصص (NULL = يُحدد يومياً)', + `days_mask` TINYINT NOT NULL DEFAULT 62 COMMENT 'قناع الأيام: bit0=أحد…bit6=سبت (62=الأحد–الخميس)', + `departure_time` TIME NOT NULL, + `valid_from` DATE NOT NULL, + `valid_until` DATE DEFAULT NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_route_active` (`route_id`, `is_active`), + KEY `idx_org` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------- +-- 10. transit_trips — الرحلات اليومية الفعلية +-- ----------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `transit_trips` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `schedule_id` INT UNSIGNED NOT NULL, + `route_id` INT UNSIGNED NOT NULL, + `org_id` INT UNSIGNED NOT NULL, + `driver_id` INT UNSIGNED NOT NULL, + `vehicle_id` INT UNSIGNED NOT NULL, + `trip_date` DATE NOT NULL, + `status` ENUM('scheduled','started','completed','cancelled','no_show') NOT NULL DEFAULT 'scheduled', + `started_at` TIMESTAMP DEFAULT NULL, + `completed_at` TIMESTAMP DEFAULT NULL, + `delay_minutes` TINYINT DEFAULT 0, + `delay_reason` VARCHAR(300) DEFAULT NULL, + `current_stop_seq` TINYINT DEFAULT NULL COMMENT 'آخر محطة جيوفينس مرّ عليها الباص', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_schedule_date` (`schedule_id`, `trip_date`), + KEY `idx_route_date` (`route_id`, `trip_date`), + KEY `idx_org_date` (`org_id`, `trip_date`), + KEY `idx_driver_date` (`driver_id`, `trip_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------- +-- 11. transit_enrollments — عضويات الطلاب/الموظفين في المؤسسة +-- ----------------------------------------------------------------- +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 الرئيسي', + `student_id` VARCHAR(50) NOT NULL COMMENT 'الرقم الجامعي/الوظيفي (encrypted)', + `member_name` VARCHAR(120) DEFAULT NULL COMMENT 'الاسم من كشف الجامعة', + `preferred_stop_id` INT UNSIGNED DEFAULT NULL COMMENT 'محطتي المفضلة', + `preferred_route_id` INT UNSIGNED DEFAULT NULL, + `verify_method` ENUM('api','roster_phone_match','roster_manual','manual_admin') NOT NULL DEFAULT 'roster_manual', + `status` ENUM('pending','active','suspended','expired') NOT NULL DEFAULT 'pending', + `verified_at` TIMESTAMP DEFAULT NULL, + `expires_at` DATE DEFAULT NULL COMMENT 'نهاية الفصل الدراسي', + `roster_id` INT UNSIGNED DEFAULT NULL COMMENT 'الكشف الذي جاءت منه العضوية', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_org_passenger` (`org_id`, `passenger_id`), + KEY `idx_org_status` (`org_id`, `status`), + KEY `idx_passenger` (`passenger_id`), + KEY `idx_preferred_route` (`preferred_route_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------- +-- 12. transit_rosters — سجلات استيراد كشوف الطلاب +-- ----------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `transit_rosters` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `org_id` INT UNSIGNED NOT NULL, + `uploaded_by` INT UNSIGNED NOT NULL COMMENT 'admin_id', + `filename` VARCHAR(200) DEFAULT NULL, + `total_rows` INT DEFAULT 0, + `matched_rows` INT DEFAULT 0 COMMENT 'طلاب طابق هاتفهم حساباً موجوداً', + `new_enrollments` INT DEFAULT 0, + `expired_count` INT DEFAULT 0 COMMENT 'طلاب تخرجوا وأُوقفت عضويتهم', + `semester` VARCHAR(30) DEFAULT NULL COMMENT 'مثال: 2026-S1', + `notes` VARCHAR(300) DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_org` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------- +-- 13. transit_guardians — أولياء الأمور (للمدارس) +-- ----------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `transit_guardians` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `enrollment_id` INT UNSIGNED NOT NULL, + `org_id` INT UNSIGNED NOT NULL, + `guardian_passenger_id` VARCHAR(100) NOT NULL COMMENT 'معرّف ولي الأمر في passengers الرئيسي', + `relation` ENUM('father','mother','guardian') NOT NULL DEFAULT 'guardian', + `notify_depart` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'إشعار عند انطلاق الباص', + `notify_board` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'إشعار عند صعود الطفل', + `notify_arrive` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'إشعار عند وصول الباص للمدرسة', + `notify_return` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'إشعار عند العودة للبيت', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_enrollment_guardian` (`enrollment_id`, `guardian_passenger_id`), + KEY `idx_org` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------- +-- 14. transit_broadcasts — إعلانات المشرف للمشتركين +-- ----------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `transit_broadcasts` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `org_id` INT UNSIGNED NOT NULL, + `sent_by` INT UNSIGNED NOT NULL COMMENT 'admin_id', + `target_type` ENUM('all','route','role') NOT NULL DEFAULT 'all', + `target_id` INT UNSIGNED DEFAULT NULL COMMENT 'route_id عند target_type=route', + `title_ar` VARCHAR(200) DEFAULT NULL, + `body_ar` TEXT NOT NULL, + `fcm_topic` VARCHAR(150) DEFAULT NULL COMMENT 'الموضوع المستخدم للإرسال', + `sent_at` TIMESTAMP DEFAULT NULL, + `delivery_count` INT DEFAULT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_org` (`org_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------- +-- 15. transit_boardings — إثبات الصعود والنزول (مرحلة ثانية) +-- ----------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `transit_boardings` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `trip_id` INT UNSIGNED NOT NULL, + `enrollment_id` INT UNSIGNED NOT NULL, + `stop_id` INT UNSIGNED DEFAULT NULL, + `boarded_at` TIMESTAMP DEFAULT NULL, + `alighted_at` TIMESTAMP DEFAULT NULL, + `method` ENUM('geofence','qr','driver_tap') NOT NULL DEFAULT 'geofence', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_trip` (`trip_id`), + KEY `idx_enrollment` (`enrollment_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------- +-- 16. transit_otp_log — سجل OTPs لمشرفي الويب (تنظيف دوري) +-- ----------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `transit_otp_log` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `phone` VARCHAR(25) NOT NULL, + `otp_hash` VARCHAR(64) NOT NULL COMMENT 'sha256 للكود', + `expires_at` TIMESTAMP NOT NULL, + `used` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_phone` (`phone`), + KEY `idx_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SET FOREIGN_KEY_CHECKS = 1; + +-- ----------------------------------------------------------------- +-- ملاحظات الـ Redis (keys تُكتب من PHP — ليست في SQL) +-- transit:trip:{trip_id}:pos → آخر موقع GPS للباص (Hash: lat,lng,ts) +-- transit:trip:{trip_id}:status → حالة الرحلة الحية +-- transit:otp:{phone} → OTP مشرف الويب (TTL 300s) +-- transit:session:{token_hash} → admin_id+org_id (TTL 86400s) +-- transit:driver_mode:{driver_id} → وضع السائق الحالي (bus/ride), TTL session +-- FCM Topics: transit_route_{route_id} ← كل مشتركي خط +-- ----------------------------------------------------------------- diff --git a/backend/transit/trip/delay.php b/backend/transit/trip/delay.php new file mode 100644 index 00000000..a4e89a56 --- /dev/null +++ b/backend/transit/trip/delay.php @@ -0,0 +1,33 @@ +prepare( + "SELECT t.id, t.route_id, r.name_ar AS route_name + FROM transit_trips t JOIN transit_routes r ON r.id=t.route_id + WHERE t.id=? AND t.driver_id=? AND t.status='started' LIMIT 1" +); +$st->execute([$tripId, $driverId]); +$trip = $st->fetch(); +if (!$trip) jsonError('Active trip not found', 404); + +$transit_con->prepare( + "UPDATE transit_trips SET delay_minutes=?, delay_reason=?, updated_at=NOW() WHERE id=?" +)->execute([$delay, $reason, $tripId]); + +transitSendTopicNotification( + transitRouteTopic((int)$trip['route_id']), + 'تأخير في الباص', + 'خط ' . $trip['route_name'] . ' متأخر ' . $delay . ' دقيقة' . ($reason ? " — $reason" : ''), + ['type' => 'transit_delay', 'trip_id' => (string)$tripId, 'delay' => (string)$delay] +); + +jsonSuccess(['delay_minutes' => $delay], 'Delay reported and passengers notified'); diff --git a/backend/transit/trip/end.php b/backend/transit/trip/end.php new file mode 100644 index 00000000..8656c9be --- /dev/null +++ b/backend/transit/trip/end.php @@ -0,0 +1,32 @@ +prepare( + "SELECT id, route_id, status FROM transit_trips WHERE id=? AND driver_id=? LIMIT 1" +); +$st->execute([$tripId, $driverId]); +$trip = $st->fetch(); + +if (!$trip) jsonError('Trip not found', 404); +if ($trip['status'] !== 'started') jsonError('Trip is not in started state', 409); + +$transit_con->prepare( + "UPDATE transit_trips SET status='completed', completed_at=NOW(), updated_at=NOW() WHERE id=?" +)->execute([$tripId]); + +global $redis; +if ($redis) $redis->del("transit:trip:{$tripId}:status"); + +// موقع الباص + ملكية الرحلة على Redis سيرفر الموقع +transitClearTripOwner($tripId); +global $redisLocation; +if ($redisLocation) $redisLocation->del("transit:trip:{$tripId}:pos"); + +jsonSuccess(['trip_id' => $tripId, 'status' => 'completed'], 'Trip completed'); diff --git a/backend/transit/trip/live.php b/backend/transit/trip/live.php new file mode 100644 index 00000000..aacadbd9 --- /dev/null +++ b/backend/transit/trip/live.php @@ -0,0 +1,53 @@ +prepare( + "SELECT id FROM transit_trips WHERE route_id=? AND trip_date=? AND status='started' LIMIT 1" + ); + $st->execute([$routeId, date('Y-m-d')]); + $row = $st->fetch(); + $tripId = $row ? (int)$row['id'] : 0; +} + +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 + FROM transit_trips t + JOIN transit_routes r ON r.id = t.route_id + JOIN transit_schedules sc ON sc.id = t.schedule_id + JOIN transit_drivers d ON d.id = t.driver_id + WHERE t.id=? LIMIT 1" +); +$st->execute([$tripId]); +$trip = $st->fetch(); +if (!$trip) jsonError('Trip not found', 404); + +// الراكب يحتاج عضوية نشطة في مؤسسة هذا الخط قبل رؤية موقع الباص +$chk = $transit_con->prepare( + "SELECT id FROM transit_enrollments WHERE org_id=? AND passenger_id=? AND status='active' LIMIT 1" +); +$chk->execute([$trip['org_id'], $transit_user_id]); +if (!$chk->fetch()) jsonError('Active enrollment required', 403); + +$stStops = $transit_con->prepare( + "SELECT s.id, s.sequence, s.name_ar, s.latitude, s.longitude, + s.geofence_radius, s.eta_offset_min, s.is_major + FROM transit_stops s + JOIN transit_trips t ON t.route_id = s.route_id + WHERE t.id=? ORDER BY s.sequence ASC" +); +$stStops->execute([$tripId]); + +jsonSuccess([ + 'trip' => $trip, + 'bus_position' => transitGetBusPosition($tripId), + 'stops' => $stStops->fetchAll(), +]); diff --git a/backend/transit/trip/start.php b/backend/transit/trip/start.php new file mode 100644 index 00000000..9bc771c9 --- /dev/null +++ b/backend/transit/trip/start.php @@ -0,0 +1,51 @@ +prepare( + "SELECT t.id, t.route_id, t.status, r.name_ar AS route_name, d.main_driver_id + FROM transit_trips t + JOIN transit_routes r ON r.id = t.route_id + JOIN transit_drivers d ON d.id = t.driver_id + WHERE t.id=? AND t.driver_id=? LIMIT 1" +); +$st->execute([$tripId, $driverId]); +$trip = $st->fetch(); + +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=?" +)->execute([$tripId]); + +transitUpdateBusPosition($tripId, $lat, $lng); + +// تخزين ملكية الرحلة — يتحقق منها driver_socket قبل أي بثّ حي للراكبين +transitSetTripOwner($tripId, (string)$trip['main_driver_id'], (int)$trip['route_id']); + +global $redis; +if ($redis) { + $redis->set("transit:trip:{$tripId}:status", 'started'); + $redis->expire("transit:trip:{$tripId}:status", 86400); +} + +transitSendTopicNotification( + transitRouteTopic((int)$trip['route_id']), + 'الباص انطلق الآن', + 'خط ' . $trip['route_name'] . ' بدأ رحلته', + ['type' => 'transit_started', 'trip_id' => (string)$tripId] +); + +jsonSuccess(['trip_id' => $tripId, 'status' => 'started', 'started_at' => date('Y-m-d H:i:s')], 'Trip started'); diff --git a/backend/transit/trip/today.php b/backend/transit/trip/today.php new file mode 100644 index 00000000..aeca02d3 --- /dev/null +++ b/backend/transit/trip/today.php @@ -0,0 +1,70 @@ +prepare( + "SELECT id, org_id FROM transit_drivers WHERE id=? AND status='active' LIMIT 1" +); +$chk->execute([$driverTransitId]); +$driver = $chk->fetch(); +if (!$driver) jsonError('Driver not found or not active', 403); + +$today = date('Y-m-d'); + +// أنشئ رحلات اليوم من الجداول النشطة إن لم تُنشأ بعد +$stScheds = $transit_con->prepare( + "SELECT id AS schedule_id, route_id, vehicle_id, days_mask + FROM transit_schedules + WHERE driver_id=? AND org_id=? AND is_active=1 + AND valid_from <= ? AND (valid_until IS NULL OR valid_until >= ?)" +); +$stScheds->execute([$driverTransitId, $driver['org_id'], $today, $today]); + +foreach ($stScheds->fetchAll() as $sch) { + if (!transitDayActive((int)$sch['days_mask'])) continue; + $ex = $transit_con->prepare("SELECT id FROM transit_trips WHERE schedule_id=? AND trip_date=? LIMIT 1"); + $ex->execute([$sch['schedule_id'], $today]); + if (!$ex->fetch()) { + $transit_con->prepare( + "INSERT INTO transit_trips + (schedule_id, route_id, org_id, driver_id, vehicle_id, trip_date, status) + VALUES (?,?,?,?,?,?,'scheduled')" + )->execute([ + $sch['schedule_id'], $sch['route_id'], $driver['org_id'], + $driverTransitId, $sch['vehicle_id'], $today, + ]); + } +} + +// اجلب رحلات اليوم مع التفاصيل +$stTrips = $transit_con->prepare( + "SELECT t.id, t.route_id, t.status, t.delay_minutes, t.current_stop_seq, + t.started_at, t.completed_at, + r.name_ar AS route_name, r.polyline, + sc.departure_time, + v.plate AS vehicle_plate, v.capacity + FROM transit_trips t + JOIN transit_routes r ON r.id = t.route_id + JOIN transit_schedules sc ON sc.id = t.schedule_id + LEFT JOIN transit_vehicles v ON v.id = t.vehicle_id + WHERE t.driver_id=? AND t.trip_date=? + ORDER BY sc.departure_time ASC" +); +$stTrips->execute([$driverTransitId, $today]); +$trips = $stTrips->fetchAll(); + +$stStops = $transit_con->prepare( + "SELECT sequence, name_ar, latitude, longitude, geofence_radius, eta_offset_min + FROM transit_stops WHERE route_id=? ORDER BY sequence ASC" +); +foreach ($trips as &$trip) { + $stStops->execute([$trip['route_id']]); + $trip['stops'] = $stStops->fetchAll(); +} +unset($trip); + +jsonSuccess(['date' => $today, 'trips' => $trips]); diff --git a/backend/transit/trip/update_position.php b/backend/transit/trip/update_position.php new file mode 100644 index 00000000..faa0b92b --- /dev/null +++ b/backend/transit/trip/update_position.php @@ -0,0 +1,38 @@ +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/backend/transit/vehicle/add.php b/backend/transit/vehicle/add.php new file mode 100644 index 00000000..f9425587 --- /dev/null +++ b/backend/transit/vehicle/add.php @@ -0,0 +1,27 @@ +prepare("SELECT id FROM transit_vehicles WHERE plate=? LIMIT 1"); +$chk->execute([$plate]); +if ($chk->fetch()) jsonError('Vehicle plate already registered', 409); + +$transit_con->prepare( + "INSERT INTO transit_vehicles (org_id,plate,make,model,year,color,capacity,vehicle_type,notes) + VALUES (?,?,?,?,?,?,?,?,?)" +)->execute([ + $transit_org_id, $plate, + filterRequest('make'), filterRequest('model'), + filterRequest('year','int'), filterRequest('color'), + $capacity, $vType, filterRequest('notes'), +]); + +jsonSuccess(['vehicle_id' => (int)$transit_con->lastInsertId()], 'Vehicle added'); diff --git a/backend/transit/vehicle/list.php b/backend/transit/vehicle/list.php new file mode 100644 index 00000000..61eeb3ab --- /dev/null +++ b/backend/transit/vehicle/list.php @@ -0,0 +1,12 @@ +prepare( + "SELECT id, plate, make, model, year, color, capacity, vehicle_type, is_active, notes, created_at + FROM transit_vehicles WHERE org_id=? ORDER BY plate ASC" +); +$st->execute([$transit_org_id]); + +jsonSuccess(['vehicles' => $st->fetchAll()]); diff --git a/loction_server/driver_socket.php b/loction_server/driver_socket.php index b41afa33..9fc58d68 100755 --- a/loction_server/driver_socket.php +++ b/loction_server/driver_socket.php @@ -182,6 +182,61 @@ function forwardLocationToPassengerSocket( ); } +// ============================================================ +// 🚌 Forward موقع الباص → سيرفر الراكب (بثّ لغرفة الخط، ASYNC + throttle) +// نفس آلية forwardLocationToPassengerSocket لكن للبثّ الجماعي +// (باص واحد → كل ركاب الخط) بدل (سائق → راكب واحد) +// ============================================================ +function forwardBusLocationToRoute( + int $tripId, + int $routeId, + array $payload, + string $internalKey, + array &$busThrottle +): void { + if ($routeId <= 0) return; + + $now = time(); + $last = $busThrottle[$tripId] ?? null; + + if ($last !== null) { + $timeDiff = $now - $last['ts']; + $dist = haversineDistance( + $last['lat'], $last['lng'], + (float)$payload['latitude'], (float)$payload['longitude'] + ); + if ($dist < FORWARD_MIN_METERS && $timeDiff < FORWARD_MAX_SECONDS) return; + } + + $busThrottle[$tripId] = [ + 'ts' => $now, + 'lat' => (float)$payload['latitude'], + 'lng' => (float)$payload['longitude'], + ]; + + $passengerSocketUrl = getenv('PASSENGER_SOCKET_INTERNAL_URL') ?: 'http://127.0.0.1:3031'; + $http = new AsyncHttp(); + $http->request( + $passengerSocketUrl, + [ + 'method' => 'POST', + 'data' => http_build_query([ + 'action' => 'broadcast_bus_location', + 'route_id' => $routeId, + 'payload' => json_encode($payload), + ]), + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'x-internal-key' => $internalKey, + 'Connection' => 'close', + ], + 'timeout' => 3, + ], + null, + fn(\Exception $e) => logMsg('⚠️ Bus forward failed: ' . $e->getMessage()) + ); +} + // ============================================================ // 📲 FCM (ASYNC) // ============================================================ @@ -215,9 +270,10 @@ function sendFCM_Async(string $token, string $title, string $body, array $rideDa // ============================================================ $connectedDrivers = []; $active_orders_drivers = []; -$driverState = []; -$fwdThrottle = []; +$driverState = []; +$fwdThrottle = []; $eventBuffer = []; // 🚀 Level 2: مصفوفة تجميع الأحداث لـ Redis +$busFwdThrottle = []; // 🚌 throttle بثّ موقع الباص لكل رحلة (trip_id) // ============================================================ // 🚀 Socket.IO — بورت 2020 @@ -592,6 +648,21 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY) { $connection->send('Error'); } + // ── 8. 🚌 Get Bus Position (آخر موقع للباص — للتحميل الأولي) ─ + // يستدعيه الباك اند (transit/trip/live.php) ليعطي الراكب آخر + // موقع معروف فوراً عند فتح الخط، قبل أول بثّ حي عبر السوكت. + } elseif ($action === 'get_bus_position') { + $tripId = (int)($post['trip_id'] ?? 0); + if ($tripId <= 0 || !$redis) { + $connection->send(json_encode(['status' => false, 'data' => null])); + return; + } + $pos = $redis->hgetall("transit:trip:$tripId:pos"); + $connection->send(json_encode([ + 'status' => !empty($pos), + 'data' => $pos ?: null, + ])); + } else { $connection->send('Unknown action'); } @@ -604,7 +675,7 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY) { // B. WebSocket Events للسائقين // ============================================================ $io->on('connection', function ($socket) use ($INTERNAL_KEY) { - global $connectedDrivers, $driverState, $fwdThrottle, $eventBuffer; + global $connectedDrivers, $driverState, $fwdThrottle, $eventBuffer, $busFwdThrottle; $query = $socket->handshake['query'] ?? []; $driverId = $query['driver_id'] ?? null; @@ -826,13 +897,73 @@ $io->on('connection', function ($socket) use ($INTERNAL_KEY) { } }); + // ── 🚌 وضع الباص (مواصلاتي) ──────────────────────────────── + // سائق الباص هو سائق عادي (JWT role=driver) لكنه في وضع الباص لا + // يدخل حوض الرحلات (geo:drivers:*). يبعث update_bus_location فقط. + // نخزّن آخر موقع في Redis سيرفر الموقع + نبثّه لغرفة الخط. + $socket->on('update_bus_location', function ($data) + use ($driverId, $INTERNAL_KEY, &$busFwdThrottle) + { + $data = (array) $data; + + $tripId = (int)($data['trip_id'] ?? 0); + $lat = isset($data['lat']) ? (float)$data['lat'] : null; + $lng = isset($data['lng']) ? (float)$data['lng'] : null; + $heading = (float)($data['heading'] ?? 0); + $speed = (float)($data['speed'] ?? 0); + $stopSeq = isset($data['current_stop_seq']) ? (int)$data['current_stop_seq'] : null; + + if (!$tripId || $lat === null || $lng === null) return; + + $redis = getRedis(); + if (!$redis) return; + + // ── تحقق الملكية: هذه الرحلة فعلاً مسندة لهذا السائق؟ ──────── + // الكاش يُكتب من transit/trip/start.php عند بدء الرحلة (transitSetTripOwner) + // ويُحذف عند إنهائها. أي سائق آخر (حتى لو خمّن trip_id صحيح) يُرفض هنا، + // ولا نثق بـ route_id القادم من العميل — نأخذه دائماً من الكاش الموثوق. + $owner = $redis->hgetall("transit:trip:$tripId:owner"); + if (empty($owner) || (string)($owner['driver_id'] ?? '') !== (string)$driverId) { + logMsg("🚫 update_bus_location rejected: driver #$driverId is not the owner of trip #$tripId"); + return; + } + $routeId = (int)($owner['route_id'] ?? 0); + if ($routeId <= 0) return; + + // 1. آخر موقع في Redis سيرفر الموقع (بدون بادئة — يقرؤه الباك اند عبر $redisLocation) + $posKey = "transit:trip:$tripId:pos"; + $redis->hmset($posKey, [ + 'lat' => $lat, + 'lng' => $lng, + 'heading' => $heading, + 'speed' => $speed, + 'driver_id' => $driverId, + 'ts' => time(), + ]); + $redis->expire($posKey, 86400); + + // 2. بثّ الموقع لكل ركاب الخط عبر سيرفر الراكب (throttled) + forwardBusLocationToRoute($tripId, $routeId, [ + 'trip_id' => $tripId, + 'route_id' => $routeId, + 'latitude' => $lat, + 'longitude' => $lng, + 'heading' => $heading, + 'speed' => $speed, + 'current_stop_seq' => $stopSeq, + 'driver_id' => $driverId, + ], $INTERNAL_KEY, $busFwdThrottle); + }); + $socket->on('disconnect', function () use ($driverId) { global $connectedDrivers, $driverState, $fwdThrottle; unset($connectedDrivers[$driverId]); unset($driverState[$driverId]); unset($fwdThrottle[$driverId]); - + // ملاحظة: $busFwdThrottle مُفهرس بـ trip_id (لا driver_id) — يُستبدَل + // تلقائياً في الرحلة التالية، فلا حاجة لحذفه هنا. + logMsg("❌ Driver Disconnected: #$driverId"); }); }); diff --git a/passenger_server/passenger_socket.php b/passenger_server/passenger_socket.php index ed74e64b..c12d3109 100644 --- a/passenger_server/passenger_socket.php +++ b/passenger_server/passenger_socket.php @@ -134,6 +134,28 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY, $INTERNAL_PORT) { $connection->send('OK'); + } elseif ($action === 'broadcast_bus_location') { + + // 🚌 بثّ موقع الباص لكل ركاب الخط المشتركين (مواصلاتي) + // يصل من driver_socket بعد أن يبعث سائق الباص update_bus_location + $routeId = $post['route_id'] ?? null; + $rawPayload = $post['payload'] ?? null; + + if (!$routeId || !$rawPayload) { + socket_log("[HTTP_ERROR] Missing route_id or payload for action: broadcast_bus_location", $post); + $connection->send('Error: Missing route_id or payload'); + return; + } + + $payload = is_string($rawPayload) + ? (json_decode($rawPayload, true) ?? $rawPayload) + : $rawPayload; + + socket_log("[HTTP_SUCCESS] Emitting 'bus_location_update' to route #$routeId", $payload); + $io->to('transit_route_' . $routeId)->emit('bus_location_update', $payload); + + $connection->send('OK'); + } else { socket_log("[HTTP_WARNING] Unknown action received: $action", $post); $connection->send('Unknown action: ' . $action); @@ -183,6 +205,27 @@ $io->on('connection', function ($socket) { // socket_log("[SOCKET_HEARTBEAT] Received from Passenger #$passengerId"); }); + // 🚌 اشتراك الراكب في بثّ موقع باص خط (مواصلاتي) + // يُستدعى عند فتح الراكب لخط في قائمة مواصلاتي + $socket->on('subscribe_transit_route', function ($data) use ($socket, $passengerId) { + $data = (array) $data; + $routeId = (int)($data['route_id'] ?? 0); + if ($routeId <= 0) return; + + $socket->join('transit_route_' . $routeId); + socket_log("[TRANSIT] Passenger #$passengerId subscribed to route #$routeId"); + }); + + // 🚌 إلغاء الاشتراك عند إغلاق الراكب للخط + $socket->on('unsubscribe_transit_route', function ($data) use ($socket, $passengerId) { + $data = (array) $data; + $routeId = (int)($data['route_id'] ?? 0); + if ($routeId <= 0) return; + + $socket->leave('transit_route_' . $routeId); + socket_log("[TRANSIT] Passenger #$passengerId unsubscribed from route #$routeId"); + }); + $socket->on('disconnect', function () use ($passengerId, $clientIp) { socket_log("[SOCKET_DISCONNECTED] Passenger Disconnected: #$passengerId (IP: $clientIp)"); }); diff --git a/siro_admin/lib/controller/transit/transit_admin_controller.dart b/siro_admin/lib/controller/transit/transit_admin_controller.dart new file mode 100644 index 00000000..113b7fd6 --- /dev/null +++ b/siro_admin/lib/controller/transit/transit_admin_controller.dart @@ -0,0 +1,134 @@ +// transit_admin_controller.dart — تحكم شاشات مواصلاتي (لوحة إدارة سيرو) +import 'package:get/get.dart'; + +import 'transit_admin_models.dart'; +import 'transit_admin_service.dart'; + +class TransitAdminController extends GetxController { + bool isLoadingList = false; + List orgs = []; + String? filterCountry; + String? filterType; + String? filterStatus; + String searchQuery = ''; + + bool isLoadingDetails = false; + TransitOrgDetails? selectedOrgDetails; + + bool isCreating = false; + + // ── مشرفو المؤسسة ──────────────────────────────────────────── + bool isLoadingAdmins = false; + List> orgAdmins = []; + bool isSavingAdmin = false; + + @override + void onInit() { + super.onInit(); + fetchOrgs(); + } + + Future fetchOrgs() async { + isLoadingList = true; + update(); + + final res = await TransitAdminService.listOrgs( + country: filterCountry, + type: filterType, + contractStatus: filterStatus, + search: searchQuery, + ); + if (res.success) orgs = res.data ?? []; + isLoadingList = false; + update(); + } + + Future loadOrgDetails(int orgId) async { + isLoadingDetails = true; + selectedOrgDetails = null; + update(); + + final res = await TransitAdminService.getOrgDetails(orgId); + if (res.success) selectedOrgDetails = res.data; + isLoadingDetails = false; + update(); + } + + Future createOrg({ + required String type, + required String country, + required String city, + required String nameAr, + required String nameEn, + required String adminName, + required String adminPhone, + }) async { + isCreating = true; + update(); + + final res = await TransitAdminService.createOrg( + type: type, + country: country, + city: city, + nameAr: nameAr, + nameEn: nameEn, + adminName: adminName, + adminPhone: adminPhone, + ); + + isCreating = false; + update(); + + if (res.success) { + await fetchOrgs(); + return true; + } + Get.snackbar('مواصلاتي', res.message); + return false; + } + + Future fetchOrgAdmins(int orgId) async { + isLoadingAdmins = true; + update(); + final res = await TransitAdminService.listOrgAdmins(orgId); + if (res.success) orgAdmins = res.data ?? []; + isLoadingAdmins = false; + update(); + } + + Future addOrgAdmin({ + required int orgId, + required String name, + required String phone, + required String role, + }) async { + isSavingAdmin = true; + update(); + + final res = await TransitAdminService.addOrgAdmin( + orgId: orgId, + name: name, + phone: phone, + role: role, + ); + + isSavingAdmin = false; + update(); + + if (res.success) { + await fetchOrgAdmins(orgId); + return true; + } + Get.snackbar('مواصلاتي', res.message); + return false; + } + + Future toggleOrgAdmin(int orgId, int adminId, bool isActive) async { + final res = await TransitAdminService.toggleOrgAdmin(adminId: adminId, isActive: isActive); + if (res.success) { + await fetchOrgAdmins(orgId); + } else { + Get.snackbar('مواصلاتي', res.message); + } + } +} diff --git a/siro_admin/lib/controller/transit/transit_admin_models.dart b/siro_admin/lib/controller/transit/transit_admin_models.dart new file mode 100644 index 00000000..e26d2e07 --- /dev/null +++ b/siro_admin/lib/controller/transit/transit_admin_models.dart @@ -0,0 +1,70 @@ +// transit_admin_models.dart — نماذج بيانات مواصلاتي (لوحة إدارة سيرو) + +class TransitOrgSummary { + final int id; + final String type; + final String country; + final String city; + final String nameAr; + final String nameEn; + final String? logoUrl; + final String contractStatus; + final String? trialEndsAt; + final int driversCount; + final int vehiclesCount; + final int activeRoutes; + final int activeEnrollments; + + TransitOrgSummary({ + required this.id, + required this.type, + required this.country, + required this.city, + required this.nameAr, + required this.nameEn, + required this.contractStatus, + this.logoUrl, + this.trialEndsAt, + this.driversCount = 0, + this.vehiclesCount = 0, + this.activeRoutes = 0, + this.activeEnrollments = 0, + }); + + factory TransitOrgSummary.fromJson(Map j) => TransitOrgSummary( + id: int.tryParse(j['id'].toString()) ?? 0, + type: j['type']?.toString() ?? '', + country: j['country']?.toString() ?? '', + city: j['city']?.toString() ?? '', + nameAr: j['name_ar']?.toString() ?? '', + nameEn: j['name_en']?.toString() ?? '', + logoUrl: j['logo_url']?.toString(), + contractStatus: j['contract_status']?.toString() ?? '', + trialEndsAt: j['trial_ends_at']?.toString(), + driversCount: int.tryParse(j['drivers_count']?.toString() ?? '0') ?? 0, + vehiclesCount: int.tryParse(j['vehicles_count']?.toString() ?? '0') ?? 0, + activeRoutes: int.tryParse(j['active_routes']?.toString() ?? '0') ?? 0, + activeEnrollments: int.tryParse(j['active_enrollments']?.toString() ?? '0') ?? 0, + ); +} + +class TransitOrgDetails { + final Map org; + final Map counts; + final Map trips; + final List routes; + + TransitOrgDetails({ + required this.org, + required this.counts, + required this.trips, + required this.routes, + }); + + factory TransitOrgDetails.fromJson(Map j) => TransitOrgDetails( + org: Map.from(j['org'] ?? {}), + counts: Map.from(j['counts'] ?? {}), + trips: Map.from(j['trips'] ?? {}), + routes: (j['routes'] is List) ? List.from(j['routes']) : [], + ); +} diff --git a/siro_admin/lib/controller/transit/transit_admin_service.dart b/siro_admin/lib/controller/transit/transit_admin_service.dart new file mode 100644 index 00000000..fd54ddd9 --- /dev/null +++ b/siro_admin/lib/controller/transit/transit_admin_service.dart @@ -0,0 +1,139 @@ +// transit_admin_service.dart — طبقة الاتصال بـ backend/Admin/transit (لوحة إدارة سيرو) +import '../functions/crud.dart'; +import '../../constant/links.dart'; +import 'transit_admin_models.dart'; + +class TransitApiResult { + final bool success; + final T? data; + final String message; + TransitApiResult(this.success, this.data, this.message); +} + +class TransitAdminService { + static String get _base => '${AppLink.server}/Admin/transit'; + + static String _errMsg(dynamic res) { + if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت'; + if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً'; + if (res is Map && res['message'] is String) return res['message']; + return 'حدث خطأ، حاول مجدداً'; + } + + static Future>> listOrgs({ + String? country, + String? type, + String? contractStatus, + String? search, + }) async { + final payload = {}; + if (country != null && country.isNotEmpty) payload['country'] = country; + if (type != null && type.isNotEmpty) payload['type'] = type; + if (contractStatus != null && contractStatus.isNotEmpty) { + payload['contract_status'] = contractStatus; + } + if (search != null && search.isNotEmpty) payload['search'] = search; + + final res = await CRUD().post(link: '$_base/org/list.php', payload: payload); + + if (res is Map && res['status'] == 'success' && res['message'] is Map) { + final msg = res['message'] as Map; + final list = (msg['orgs'] is List) + ? (msg['orgs'] as List) + .map((o) => TransitOrgSummary.fromJson(Map.from(o))) + .toList() + : []; + return TransitApiResult(true, list, 'ok'); + } + return TransitApiResult(false, null, _errMsg(res)); + } + + static Future> getOrgDetails(int orgId) async { + final res = await CRUD().post( + link: '$_base/org/details.php', + payload: {'org_id': orgId.toString()}, + ); + if (res is Map && res['status'] == 'success' && res['message'] is Map) { + return TransitApiResult( + true, TransitOrgDetails.fromJson(Map.from(res['message'])), 'ok'); + } + return TransitApiResult(false, null, _errMsg(res)); + } + + static Future>> createOrg({ + required String type, + required String country, + required String city, + required String nameAr, + required String nameEn, + required String adminName, + required String adminPhone, + String? contactPhone, + String? contactEmail, + String? website, + }) async { + final res = await CRUD().post(link: '$_base/org/create.php', payload: { + 'type': type, + 'country': country, + 'city': city, + 'name_ar': nameAr, + 'name_en': nameEn, + 'admin_name': adminName, + 'admin_phone': adminPhone, + if (contactPhone != null) 'contact_phone': contactPhone, + if (contactEmail != null) 'contact_email': contactEmail, + if (website != null) 'website': website, + }); + + if (res is Map && res['status'] == 'success') { + return TransitApiResult(true, Map.from(res['message'] ?? {}), 'ok'); + } + return TransitApiResult(false, null, _errMsg(res)); + } + + // ── مشرفو المؤسسة ──────────────────────────────────────────── + + static Future>>> listOrgAdmins( + int orgId) async { + final res = await CRUD().post( + link: '$_base/org/admins_list.php', + payload: {'org_id': orgId.toString()}, + ); + if (res is Map && res['status'] == 'success' && res['message'] is Map) { + final msg = res['message'] as Map; + final list = (msg['admins'] is List) + ? (msg['admins'] as List).map((a) => Map.from(a)).toList() + : >[]; + return TransitApiResult(true, list, 'ok'); + } + return TransitApiResult(false, null, _errMsg(res)); + } + + static Future> addOrgAdmin({ + required int orgId, + required String name, + required String phone, + required String role, + }) async { + final res = await CRUD().post(link: '$_base/org/admin_add.php', payload: { + 'org_id': orgId.toString(), + 'name': name, + 'phone': phone, + 'role': role, + }); + if (res is Map && res['status'] == 'success') return TransitApiResult(true, null, 'ok'); + return TransitApiResult(false, null, _errMsg(res)); + } + + static Future> toggleOrgAdmin({ + required int adminId, + required bool isActive, + }) async { + final res = await CRUD().post(link: '$_base/org/admin_toggle.php', payload: { + 'admin_id': adminId.toString(), + 'is_active': isActive ? '1' : '0', + }); + if (res is Map && res['status'] == 'success') return TransitApiResult(true, null, 'ok'); + return TransitApiResult(false, null, _errMsg(res)); + } +} diff --git a/siro_admin/lib/views/admin/admin_home_page.dart b/siro_admin/lib/views/admin/admin_home_page.dart index e99eabbb..58e6257b 100644 --- a/siro_admin/lib/views/admin/admin_home_page.dart +++ b/siro_admin/lib/views/admin/admin_home_page.dart @@ -36,6 +36,7 @@ import 'static/advanced_analytics_page.dart'; import 'financial/financial_v2_page.dart'; import 'security/audit_logs_page.dart'; import 'analytics/live_analytics_page.dart'; +import '../transit/org_list_page.dart'; class AdminHomePage extends StatefulWidget { const AdminHomePage({super.key}); @@ -747,6 +748,13 @@ class _AdminHomePageState extends State () => Get.to(() => SiroTrackerScreen())), ], ), + ActionCategory( + title: 'مواصلاتي', + items: [ + ActionItem('المؤسسات', Icons.directions_bus_filled_rounded, _accent, + () => Get.to(() => const TransitOrgListPage())), + ], + ), ActionCategory( title: 'إدارة النظام الجديد', items: [ diff --git a/siro_admin/lib/views/transit/org_admins_page.dart b/siro_admin/lib/views/transit/org_admins_page.dart new file mode 100644 index 00000000..77bd08a4 --- /dev/null +++ b/siro_admin/lib/views/transit/org_admins_page.dart @@ -0,0 +1,185 @@ +// org_admins_page.dart — متابعة إدارة المؤسسة وإضافة المشرفين (لوحة إدارة سيرو) +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../constant/colors.dart'; +import '../../controller/transit/transit_admin_controller.dart'; + +class TransitOrgAdminsPage extends StatefulWidget { + final int orgId; + final String orgName; + const TransitOrgAdminsPage({super.key, required this.orgId, required this.orgName}); + + @override + State createState() => _TransitOrgAdminsPageState(); +} + +class _TransitOrgAdminsPageState extends State { + final _name = TextEditingController(); + final _phone = TextEditingController(); + String _role = 'transport_manager'; + + final _roles = const { + 'owner': 'مالك', + 'transport_manager': 'مدير نقل', + 'dispatcher': 'منسّق', + }; + + @override + void initState() { + super.initState(); + Get.find().fetchOrgAdmins(widget.orgId); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + builder: (c) => Scaffold( + backgroundColor: AppColor.bg, + appBar: AppBar( + backgroundColor: AppColor.bg, + elevation: 0, + title: Text('مشرفو ${widget.orgName}', + style: const TextStyle(color: AppColor.textPrimary)), + ), + floatingActionButton: FloatingActionButton( + backgroundColor: AppColor.accent, + onPressed: () => _showAddAdminSheet(context, c), + child: const Icon(Icons.person_add_alt_1, color: Colors.white), + ), + body: c.isLoadingAdmins + ? const Center(child: CircularProgressIndicator(color: AppColor.accent)) + : c.orgAdmins.isEmpty + ? const Center( + child: Text('لا يوجد مشرفون بعد', style: TextStyle(color: AppColor.textSecondary))) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: c.orgAdmins.length, + itemBuilder: (_, i) => _adminCard(c, c.orgAdmins[i]), + ), + ), + ); + } + + Widget _adminCard(TransitAdminController c, Map admin) { + final isActive = admin['is_active'].toString() == '1'; + return Card( + color: AppColor.surface, + margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColor.accentSoft, + child: Icon(Icons.person, color: isActive ? AppColor.accent : AppColor.textMuted), + ), + title: Text(admin['name']?.toString() ?? '', + style: const TextStyle(color: AppColor.textPrimary, fontWeight: FontWeight.bold)), + subtitle: Text( + '${admin['phone'] ?? ''} · ${_roles[admin['role']] ?? admin['role']}', + style: const TextStyle(color: AppColor.textSecondary, fontSize: 12), + ), + trailing: Switch( + value: isActive, + activeThumbColor: AppColor.success, + onChanged: (v) => c.toggleOrgAdmin(widget.orgId, int.parse(admin['id'].toString()), v), + ), + ), + ); + } + + void _showAddAdminSheet(BuildContext context, TransitAdminController c) { + _name.clear(); + _phone.clear(); + _role = 'transport_manager'; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppColor.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSheetState) => GetBuilder( + builder: (c) => Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('إضافة مشرف جديد', + style: TextStyle( + color: AppColor.textPrimary, fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + TextField( + controller: _name, + style: const TextStyle(color: AppColor.textPrimary), + decoration: _inputDecoration('اسم المشرف'), + ), + const SizedBox(height: 12), + TextField( + controller: _phone, + keyboardType: TextInputType.phone, + style: const TextStyle(color: AppColor.textPrimary), + decoration: _inputDecoration('رقم الهاتف'), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _role, + dropdownColor: AppColor.surface, + style: const TextStyle(color: AppColor.textPrimary), + decoration: _inputDecoration('الصلاحية'), + items: _roles.entries + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (v) => setSheetState(() => _role = v!), + ), + const SizedBox(height: 20), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColor.accent, + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: c.isSavingAdmin + ? null + : () async { + if (_name.text.trim().isEmpty || _phone.text.trim().isEmpty) { + Get.snackbar('مواصلاتي', 'الرجاء تعبئة كل الحقول'); + return; + } + final ok = await c.addOrgAdmin( + orgId: widget.orgId, + name: _name.text.trim(), + phone: _phone.text.trim(), + role: _role, + ); + if (ok && ctx.mounted) Navigator.pop(ctx); + }, + child: c.isSavingAdmin + ? const SizedBox( + height: 20, width: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('إضافة', style: TextStyle(color: Colors.white)), + ), + ], + ), + ), + ), + ), + ); + } + + InputDecoration _inputDecoration(String label) => InputDecoration( + labelText: label, + labelStyle: const TextStyle(color: AppColor.textSecondary), + filled: true, + fillColor: AppColor.surfaceElevated, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), + ); +} diff --git a/siro_admin/lib/views/transit/org_create_page.dart b/siro_admin/lib/views/transit/org_create_page.dart new file mode 100644 index 00000000..019c129f --- /dev/null +++ b/siro_admin/lib/views/transit/org_create_page.dart @@ -0,0 +1,143 @@ +// org_create_page.dart — إضافة مؤسسة جديدة (لوحة إدارة سيرو) +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../constant/colors.dart'; +import '../../controller/transit/transit_admin_controller.dart'; + +class TransitOrgCreatePage extends StatefulWidget { + const TransitOrgCreatePage({super.key}); + + @override + State createState() => _TransitOrgCreatePageState(); +} + +class _TransitOrgCreatePageState extends State { + final _nameAr = TextEditingController(); + final _nameEn = TextEditingController(); + final _city = TextEditingController(); + final _adminName = TextEditingController(); + final _adminPhone = TextEditingController(); + + String _type = 'university'; + String _country = 'JO'; + + final _types = const { + 'university': 'جامعة', + 'school': 'مدرسة', + 'hotel': 'فندق', + 'company': 'شركة', + 'transporter': 'ناقل', + }; + final _countries = const {'JO': 'الأردن', 'SY': 'سوريا', 'EG': 'مصر'}; + + @override + Widget build(BuildContext context) { + return GetBuilder( + builder: (c) => Scaffold( + backgroundColor: AppColor.bg, + appBar: AppBar( + backgroundColor: AppColor.bg, + elevation: 0, + title: const Text('إضافة مؤسسة', style: TextStyle(color: AppColor.textPrimary)), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _dropdown('نوع المؤسسة', _type, _types, (v) => setState(() => _type = v!)), + const SizedBox(height: 12), + _dropdown('الدولة', _country, _countries, (v) => setState(() => _country = v!)), + const SizedBox(height: 12), + _field(_nameAr, 'اسم المؤسسة (عربي)'), + const SizedBox(height: 12), + _field(_nameEn, 'اسم المؤسسة (إنجليزي)'), + const SizedBox(height: 12), + _field(_city, 'المدينة'), + const SizedBox(height: 20), + const Divider(color: AppColor.surfaceElevated), + const SizedBox(height: 8), + const Text('أول مشرف (owner) لهذه المؤسسة', + style: TextStyle(color: AppColor.textSecondary)), + const SizedBox(height: 12), + _field(_adminName, 'اسم المشرف'), + const SizedBox(height: 12), + _field(_adminPhone, 'هاتف المشرف', keyboardType: TextInputType.phone), + const SizedBox(height: 24), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColor.accent, + minimumSize: const Size.fromHeight(50), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: c.isCreating ? null : _submit, + child: c.isCreating + ? const SizedBox( + height: 20, width: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('إنشاء', style: TextStyle(color: Colors.white)), + ), + ], + ), + ), + ); + } + + Widget _field(TextEditingController ctrl, String label, {TextInputType? keyboardType}) { + return TextField( + controller: ctrl, + keyboardType: keyboardType, + style: const TextStyle(color: AppColor.textPrimary), + decoration: InputDecoration( + labelText: label, + labelStyle: const TextStyle(color: AppColor.textSecondary), + filled: true, + fillColor: AppColor.surface, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), + ), + ); + } + + Widget _dropdown(String label, String value, Map options, + void Function(String?) onChanged) { + return DropdownButtonFormField( + initialValue: value, + dropdownColor: AppColor.surface, + style: const TextStyle(color: AppColor.textPrimary), + decoration: InputDecoration( + labelText: label, + labelStyle: const TextStyle(color: AppColor.textSecondary), + filled: true, + fillColor: AppColor.surface, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), + ), + items: options.entries + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: onChanged, + ); + } + + Future _submit() async { + if (_nameAr.text.trim().isEmpty || + _nameEn.text.trim().isEmpty || + _city.text.trim().isEmpty || + _adminName.text.trim().isEmpty || + _adminPhone.text.trim().isEmpty) { + Get.snackbar('مواصلاتي', 'الرجاء تعبئة كل الحقول'); + return; + } + + final c = Get.find(); + final ok = await c.createOrg( + type: _type, + country: _country, + city: _city.text.trim(), + nameAr: _nameAr.text.trim(), + nameEn: _nameEn.text.trim(), + adminName: _adminName.text.trim(), + adminPhone: _adminPhone.text.trim(), + ); + + if (ok && mounted) Get.back(result: true); + } +} diff --git a/siro_admin/lib/views/transit/org_details_page.dart b/siro_admin/lib/views/transit/org_details_page.dart new file mode 100644 index 00000000..da93f2f6 --- /dev/null +++ b/siro_admin/lib/views/transit/org_details_page.dart @@ -0,0 +1,170 @@ +// org_details_page.dart — تفاصيل وتحليلات مؤسسة (لوحة إدارة سيرو) +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../constant/colors.dart'; +import '../../controller/transit/transit_admin_controller.dart'; +import 'org_admins_page.dart'; + +class TransitOrgDetailsPage extends StatefulWidget { + final int orgId; + final String orgName; + const TransitOrgDetailsPage({super.key, required this.orgId, required this.orgName}); + + @override + State createState() => _TransitOrgDetailsPageState(); +} + +class _TransitOrgDetailsPageState extends State { + @override + void initState() { + super.initState(); + Get.find().loadOrgDetails(widget.orgId); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + builder: (c) => Scaffold( + backgroundColor: AppColor.bg, + appBar: AppBar( + backgroundColor: AppColor.bg, + elevation: 0, + title: Text(widget.orgName, style: const TextStyle(color: AppColor.textPrimary)), + actions: [ + IconButton( + tooltip: 'إدارة المشرفين', + icon: const Icon(Icons.admin_panel_settings_outlined, color: AppColor.accent), + onPressed: () => Get.to( + () => TransitOrgAdminsPage(orgId: widget.orgId, orgName: widget.orgName), + ), + ), + ], + ), + body: c.isLoadingDetails + ? const Center(child: CircularProgressIndicator(color: AppColor.accent)) + : c.selectedOrgDetails == null + ? const Center( + child: Text('تعذّر تحميل البيانات', style: TextStyle(color: AppColor.textSecondary))) + : _content(c), + ), + ); + } + + Widget _content(TransitAdminController c) { + final d = c.selectedOrgDetails!; + final counts = d.counts; + final trips = d.trips; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _sectionTitle('الأسطول'), + Row( + children: [ + _statCard('السائقون', '${counts['drivers_active'] ?? 0}/${counts['drivers_total'] ?? 0}', + Icons.badge_outlined), + const SizedBox(width: 10), + _statCard('المركبات', '${counts['vehicles_active'] ?? 0}/${counts['vehicles_total'] ?? 0}', + Icons.directions_bus_outlined), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + _statCard('الخطوط النشطة', '${counts['routes_active'] ?? 0}', Icons.route_outlined), + const SizedBox(width: 10), + _statCard('عضويات نشطة', '${counts['enrollments_active'] ?? 0}', Icons.people_outline), + ], + ), + if ((int.tryParse(counts['enrollments_pending']?.toString() ?? '0') ?? 0) > 0) ...[ + const SizedBox(height: 10), + _statCard('طلبات بانتظار الموافقة', '${counts['enrollments_pending']}', + Icons.pending_actions_outlined, color: AppColor.warning), + ], + const SizedBox(height: 24), + _sectionTitle('الرحلات'), + Row( + children: [ + _statCard('اليوم', '${trips['today'] ?? 0}', Icons.today_outlined), + const SizedBox(width: 10), + _statCard('هذا الأسبوع', '${trips['this_week'] ?? 0}', Icons.date_range_outlined), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + _statCard('هذا الشهر', '${trips['this_month'] ?? 0}', Icons.calendar_month_outlined), + const SizedBox(width: 10), + _statCard('ساعات القيادة', '${trips['total_hours_driven'] ?? 0}', Icons.timer_outlined), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + _statCard('مكتملة', '${trips['completed'] ?? 0}', Icons.check_circle_outline, + color: AppColor.success), + const SizedBox(width: 10), + _statCard('متوسط التأخير', '${trips['avg_delay_minutes'] ?? 0} د', Icons.timelapse, + color: AppColor.warning), + ], + ), + const SizedBox(height: 24), + _sectionTitle('الخطوط'), + ...d.routes.map((r) => Card( + color: AppColor.surface, + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + title: Text(r['name_ar']?.toString() ?? '', + style: const TextStyle(color: AppColor.textPrimary)), + subtitle: Text( + '${r['stops_count'] ?? 0} محطة · ${r['completed_trips'] ?? 0} رحلة مكتملة', + style: const TextStyle(color: AppColor.textSecondary, fontSize: 12), + ), + trailing: Text( + r['status']?.toString() ?? '', + style: TextStyle( + color: r['status'] == 'active' ? AppColor.success : AppColor.textSecondary, + fontSize: 12, + ), + ), + ), + )), + ], + ); + } + + Widget _sectionTitle(String text) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text(text, + style: const TextStyle( + color: AppColor.textPrimary, fontSize: 16, fontWeight: FontWeight.bold)), + ); + + Widget _statCard(String label, String value, IconData icon, {Color? color}) { + return Expanded( + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColor.surface, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color ?? AppColor.accent, size: 20), + const SizedBox(height: 8), + Text(value, + style: TextStyle( + color: color ?? AppColor.textPrimary, + fontSize: 18, + fontWeight: FontWeight.bold)), + const SizedBox(height: 2), + Text(label, style: const TextStyle(color: AppColor.textSecondary, fontSize: 11)), + ], + ), + ), + ); + } +} diff --git a/siro_admin/lib/views/transit/org_list_page.dart b/siro_admin/lib/views/transit/org_list_page.dart new file mode 100644 index 00000000..11cf057f --- /dev/null +++ b/siro_admin/lib/views/transit/org_list_page.dart @@ -0,0 +1,150 @@ +// org_list_page.dart — قائمة مؤسسات مواصلاتي (لوحة إدارة سيرو) +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../constant/colors.dart'; +import '../../controller/transit/transit_admin_controller.dart'; +import '../../controller/transit/transit_admin_models.dart'; +import 'org_create_page.dart'; +import 'org_details_page.dart'; + +class TransitOrgListPage extends StatefulWidget { + const TransitOrgListPage({super.key}); + + @override + State createState() => _TransitOrgListPageState(); +} + +class _TransitOrgListPageState extends State { + final _searchCtrl = TextEditingController(); + + @override + void initState() { + super.initState(); + Get.put(TransitAdminController()); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + builder: (c) => Scaffold( + backgroundColor: AppColor.bg, + appBar: AppBar( + backgroundColor: AppColor.bg, + elevation: 0, + title: const Text('مواصلاتي — المؤسسات', style: TextStyle(color: AppColor.textPrimary)), + actions: [ + IconButton( + icon: const Icon(Icons.add, color: AppColor.accent), + onPressed: () async { + final created = await Get.to(() => const TransitOrgCreatePage()); + if (created == true) c.fetchOrgs(); + }, + ), + ], + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: TextField( + controller: _searchCtrl, + style: const TextStyle(color: AppColor.textPrimary), + onSubmitted: (v) { + c.searchQuery = v; + c.fetchOrgs(); + }, + decoration: InputDecoration( + hintText: 'ابحث عن مؤسسة...', + hintStyle: const TextStyle(color: AppColor.textSecondary), + prefixIcon: const Icon(Icons.search, color: AppColor.textSecondary), + filled: true, + fillColor: AppColor.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ), + Expanded( + child: c.isLoadingList + ? const Center(child: CircularProgressIndicator(color: AppColor.accent)) + : c.orgs.isEmpty + ? const Center( + child: Text('لا توجد مؤسسات', style: TextStyle(color: AppColor.textSecondary)), + ) + : RefreshIndicator( + onRefresh: c.fetchOrgs, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: c.orgs.length, + itemBuilder: (_, i) => _orgCard(c.orgs[i]), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _orgCard(TransitOrgSummary org) { + Color statusColor; + switch (org.contractStatus) { + case 'active': + statusColor = AppColor.success; + break; + case 'trial': + statusColor = AppColor.info; + break; + case 'suspended': + statusColor = AppColor.warning; + break; + default: + statusColor = AppColor.danger; + } + + return Card( + color: AppColor.surface, + margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + title: Text(org.nameAr, + style: const TextStyle(color: AppColor.textPrimary, fontWeight: FontWeight.bold)), + subtitle: Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text(org.contractStatus, + style: TextStyle(color: statusColor, fontSize: 11)), + ), + const SizedBox(width: 8), + Text('${org.city} · ${org.country}', + style: const TextStyle(color: AppColor.textSecondary, fontSize: 12)), + const Spacer(), + Icon(Icons.directions_bus, size: 14, color: AppColor.textSecondary), + const SizedBox(width: 2), + Text('${org.vehiclesCount}', + style: const TextStyle(color: AppColor.textSecondary, fontSize: 12)), + const SizedBox(width: 10), + Icon(Icons.people, size: 14, color: AppColor.textSecondary), + const SizedBox(width: 2), + Text('${org.activeEnrollments}', + style: const TextStyle(color: AppColor.textSecondary, fontSize: 12)), + ], + ), + ), + trailing: const Icon(Icons.arrow_forward_ios, size: 14, color: AppColor.textSecondary), + onTap: () => Get.to(() => TransitOrgDetailsPage(orgId: org.id, orgName: org.nameAr)), + ), + ); + } +} diff --git a/siro_driver/lib/controller/functions/location_controller.dart b/siro_driver/lib/controller/functions/location_controller.dart index 966cf7ef..970bcb13 100755 --- a/siro_driver/lib/controller/functions/location_controller.dart +++ b/siro_driver/lib/controller/functions/location_controller.dart @@ -46,6 +46,19 @@ class LocationController extends GetxController with WidgetsBindingObserver { bool isSocketConnected = false; Timer? _socketHeartbeat; + // 🚌 مواصلاتي — وضع الباص: بينما نشط، الموقع يُبَث فقط عبر update_bus_location + // ولا يدخل حوض الرحلات العادي (geo:drivers:*) + bool isBusMode = false; + int? activeBusTripId; + int? activeBusRouteId; + + void setBusMode({required bool enabled, int? tripId, int? routeId}) { + isBusMode = enabled; + activeBusTripId = enabled ? tripId : null; + activeBusRouteId = enabled ? routeId : null; + Log.print('🚌 Bus mode ${enabled ? "enabled" : "disabled"} (trip: $tripId, route: $routeId)'); + } + StreamSubscription? _locSub; StreamSubscription? _batterySub; @@ -431,7 +444,11 @@ class LocationController extends GetxController with WidgetsBindingObserver { // الـ _locSub يرسل update_location عند كل تحرك (كل 5-10 ثوانٍ) تلقائياً. // الـ heartbeat يكون مفيداً فقط عندما يتوقف الـ stream (الجهاز ثابت أو أوقف الخدمة). if (_locSub != null) return; - if (socket != null && isSocketConnected && myLocation.latitude != 0) { + if (socket == null || !isSocketConnected || myLocation.latitude == 0) return; + + if (isBusMode) { + emitBusLocationToSocket(myLocation, heading, speed); + } else { emitLocationToSocket(myLocation, heading, speed); } }); @@ -477,6 +494,22 @@ class LocationController extends GetxController with WidgetsBindingObserver { socket!.emit('update_location', payload); } } + + // 🚌 مواصلاتي — بثّ موقع الباص (بديل عن update_location أثناء وضع الباص) + void emitBusLocationToSocket(LatLng pos, double head, double spd, {int? currentStopSeq}) { + if (activeBusTripId == null || activeBusRouteId == null) return; + if (socket == null || !socket!.connected) return; + + socket!.emit('update_bus_location', { + 'trip_id': activeBusTripId, + 'route_id': activeBusRouteId, + 'lat': pos.latitude, + 'lng': pos.longitude, + 'heading': head, + 'speed': spd * 3.6, + if (currentStopSeq != null) 'current_stop_seq': currentStopSeq, + }); + } // =================================================================== // ====== Tracking Logic ====== // =================================================================== @@ -535,6 +568,14 @@ class LocationController extends GetxController with WidgetsBindingObserver { _lastPosForDistance = pos; update(); + + // 🚌 وضع الباص: بثّ موقع الباص فقط — لا يدخل حوض الرحلات العادي + if (isBusMode) { + emitBusLocationToSocket(pos, heading, speed); + await _saveBehaviorIfMoved(pos, now, currentSpeed: speed); + return; + } + emitLocationToSocket(pos, heading, speed); if (Get.isRegistered()) { diff --git a/siro_driver/lib/controller/local/ar_eg.dart b/siro_driver/lib/controller/local/ar_eg.dart index 4846563f..5f87921e 100644 --- a/siro_driver/lib/controller/local/ar_eg.dart +++ b/siro_driver/lib/controller/local/ar_eg.dart @@ -1,4 +1,15 @@ final Map ar_eg = { + // ── مواصلاتي (Mawasalati) ── + "Mawasalati": "مواصلاتي", + "This account is not registered as a bus driver in any institution": + "هذا الحساب غير مسجّل كسائق باص في أي مؤسسة", + "No trips today": "لا توجد رحلات اليوم", + "Stops": "محطات", + "Delayed by": "متأخر", + "Trip completed": "اكتملت الرحلة", + "Report Delay": "الإبلاغ عن تأخير", + "Number of minutes": "عدد الدقائق", + "Send": "إرسال", " \${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 65cdbc21..f4140a27 100644 --- a/siro_driver/lib/controller/local/ar_jo.dart +++ b/siro_driver/lib/controller/local/ar_jo.dart @@ -1,4 +1,15 @@ final Map ar_jo = { + // ── مواصلاتي (Mawasalati) ── + "Mawasalati": "مواصلاتي", + "This account is not registered as a bus driver in any institution": + "هذا الحساب غير مسجّل كسائق باص في أي مؤسسة", + "No trips today": "لا توجد رحلات اليوم", + "Stops": "محطات", + "Delayed by": "متأخر", + "Trip completed": "اكتملت الرحلة", + "Report Delay": "الإبلاغ عن تأخير", + "Number of minutes": "عدد الدقائق", + "Send": "إرسال", " \${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 03dfcc2e..e0a218b5 100644 --- a/siro_driver/lib/controller/local/ar_sy.dart +++ b/siro_driver/lib/controller/local/ar_sy.dart @@ -1,4 +1,15 @@ final Map ar_sy = { + // ── مواصلاتي (Mawasalati) ── + "Mawasalati": "مواصلاتي", + "This account is not registered as a bus driver in any institution": + "هذا الحساب غير مسجّل كسائق باص في أي مؤسسة", + "No trips today": "لا توجد رحلات اليوم", + "Stops": "محطات", + "Delayed by": "متأخر", + "Trip completed": "اكتملت الرحلة", + "Report Delay": "الإبلاغ عن تأخير", + "Number of minutes": "عدد الدقائق", + "Send": "إرسال", " \${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 new file mode 100644 index 00000000..79cd3c63 --- /dev/null +++ b/siro_driver/lib/controller/transit/transit_driver_controller.dart @@ -0,0 +1,138 @@ +// transit_driver_controller.dart — تحكم وضع الباص (جهة السائق) +import 'package:get/get.dart'; +import 'package:intaleq_maps/intaleq_maps.dart' show LatLng; + +import '../functions/location_controller.dart'; +import 'transit_driver_models.dart'; +import 'transit_driver_service.dart'; + +class TransitDriverController extends GetxController { + bool isCheckingBusDriver = true; + bool isBusDriver = false; + int? driverTransitId; + String orgName = ''; + + bool isLoadingTrips = false; + List todayTrips = []; + + TransitDriverTrip? activeTrip; + bool isActionInProgress = false; + + @override + void onInit() { + super.onInit(); + checkBusDriverStatus(); + } + + Future checkBusDriverStatus() async { + isCheckingBusDriver = true; + update(); + + final res = await TransitDriverService.checkIsBusDriver(); + if (res.success && res.data != null) { + isBusDriver = res.data!['is_bus_driver'] == true; + if (isBusDriver) { + driverTransitId = int.tryParse(res.data!['driver_transit_id'].toString()); + orgName = res.data!['org_name']?.toString() ?? ''; + await fetchTodayTrips(); + } + } + isCheckingBusDriver = false; + update(); + } + + Future fetchTodayTrips() async { + if (driverTransitId == null) return; + isLoadingTrips = true; + update(); + + final res = await TransitDriverService.getTodayTrips(driverTransitId!); + 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, + tripId: activeTrip!.id, + routeId: activeTrip!.routeId, + ); + } + } + isLoadingTrips = false; + update(); + } + + Future startTrip(TransitDriverTrip trip) async { + if (driverTransitId == null || isActionInProgress) return false; + isActionInProgress = true; + update(); + + LatLng pos = const LatLng(0, 0); + if (Get.isRegistered()) { + pos = Get.find().myLocation; + } + + final res = await TransitDriverService.startTrip( + tripId: trip.id, + driverTransitId: driverTransitId!, + lat: pos.latitude, + lng: pos.longitude, + ); + + if (res.success) { + if (Get.isRegistered()) { + Get.find().setBusMode( + enabled: true, + tripId: trip.id, + routeId: trip.routeId, + ); + } + await fetchTodayTrips(); + } else { + Get.snackbar('مواصلاتي', res.message); + } + + isActionInProgress = false; + update(); + return res.success; + } + + Future endTrip(TransitDriverTrip trip) async { + if (driverTransitId == null || isActionInProgress) return false; + isActionInProgress = true; + update(); + + final res = await TransitDriverService.endTrip( + tripId: trip.id, + driverTransitId: driverTransitId!, + ); + + if (res.success) { + if (Get.isRegistered()) { + Get.find().setBusMode(enabled: false); + } + await fetchTodayTrips(); + } else { + Get.snackbar('مواصلاتي', res.message); + } + + isActionInProgress = false; + update(); + return res.success; + } + + Future reportDelay(TransitDriverTrip trip, int minutes, {String? reason}) async { + if (driverTransitId == null) return false; + final res = await TransitDriverService.reportDelay( + tripId: trip.id, + driverTransitId: driverTransitId!, + delayMinutes: minutes, + reason: reason, + ); + if (!res.success) Get.snackbar('مواصلاتي', res.message); + return res.success; + } +} diff --git a/siro_driver/lib/controller/transit/transit_driver_models.dart b/siro_driver/lib/controller/transit/transit_driver_models.dart new file mode 100644 index 00000000..a875040e --- /dev/null +++ b/siro_driver/lib/controller/transit/transit_driver_models.dart @@ -0,0 +1,72 @@ +// transit_driver_models.dart — نماذج بيانات مواصلاتي (جهة سائق الباص) + +class TransitStopInfo { + final int sequence; + final String nameAr; + final double lat; + final double lng; + final int? etaOffsetMin; + + TransitStopInfo({ + required this.sequence, + required this.nameAr, + required this.lat, + required this.lng, + 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, + etaOffsetMin: j['eta_offset_min'] == null + ? null + : int.tryParse(j['eta_offset_min'].toString()), + ); +} + +class TransitDriverTrip { + final int id; + final int routeId; + final String status; // scheduled | started | completed | cancelled | no_show + final int delayMinutes; + final int? currentStopSeq; + final String routeName; + final String? departureTime; + final String? vehiclePlate; + final int? capacity; + final List stops; + + TransitDriverTrip({ + required this.id, + required this.routeId, + required this.status, + required this.delayMinutes, + required this.routeName, + this.currentStopSeq, + this.departureTime, + this.vehiclePlate, + this.capacity, + this.stops = const [], + }); + + factory TransitDriverTrip.fromJson(Map j) => TransitDriverTrip( + id: int.tryParse(j['id'].toString()) ?? 0, + routeId: int.tryParse(j['route_id']?.toString() ?? '0') ?? 0, + status: j['status']?.toString() ?? '', + delayMinutes: int.tryParse(j['delay_minutes']?.toString() ?? '0') ?? 0, + currentStopSeq: j['current_stop_seq'] == null + ? null + : int.tryParse(j['current_stop_seq'].toString()), + routeName: j['route_name']?.toString() ?? '', + departureTime: j['departure_time']?.toString(), + vehiclePlate: j['vehicle_plate']?.toString(), + capacity: j['capacity'] == null ? null : int.tryParse(j['capacity'].toString()), + stops: (j['stops'] is List) + ? (j['stops'] as List) + .map((s) => TransitStopInfo.fromJson(Map.from(s))) + .toList() + : const [], + ); +} diff --git a/siro_driver/lib/controller/transit/transit_driver_service.dart b/siro_driver/lib/controller/transit/transit_driver_service.dart new file mode 100644 index 00000000..755d8e5c --- /dev/null +++ b/siro_driver/lib/controller/transit/transit_driver_service.dart @@ -0,0 +1,109 @@ +// transit_driver_service.dart — طبقة الاتصال بـ backend/transit (جهة سائق الباص) +import '../functions/crud.dart'; +import '../../constant/links.dart'; +import 'transit_driver_models.dart'; + +class TransitApiResult { + final bool success; + final T? data; + final String message; + TransitApiResult(this.success, this.data, this.message); +} + +class TransitDriverService { + static String get _base => '${AppLink.server}/transit'; + + static String _errMsg(dynamic res) { + if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت'; + if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً'; + if (res is Map && res['message'] is String) return res['message']; + return 'حدث خطأ، حاول مجدداً'; + } + + /// هل هذا الحساب سائق باص، وما معرّفه في مواصلاتي؟ + static Future>> checkIsBusDriver() async { + final res = await CRUD().post(link: '$_base/driver/me.php'); + 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)); + } + + /// رحلات اليوم (تُنشأ تلقائياً من الجداول عند أول استدعاء) + static Future>> getTodayTrips( + int driverTransitId) async { + final res = await CRUD().post( + link: '$_base/trip/today.php', + payload: {'driver_transit_id': driverTransitId.toString()}, + ); + if (res is Map && res['status'] == 'success' && res['message'] is Map) { + final msg = res['message'] as Map; + final list = (msg['trips'] is List) + ? (msg['trips'] as List) + .map((t) => TransitDriverTrip.fromJson(Map.from(t))) + .toList() + : []; + return TransitApiResult(true, list, 'ok'); + } + return TransitApiResult(false, null, _errMsg(res)); + } + + static Future>> startTrip({ + required int tripId, + required int driverTransitId, + required double lat, + required double lng, + }) async { + final res = await CRUD().post( + link: '$_base/trip/start.php', + payload: { + 'trip_id': tripId.toString(), + 'driver_transit_id': driverTransitId.toString(), + 'lat': lat.toString(), + 'lng': lng.toString(), + }, + ); + if (res is Map && res['status'] == 'success') { + return TransitApiResult(true, Map.from(res['message'] ?? {}), 'ok'); + } + return TransitApiResult(false, null, _errMsg(res)); + } + + 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(), + }, + ); + if (res is Map && res['status'] == 'success') { + return TransitApiResult(true, Map.from(res['message'] ?? {}), 'ok'); + } + return TransitApiResult(false, null, _errMsg(res)); + } + + static Future> reportDelay({ + required int tripId, + required int driverTransitId, + required int delayMinutes, + String? reason, + }) async { + final res = await CRUD().post( + link: '$_base/trip/delay.php', + payload: { + 'trip_id': tripId.toString(), + 'driver_transit_id': driverTransitId.toString(), + 'delay_minutes': delayMinutes.toString(), + if (reason != null) 'reason': reason, + }, + ); + if (res is Map && res['status'] == 'success') { + return TransitApiResult(true, null, 'ok'); + } + return TransitApiResult(false, null, _errMsg(res)); + } +} diff --git a/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart b/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart index b3708611..29e6d5e8 100755 --- a/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart +++ b/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart @@ -32,6 +32,7 @@ import '../../../../constant/colors.dart'; import '../About Us/video_page.dart'; import '../assurance_health_page.dart'; import '../maintain_center_page.dart'; +import '../../../transit/transit_driver_home_page.dart'; // 1. إنشاء Class لتعريف بيانات كل عنصر في القائمة class DrawerItem { @@ -55,6 +56,11 @@ class AppDrawer extends StatelessWidget { // 2. تعريف بيانات القائمة بشكل مركزي ومنظم final List drawerItems = [ + DrawerItem( + title: 'Mawasalati'.tr, + icon: Icons.directions_bus_filled_rounded, + color: Colors.teal, + onTap: () => Get.to(() => const TransitDriverHomePage())), DrawerItem( title: 'Balance'.tr, icon: Icons.account_balance_wallet, diff --git a/siro_driver/lib/views/transit/transit_driver_home_page.dart b/siro_driver/lib/views/transit/transit_driver_home_page.dart new file mode 100644 index 00000000..6df54f0e --- /dev/null +++ b/siro_driver/lib/views/transit/transit_driver_home_page.dart @@ -0,0 +1,172 @@ +// transit_driver_home_page.dart — رحلات اليوم لسائق الباص (مواصلاتي) +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../constant/colors.dart'; +import '../../constant/style.dart'; +import '../../controller/transit/transit_driver_controller.dart'; +import '../../controller/transit/transit_driver_models.dart'; +import '../widgets/elevated_btn.dart'; +import '../widgets/my_scafold.dart'; + +class TransitDriverHomePage extends StatelessWidget { + const TransitDriverHomePage({super.key}); + + @override + Widget build(BuildContext context) { + Get.put(TransitDriverController()); + + return GetBuilder( + builder: (c) { + if (c.isCheckingBusDriver) { + return MyScafolld( + title: 'Mawasalati'.tr, + isleading: true, + body: const [Expanded(child: Center(child: CircularProgressIndicator()))], + ); + } + + if (!c.isBusDriver) { + return MyScafolld( + title: 'Mawasalati'.tr, + isleading: true, + body: [ + 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 MyScafolld( + title: c.orgName, + isleading: true, + body: [ + Expanded( + child: RefreshIndicator( + onRefresh: c.fetchTodayTrips, + child: c.isLoadingTrips + ? const Center(child: CircularProgressIndicator()) + : c.todayTrips.isEmpty + ? ListView( + children: [ + const SizedBox(height: 80), + Center( + child: Text('No trips today'.tr, style: AppStyle.title), + ), + ], + ) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: c.todayTrips.length, + itemBuilder: (_, i) => _tripCard(c, c.todayTrips[i]), + ), + ), + ), + ], + ); + }, + ); + } + + Widget _tripCard(TransitDriverController c, TransitDriverTrip trip) { + final isStarted = trip.status == 'started'; + final isCompleted = trip.status == 'completed'; + + return Card( + margin: const EdgeInsets.only(bottom: 14), + color: AppColor.cardColor, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide(color: isStarted ? AppColor.greenColor : AppColor.borderColor, + width: isStarted ? 1.5 : 1), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.directions_bus, color: AppColor.accentColor), + const SizedBox(width: 8), + Expanded( + child: Text(trip.routeName, + style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)), + ), + if (trip.departureTime != null) + Text(trip.departureTime!, style: AppStyle.subtitle), + ], + ), + const SizedBox(height: 4), + Text('${trip.stops.length} ${'Stops'.tr}', style: AppStyle.subtitle), + if (trip.delayMinutes > 0) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text('${'Delayed by'.tr} ${trip.delayMinutes} ${'min'.tr}', + style: AppStyle.subtitle.copyWith(color: AppColor.yellowColor)), + ), + const SizedBox(height: 12), + if (!isCompleted) + Row( + children: [ + Expanded( + child: MyElevatedButton( + title: isStarted ? 'End Trip'.tr : 'Start Trip'.tr, + kolor: isStarted ? AppColor.redColor : AppColor.greenColor, + onPressed: c.isActionInProgress + ? () {} + : () => isStarted ? c.endTrip(trip) : c.startTrip(trip), + ), + ), + if (isStarted) ...[ + const SizedBox(width: 8), + IconButton( + icon: Icon(Icons.report_gmailerrorred, color: AppColor.yellowColor), + onPressed: () => _showDelayDialog(c, trip), + ), + ], + ], + ) + else + Text('Trip completed'.tr, + style: AppStyle.subtitle.copyWith(color: AppColor.greenColor)), + ], + ), + ), + ); + } + + void _showDelayDialog(TransitDriverController c, TransitDriverTrip trip) { + final ctrl = TextEditingController(text: '5'); + Get.defaultDialog( + title: 'Report Delay'.tr, + content: Column( + children: [ + TextField( + controller: ctrl, + keyboardType: TextInputType.number, + decoration: InputDecoration(labelText: 'Number of minutes'.tr), + ), + ], + ), + textConfirm: 'Send'.tr, + textCancel: 'Cancel'.tr, + onConfirm: () { + final minutes = int.tryParse(ctrl.text.trim()) ?? 5; + c.reportDelay(trip, minutes); + Get.back(); + }, + ); + } +} 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 95fbf213..2bbcf7ec 100644 --- a/siro_rider/lib/controller/home/map/map_socket_controller.dart +++ b/siro_rider/lib/controller/home/map/map_socket_controller.dart @@ -21,6 +21,10 @@ class MapSocketController extends GetxController { int _socketLocationUpdatesCount = 0; Timer? _watchdogTimer; + // 🚌 مواصلاتي — الخط الحالي المشترَك فيه + مستمع تحديث موقع الباص + int? _subscribedTransitRouteId; + void Function(Map data)? onBusLocationUpdate; + DateTime? get lastDriverLocationTime => _lastSocketLocationTime; int get socketLocationUpdatesCount => _socketLocationUpdatesCount; @@ -61,6 +65,10 @@ class MapSocketController extends GetxController { }); Log.print("📡 Re-subscribed to driver location after connect"); } + if (_subscribedTransitRouteId != null) { + socket.emit('subscribe_transit_route', {'route_id': _subscribedTransitRouteId}); + Log.print("🚌 Re-subscribed to transit route after connect"); + } update(); }); @@ -89,6 +97,10 @@ class MapSocketController extends GetxController { }); Log.print("📡 Re-subscribed to driver location after reconnect"); } + if (_subscribedTransitRouteId != null) { + socket.emit('subscribe_transit_route', {'route_id': _subscribedTransitRouteId}); + Log.print("🚌 Re-subscribed to transit route after reconnect"); + } if (rideLifecycle.isActiveRideState()) { Log.print("✅ Socket back online — stopping Fast Polling Fallback"); @@ -131,6 +143,39 @@ class MapSocketController extends GetxController { socket.on('driver_location_update', (data) { handleDriverLocationUpdate(data); }); + + // 🚌 مواصلاتي — بثّ موقع الباص الحي لخط مشترَك فيه + socket.on('bus_location_update', (data) { + if (data == null) return; + try { + final map = Map.from(data as Map); + onBusLocationUpdate?.call(map); + } catch (e) { + Log.print('Error parsing bus_location_update: $e'); + } + }); + } + + // ── مواصلاتي: اشتراك/إلغاء اشتراك ببثّ موقع خط ───────────── + // يُستدعى عند فتح/إغلاق شاشة تتبع الباص الحي. يضمن السوكيت متصلاً أولاً. + void subscribeToTransitRoute(int routeId) { + _subscribedTransitRouteId = routeId; + if (!isSocketConnected) { + initConnectionWithSocket(); + // سيُعاد الاشتراك تلقائياً من onConnect إن أضفنا ذلك، لكن نحاول فوراً أيضاً + } + if (socket.connected) { + socket.emit('subscribe_transit_route', {'route_id': routeId}); + Log.print('🚌 Subscribed to transit route #$routeId'); + } + } + + void unsubscribeFromTransitRoute(int routeId) { + if (_subscribedTransitRouteId == routeId) _subscribedTransitRouteId = null; + if (isSocketConnected && socket.connected) { + socket.emit('unsubscribe_transit_route', {'route_id': routeId}); + Log.print('🚌 Unsubscribed from transit route #$routeId'); + } } void _startHeartbeat() { diff --git a/siro_rider/lib/controller/local/ar_eg.dart b/siro_rider/lib/controller/local/ar_eg.dart index 32a1b96c..045b61f9 100644 --- a/siro_rider/lib/controller/local/ar_eg.dart +++ b/siro_rider/lib/controller/local/ar_eg.dart @@ -1,4 +1,26 @@ final Map ar_eg = { + // ── مواصلاتي (Mawasalati) ── + "Mawasalati": "مواصلاتي", + "My Memberships": "عضوياتي", + "Not enrolled in any institution yet": "لست مشتركاً في أي مؤسسة بعد", + "Activate your membership in your university or institution to track your bus live": + "فعّل عضويتك في جامعتك أو مؤسستك لمتابعة الباص الخاص بك حياً", + "Join a new institution": "انضمام لمؤسسة جديدة", + "University, school, or hotel": "جامعة، مدرسة، أو فندق", + "Active": "نشطة", + "Pending Review": "قيد المراجعة", + "Suspended": "موقوفة", + "Choose your institution": "اختر مؤسستك", + "Search for a university or institution...": "ابحث عن جامعة أو مؤسسة...", + "No results": "لا توجد نتائج", + "Enter your student/employee ID to activate membership": + "أدخل رقمك الجامعي/الوظيفي لتفعيل العضوية", + "Student ID": "الرقم الجامعي", + "Activate": "تفعيل", + "No active routes currently": "لا توجد خطوط نشطة حالياً", + "Stops": "محطات", + "No bus running on this route right now": "لا يوجد باص يعمل على هذا الخط الآن", + "Bus is delayed": "الباص متأخر", " ')[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 fa563812..8abd0e15 100644 --- a/siro_rider/lib/controller/local/ar_jo.dart +++ b/siro_rider/lib/controller/local/ar_jo.dart @@ -1,4 +1,26 @@ final Map ar_jo = { + // ── مواصلاتي (Mawasalati) ── + "Mawasalati": "مواصلاتي", + "My Memberships": "عضوياتي", + "Not enrolled in any institution yet": "لست مشتركاً في أي مؤسسة بعد", + "Activate your membership in your university or institution to track your bus live": + "فعّل عضويتك في جامعتك أو مؤسستك لمتابعة الباص الخاص بك حياً", + "Join a new institution": "انضمام لمؤسسة جديدة", + "University, school, or hotel": "جامعة، مدرسة، أو فندق", + "Active": "نشطة", + "Pending Review": "قيد المراجعة", + "Suspended": "موقوفة", + "Choose your institution": "اختر مؤسستك", + "Search for a university or institution...": "ابحث عن جامعة أو مؤسسة...", + "No results": "لا توجد نتائج", + "Enter your student/employee ID to activate membership": + "أدخل رقمك الجامعي/الوظيفي لتفعيل العضوية", + "Student ID": "الرقم الجامعي", + "Activate": "تفعيل", + "No active routes currently": "لا توجد خطوط نشطة حالياً", + "Stops": "محطات", + "No bus running on this route right now": "لا يوجد باص يعمل على هذا الخط الآن", + "Bus is delayed": "الباص متأخر", " ')[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 8192382f..a40a6bdd 100644 --- a/siro_rider/lib/controller/local/ar_sy.dart +++ b/siro_rider/lib/controller/local/ar_sy.dart @@ -1,4 +1,26 @@ final Map ar_sy = { + // ── مواصلاتي (Mawasalati) ── + "Mawasalati": "مواصلاتي", + "My Memberships": "عضوياتي", + "Not enrolled in any institution yet": "لست مشتركاً في أي مؤسسة بعد", + "Activate your membership in your university or institution to track your bus live": + "فعّل عضويتك في جامعتك أو مؤسستك لمتابعة الباص الخاص بك حياً", + "Join a new institution": "انضمام لمؤسسة جديدة", + "University, school, or hotel": "جامعة، مدرسة، أو فندق", + "Active": "نشطة", + "Pending Review": "قيد المراجعة", + "Suspended": "موقوفة", + "Choose your institution": "اختر مؤسستك", + "Search for a university or institution...": "ابحث عن جامعة أو مؤسسة...", + "No results": "لا توجد نتائج", + "Enter your student/employee ID to activate membership": + "أدخل رقمك الجامعي/الوظيفي لتفعيل العضوية", + "Student ID": "الرقم الجامعي", + "Activate": "تفعيل", + "No active routes currently": "لا توجد خطوط نشطة حالياً", + "Stops": "محطات", + "No bus running on this route right now": "لا يوجد باص يعمل على هذا الخط الآن", + "Bus is delayed": "الباص متأخر", " ')[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 new file mode 100644 index 00000000..d80b4eb3 --- /dev/null +++ b/siro_rider/lib/controller/transit/transit_controller.dart @@ -0,0 +1,142 @@ +// transit_controller.dart — تحكم شاشات مواصلاتي (جهة الراكب) +import 'package:get/get.dart'; +import '../home/map/map_socket_controller.dart'; +import '../../print.dart'; +import 'transit_models.dart'; +import 'transit_service.dart'; + +class TransitController extends GetxController { + bool isLoadingEnrollments = false; + List myEnrollments = []; + + bool isLoadingOrgs = false; + List browsableOrgs = []; + + bool isLoadingRoutes = false; + List currentOrgRoutes = []; + int? currentOrgId; + + // ── تتبع الباص الحي ────────────────────────────────────────── + int? liveRouteId; + int? liveTripId; + Map? liveTripData; + BusPosition? liveBusPosition; + String liveError = ''; + bool isLoadingLiveTrip = false; + + @override + void onInit() { + super.onInit(); + fetchMyEnrollments(); + } + + Future fetchMyEnrollments() async { + isLoadingEnrollments = true; + update(); + final res = await TransitService.getMyEnrollments(); + if (res.success) myEnrollments = res.data ?? []; + isLoadingEnrollments = false; + update(); + } + + Future browseOrgs({String? search}) async { + isLoadingOrgs = true; + update(); + final res = await TransitService.browseOrgs(search: search); + if (res.success) browsableOrgs = res.data ?? []; + isLoadingOrgs = false; + update(); + } + + Future activateEnrollment(int orgId, String studentId) async { + final res = await TransitService.activateEnrollment( + orgId: orgId, + studentId: studentId, + ); + if (res.success) { + await fetchMyEnrollments(); + return true; + } + Get.snackbar('مواصلاتي', res.message); + return false; + } + + Future openOrgRoutes(int orgId) async { + currentOrgId = orgId; + isLoadingRoutes = true; + currentOrgRoutes = []; + update(); + + final res = await TransitService.getRoutesForOrg(orgId); + if (res.success) { + currentOrgRoutes = res.data ?? []; + } else { + Get.snackbar('مواصلاتي', res.message); + } + isLoadingRoutes = false; + update(); + } + + // ── الخط الحي: تحميل أولي + اشتراك سوكيت ──────────────────── + Future openLiveRoute(int routeId) async { + liveRouteId = routeId; + liveTripData = null; + liveBusPosition = null; + liveError = ''; + isLoadingLiveTrip = true; + update(); + + final res = await TransitService.getLiveTrip(routeId: routeId); + if (res.success && res.data != null) { + liveTripData = res.data; + liveTripId = int.tryParse(liveTripData?['trip']?['id']?.toString() ?? ''); + final pos = liveTripData?['bus_position']; + if (pos is Map) liveBusPosition = BusPosition.fromJson(pos); + } else { + liveError = res.message; + } + isLoadingLiveTrip = false; + update(); + + if (Get.isRegistered()) { + final ms = Get.find(); + ms.onBusLocationUpdate = _handleLiveBusUpdate; + ms.subscribeToTransitRoute(routeId); + } + } + + void _handleLiveBusUpdate(Map data) { + final incomingRouteId = int.tryParse(data['route_id']?.toString() ?? ''); + if (incomingRouteId == null || incomingRouteId != liveRouteId) return; + + liveBusPosition = BusPosition( + lat: double.tryParse(data['latitude']?.toString() ?? '0') ?? 0, + lng: double.tryParse(data['longitude']?.toString() ?? '0') ?? 0, + heading: double.tryParse(data['heading']?.toString() ?? '0') ?? 0, + speed: double.tryParse(data['speed']?.toString() ?? '0') ?? 0, + currentStopSeq: data['current_stop_seq'] == null + ? null + : int.tryParse(data['current_stop_seq'].toString()), + ); + Log.print('🚌 Live bus update: ${liveBusPosition?.lat}, ${liveBusPosition?.lng}'); + update(); + } + + void closeLiveRoute() { + if (liveRouteId != null && Get.isRegistered()) { + final ms = Get.find(); + ms.unsubscribeFromTransitRoute(liveRouteId!); + ms.onBusLocationUpdate = null; + } + liveRouteId = null; + liveTripId = null; + liveTripData = null; + liveBusPosition = null; + } + + @override + void onClose() { + closeLiveRoute(); + super.onClose(); + } +} diff --git a/siro_rider/lib/controller/transit/transit_models.dart b/siro_rider/lib/controller/transit/transit_models.dart new file mode 100644 index 00000000..a85287d0 --- /dev/null +++ b/siro_rider/lib/controller/transit/transit_models.dart @@ -0,0 +1,164 @@ +// transit_models.dart — نماذج بيانات مواصلاتي (جهة الراكب) + +class TransitOrg { + final int id; + final String type; + final String nameAr; + final String nameEn; + final String? logoUrl; + final String city; + + TransitOrg({ + required this.id, + required this.type, + required this.nameAr, + required this.nameEn, + required this.city, + this.logoUrl, + }); + + factory TransitOrg.fromJson(Map j) => TransitOrg( + id: int.tryParse(j['id'].toString()) ?? 0, + type: j['type']?.toString() ?? '', + nameAr: j['name_ar']?.toString() ?? '', + nameEn: j['name_en']?.toString() ?? '', + city: j['city']?.toString() ?? '', + logoUrl: j['logo_url']?.toString(), + ); +} + +class TransitEnrollment { + final int id; + final int orgId; + final String orgName; + final String status; // pending | active | suspended | expired + final String verifyMethod; + + TransitEnrollment({ + required this.id, + required this.orgId, + required this.orgName, + required this.status, + required this.verifyMethod, + }); + + factory TransitEnrollment.fromActivateResponse( + Map j, int orgId) => + TransitEnrollment( + id: int.tryParse(j['enrollment_id'].toString()) ?? 0, + orgId: orgId, + orgName: j['org_name']?.toString() ?? '', + status: j['status']?.toString() ?? 'pending', + verifyMethod: j['verify_method']?.toString() ?? '', + ); +} + +class TransitStop { + final int id; + final int sequence; + final String nameAr; + final double lat; + final double lng; + final int etaOffsetMin; + final bool isMajor; + + TransitStop({ + required this.id, + required this.sequence, + required this.nameAr, + required this.lat, + required this.lng, + required this.etaOffsetMin, + required this.isMajor, + }); + + factory TransitStop.fromJson(Map j) => TransitStop( + id: int.tryParse(j['id'].toString()) ?? 0, + 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, + etaOffsetMin: int.tryParse(j['eta_offset_min']?.toString() ?? '0') ?? 0, + isMajor: (j['is_major']?.toString() ?? '0') == '1', + ); +} + +class TransitRouteSummary { + final int id; + final String nameAr; + final String status; + final int stopCount; + + TransitRouteSummary({ + required this.id, + required this.nameAr, + required this.status, + required this.stopCount, + }); + + factory TransitRouteSummary.fromJson(Map j) => + TransitRouteSummary( + id: int.tryParse(j['id'].toString()) ?? 0, + nameAr: j['name_ar']?.toString() ?? '', + status: j['status']?.toString() ?? '', + stopCount: int.tryParse(j['stop_count']?.toString() ?? '0') ?? 0, + ); +} + +class TransitLiveTrip { + final int id; + final String status; + final int delayMinutes; + final int? currentStopSeq; + final String routeName; + final String? departureTime; + final String? driverName; + + TransitLiveTrip({ + required this.id, + required this.status, + required this.delayMinutes, + required this.routeName, + this.currentStopSeq, + this.departureTime, + this.driverName, + }); + + factory TransitLiveTrip.fromJson(Map j) => TransitLiveTrip( + id: int.tryParse(j['id'].toString()) ?? 0, + status: j['status']?.toString() ?? '', + delayMinutes: int.tryParse(j['delay_minutes']?.toString() ?? '0') ?? 0, + currentStopSeq: j['current_stop_seq'] == null + ? null + : int.tryParse(j['current_stop_seq'].toString()), + routeName: j['route_name']?.toString() ?? '', + departureTime: j['departure_time']?.toString(), + driverName: j['driver_name']?.toString(), + ); +} + +class BusPosition { + final double lat; + final double lng; + final double heading; + final double speed; + final int? currentStopSeq; + + BusPosition({ + required this.lat, + required this.lng, + this.heading = 0, + this.speed = 0, + this.currentStopSeq, + }); + + factory BusPosition.fromJson(Map j) => BusPosition( + lat: double.tryParse(j['lat']?.toString() ?? '0') ?? 0, + lng: double.tryParse(j['lng']?.toString() ?? '0') ?? 0, + heading: double.tryParse(j['heading']?.toString() ?? '0') ?? 0, + speed: double.tryParse(j['speed']?.toString() ?? '0') ?? 0, + currentStopSeq: j['current_stop_seq'] == null + ? null + : int.tryParse(j['current_stop_seq'].toString()), + ); +} diff --git a/siro_rider/lib/controller/transit/transit_service.dart b/siro_rider/lib/controller/transit/transit_service.dart new file mode 100644 index 00000000..ee4f85cf --- /dev/null +++ b/siro_rider/lib/controller/transit/transit_service.dart @@ -0,0 +1,130 @@ +// transit_service.dart — طبقة الاتصال بـ backend/transit (جهة الراكب) +import '../functions/crud.dart'; +import '../../constant/links.dart'; +import 'transit_models.dart'; + +class TransitApiResult { + final bool success; + final T? data; + final String message; + TransitApiResult(this.success, this.data, this.message); +} + +class TransitService { + static String get _base => '${AppLink.server}/transit'; + + /// تصفح المؤسسات المتاحة للتفعيل (قبل الانضمام) + static Future>> browseOrgs({ + String? country, + String? search, + }) async { + final payload = {}; + if (country != null) payload['country'] = country; + if (search != null && search.isNotEmpty) payload['search'] = search; + + final res = await CRUD().post(link: '$_base/org/browse.php', payload: payload); + + if (res is Map && res['status'] == 'success') { + final msg = res['message']; + final list = (msg is Map && msg['orgs'] is List) + ? (msg['orgs'] as List) + .map((o) => TransitOrg.fromJson(Map.from(o))) + .toList() + : []; + return TransitApiResult(true, list, 'ok'); + } + return TransitApiResult(false, null, _errMsg(res)); + } + + /// تفعيل عضوية الراكب في مؤسسة بالرقم الجامعي/الوظيفي + static Future> activateEnrollment({ + required int orgId, + required String studentId, + }) async { + final res = await CRUD().post( + link: '$_base/enrollment/activate.php', + payload: {'org_id': orgId.toString(), 'student_id': studentId}, + ); + + if (res is Map && res['status'] == 'success') { + final msg = res['message']; + if (msg is Map) { + return TransitApiResult( + true, + TransitEnrollment.fromActivateResponse(msg, orgId), + msg['message']?.toString() ?? 'تم', + ); + } + } + return TransitApiResult(false, null, _errMsg(res)); + } + + /// عضوياتي في كل المؤسسات + static Future>> + getMyEnrollments() async { + final res = await CRUD().post(link: '$_base/enrollment/my_enrollments.php'); + + if (res is Map && res['status'] == 'success') { + final msg = res['message']; + final list = (msg is Map && msg['enrollments'] is List) + ? (msg['enrollments'] as List) + .map((e) => TransitEnrollment( + id: int.tryParse(e['id'].toString()) ?? 0, + orgId: int.tryParse(e['org_id'].toString()) ?? 0, + orgName: e['org_name']?.toString() ?? '', + status: e['status']?.toString() ?? '', + verifyMethod: e['verify_method']?.toString() ?? '', + )) + .toList() + : []; + return TransitApiResult(true, list, 'ok'); + } + return TransitApiResult(false, null, _errMsg(res)); + } + + /// خطوط مؤسسة معيّنة (يشترط عضوية نشطة) + static Future>> getRoutesForOrg( + int orgId) async { + final res = await CRUD().post( + link: '$_base/route/for_org.php', + payload: {'org_id': orgId.toString()}, + ); + + if (res is Map && res['status'] == 'success') { + final msg = res['message']; + final list = (msg is Map && msg['routes'] is List) + ? (msg['routes'] as List) + .map((r) => TransitRouteSummary.fromJson( + Map.from(r))) + .toList() + : []; + return TransitApiResult(true, list, 'ok'); + } + return TransitApiResult(false, null, _errMsg(res)); + } + + /// تفاصيل الرحلة الحية لخط (المحطات + آخر موقع معروف) + static Future>> getLiveTrip({ + int? tripId, + int? routeId, + }) async { + final payload = {}; + if (tripId != null) payload['trip_id'] = tripId.toString(); + if (routeId != null) payload['route_id'] = routeId.toString(); + + final res = await CRUD().post(link: '$_base/trip/live.php', payload: payload); + + if (res is Map && res['status'] == 'success') { + final msg = res['message']; + if (msg is Map) return TransitApiResult(true, Map.from(msg), 'ok'); + } + return TransitApiResult(false, null, _errMsg(res)); + } + + static String _errMsg(dynamic res) { + if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت'; + if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً'; + if (res is Map && res['message'] is String) return res['message']; + return 'حدث خطأ، حاول مجدداً'; + } +} diff --git a/siro_rider/lib/views/home/map_widget.dart/map_menu_widget.dart b/siro_rider/lib/views/home/map_widget.dart/map_menu_widget.dart index 663d94e2..114b9248 100644 --- a/siro_rider/lib/views/home/map_widget.dart/map_menu_widget.dart +++ b/siro_rider/lib/views/home/map_widget.dart/map_menu_widget.dart @@ -22,6 +22,7 @@ import '../HomePage/contact_us.dart'; import '../HomePage/share_app_page.dart'; import '../setting_page.dart'; import '../profile/passenger_profile_page.dart'; +import '../../transit/transit_home_page.dart'; // ─── ألوان النظام (Integrated with AppColor) ────────────────────────────────── Color get _kCyan => AppColor.cyanBlue; @@ -148,6 +149,11 @@ class MapMenuWidget extends StatelessWidget { padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 4), children: [ + MenuListItem( + title: 'Mawasalati'.tr, + icon: Icons.directions_bus_filled_rounded, + onTap: () => Get.to(() => const TransitHomePage()), + ), MenuListItem( title: 'My Balance'.tr, icon: Icons.account_balance_wallet_outlined, diff --git a/siro_rider/lib/views/transit/transit_home_page.dart b/siro_rider/lib/views/transit/transit_home_page.dart new file mode 100644 index 00000000..bfbe662e --- /dev/null +++ b/siro_rider/lib/views/transit/transit_home_page.dart @@ -0,0 +1,147 @@ +// transit_home_page.dart — الصفحة الرئيسية لتبويب "مواصلاتي" +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../constant/colors.dart'; +import '../../constant/style.dart'; +import '../../controller/transit/transit_controller.dart'; +import '../../controller/transit/transit_models.dart'; +import '../widgets/my_scafold.dart'; +import 'transit_org_browse_page.dart'; +import 'transit_routes_page.dart'; + +class TransitHomePage extends StatelessWidget { + const TransitHomePage({super.key}); + + @override + Widget build(BuildContext context) { + Get.put(TransitController()); + + return GetBuilder( + builder: (c) => MyScafolld( + title: 'Mawasalati'.tr, + isleading: true, + body: [ + Expanded( + child: RefreshIndicator( + onRefresh: c.fetchMyEnrollments, + child: c.isLoadingEnrollments + ? const Center(child: CircularProgressIndicator()) + : c.myEnrollments.isEmpty + ? _emptyState(c) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('My Memberships'.tr, + style: AppStyle.headTitle2.copyWith(fontSize: 18)), + const SizedBox(height: 12), + ...c.myEnrollments.map((e) => _enrollmentCard(e, c)), + const SizedBox(height: 20), + _addOrgTile(), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _emptyState(TransitController c) { + return ListView( + padding: const EdgeInsets.all(24), + children: [ + const SizedBox(height: 60), + Icon(Icons.directions_bus_filled_outlined, + size: 72, color: AppColor.grayColor), + const SizedBox(height: 16), + Text( + 'Not enrolled in any institution yet'.tr, + textAlign: TextAlign.center, + style: AppStyle.title.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + 'Activate your membership in your university or institution to track your bus live'.tr, + textAlign: TextAlign.center, + style: AppStyle.subtitle.copyWith(color: AppColor.grayColor), + ), + const SizedBox(height: 20), + _addOrgTile(), + ], + ); + } + + Widget _addOrgTile() { + return Card( + color: AppColor.cardColor, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide(color: AppColor.borderColor), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: CircleAvatar( + backgroundColor: AppColor.accentColor.withOpacity(0.15), + child: Icon(Icons.add, color: AppColor.accentColor), + ), + title: Text('Join a new institution'.tr, style: AppStyle.title), + subtitle: Text('University, school, or hotel'.tr, style: AppStyle.subtitle), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () => Get.to(() => const TransitOrgBrowsePage()), + ), + ); + } + + Widget _enrollmentCard(TransitEnrollment e, TransitController c) { + Color statusColor; + String statusText; + switch (e.status) { + case 'active': + statusColor = AppColor.greenColor; + statusText = 'Active'.tr; + break; + case 'pending': + statusColor = AppColor.yellowColor; + statusText = 'Pending Review'.tr; + break; + default: + statusColor = AppColor.redColor; + statusText = 'Suspended'.tr; + } + + return Card( + margin: const EdgeInsets.only(bottom: 12), + color: AppColor.cardColor, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide(color: AppColor.borderColor), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: CircleAvatar( + backgroundColor: AppColor.primaryColor.withOpacity(0.1), + child: Icon(Icons.school_outlined, color: AppColor.primaryColor), + ), + title: Text(e.orgName, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)), + subtitle: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: statusColor, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text(statusText, style: AppStyle.subtitle.copyWith(color: statusColor)), + ], + ), + trailing: e.status == 'active' ? const Icon(Icons.arrow_forward_ios, size: 16) : null, + onTap: e.status == 'active' + ? () => Get.to(() => TransitRoutesPage(orgId: e.orgId, orgName: e.orgName)) + : 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 new file mode 100644 index 00000000..4fb72597 --- /dev/null +++ b/siro_rider/lib/views/transit/transit_live_map_page.dart @@ -0,0 +1,146 @@ +// 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(); + 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: [ + 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)), + ); + } +} diff --git a/siro_rider/lib/views/transit/transit_org_browse_page.dart b/siro_rider/lib/views/transit/transit_org_browse_page.dart new file mode 100644 index 00000000..52b86477 --- /dev/null +++ b/siro_rider/lib/views/transit/transit_org_browse_page.dart @@ -0,0 +1,145 @@ +// transit_org_browse_page.dart — تصفح المؤسسات + تفعيل عضوية بالرقم الجامعي +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../constant/colors.dart'; +import '../../constant/style.dart'; +import '../../controller/transit/transit_controller.dart'; +import '../../controller/transit/transit_models.dart'; +import '../widgets/elevated_btn.dart'; +import '../widgets/my_scafold.dart'; + +class TransitOrgBrowsePage extends StatefulWidget { + const TransitOrgBrowsePage({super.key}); + + @override + State createState() => _TransitOrgBrowsePageState(); +} + +class _TransitOrgBrowsePageState extends State { + final _searchCtrl = TextEditingController(); + + @override + void initState() { + super.initState(); + Get.find().browseOrgs(); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + builder: (c) => MyScafolld( + title: 'Choose your institution'.tr, + isleading: true, + body: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: _searchCtrl, + onSubmitted: (v) => c.browseOrgs(search: v), + decoration: InputDecoration( + hintText: 'Search for a university or institution...'.tr, + prefixIcon: const Icon(Icons.search), + filled: true, + fillColor: AppColor.cardColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: AppColor.borderColor), + ), + ), + ), + ), + Expanded( + child: c.isLoadingOrgs + ? const Center(child: CircularProgressIndicator()) + : c.browsableOrgs.isEmpty + ? Center( + child: Text('No results'.tr, style: AppStyle.title), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: c.browsableOrgs.length, + itemBuilder: (_, i) { + final org = c.browsableOrgs[i]; + return Card( + margin: const EdgeInsets.only(bottom: 10), + color: AppColor.cardColor, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide(color: AppColor.borderColor), + ), + child: ListTile( + title: Text(org.nameAr, style: AppStyle.title), + subtitle: Text(org.city, style: AppStyle.subtitle), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () => _showActivateSheet(context, c, org), + ), + ); + }, + ), + ), + ], + ), + ); + } + + void _showActivateSheet(BuildContext context, TransitController c, TransitOrg org) { + final idCtrl = TextEditingController(); + bool loading = false; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppColor.cardColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setState) => Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(org.nameAr, style: AppStyle.headTitle2.copyWith(fontSize: 18)), + const SizedBox(height: 6), + Text('Enter your student/employee ID to activate membership'.tr, + style: AppStyle.subtitle), + const SizedBox(height: 16), + TextField( + controller: idCtrl, + decoration: InputDecoration( + hintText: 'Student ID'.tr, + filled: true, + fillColor: AppColor.secondaryColor, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + const SizedBox(height: 16), + MyElevatedButton( + title: 'Activate'.tr, + isLoading: loading, + onPressed: () async { + if (idCtrl.text.trim().isEmpty) return; + setState(() => loading = true); + final ok = await c.activateEnrollment(org.id, idCtrl.text.trim()); + setState(() => loading = false); + if (ok && ctx.mounted) { + Navigator.pop(ctx); + Get.back(); // العودة للصفحة الرئيسية لمواصلاتي + } + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/siro_rider/lib/views/transit/transit_routes_page.dart b/siro_rider/lib/views/transit/transit_routes_page.dart new file mode 100644 index 00000000..e69544d9 --- /dev/null +++ b/siro_rider/lib/views/transit/transit_routes_page.dart @@ -0,0 +1,74 @@ +// transit_routes_page.dart — خطوط مؤسسة الراكب +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +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'; + +class TransitRoutesPage extends StatefulWidget { + final int orgId; + final String orgName; + const TransitRoutesPage({super.key, required this.orgId, required this.orgName}); + + @override + State createState() => _TransitRoutesPageState(); +} + +class _TransitRoutesPageState extends State { + @override + void initState() { + super.initState(); + Get.find().openOrgRoutes(widget.orgId); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + builder: (c) => MyScafolld( + title: widget.orgName, + isleading: true, + body: [ + Expanded( + child: c.isLoadingRoutes + ? const Center(child: CircularProgressIndicator()) + : c.currentOrgRoutes.isEmpty + ? Center( + child: Text('No active routes currently'.tr, style: AppStyle.title), + ) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: c.currentOrgRoutes.length, + itemBuilder: (_, i) { + final r = c.currentOrgRoutes[i]; + return Card( + margin: const EdgeInsets.only(bottom: 10), + color: AppColor.cardColor, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide(color: AppColor.borderColor), + ), + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColor.accentColor.withOpacity(0.15), + child: Icon(Icons.directions_bus, color: AppColor.accentColor), + ), + title: Text(r.nameAr, style: AppStyle.title), + 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), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +}