feat(apps): الخريطة وطلب الرحلة ودورة السائق — المرحلة 4
## حسم تعارض الخرائط (docs/38 §7) لصالح القرار الأحدث 2026-07-20: كل الخرائط — البلاطات والبحث والعكسي والمسار — مباشرة إلى map-saas بترويسة x-api-key، بلا مرور بباك إند تريبز. نقاط /maps/* في الباك إند لا تُستعمل، وإحداها (geocode) معطوبة على المنشور. العقد تُحقّق حيّاً بـcurl قبل كتابة سطر. ## النواة المشتركة - core/api/antlaq_api.dart: عميل منفصل بلا AuthInterceptor — توكن تريبز لا شأن لخادم الخرائط به، وإرساله إليه تسريب بلا مقابل - core/realtime: Socket.IO واحد (trip:update · driver:location · trip:offer · trip:offer_taken) - core/location: نقطة الموقع الوحيدة بمرشّح 25 متراً - features/trip/data: سبعة نماذج + MapsRepository + TripRepository متطابق حرفياً بين التوأمين ## الراكب RideCubit بستّ مراحل + RidePage وثمانية ويدجت: الخريطة · دبّوس المنتصف · المخطّط · الخط الزمني · اختيار النوع · التأكيد · البحث عن سائق · الرحلة. ## السائق DutyCubit بأربع مراحل + DutyPage: مفتاح الاتصال · العرض بمؤقّت · الرحلة. ## قرارات تحمي الأداء والصحّة - GeoPoint مستقلّ عن حزمة الخريطة؛ intaleq_maps مستوردة في ملف واحد فقط لكل تطبيق. تبديل محرّك الخريطة لا يلمس منطقاً - الخريطة خارج BlocBuilder الورقة ولها BlocSelector خاص: نبضة موقع كل 25م لا تعيد بناء الشاشة - مهلة بحث محليّة 90ث + استطلاع كل 5ث: الخادم قد لا يُطلق expired/no_drivers أبداً (ثغرة R1)، وانتظار حدث قد لا يصل = شاشة بحث أبدية - offeredDrivers == 0 جواب نهائي فوري بلا انتظار المهلة - الأجرة لا تُعرض إلا إن كانت صالحة: /tariff/quote يرجّع 200 بقيم null صامتة - موقع السائق بمسارين عمداً: REST لمطابقة Redis والسوكت لخريطة الراكب flutter analyze نظيف · الراكب 70 ملف/5,294 سطر · السائق 67 ملف/4,771 سطر. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c52338f3bb
commit
1156299d09
@@ -0,0 +1,34 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../config.dart';
|
||||
import 'api_exception.dart';
|
||||
|
||||
/// عميل map-saas — **منفصل تماماً** عن `ApiClient`.
|
||||
///
|
||||
/// عمداً بلا `AuthInterceptor`: توكن تريبز لا شأن لخادم الخرائط، وإرساله
|
||||
/// إليه تسريبٌ بلا مقابل. المصادقة هنا ترويسة `x-api-key` وحدها.
|
||||
class AntlaqApi {
|
||||
AntlaqApi._(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
factory AntlaqApi.create() {
|
||||
return AntlaqApi._(Dio(BaseOptions(
|
||||
baseUrl: AppConfig.mapBaseUrl,
|
||||
connectTimeout: AppConfig.connectTimeout,
|
||||
receiveTimeout: AppConfig.receiveTimeout,
|
||||
// المفتاح ترويسةً لا استعلاماً: map-saas يرفض `api_key` في الـquery
|
||||
// بـ400 `property api_key should not exist`.
|
||||
headers: {'x-api-key': AppConfig.mapApiKey},
|
||||
)));
|
||||
}
|
||||
|
||||
Future<T> get<T>(String path, {Map<String, dynamic>? query}) async {
|
||||
try {
|
||||
final res = await _dio.get<T>(path, queryParameters: query);
|
||||
return res.data as T;
|
||||
} on DioException catch (e) {
|
||||
throw ApiException.from(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,35 @@ class AppConfig {
|
||||
/// (docs/38 §1). **يُثبَّت عند البناء ولا يتغيّر في وقت التشغيل أبداً.**
|
||||
static const String appRole = 'rider';
|
||||
|
||||
// ── الخرائط ──────────────────────────────────────────────────────────
|
||||
// قرار المالك 2026-07-20: كل الخرائط — البلاطات والجيوكودنغ والمسار —
|
||||
// **مباشرة** إلى map-saas بلا مرور بباك إند تريبز. map-saas مِلك المالك،
|
||||
// والمفتاح يُقيَّد ببصمة التطبيق (نفس نموذج Google Maps SDK). هذا يُلغي
|
||||
// نقاط `/maps/*` في باك إند تريبز، وهو ما حسم التعارض المسجّل في
|
||||
// docs/38 §7 — ومنها `/maps/geocode` المعطوب على الخادم المنشور.
|
||||
|
||||
static const String mapBaseUrl = String.fromEnvironment(
|
||||
'MAP_BASE_URL',
|
||||
defaultValue: 'https://map-saas.intaleqapp.com/api',
|
||||
);
|
||||
|
||||
/// المفتاح ترويسة **`x-api-key`** لا معامل استعلام — map-saas يرفض
|
||||
/// المفتاح في الـquery بـ400. الحالي مفتاح سيرو حتى يُصدر المالك مفتاح
|
||||
/// تريبز المقيّد بالبصمة.
|
||||
static const String mapApiKey = String.fromEnvironment(
|
||||
'MAP_API_KEY',
|
||||
defaultValue: 'in_9478b32836d19cff73db3063',
|
||||
);
|
||||
|
||||
/// كل كم متر يُرفع موقع السائق. أقل من ذلك يستنزف البطارية بلا فائدة.
|
||||
static const int locationFilterMeters = 25;
|
||||
|
||||
/// مهلة البحث عن سائق قبل عرض «لا يوجد سائقون».
|
||||
///
|
||||
/// **إلزامية**: الخادم قد لا يُطلق `expired`/`no_drivers` أبداً (ثغرة R1،
|
||||
/// docs/38 §4)، فانتظار حدثٍ قد لا يصل يعني شاشة بحث أبدية.
|
||||
static const Duration searchingTimeout = Duration(seconds: 90);
|
||||
|
||||
/// طول رمز التحقّق كما يولّده الخادم (docs/38 §2).
|
||||
static const int otpLength = 4;
|
||||
|
||||
|
||||
@@ -7,7 +7,13 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../features/auth/cubit/login_cubit.dart';
|
||||
import '../features/auth/data/auth_repository.dart';
|
||||
import '../features/settings/cubit/settings_cubit.dart';
|
||||
import '../features/trip/cubit/ride_cubit.dart';
|
||||
import '../features/trip/data/maps_repository.dart';
|
||||
import '../features/trip/data/trip_repository.dart';
|
||||
import 'api/antlaq_api.dart';
|
||||
import 'api/api_client.dart';
|
||||
import 'location/location_service.dart';
|
||||
import 'realtime/realtime_service.dart';
|
||||
import 'session/session_cubit.dart';
|
||||
import 'storage/token_store.dart';
|
||||
|
||||
@@ -44,6 +50,20 @@ Future<void> setupInjector() async {
|
||||
|
||||
// تدفّق الدخول قصير العمر: نسخة جديدة لكل دخول، لا نسخة واحدة أبدية.
|
||||
sl.registerFactory<LoginCubit>(() => LoginCubit(sl(), sl()));
|
||||
|
||||
// الخرائط عميل منفصل بلا توكن تريبز — map-saas لا شأن له بجلستنا.
|
||||
sl.registerSingleton<AntlaqApi>(AntlaqApi.create());
|
||||
sl.registerSingleton<MapsRepository>(MapsRepository(sl()));
|
||||
sl.registerSingleton<TripRepository>(TripRepository(sl()));
|
||||
sl.registerSingleton<LocationService>(LocationService());
|
||||
sl.registerSingleton<RealtimeService>(RealtimeService(tokens));
|
||||
|
||||
sl.registerFactory<RideCubit>(() => RideCubit(
|
||||
trips: sl(),
|
||||
maps: sl(),
|
||||
location: sl(),
|
||||
realtime: sl(),
|
||||
));
|
||||
}
|
||||
|
||||
/// بصمة الجهاز (`x-device-id`) — يفرضها الخادم عند تفعيل
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
{
|
||||
"@@locale": "ar",
|
||||
|
||||
"appTitle": "Tripz",
|
||||
|
||||
"actionRetry": "أعد المحاولة",
|
||||
"actionCancel": "إلغاء",
|
||||
"actionContinue": "متابعة",
|
||||
"actionSave": "حفظ",
|
||||
"actionOpenSettings": "افتح الإعدادات",
|
||||
|
||||
"errorGeneric": "حدث خطأ غير متوقّع",
|
||||
"errorNetwork": "تعذّر الاتصال بالخادم",
|
||||
"errorTimeout": "انتهت مهلة الاتصال",
|
||||
"emptyDefault": "لا يوجد شيء هنا بعد",
|
||||
|
||||
"splashRider": "تطبيق الراكب",
|
||||
"splashDriver": "تطبيق السائق",
|
||||
|
||||
"agreementTitle": "شروط الاستخدام",
|
||||
"agreementLead": "قبل أن نبدأ، اقرأ الشروط ووافق عليها.",
|
||||
"agreementCheckbox": "قرأت الشروط وأوافق عليها",
|
||||
"agreementAccept": "أوافق وأتابع",
|
||||
|
||||
"permissionTitle": "نحتاج موقعك",
|
||||
"permissionLead": "الموقع هو ما يجعل الرحلة ممكنة: به نعرف أين أنت وأين السائق.",
|
||||
"permissionAllow": "السماح بالوصول للموقع",
|
||||
"permissionDeniedForever": "رفضتَ الإذن نهائياً. افتح الإعدادات وفعّله يدوياً ثم عُد.",
|
||||
|
||||
"phoneTitle": "أهلاً بك في Tripz",
|
||||
"phoneLead": "أدخل رقم هاتفك، ونرسل لك رمز تحقّق.",
|
||||
"phoneLabel": "رقم الهاتف",
|
||||
@@ -35,27 +28,35 @@
|
||||
"phoneErrEmpty": "أدخل رقم هاتفك",
|
||||
"phoneErrLeadingZero": "أدخل الرقم بلا الصفر في البداية",
|
||||
"phoneErrShort": "الرقم قصير جداً",
|
||||
|
||||
"otpTitle": "أدخل رمز التحقّق",
|
||||
"otpLead": "أرسلنا رمزاً من أربع خانات إلى {phone}",
|
||||
"@otpLead": { "placeholders": { "phone": { "type": "String" } } },
|
||||
"@otpLead": {
|
||||
"placeholders": {
|
||||
"phone": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"otpVerify": "تحقّق وتابع",
|
||||
"otpResend": "إعادة إرسال الرمز",
|
||||
"otpResendIn": "إعادة الإرسال بعد {seconds} ثانية",
|
||||
"@otpResendIn": { "placeholders": { "seconds": { "type": "int" } } },
|
||||
"@otpResendIn": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"otpErrIncomplete": "أدخل الرمز كاملاً",
|
||||
"otpErrInvalid": "الرمز غير صحيح أو انتهت صلاحيته",
|
||||
"otpErrTooManyAttempts": "محاولات كثيرة. اطلب رمزاً جديداً.",
|
||||
"otpErrRateLimited": "طلبتَ الرمز مرات كثيرة. انتظر قليلاً ثم أعد المحاولة.",
|
||||
"otpChangeNumber": "تعديل الرقم",
|
||||
|
||||
"profileTitle": "أكمل ملفك",
|
||||
"profileLead": "اسمك يظهر للسائق عند الرحلة.",
|
||||
"profileNameLabel": "الاسم",
|
||||
"profileNameErrEmpty": "أدخل اسمك",
|
||||
|
||||
"sessionExpired": "انتهت جلستك. سجّل الدخول من جديد.",
|
||||
|
||||
"settingsTitle": "الإعدادات",
|
||||
"settingsTheme": "المظهر",
|
||||
"settingsThemeSystem": "حسب النظام",
|
||||
@@ -63,5 +64,64 @@
|
||||
"settingsThemeDark": "داكن",
|
||||
"settingsLanguage": "اللغة",
|
||||
"settingsLanguageArabic": "العربية",
|
||||
"settingsLanguageEnglish": "English"
|
||||
}
|
||||
"settingsLanguageEnglish": "English",
|
||||
"rideWhereTo": "إلى أين؟",
|
||||
"rideOrigin": "من",
|
||||
"rideDestination": "إلى",
|
||||
"rideSearchHint": "ابحث عن مكان",
|
||||
"rideAddStop": "إضافة محطة",
|
||||
"rideStop": "محطة {n}",
|
||||
"@rideStop": {
|
||||
"placeholders": {
|
||||
"n": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ridePickOnMap": "تحديد من الخريطة",
|
||||
"rideConfirmPoint": "تأكيد هذه النقطة",
|
||||
"rideConfirmTrip": "اطلب الرحلة",
|
||||
"rideSearchingTitle": "نبحث لك عن سائق",
|
||||
"rideSearchingLead": "عادةً أقل من دقيقة",
|
||||
"rideCancelSearch": "إلغاء البحث",
|
||||
"rideNoDrivers": "لا يوجد سائقون متاحون الآن. جرّب بعد قليل.",
|
||||
"rideQuoteFailed": "تعذّر حساب الأجرة",
|
||||
"rideRouteFailed": "تعذّر حساب المسار",
|
||||
"rideRequestFailed": "تعذّر إرسال الطلب",
|
||||
"rideSurge": "الطلب مرتفع الآن",
|
||||
"rideCancelTrip": "إلغاء الرحلة",
|
||||
"rideEta": "{min} دقيقة",
|
||||
"@rideEta": {
|
||||
"placeholders": {
|
||||
"min": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rideDistanceKm": "{km} كم",
|
||||
"@rideDistanceKm": {
|
||||
"placeholders": {
|
||||
"km": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tripAssigned": "السائق في الطريق إليك",
|
||||
"tripDriverArriving": "السائق يقترب",
|
||||
"tripDriverArrived": "السائق وصل وينتظرك",
|
||||
"tripInProgress": "في الطريق إلى وجهتك",
|
||||
"tripCompleted": "وصلت — رحلة موفّقة",
|
||||
"driverGoOnline": "ابدأ الاستقبال",
|
||||
"driverGoOffline": "إيقاف الاستقبال",
|
||||
"driverOnline": "متصل",
|
||||
"driverOffline": "غير متصل",
|
||||
"driverNoOffers": "لا طلبات الآن. ابقَ متصلاً.",
|
||||
"driverOfferTitle": "طلب رحلة جديد",
|
||||
"driverAccept": "قبول",
|
||||
"driverReject": "تجاهل",
|
||||
"driverOfferTaken": "سبقك سائق آخر لهذا الطلب",
|
||||
"driverArrivedAction": "وصلت لموقع الراكب",
|
||||
"driverStartTrip": "بدء الرحلة",
|
||||
"driverEndTrip": "إنهاء الرحلة",
|
||||
"driverOnTheWay": "في الطريق للراكب"
|
||||
}
|
||||
@@ -1,32 +1,25 @@
|
||||
{
|
||||
"@@locale": "en",
|
||||
|
||||
"appTitle": "Tripz",
|
||||
|
||||
"actionRetry": "Try again",
|
||||
"actionCancel": "Cancel",
|
||||
"actionContinue": "Continue",
|
||||
"actionSave": "Save",
|
||||
"actionOpenSettings": "Open settings",
|
||||
|
||||
"errorGeneric": "Something went wrong",
|
||||
"errorNetwork": "Could not reach the server",
|
||||
"errorTimeout": "The connection timed out",
|
||||
"emptyDefault": "Nothing here yet",
|
||||
|
||||
"splashRider": "Rider app",
|
||||
"splashDriver": "Driver app",
|
||||
|
||||
"agreementTitle": "Terms of use",
|
||||
"agreementLead": "Before we start, read the terms and accept them.",
|
||||
"agreementCheckbox": "I have read and accept the terms",
|
||||
"agreementAccept": "Accept and continue",
|
||||
|
||||
"permissionTitle": "We need your location",
|
||||
"permissionLead": "Location is what makes a trip possible: it tells us where you are and where your driver is.",
|
||||
"permissionAllow": "Allow location access",
|
||||
"permissionDeniedForever": "You denied the permission permanently. Open settings, enable it, then come back.",
|
||||
|
||||
"phoneTitle": "Welcome to Tripz",
|
||||
"phoneLead": "Enter your phone number and we'll send you a verification code.",
|
||||
"phoneLabel": "Phone number",
|
||||
@@ -35,27 +28,35 @@
|
||||
"phoneErrEmpty": "Enter your phone number",
|
||||
"phoneErrLeadingZero": "Enter the number without the leading zero",
|
||||
"phoneErrShort": "That number is too short",
|
||||
|
||||
"otpTitle": "Enter your code",
|
||||
"otpLead": "We sent a 4-digit code to {phone}",
|
||||
"@otpLead": { "placeholders": { "phone": { "type": "String" } } },
|
||||
"@otpLead": {
|
||||
"placeholders": {
|
||||
"phone": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"otpVerify": "Verify and continue",
|
||||
"otpResend": "Resend the code",
|
||||
"otpResendIn": "Resend in {seconds}s",
|
||||
"@otpResendIn": { "placeholders": { "seconds": { "type": "int" } } },
|
||||
"@otpResendIn": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"otpErrIncomplete": "Enter the full code",
|
||||
"otpErrInvalid": "That code is wrong or expired",
|
||||
"otpErrTooManyAttempts": "Too many attempts. Request a new code.",
|
||||
"otpErrRateLimited": "You requested the code too many times. Wait a moment and try again.",
|
||||
"otpChangeNumber": "Change number",
|
||||
|
||||
"profileTitle": "Complete your profile",
|
||||
"profileLead": "Your name is shown to the driver during a trip.",
|
||||
"profileNameLabel": "Name",
|
||||
"profileNameErrEmpty": "Enter your name",
|
||||
|
||||
"sessionExpired": "Your session ended. Please sign in again.",
|
||||
|
||||
"settingsTitle": "Settings",
|
||||
"settingsTheme": "Appearance",
|
||||
"settingsThemeSystem": "Follow system",
|
||||
@@ -63,5 +64,64 @@
|
||||
"settingsThemeDark": "Dark",
|
||||
"settingsLanguage": "Language",
|
||||
"settingsLanguageArabic": "العربية",
|
||||
"settingsLanguageEnglish": "English"
|
||||
}
|
||||
"settingsLanguageEnglish": "English",
|
||||
"rideWhereTo": "Where to?",
|
||||
"rideOrigin": "From",
|
||||
"rideDestination": "To",
|
||||
"rideSearchHint": "Search for a place",
|
||||
"rideAddStop": "Add a stop",
|
||||
"rideStop": "Stop {n}",
|
||||
"@rideStop": {
|
||||
"placeholders": {
|
||||
"n": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ridePickOnMap": "Pick on the map",
|
||||
"rideConfirmPoint": "Confirm this point",
|
||||
"rideConfirmTrip": "Request ride",
|
||||
"rideSearchingTitle": "Finding you a driver",
|
||||
"rideSearchingLead": "Usually under a minute",
|
||||
"rideCancelSearch": "Cancel search",
|
||||
"rideNoDrivers": "No drivers available right now. Try again shortly.",
|
||||
"rideQuoteFailed": "Could not price this trip",
|
||||
"rideRouteFailed": "Could not build the route",
|
||||
"rideRequestFailed": "Could not send the request",
|
||||
"rideSurge": "Demand is high right now",
|
||||
"rideCancelTrip": "Cancel ride",
|
||||
"rideEta": "{min} min",
|
||||
"@rideEta": {
|
||||
"placeholders": {
|
||||
"min": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rideDistanceKm": "{km} km",
|
||||
"@rideDistanceKm": {
|
||||
"placeholders": {
|
||||
"km": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tripAssigned": "Your driver is on the way",
|
||||
"tripDriverArriving": "Your driver is close",
|
||||
"tripDriverArrived": "Your driver is waiting",
|
||||
"tripInProgress": "On the way to your destination",
|
||||
"tripCompleted": "You've arrived",
|
||||
"driverGoOnline": "Go online",
|
||||
"driverGoOffline": "Go offline",
|
||||
"driverOnline": "Online",
|
||||
"driverOffline": "Offline",
|
||||
"driverNoOffers": "No requests yet. Stay online.",
|
||||
"driverOfferTitle": "New ride request",
|
||||
"driverAccept": "Accept",
|
||||
"driverReject": "Dismiss",
|
||||
"driverOfferTaken": "Another driver took this request",
|
||||
"driverArrivedAction": "I've arrived",
|
||||
"driverStartTrip": "Start trip",
|
||||
"driverEndTrip": "End trip",
|
||||
"driverOnTheWay": "Heading to rider"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
import '../config.dart';
|
||||
|
||||
/// الموقع — نقطة واحدة. لا `Geolocator` منثوراً في الشاشات.
|
||||
class LocationService {
|
||||
StreamSubscription<Position>? _sub;
|
||||
|
||||
Future<({double lat, double lng})?> current() async {
|
||||
try {
|
||||
final p = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
timeLimit: Duration(seconds: 12),
|
||||
),
|
||||
);
|
||||
return (lat: p.latitude, lng: p.longitude);
|
||||
} catch (_) {
|
||||
// آخر موقع معروف أفضل من لا شيء: خريطة تفتح على الصفر تجربة مكسورة.
|
||||
final last = await Geolocator.getLastKnownPosition();
|
||||
return last == null ? null : (lat: last.latitude, lng: last.longitude);
|
||||
}
|
||||
}
|
||||
|
||||
/// تدفّق المواقع بمرشّح مسافة — رفع كل متر يستنزف البطارية بلا فائدة.
|
||||
Stream<Position> watch() {
|
||||
return Geolocator.getPositionStream(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: AppConfig.locationFilterMeters,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void listen(void Function(Position) onPosition) {
|
||||
_sub?.cancel();
|
||||
_sub = watch().listen(onPosition);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _sub?.cancel();
|
||||
_sub = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:socket_io_client/socket_io_client.dart' as io;
|
||||
|
||||
import '../config.dart';
|
||||
import '../storage/token_store.dart';
|
||||
|
||||
/// أحداث الخادم كما هي في `backend-archive/src/realtime` (docs/38 §9).
|
||||
class RealtimeEvent {
|
||||
const RealtimeEvent(this.name, this.data);
|
||||
|
||||
final String name;
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
static const tripUpdate = 'trip:update';
|
||||
static const driverLocation = 'driver:location';
|
||||
static const tripOffer = 'trip:offer';
|
||||
static const tripOfferTaken = 'trip:offer_taken';
|
||||
}
|
||||
|
||||
/// اتصال Socket.IO واحد للتطبيق كله.
|
||||
///
|
||||
/// **الاتصال ليس مصدر الحقيقة**: كل ما يصل عبره يجب أن يكون قابلاً للاستنتاج
|
||||
/// من REST أيضاً. حدثٌ ضائع (شبكة سيّئة، أو ثغرة R1 التي قد تمنع
|
||||
/// `expired`/`no_drivers` أصلاً) يجب ألّا يُجمّد شاشة.
|
||||
class RealtimeService {
|
||||
RealtimeService(this._tokens);
|
||||
|
||||
final TokenStore _tokens;
|
||||
|
||||
io.Socket? _socket;
|
||||
final _events = StreamController<RealtimeEvent>.broadcast();
|
||||
|
||||
Stream<RealtimeEvent> get events => _events.stream;
|
||||
bool get isConnected => _socket?.connected ?? false;
|
||||
|
||||
Future<void> connect() async {
|
||||
if (_socket != null) return;
|
||||
final token = _tokens.accessToken;
|
||||
if (token == null) return;
|
||||
|
||||
// الأصل بلا `/api` — الـSocket.IO ليس على مسار الـREST (docs/38 §9).
|
||||
final socket = io.io(
|
||||
AppConfig.wsBaseUrl,
|
||||
io.OptionBuilder()
|
||||
.setTransports(['websocket'])
|
||||
.setAuth({'token': token})
|
||||
.enableReconnection()
|
||||
.build(),
|
||||
);
|
||||
|
||||
for (final name in const [
|
||||
RealtimeEvent.tripUpdate,
|
||||
RealtimeEvent.driverLocation,
|
||||
RealtimeEvent.tripOffer,
|
||||
RealtimeEvent.tripOfferTaken,
|
||||
]) {
|
||||
socket.on(name, (data) {
|
||||
if (data is Map) {
|
||||
_events.add(RealtimeEvent(name, Map<String, dynamic>.from(data)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_socket = socket;
|
||||
}
|
||||
|
||||
/// الانضمام **قبل** أي انتقال حالة، وإلا فاتت الأحداث (docs/38 §9).
|
||||
void joinTrip(String tripId) => _socket?.emit('trip:join', {'tripId': tripId});
|
||||
|
||||
void sendDriverLocation(double lat, double lng, {double? heading}) {
|
||||
_socket?.emit(RealtimeEvent.driverLocation, {
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
'heading': ?heading,
|
||||
});
|
||||
}
|
||||
|
||||
/// عند تجديد التوكن أو تبديل المستخدم: قطعٌ وإعادة اتصال بالتوكن الجديد.
|
||||
Future<void> reconnect() async {
|
||||
disconnect();
|
||||
await connect();
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
_socket?.dispose();
|
||||
_socket = null;
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
disconnect();
|
||||
await _events.close();
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import 'session/session_cubit.dart';
|
||||
import 'session/session_state.dart';
|
||||
import '../features/auth/view/login_page.dart';
|
||||
import '../features/auth/view/profile_page.dart';
|
||||
import '../features/home/view/home_page.dart';
|
||||
import '../features/trip/view/ride_page.dart';
|
||||
import '../features/splash/view/splash_page.dart';
|
||||
import 'di.dart';
|
||||
|
||||
@@ -57,7 +57,7 @@ final appRouter = GoRouter(
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.home,
|
||||
builder: (context, state) => const HomePage(),
|
||||
builder: (context, state) => const RidePage(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user