From 1156299d09d74f6a1d3998d7eb8273942a7a0488 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Tue, 4 Aug 2026 23:33:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(apps):=20=D8=A7=D9=84=D8=AE=D8=B1=D9=8A?= =?UTF-8?q?=D8=B7=D8=A9=20=D9=88=D8=B7=D9=84=D8=A8=20=D8=A7=D9=84=D8=B1?= =?UTF-8?q?=D8=AD=D9=84=D8=A9=20=D9=88=D8=AF=D9=88=D8=B1=D8=A9=20=D8=A7?= =?UTF-8?q?=D9=84=D8=B3=D8=A7=D8=A6=D9=82=20=E2=80=94=20=D8=A7=D9=84=D9=85?= =?UTF-8?q?=D8=B1=D8=AD=D9=84=D8=A9=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## حسم تعارض الخرائط (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 --- apps/driver_new/lib/core/api/antlaq_api.dart | 34 ++ apps/driver_new/lib/core/config.dart | 29 ++ apps/driver_new/lib/core/di.dart | 19 + apps/driver_new/lib/core/l10n/app_ar.arb | 90 ++++- apps/driver_new/lib/core/l10n/app_en.arb | 90 ++++- .../lib/core/location/location_service.dart | 46 +++ .../lib/core/realtime/realtime_service.dart | 94 +++++ apps/driver_new/lib/core/router.dart | 4 +- .../lib/features/home/view/home_page.dart | 58 --- .../lib/features/trip/cubit/duty_cubit.dart | 226 +++++++++++ .../lib/features/trip/cubit/duty_state.dart | 89 +++++ .../features/trip/data/maps_repository.dart | 52 +++ .../features/trip/data/models/fare_quote.dart | 42 ++ .../features/trip/data/models/geo_point.dart | 22 ++ .../lib/features/trip/data/models/place.dart | 43 ++ .../features/trip/data/models/ride_type.dart | 47 +++ .../features/trip/data/models/route_info.dart | 33 ++ .../lib/features/trip/data/models/trip.dart | 125 ++++++ .../features/trip/data/trip_repository.dart | 128 ++++++ .../lib/features/trip/view/duty_page.dart | 140 +++++++ .../trip/view/widgets/active_duty_sheet.dart | 71 ++++ .../trip/view/widgets/driver_badge.dart | 92 +++++ .../trip/view/widgets/duty_toggle.dart | 68 ++++ .../trip/view/widgets/offer_sheet.dart | 126 ++++++ .../features/trip/view/widgets/ride_map.dart | 105 +++++ apps/driver_new/pubspec.lock | 216 ++++++++++ apps/driver_new/pubspec.yaml | 5 + apps/rider_new/lib/core/api/antlaq_api.dart | 34 ++ apps/rider_new/lib/core/config.dart | 29 ++ apps/rider_new/lib/core/di.dart | 20 + apps/rider_new/lib/core/l10n/app_ar.arb | 90 ++++- apps/rider_new/lib/core/l10n/app_en.arb | 90 ++++- .../lib/core/location/location_service.dart | 46 +++ .../lib/core/realtime/realtime_service.dart | 94 +++++ apps/rider_new/lib/core/router.dart | 4 +- .../lib/features/home/view/home_page.dart | 58 --- .../lib/features/trip/cubit/ride_cubit.dart | 372 ++++++++++++++++++ .../lib/features/trip/cubit/ride_state.dart | 165 ++++++++ .../features/trip/data/maps_repository.dart | 52 +++ .../features/trip/data/models/fare_quote.dart | 42 ++ .../features/trip/data/models/geo_point.dart | 22 ++ .../lib/features/trip/data/models/place.dart | 43 ++ .../features/trip/data/models/ride_type.dart | 47 +++ .../features/trip/data/models/route_info.dart | 33 ++ .../lib/features/trip/data/models/trip.dart | 125 ++++++ .../features/trip/data/trip_repository.dart | 128 ++++++ .../lib/features/trip/view/ride_page.dart | 188 +++++++++ .../trip/view/widgets/active_trip_sheet.dart | 75 ++++ .../trip/view/widgets/center_pin.dart | 41 ++ .../trip/view/widgets/confirm_sheet.dart | 113 ++++++ .../trip/view/widgets/driver_badge.dart | 92 +++++ .../trip/view/widgets/planner_sheet.dart | 93 +++++ .../features/trip/view/widgets/ride_map.dart | 105 +++++ .../trip/view/widgets/ride_type_picker.dart | 53 +++ .../trip/view/widgets/route_timeline.dart | 112 ++++++ .../trip/view/widgets/searching_sheet.dart | 112 ++++++ apps/rider_new/pubspec.lock | 216 ++++++++++ apps/rider_new/pubspec.yaml | 5 + docs/37-newapps-cubit-plan.md | 40 +- 59 files changed, 4652 insertions(+), 181 deletions(-) create mode 100644 apps/driver_new/lib/core/api/antlaq_api.dart create mode 100644 apps/driver_new/lib/core/location/location_service.dart create mode 100644 apps/driver_new/lib/core/realtime/realtime_service.dart delete mode 100644 apps/driver_new/lib/features/home/view/home_page.dart create mode 100644 apps/driver_new/lib/features/trip/cubit/duty_cubit.dart create mode 100644 apps/driver_new/lib/features/trip/cubit/duty_state.dart create mode 100644 apps/driver_new/lib/features/trip/data/maps_repository.dart create mode 100644 apps/driver_new/lib/features/trip/data/models/fare_quote.dart create mode 100644 apps/driver_new/lib/features/trip/data/models/geo_point.dart create mode 100644 apps/driver_new/lib/features/trip/data/models/place.dart create mode 100644 apps/driver_new/lib/features/trip/data/models/ride_type.dart create mode 100644 apps/driver_new/lib/features/trip/data/models/route_info.dart create mode 100644 apps/driver_new/lib/features/trip/data/models/trip.dart create mode 100644 apps/driver_new/lib/features/trip/data/trip_repository.dart create mode 100644 apps/driver_new/lib/features/trip/view/duty_page.dart create mode 100644 apps/driver_new/lib/features/trip/view/widgets/active_duty_sheet.dart create mode 100644 apps/driver_new/lib/features/trip/view/widgets/driver_badge.dart create mode 100644 apps/driver_new/lib/features/trip/view/widgets/duty_toggle.dart create mode 100644 apps/driver_new/lib/features/trip/view/widgets/offer_sheet.dart create mode 100644 apps/driver_new/lib/features/trip/view/widgets/ride_map.dart create mode 100644 apps/rider_new/lib/core/api/antlaq_api.dart create mode 100644 apps/rider_new/lib/core/location/location_service.dart create mode 100644 apps/rider_new/lib/core/realtime/realtime_service.dart delete mode 100644 apps/rider_new/lib/features/home/view/home_page.dart create mode 100644 apps/rider_new/lib/features/trip/cubit/ride_cubit.dart create mode 100644 apps/rider_new/lib/features/trip/cubit/ride_state.dart create mode 100644 apps/rider_new/lib/features/trip/data/maps_repository.dart create mode 100644 apps/rider_new/lib/features/trip/data/models/fare_quote.dart create mode 100644 apps/rider_new/lib/features/trip/data/models/geo_point.dart create mode 100644 apps/rider_new/lib/features/trip/data/models/place.dart create mode 100644 apps/rider_new/lib/features/trip/data/models/ride_type.dart create mode 100644 apps/rider_new/lib/features/trip/data/models/route_info.dart create mode 100644 apps/rider_new/lib/features/trip/data/models/trip.dart create mode 100644 apps/rider_new/lib/features/trip/data/trip_repository.dart create mode 100644 apps/rider_new/lib/features/trip/view/ride_page.dart create mode 100644 apps/rider_new/lib/features/trip/view/widgets/active_trip_sheet.dart create mode 100644 apps/rider_new/lib/features/trip/view/widgets/center_pin.dart create mode 100644 apps/rider_new/lib/features/trip/view/widgets/confirm_sheet.dart create mode 100644 apps/rider_new/lib/features/trip/view/widgets/driver_badge.dart create mode 100644 apps/rider_new/lib/features/trip/view/widgets/planner_sheet.dart create mode 100644 apps/rider_new/lib/features/trip/view/widgets/ride_map.dart create mode 100644 apps/rider_new/lib/features/trip/view/widgets/ride_type_picker.dart create mode 100644 apps/rider_new/lib/features/trip/view/widgets/route_timeline.dart create mode 100644 apps/rider_new/lib/features/trip/view/widgets/searching_sheet.dart diff --git a/apps/driver_new/lib/core/api/antlaq_api.dart b/apps/driver_new/lib/core/api/antlaq_api.dart new file mode 100644 index 0000000..9470484 --- /dev/null +++ b/apps/driver_new/lib/core/api/antlaq_api.dart @@ -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 get(String path, {Map? query}) async { + try { + final res = await _dio.get(path, queryParameters: query); + return res.data as T; + } on DioException catch (e) { + throw ApiException.from(e); + } + } +} diff --git a/apps/driver_new/lib/core/config.dart b/apps/driver_new/lib/core/config.dart index 53c07b4..0314ffa 100644 --- a/apps/driver_new/lib/core/config.dart +++ b/apps/driver_new/lib/core/config.dart @@ -21,6 +21,35 @@ class AppConfig { /// (docs/38 §1). **يُثبَّت عند البناء ولا يتغيّر في وقت التشغيل أبداً.** static const String appRole = 'driver'; + // ── الخرائط ────────────────────────────────────────────────────────── + // قرار المالك 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; diff --git a/apps/driver_new/lib/core/di.dart b/apps/driver_new/lib/core/di.dart index f042280..13610d5 100644 --- a/apps/driver_new/lib/core/di.dart +++ b/apps/driver_new/lib/core/di.dart @@ -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/duty_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,19 @@ Future setupInjector() async { // تدفّق الدخول قصير العمر: نسخة جديدة لكل دخول، لا نسخة واحدة أبدية. sl.registerFactory(() => LoginCubit(sl(), sl())); + + // الخرائط عميل منفصل بلا توكن تريبز — map-saas لا شأن له بجلستنا. + sl.registerSingleton(AntlaqApi.create()); + sl.registerSingleton(MapsRepository(sl())); + sl.registerSingleton(TripRepository(sl())); + sl.registerSingleton(LocationService()); + sl.registerSingleton(RealtimeService(tokens)); + + sl.registerFactory(() => DutyCubit( + trips: sl(), + location: sl(), + realtime: sl(), + )); } /// بصمة الجهاز (`x-device-id`) — يفرضها الخادم عند تفعيل diff --git a/apps/driver_new/lib/core/l10n/app_ar.arb b/apps/driver_new/lib/core/l10n/app_ar.arb index 6007d81..5b99602 100644 --- a/apps/driver_new/lib/core/l10n/app_ar.arb +++ b/apps/driver_new/lib/core/l10n/app_ar.arb @@ -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": "أهلاً بك، كابتن", "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": "في الطريق للراكب" +} \ No newline at end of file diff --git a/apps/driver_new/lib/core/l10n/app_en.arb b/apps/driver_new/lib/core/l10n/app_en.arb index 7074e55..7e1cb12 100644 --- a/apps/driver_new/lib/core/l10n/app_en.arb +++ b/apps/driver_new/lib/core/l10n/app_en.arb @@ -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, captain", "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 rider 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" +} \ No newline at end of file diff --git a/apps/driver_new/lib/core/location/location_service.dart b/apps/driver_new/lib/core/location/location_service.dart new file mode 100644 index 0000000..83e0be3 --- /dev/null +++ b/apps/driver_new/lib/core/location/location_service.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:geolocator/geolocator.dart'; + +import '../config.dart'; + +/// الموقع — نقطة واحدة. لا `Geolocator` منثوراً في الشاشات. +class LocationService { + StreamSubscription? _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 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 stop() async { + await _sub?.cancel(); + _sub = null; + } +} diff --git a/apps/driver_new/lib/core/realtime/realtime_service.dart b/apps/driver_new/lib/core/realtime/realtime_service.dart new file mode 100644 index 0000000..48c8817 --- /dev/null +++ b/apps/driver_new/lib/core/realtime/realtime_service.dart @@ -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 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.broadcast(); + + Stream get events => _events.stream; + bool get isConnected => _socket?.connected ?? false; + + Future 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.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 reconnect() async { + disconnect(); + await connect(); + } + + void disconnect() { + _socket?.dispose(); + _socket = null; + } + + Future dispose() async { + disconnect(); + await _events.close(); + } +} diff --git a/apps/driver_new/lib/core/router.dart b/apps/driver_new/lib/core/router.dart index 8284422..083cb5c 100644 --- a/apps/driver_new/lib/core/router.dart +++ b/apps/driver_new/lib/core/router.dart @@ -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/duty_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 DutyPage(), ), ], ); diff --git a/apps/driver_new/lib/features/home/view/home_page.dart b/apps/driver_new/lib/features/home/view/home_page.dart deleted file mode 100644 index 04a9fac..0000000 --- a/apps/driver_new/lib/features/home/view/home_page.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../../core/design/tokens.dart'; -import '../../../core/design/tripz_colors.dart'; -import '../../../core/l10n/l10n.dart'; -import '../../../core/ui/tripz_button.dart'; -import '../../../core/ui/tripz_card.dart'; -import '../../../core/ui/tripz_scaffold.dart'; -import '../../../core/session/session_cubit.dart'; -import '../../../core/session/session_state.dart'; - -/// هيكل مؤقّت — تحلّ محلّه شاشة الخريطة وطلب الرحلة في م4 (docs/37). -/// الغرض الآن: إثبات أن سلسلة الدخول والجلسة والتوجيه تعمل طرفاً لطرف. -class HomePage extends StatelessWidget { - const HomePage({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, session) { - final user = session.user; - return TripzScaffold( - title: context.l10n.appTitle, - child: ListView( - children: [ - TripzCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user?.name ?? '', - style: context.texts.titleMedium, - ), - const SizedBox(height: Space.xxs), - Text( - user?.phone ?? '', - textDirection: TextDirection.ltr, - style: context.texts.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), - ), - const SizedBox(height: Space.lg), - TripzButton.secondary( - label: context.l10n.actionCancel, - expanded: true, - onPressed: context.read().logout, - ), - ], - ), - ); - }, - ); - } -} diff --git a/apps/driver_new/lib/features/trip/cubit/duty_cubit.dart b/apps/driver_new/lib/features/trip/cubit/duty_cubit.dart new file mode 100644 index 0000000..92faea8 --- /dev/null +++ b/apps/driver_new/lib/features/trip/cubit/duty_cubit.dart @@ -0,0 +1,226 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/api/api_exception.dart'; +import '../../../core/location/location_service.dart'; +import '../../../core/realtime/realtime_service.dart'; +import '../data/models/geo_point.dart'; +import '../data/models/trip.dart'; +import '../data/trip_repository.dart'; +import 'duty_state.dart'; + +/// دوام السائق: الاتصال ← استقبال العروض ← القبول ← إدارة الرحلة. +class DutyCubit extends Cubit { + DutyCubit({ + required TripRepository trips, + required LocationService location, + required RealtimeService realtime, + }) : _trips = trips, + _location = location, + _realtime = realtime, + super(const DutyState()); + + final TripRepository _trips; + final LocationService _location; + final RealtimeService _realtime; + + StreamSubscription? _events; + Timer? _offerTimer; + + @override + Future close() { + _events?.cancel(); + _offerTimer?.cancel(); + _location.stop(); + return super.close(); + } + + Future init() async { + _events = _realtime.events.listen(_onRealtime); + await _realtime.connect(); + unawaited(_loadCredit()); + unawaited(_resumeActiveTrip()); + } + + Future _loadCredit() async { + try { + final credit = await _trips.credit(); + if (isClosed) return; + emit(state.copyWith( + creditBalance: credit.balance, + creditBlocked: credit.blocked, + )); + } on ApiException { + // الرصيد معلومة مساعدة لا حاجز إقلاع. + } + } + + Future _resumeActiveTrip() async { + try { + final live = (await _trips.mine()).where((t) => t.status.isLive); + if (isClosed || live.isEmpty) return; + _attachTrip(live.first); + } on ApiException { + // لا رحلة جارية. + } + } + + // ── الاتصال ──────────────────────────────────────────────────────────── + + Future toggleOnline() async { + final goingOnline = !state.isOnline; + emit(state.copyWith(busy: true, error: DutyError.none)); + try { + await _trips.setOnline(goingOnline); + if (isClosed) return; + + if (goingOnline) { + // الموقع يُرفع فور الاتصال: سائقٌ متصل بلا موقع لا يصله عمل. + _location.listen(_onPosition); + emit(state.copyWith(phase: DutyPhase.waiting, busy: false)); + } else { + await _location.stop(); + emit(state.copyWith( + phase: DutyPhase.offline, + busy: false, + clearOffer: true, + )); + } + } on ApiException { + if (!isClosed) { + emit(state.copyWith(busy: false, error: DutyError.goOnlineFailed)); + } + } + } + + void _onPosition(dynamic position) { + final lat = position.latitude as double; + final lng = position.longitude as double; + final heading = position.heading as double?; + + emit(state.copyWith(myLocation: GeoPoint(lat, lng))); + + // مساران عمداً: REST يغذّي المطابقة في Redis، والسوكت يغذّي خريطة + // الراكب الحيّة. سقوط أحدهما لا يُعمي الآخر. + unawaited(_trips + .pushLocation(GeoPoint(lat, lng), heading: heading) + .catchError((_) {})); + _realtime.sendDriverLocation(lat, lng, heading: heading); + } + + // ── العروض ───────────────────────────────────────────────────────────── + + void _showOffer(Trip offer) { + if (state.phase == DutyPhase.active) return; // مشغول برحلة + _offerTimer?.cancel(); + emit(state.copyWith( + offer: offer, + phase: DutyPhase.offered, + error: DutyError.none, + )); + + // العرض لا يبقى معلّقاً إلى الأبد: الخادم قد يعطيه لغيره بلا إشعارنا. + _offerTimer = Timer(const Duration(seconds: 25), dismissOffer); + } + + void dismissOffer() { + _offerTimer?.cancel(); + if (isClosed) return; + emit(state.copyWith( + clearOffer: true, + phase: state.trip != null ? DutyPhase.active : DutyPhase.waiting, + )); + } + + Future acceptOffer() async { + final offer = state.offer; + if (offer == null) return; + _offerTimer?.cancel(); + emit(state.copyWith(busy: true, error: DutyError.none)); + try { + await _trips.accept(offer.id); + _attachTrip(await _trips.get(offer.id)); + } on ApiException catch (e) { + if (isClosed) return; + // القبول ذرّي: أول سائق يفوز، والباقي يُرفض. هذا ليس عطلاً — يُعرض + // «سبقك سائق آخر» لا رسالة خطأ عامة. + emit(state.copyWith( + busy: false, + clearOffer: true, + phase: DutyPhase.waiting, + error: e.isForbidden || e.statusCode == 409 + ? DutyError.offerTaken + : DutyError.acceptFailed, + )); + } + } + + // ── الرحلة ───────────────────────────────────────────────────────────── + + /// الانتقال التالي في آلة الحالات — الشاشة لا تقرّره (docs/38 §4). + TripStatus? get nextStatus => switch (state.trip?.status) { + TripStatus.assigned => TripStatus.driverArriving, + TripStatus.driverArriving => TripStatus.driverArrived, + TripStatus.driverArrived => TripStatus.inProgress, + TripStatus.inProgress => TripStatus.completed, + _ => null, + }; + + Future advanceTrip() async { + final trip = state.trip; + final next = nextStatus; + if (trip == null || next == null) return; + + emit(state.copyWith(busy: true, error: DutyError.none)); + try { + await _trips.updateStatus(trip.id, next); + _attachTrip(await _trips.get(trip.id)); + // العمولة تُخصم من الرصيد التشغيلي عند الإنهاء — يُحدَّث فوراً. + if (next == TripStatus.completed) unawaited(_loadCredit()); + } on ApiException { + if (!isClosed) { + emit(state.copyWith(busy: false, error: DutyError.statusFailed)); + } + } + } + + void _attachTrip(Trip trip) { + _realtime.joinTrip(trip.id); + if (trip.status.isOver) { + emit(state.copyWith( + clearTrip: true, + clearOffer: true, + busy: false, + phase: state.isOnline ? DutyPhase.waiting : DutyPhase.offline, + )); + return; + } + emit(state.copyWith( + trip: trip, + clearOffer: true, + busy: false, + phase: DutyPhase.active, + )); + } + + void _onRealtime(RealtimeEvent event) { + switch (event.name) { + case RealtimeEvent.tripOffer: + _showOffer(Trip.fromJson(event.data)); + + case RealtimeEvent.tripOfferTaken: + final id = event.data['id'] ?? event.data['tripId']; + if (id == state.offer?.id) { + dismissOffer(); + emit(state.copyWith(error: DutyError.offerTaken)); + } + + case RealtimeEvent.tripUpdate: + final id = event.data['id'] ?? event.data['tripId']; + if (id != null && id == state.trip?.id) { + _attachTrip(Trip.fromJson(event.data)); + } + } + } +} diff --git a/apps/driver_new/lib/features/trip/cubit/duty_state.dart b/apps/driver_new/lib/features/trip/cubit/duty_state.dart new file mode 100644 index 0000000..7100c65 --- /dev/null +++ b/apps/driver_new/lib/features/trip/cubit/duty_state.dart @@ -0,0 +1,89 @@ +import 'package:equatable/equatable.dart'; + +import '../data/models/geo_point.dart'; +import '../data/models/trip.dart'; + +/// حالة السائق. الفرق الجوهري عن الراكب: السائق **يستقبل** عملاً بدل أن +/// يطلبه، فالمحور هنا الاتصال والعرض لا التخطيط. +enum DutyPhase { + /// غير متصل — لا يصله شيء. + offline, + + /// متصل بانتظار عرض. + waiting, + + /// عرض معروض بمؤقّت. + offered, + + /// رحلة مقبولة جارية. + active, +} + +enum DutyError { none, goOnlineFailed, acceptFailed, offerTaken, statusFailed } + +class DutyState extends Equatable { + const DutyState({ + this.phase = DutyPhase.offline, + this.myLocation, + this.offer, + this.trip, + this.busy = false, + this.error = DutyError.none, + this.creditBalance, + this.creditBlocked = false, + }); + + final DutyPhase phase; + final GeoPoint? myLocation; + + /// العرض المعروض حالياً — يختفي إن سبق إليه سائق آخر. + final Trip? offer; + + final Trip? trip; + final bool busy; + final DutyError error; + + /// الرصيد التشغيلي: العمولة تُخصم منه عند الإنهاء (docs/38 §5). + final String? creditBalance; + + /// محجوب لنفاد الرصيد — لا يصله عمل، ولا بد أن يعرف السبب صراحةً. + final bool creditBlocked; + + bool get isOnline => phase != DutyPhase.offline; + + DutyState copyWith({ + DutyPhase? phase, + GeoPoint? myLocation, + Trip? offer, + Trip? trip, + bool? busy, + DutyError? error, + String? creditBalance, + bool? creditBlocked, + bool clearOffer = false, + bool clearTrip = false, + }) { + return DutyState( + phase: phase ?? this.phase, + myLocation: myLocation ?? this.myLocation, + offer: clearOffer ? null : (offer ?? this.offer), + trip: clearTrip ? null : (trip ?? this.trip), + busy: busy ?? this.busy, + error: error ?? this.error, + creditBalance: creditBalance ?? this.creditBalance, + creditBlocked: creditBlocked ?? this.creditBlocked, + ); + } + + @override + List get props => [ + phase, + myLocation, + offer, + trip, + busy, + error, + creditBalance, + creditBlocked, + ]; +} diff --git a/apps/driver_new/lib/features/trip/data/maps_repository.dart b/apps/driver_new/lib/features/trip/data/maps_repository.dart new file mode 100644 index 0000000..f3dff78 --- /dev/null +++ b/apps/driver_new/lib/features/trip/data/maps_repository.dart @@ -0,0 +1,52 @@ +import '../../../core/api/antlaq_api.dart'; +import 'models/geo_point.dart'; +import 'models/place.dart'; +import 'models/route_info.dart'; + +/// كل الخرائط من map-saas مباشرة (قرار 2026-07-20). نقاط `/maps/*` في باك إند +/// تريبز **لا تُستعمل** — إحداها (`geocode`) معطوبة على المنشور أصلاً. +class MapsRepository { + MapsRepository(this._api); + + final AntlaqApi _api; + + Future> search(String query, {GeoPoint? near}) async { + if (query.trim().isEmpty) return const []; + final res = await _api.get>( + '/geocoding/search', + query: { + 'q': query.trim(), + if (near != null) 'lat': near.lat, + if (near != null) 'lng': near.lng, + }, + ); + return res + .whereType>() + .map(Place.fromJson) + .toList(growable: false); + } + + /// يرجّع **مصفوفة** مرتّبة بالأقرب — الأول هو المطلوب. تفكيكها ككائن مفرد + /// كان باگ سيرو الذي أُصلح 2026-07-20. + Future reverse(GeoPoint point) async { + final res = await _api.get>( + '/geocoding/reverse', + query: {'lat': point.lat, 'lng': point.lng}, + ); + final first = res.whereType>().firstOrNull; + return first == null ? null : Place.fromJson(first); + } + + Future route(GeoPoint from, GeoPoint to) async { + final res = await _api.get>( + '/maps/route', + query: { + 'fromLat': from.lat, + 'fromLng': from.lng, + 'toLat': to.lat, + 'toLng': to.lng, + }, + ); + return RouteInfo.fromJson(res); + } +} diff --git a/apps/driver_new/lib/features/trip/data/models/fare_quote.dart b/apps/driver_new/lib/features/trip/data/models/fare_quote.dart new file mode 100644 index 0000000..898993c --- /dev/null +++ b/apps/driver_new/lib/features/trip/data/models/fare_quote.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; + +/// تسعيرة مسبقة من `POST /tariff/quote`. +/// +/// ⚠️ مصيدتان مثبتتان (docs/38 §7): +/// 1. هذه النقطة **camelCase وحدها** بخلاف كل الـAPI. +/// 2. بالمعاملات الخطأ ترجّع **200 بقيم `null` صامتة** لا خطأً — لذلك +/// [isUsable] شرطٌ قبل عرض أي رقم للمستخدم. +class FareQuote extends Equatable { + const FareQuote({ + required this.total, + required this.currency, + this.subtotal, + this.surgeMultiplier = 1, + this.window = '', + }); + + final double? total; + final String currency; + final double? subtotal; + final double surgeMultiplier; + final String window; + + factory FareQuote.fromJson(Map json) { + final quote = (json['quote'] as Map?) ?? const {}; + return FareQuote( + total: (quote['total'] as num?)?.toDouble(), + currency: quote['currency'] as String? ?? '', + subtotal: (quote['subtotal'] as num?)?.toDouble(), + surgeMultiplier: (quote['surgeMultiplier'] as num?)?.toDouble() ?? 1, + window: quote['window'] as String? ?? '', + ); + } + + bool get isUsable => total != null && total! > 0; + + /// تسعير مرتفع — يُعرض للمستخدم صراحةً قبل التأكيد لا بعده. + bool get hasSurge => surgeMultiplier > 1; + + @override + List get props => [total, currency, subtotal, surgeMultiplier]; +} diff --git a/apps/driver_new/lib/features/trip/data/models/geo_point.dart b/apps/driver_new/lib/features/trip/data/models/geo_point.dart new file mode 100644 index 0000000..b9a5f3a --- /dev/null +++ b/apps/driver_new/lib/features/trip/data/models/geo_point.dart @@ -0,0 +1,22 @@ +import 'package:equatable/equatable.dart'; + +/// إحداثية في طبقة النطاق — **مستقلة عن حزمة الخريطة** عمداً. +/// +/// الكيوبت والمستودع لا يعرفان `intaleq_maps`؛ التحويل يقع في الواجهة وحدها. +/// تغيير محرّك الخريطة لاحقاً لا يلمس منطقاً. +class GeoPoint extends Equatable { + const GeoPoint(this.lat, this.lng); + + final double lat; + final double lng; + + factory GeoPoint.fromJson(Map json) => GeoPoint( + (json['lat'] as num).toDouble(), + (json['lng'] as num).toDouble(), + ); + + Map toJson() => {'lat': lat, 'lng': lng}; + + @override + List get props => [lat, lng]; +} diff --git a/apps/driver_new/lib/features/trip/data/models/place.dart b/apps/driver_new/lib/features/trip/data/models/place.dart new file mode 100644 index 0000000..a1f7e23 --- /dev/null +++ b/apps/driver_new/lib/features/trip/data/models/place.dart @@ -0,0 +1,43 @@ +import 'package:equatable/equatable.dart'; + +import 'geo_point.dart'; + +/// مكان من map-saas. حقول الاسم `name`/`name_ar`/`address` — **لا +/// `formattedAddress`** (عقد مثبت بـcurl، راجع قرار الخرائط 2026-07-20). +class Place extends Equatable { + const Place({ + required this.id, + required this.name, + required this.point, + this.address = '', + this.category = '', + }); + + final String id; + final String name; + final GeoPoint point; + final String address; + final String category; + + factory Place.fromJson(Map json) { + return Place( + id: json['id']?.toString() ?? '', + // العربي أولاً — الواجهة عربية افتراضاً، والإنجليزي احتياط. + name: (json['name_ar'] as String?)?.trim().isNotEmpty == true + ? json['name_ar'] as String + : (json['name'] as String? ?? ''), + address: json['address'] as String? ?? '', + category: json['category'] as String? ?? '', + point: GeoPoint( + (json['latitude'] as num).toDouble(), + (json['longitude'] as num).toDouble(), + ), + ); + } + + /// ما يُعرض في حقل العنوان: الاسم، وإن غاب فالعنوان. + String get label => name.isNotEmpty ? name : address; + + @override + List get props => [id, name, point, address]; +} diff --git a/apps/driver_new/lib/features/trip/data/models/ride_type.dart b/apps/driver_new/lib/features/trip/data/models/ride_type.dart new file mode 100644 index 0000000..eaf80e5 --- /dev/null +++ b/apps/driver_new/lib/features/trip/data/models/ride_type.dart @@ -0,0 +1,47 @@ +import 'package:equatable/equatable.dart'; + +/// نوع الرحلة كما يرجّعه `GET /ride-types` (شكل مُتحقَّق حيّاً، docs/38 §7). +/// **شاشة الاختيار تُبنى من هنا** — لا قائمة مكتوبة في التطبيق. +class RideType extends Equatable { + const RideType({ + required this.id, + required this.code, + required this.nameAr, + required this.nameEn, + required this.vehicleKind, + required this.womenOnly, + required this.roundTripSupported, + required this.sort, + this.icon, + }); + + final String id; + final String code; + final String nameAr; + final String nameEn; + final String vehicleKind; + final bool womenOnly; + final bool roundTripSupported; + final int sort; + final String? icon; + + factory RideType.fromJson(Map json) { + return RideType( + id: json['id'] as String, + code: json['code'] as String, + nameAr: json['name_ar'] as String? ?? '', + nameEn: json['name_en'] as String? ?? '', + vehicleKind: json['vehicle_kind'] as String? ?? 'car', + womenOnly: json['women_only'] as bool? ?? false, + roundTripSupported: json['round_trip_supported'] as bool? ?? false, + sort: (json['sort'] as num?)?.toInt() ?? 0, + icon: json['icon'] as String?, + ); + } + + String nameFor(String languageCode) => + languageCode == 'ar' ? nameAr : (nameEn.isEmpty ? nameAr : nameEn); + + @override + List get props => [id, code, sort]; +} diff --git a/apps/driver_new/lib/features/trip/data/models/route_info.dart b/apps/driver_new/lib/features/trip/data/models/route_info.dart new file mode 100644 index 0000000..b913802 --- /dev/null +++ b/apps/driver_new/lib/features/trip/data/models/route_info.dart @@ -0,0 +1,33 @@ +import 'package:equatable/equatable.dart'; + +/// مسار من map-saas. المسار نفسه **encoded polyline** في حقل `points`، +/// ويُفكّ في الواجهة لا هنا (طبقة النطاق لا تعرف حزمة الخريطة). +class RouteInfo extends Equatable { + const RouteInfo({ + required this.distanceMeters, + required this.durationSeconds, + required this.encodedPoints, + this.name = '', + }); + + final double distanceMeters; + final int durationSeconds; + final String encodedPoints; + final String name; + + factory RouteInfo.fromJson(Map json) { + return RouteInfo( + distanceMeters: (json['distance'] as num?)?.toDouble() ?? 0, + durationSeconds: (json['duration'] as num?)?.round() ?? 0, + encodedPoints: json['points'] as String? ?? '', + name: json['routeName'] as String? ?? '', + ); + } + + /// التعرفة تُطلب بالكيلومترات والدقائق (docs/38 §7). + double get distanceKm => distanceMeters / 1000; + double get durationMin => durationSeconds / 60; + + @override + List get props => [distanceMeters, durationSeconds, encodedPoints]; +} diff --git a/apps/driver_new/lib/features/trip/data/models/trip.dart b/apps/driver_new/lib/features/trip/data/models/trip.dart new file mode 100644 index 0000000..5a7ba5b --- /dev/null +++ b/apps/driver_new/lib/features/trip/data/models/trip.dart @@ -0,0 +1,125 @@ +import 'package:equatable/equatable.dart'; + +import 'geo_point.dart'; + +/// حالات الرحلة كما يعرّفها الخادم (docs/38 §4). +enum TripStatus { + searching, + assigned, + driverArriving, + driverArrived, + inProgress, + completed, + paid, + cancelled, + expired, + noDrivers, + unknown; + + static TripStatus parse(String? raw) => switch (raw) { + 'searching' => TripStatus.searching, + 'assigned' => TripStatus.assigned, + 'driver_arriving' => TripStatus.driverArriving, + 'driver_arrived' => TripStatus.driverArrived, + 'in_progress' => TripStatus.inProgress, + 'completed' => TripStatus.completed, + 'paid' => TripStatus.paid, + 'cancelled' => TripStatus.cancelled, + 'expired' => TripStatus.expired, + 'no_drivers' => TripStatus.noDrivers, + _ => TripStatus.unknown, + }; + + String get wire => switch (this) { + TripStatus.driverArriving => 'driver_arriving', + TripStatus.driverArrived => 'driver_arrived', + TripStatus.inProgress => 'in_progress', + TripStatus.noDrivers => 'no_drivers', + _ => name, + }; + + /// رحلة جارية تُستأنف عند فتح التطبيق. + bool get isLive => const { + TripStatus.searching, + TripStatus.assigned, + TripStatus.driverArriving, + TripStatus.driverArrived, + TripStatus.inProgress, + }.contains(this); + + bool get isOver => const { + TripStatus.completed, + TripStatus.paid, + TripStatus.cancelled, + TripStatus.expired, + TripStatus.noDrivers, + }.contains(this); +} + +class Trip extends Equatable { + const Trip({ + required this.id, + required this.status, + this.origin, + this.destination, + this.quotedFare, + this.priceForPassenger, + this.priceForDriver, + this.currency = '', + this.driverName, + this.driverPhone, + this.vehiclePlate, + this.vehicleModel, + this.driverRating, + }); + + final String id; + final TripStatus status; + final GeoPoint? origin; + final GeoPoint? destination; + + /// أجرة مقفولة عند الطلب. + final String? quotedFare; + + /// **حقلان منفصلان** لا حقل واحد — الفرق هو العمولة (docs/38 §4). + final String? priceForPassenger; + final String? priceForDriver; + + final String currency; + final String? driverName; + final String? driverPhone; + final String? vehiclePlate; + final String? vehicleModel; + final String? driverRating; + + factory Trip.fromJson(Map json) { + GeoPoint? point(String key) { + final v = json[key]; + return v is Map ? GeoPoint.fromJson(v) : null; + } + + final driver = json['driver'] as Map?; + return Trip( + id: json['id'] as String, + status: TripStatus.parse(json['status'] as String?), + origin: point('origin'), + destination: point('destination'), + // كل المبالغ نصوص (docs/38 §12.2) — تُحفظ كما وصلت ولا تُحوَّل هنا. + quotedFare: json['quoted_fare']?.toString(), + priceForPassenger: json['price_for_passenger']?.toString(), + priceForDriver: json['price_for_driver']?.toString(), + currency: json['currency'] as String? ?? '', + driverName: driver?['name'] as String? ?? json['driver_name'] as String?, + driverPhone: + driver?['phone'] as String? ?? json['driver_phone'] as String?, + vehiclePlate: + driver?['vehicle_plate'] as String? ?? json['vehicle_plate'] as String?, + vehicleModel: + driver?['vehicle_model'] as String? ?? json['vehicle_model'] as String?, + driverRating: driver?['rating']?.toString(), + ); + } + + @override + List get props => [id, status, priceForPassenger, driverName]; +} diff --git a/apps/driver_new/lib/features/trip/data/trip_repository.dart b/apps/driver_new/lib/features/trip/data/trip_repository.dart new file mode 100644 index 0000000..9b1afe9 --- /dev/null +++ b/apps/driver_new/lib/features/trip/data/trip_repository.dart @@ -0,0 +1,128 @@ +import '../../../core/api/api_client.dart'; +import 'models/fare_quote.dart'; +import 'models/geo_point.dart'; +import 'models/ride_type.dart'; +import 'models/trip.dart'; + +class TripRepository { + TripRepository(this._api); + + final ApiClient _api; + + Future> rideTypes() async { + final res = await _api.get>('/ride-types'); + return res + .whereType>() + .map(RideType.fromJson) + .where((t) => true) + .toList(growable: false) + ..sort((a, b) => a.sort.compareTo(b.sort)); + } + + /// ⚠️ camelCase وحدها في كل الـAPI، وتحتاج **مسافة ومدة محسوبتين مسبقاً** + /// من `/maps/route` — إرسال الإحداثيات يرجّع 200 بقيم `null` صامتة + /// (docs/38 §7). + Future quote({ + required String city, + required String serviceClass, + required double distanceKm, + required double durationMin, + }) async { + final res = await _api.post>( + '/tariff/quote', + body: { + 'city': city, + 'serviceClass': serviceClass, + 'distanceKm': distanceKm, + 'durationMin': durationMin, + }, + ); + return FareQuote.fromJson(res); + } + + /// يرجّع الرحلة وعدد السائقين الذين عُرضت عليهم — الصفر يعني «لا سائقين + /// قريبين» فوراً، بلا انتظار مهلة. + Future<({Trip trip, int offeredDrivers})> request({ + required GeoPoint origin, + required GeoPoint destination, + required String serviceClass, + String paymentMethod = 'wallet', + List stops = const [], + String? couponCode, + bool isRoundTrip = false, + }) async { + final res = await _api.post>('/trips', body: { + 'origin': origin.toJson(), + 'destination': destination.toJson(), + 'service_class': serviceClass, + 'payment_method': paymentMethod, + 'is_round_trip': isRoundTrip, + if (stops.isNotEmpty) + 'stops': stops.map((s) => s.toJson()).toList(growable: false), + 'coupon_code': ?couponCode, + }); + return ( + trip: Trip.fromJson(res['trip'] as Map), + offeredDrivers: (res['offeredDrivers'] as num?)?.toInt() ?? 0, + ); + } + + Future get(String tripId) async { + final res = await _api.get>('/trips/$tripId'); + return Trip.fromJson(res); + } + + Future> mine() async { + final res = await _api.get>('/trips/mine'); + return res + .whereType>() + .map(Trip.fromJson) + .toList(growable: false); + } + + Future cancel(String tripId) => + _api.post>('/trips/$tripId/cancel'); + + // ── طرف السائق ───────────────────────────────────────────────────────── + + Future> available() async { + final res = await _api.get>('/trips/available'); + return res + .whereType>() + .map(Trip.fromJson) + .toList(growable: false); + } + + Future accept(String tripId) => + _api.post>('/trips/$tripId/accept'); + + Future setOnline(bool online) => _api.patch>( + '/drivers/status', + body: {'online': online}, + ); + + /// نبضة الموقع إلى Redis — أساس المطابقة. مرشّح المسافة في + /// `LocationService` يمنع إغراقها. + Future pushLocation(GeoPoint point, {double? heading, double? speed}) => + _api.post>('/drivers/location', body: { + ...point.toJson(), + 'heading': ?heading, + 'speed': ?speed, + }); + + /// الرصيد التشغيلي. للراكب يرجّع 403 `Not a driver` — لا يُنادى إلا من + /// تطبيق السائق (docs/38 §5). + Future<({String balance, bool blocked})> credit() async { + final res = await _api.get>('/credit'); + return ( + balance: res['balance']?.toString() ?? '0', + blocked: res['blocked'] as bool? ?? false, + ); + } + + Future updateStatus(String tripId, TripStatus status) => + _api.patch>( + '/trips/$tripId/status', + body: {'status': status.wire}, + ); +} diff --git a/apps/driver_new/lib/features/trip/view/duty_page.dart b/apps/driver_new/lib/features/trip/view/duty_page.dart new file mode 100644 index 0000000..c40f062 --- /dev/null +++ b/apps/driver_new/lib/features/trip/view/duty_page.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/design/tokens.dart'; +import '../../../core/design/tripz_colors.dart'; +import '../../../core/di.dart'; +import '../../../core/l10n/l10n.dart'; +import '../../../core/ui/status_banner.dart'; +import '../cubit/duty_cubit.dart'; +import '../cubit/duty_state.dart'; +import '../data/models/geo_point.dart'; +import 'widgets/active_duty_sheet.dart'; +import 'widgets/duty_toggle.dart'; +import 'widgets/offer_sheet.dart'; +import 'widgets/ride_map.dart'; + +/// شاشة السائق: خريطة + مفتاح الاتصال أعلى + ورقة تتبدّل بالمرحلة. +class DutyPage extends StatefulWidget { + const DutyPage({super.key}); + + @override + State createState() => _DutyPageState(); +} + +class _DutyPageState extends State { + late final DutyCubit _cubit = sl()..init(); + + @override + void dispose() { + _cubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cubit, + child: Scaffold( + body: Stack( + children: [ + // الخريطة معزولة عن بقية الحالة: نبضة موقع كل 25 متراً يجب ألّا + // تعيد بناء الورقة والمفتاح معها. + BlocSelector( + selector: (s) => s.myLocation, + builder: (context, me) => RideMap(origin: me, cameraTarget: me), + ), + + SafeArea( + child: Padding( + padding: Space.page, + child: Column( + children: [ + BlocSelector( + selector: (s) => (online: s.isOnline, busy: s.busy), + builder: (context, d) => DutyToggle( + isOnline: d.online, + busy: d.busy, + onToggle: _cubit.toggleOnline, + ), + ), + const SizedBox(height: Space.xs), + BlocSelector( + selector: (s) => s.creditBlocked, + builder: (context, blocked) => blocked + ? StatusBanner( + message: context.l10n.errorGeneric, + tone: BannerTone.danger, + icon: Icons.account_balance_wallet_outlined, + ) + : const SizedBox.shrink(), + ), + ], + ), + ), + ), + + Align( + alignment: Alignment.bottomCenter, + child: BlocBuilder( + builder: (context, state) => _Sheet(state: state), + ), + ), + ], + ), + ), + ); + } +} + +class _Sheet extends StatelessWidget { + const _Sheet({required this.state}); + + final DutyState state; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + final child = switch (state.phase) { + DutyPhase.offline => const SizedBox.shrink(), + DutyPhase.waiting => Padding( + padding: const EdgeInsets.symmetric(vertical: Space.md), + child: Text( + state.error == DutyError.offerTaken + ? l10n.driverOfferTaken + : l10n.driverNoOffers, + textAlign: TextAlign.center, + style: context.texts.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + DutyPhase.offered => OfferSheet(offer: state.offer!), + DutyPhase.active => + ActiveDutySheet(trip: state.trip!, busy: state.busy), + }; + + if (state.phase == DutyPhase.offline) return const SizedBox.shrink(); + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: Radii.sheetTop, + ), + child: SafeArea( + top: false, + child: Padding( + padding: Space.page, + child: AnimatedSize( + duration: Motion.inScreen, + curve: Motion.curve, + alignment: Alignment.bottomCenter, + child: child, + ), + ), + ), + ); + } +} diff --git a/apps/driver_new/lib/features/trip/view/widgets/active_duty_sheet.dart b/apps/driver_new/lib/features/trip/view/widgets/active_duty_sheet.dart new file mode 100644 index 0000000..3859068 --- /dev/null +++ b/apps/driver_new/lib/features/trip/view/widgets/active_duty_sheet.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/design/typography.dart'; +import '../../../../core/l10n/l10n.dart'; +import '../../../../core/ui/tripz_button.dart'; +import '../../cubit/duty_cubit.dart'; +import '../../data/models/trip.dart'; + +/// الرحلة من طرف السائق: **زر واحد** يدفع آلة الحالات خطوة واحدة. +/// السائق لا يختار الحالة التالية — الكيوبت يشتقّها (docs/38 §4). +class ActiveDutySheet extends StatelessWidget { + const ActiveDutySheet({super.key, required this.trip, required this.busy}); + + final Trip trip; + final bool busy; + + @override + Widget build(BuildContext context) { + final cubit = context.read(); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(_statusText(context), style: context.texts.titleMedium), + if (trip.priceForDriver != null) + Text( + '${trip.priceForDriver} ${trip.currency}', + style: numericStyle(context.texts.titleLarge).copyWith( + color: context.tripzColors.success, + ), + ), + ], + ), + const SizedBox(height: Space.md), + TripzButton.primary( + label: _actionLabel(context, cubit), + loading: busy, + onPressed: cubit.nextStatus == null ? null : cubit.advanceTrip, + ), + ], + ); + } + + String _statusText(BuildContext context) { + final l10n = context.l10n; + return switch (trip.status) { + TripStatus.assigned || TripStatus.driverArriving => l10n.driverOnTheWay, + TripStatus.driverArrived => l10n.tripDriverArrived, + TripStatus.inProgress => l10n.tripInProgress, + _ => l10n.tripCompleted, + }; + } + + String _actionLabel(BuildContext context, DutyCubit cubit) { + final l10n = context.l10n; + return switch (cubit.nextStatus) { + TripStatus.driverArriving => l10n.driverOnTheWay, + TripStatus.driverArrived => l10n.driverArrivedAction, + TripStatus.inProgress => l10n.driverStartTrip, + TripStatus.completed => l10n.driverEndTrip, + _ => l10n.tripCompleted, + }; + } +} diff --git a/apps/driver_new/lib/features/trip/view/widgets/driver_badge.dart b/apps/driver_new/lib/features/trip/view/widgets/driver_badge.dart new file mode 100644 index 0000000..cabb7ac --- /dev/null +++ b/apps/driver_new/lib/features/trip/view/widgets/driver_badge.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/design/typography.dart'; +import '../../data/models/trip.dart'; + +/// بطاقة السائق ولوحة المركبة — أهم ما يبحث عنه الراكب في الشارع، فيأخذ +/// أوضح موضع وأكبر تباين. +class DriverBadge extends StatelessWidget { + const DriverBadge({super.key, required this.trip}); + + final Trip trip; + + @override + Widget build(BuildContext context) { + if (trip.driverName == null) return const SizedBox.shrink(); + return Row( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: context.colors.primaryContainer, + child: Icon( + Icons.person_rounded, + color: context.colors.onPrimaryContainer, + ), + ), + const SizedBox(width: Space.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(trip.driverName!, style: context.texts.titleSmall), + if (trip.vehicleModel != null) + Text( + trip.vehicleModel!, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + if (trip.driverRating != null) + Row( + children: [ + Icon( + Icons.star_rounded, + size: Sizes.iconSm, + color: context.tripzColors.warning, + ), + const SizedBox(width: Space.xxs), + Text( + trip.driverRating!, + style: numericStyle(context.texts.bodySmall), + ), + ], + ), + ], + ), + ), + if (trip.vehiclePlate != null) _Plate(plate: trip.vehiclePlate!), + ], + ); + } +} + +/// لوحة مرسومة لا نصّ عادي — تُقرأ من بعيد ومن زاوية. +class _Plate extends StatelessWidget { + const _Plate({required this.plate}); + + final String plate; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: Space.sm, + vertical: Space.xxs, + ), + decoration: BoxDecoration( + borderRadius: Radii.field_, + border: Border.all(color: context.colors.outline, width: 1.5), + color: context.tripzColors.surfaceRaised, + ), + child: Text( + plate, + textDirection: TextDirection.ltr, + style: numericStyle(context.texts.titleMedium).copyWith( + letterSpacing: 1.5, + ), + ), + ); + } +} diff --git a/apps/driver_new/lib/features/trip/view/widgets/duty_toggle.dart b/apps/driver_new/lib/features/trip/view/widgets/duty_toggle.dart new file mode 100644 index 0000000..5884b5f --- /dev/null +++ b/apps/driver_new/lib/features/trip/view/widgets/duty_toggle.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/l10n/l10n.dart'; + +/// مفتاح الاتصال — أهم عنصر في تطبيق السائق، فيأخذ أعلى الشاشة وأوضح تباين. +/// الحالة معروضة نصّاً ولوناً معاً: لا يعتمد السائق على لون وحده وهو يقود. +class DutyToggle extends StatelessWidget { + const DutyToggle({ + super.key, + required this.isOnline, + required this.busy, + required this.onToggle, + }); + + final bool isOnline; + final bool busy; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final colors = context.tripzColors; + final color = isOnline ? colors.success : context.colors.onSurfaceVariant; + + return Material( + color: context.tripzColors.surfaceRaised, + borderRadius: Radii.card_, + child: InkWell( + onTap: busy ? null : onToggle, + borderRadius: Radii.card_, + child: Padding( + padding: Space.page, + child: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: Space.sm), + Expanded( + child: Text( + isOnline ? l10n.driverOnline : l10n.driverOffline, + style: context.texts.titleMedium?.copyWith(color: color), + ), + ), + if (busy) + const SizedBox( + width: Sizes.iconSm, + height: Sizes.iconSm, + child: CircularProgressIndicator(strokeWidth: 2.2), + ) + else + Text( + isOnline ? l10n.driverGoOffline : l10n.driverGoOnline, + style: context.texts.labelLarge?.copyWith( + color: context.colors.primary, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/driver_new/lib/features/trip/view/widgets/offer_sheet.dart b/apps/driver_new/lib/features/trip/view/widgets/offer_sheet.dart new file mode 100644 index 0000000..b5a82bd --- /dev/null +++ b/apps/driver_new/lib/features/trip/view/widgets/offer_sheet.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/design/typography.dart'; +import '../../../../core/l10n/l10n.dart'; +import '../../../../core/ui/tripz_button.dart'; +import '../../cubit/duty_cubit.dart'; +import '../../data/models/trip.dart'; + +/// عرض رحلة: **رقم الأرباح أولاً**، ثم العناوين، ثم الأزرار. السائق يقرّر +/// في ثوانٍ وهو خلف المقود — الترتيب هنا ليس ذوقاً. +/// +/// شريط المؤقّت ينفد فيختفي العرض تلقائياً (`dismissOffer` في الكيوبت). +class OfferSheet extends StatefulWidget { + const OfferSheet({super.key, required this.offer}); + + final Trip offer; + + @override + State createState() => _OfferSheetState(); +} + +class _OfferSheetState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _timer = AnimationController( + vsync: this, + duration: const Duration(seconds: 25), + )..forward(); + + @override + void dispose() { + _timer.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final cubit = context.read(); + final offer = widget.offer; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AnimatedBuilder( + animation: _timer, + builder: (context, _) => LinearProgressIndicator( + value: 1 - _timer.value, + minHeight: 3, + ), + ), + const SizedBox(height: Space.md), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(l10n.driverOfferTitle, style: context.texts.titleMedium), + if (offer.priceForDriver != null) + Text( + '${offer.priceForDriver} ${offer.currency}', + style: numericStyle(context.texts.headlineSmall).copyWith( + color: context.tripzColors.success, + ), + ), + ], + ), + const SizedBox(height: Space.md), + _AddressRow( + color: context.tripzColors.mapOrigin, + label: l10n.rideOrigin, + ), + _AddressRow( + color: context.tripzColors.mapDestination, + label: l10n.rideDestination, + ), + const SizedBox(height: Space.lg), + Row( + children: [ + Expanded( + child: TripzButton.secondary( + label: l10n.driverReject, + expanded: true, + onPressed: cubit.dismissOffer, + ), + ), + const SizedBox(width: Space.sm), + Expanded( + flex: 2, + child: TripzButton.primary( + label: l10n.driverAccept, + onPressed: cubit.acceptOffer, + ), + ), + ], + ), + ], + ); + } +} + +class _AddressRow extends StatelessWidget { + const _AddressRow({required this.color, required this.label}); + + final Color color; + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: Space.xxs), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: Space.sm), + Text(label, style: context.texts.bodyMedium), + ], + ), + ); + } +} diff --git a/apps/driver_new/lib/features/trip/view/widgets/ride_map.dart b/apps/driver_new/lib/features/trip/view/widgets/ride_map.dart new file mode 100644 index 0000000..65956ec --- /dev/null +++ b/apps/driver_new/lib/features/trip/view/widgets/ride_map.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:intaleq_maps/intaleq_maps.dart' as im; + +import '../../../../core/config.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../data/models/geo_point.dart'; +import '../../data/models/route_info.dart'; + +/// الخريطة — **الطبقة الوحيدة التي تعرف `intaleq_maps`**. ما فوقها يتعامل +/// مع `GeoPoint` وحده، فتبديل محرّك الخريطة لاحقاً لا يلمس منطقاً. +/// +/// البلاطات تُجلب مباشرة من map-saas بمفتاح مقيّد ببصمة التطبيق +/// (قرار 2026-07-20). +class RideMap extends StatefulWidget { + const RideMap({ + super.key, + this.origin, + this.destination, + this.driver, + this.route, + this.cameraTarget, + this.onCameraIdle, + }); + + final GeoPoint? origin; + final GeoPoint? destination; + final GeoPoint? driver; + final RouteInfo? route; + final GeoPoint? cameraTarget; + + /// مركز الخريطة عند استقرارها — يغذّي اختيار النقطة بالدبّوس الثابت. + final ValueChanged? onCameraIdle; + + @override + State createState() => _RideMapState(); +} + +class _RideMapState extends State { + im.IntaleqMapController? _controller; + im.LatLng? _center; + + static const _fallback = im.LatLng(31.9539, 35.9106); // عمّان + + @override + void didUpdateWidget(RideMap old) { + super.didUpdateWidget(old); + final target = widget.cameraTarget; + if (target != null && target != old.cameraTarget) { + _controller?.animateCamera( + im.CameraUpdate.newLatLng(im.LatLng(target.lat, target.lng)), + ); + } + } + + @override + Widget build(BuildContext context) { + final colors = context.tripzColors; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return im.IntaleqMap( + apiKey: AppConfig.mapApiKey, + initialCameraPosition: im.CameraPosition( + target: widget.origin == null + ? _fallback + : im.LatLng(widget.origin!.lat, widget.origin!.lng), + zoom: 15, + ), + // الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر + // الغرض (docs/26 §2). + styleUrl: isDark + ? im.IntaleqStyles.obsidian(AppConfig.mapApiKey) + : im.IntaleqStyles.light(AppConfig.mapApiKey), + myLocationEnabled: true, + compassEnabled: false, + zoomControlsEnabled: false, + onMapCreated: (c) => _controller = c, + onCameraMove: (position) => _center = position.target, + onCameraIdle: () { + final c = _center; + if (c != null) widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude)); + }, + markers: { + if (widget.origin != null) + _marker('origin', widget.origin!), + if (widget.destination != null) + _marker('destination', widget.destination!), + if (widget.driver != null) _marker('driver', widget.driver!), + }, + polylines: { + if (widget.route != null && widget.route!.encodedPoints.isNotEmpty) + im.Polyline( + polylineId: const im.PolylineId('route'), + points: im.PolylineUtils.decode(widget.route!.encodedPoints), + color: colors.mapRoute, + width: 5, + ), + }, + ); + } + + im.Marker _marker(String id, GeoPoint p) => im.Marker( + markerId: im.MarkerId(id), + position: im.LatLng(p.lat, p.lng), + ); +} diff --git a/apps/driver_new/pubspec.lock b/apps/driver_new/pubspec.lock index 9eb5b8c..a8ef420 100644 --- a/apps/driver_new/pubspec.lock +++ b/apps/driver_new/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -81,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" device_info_plus: dependency: "direct main" description: @@ -145,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -229,6 +253,70 @@ packages: description: flutter source: sdk version: "0.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" + url: "https://pub.dev" + source: hosted + version: "14.0.2" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: "86ea1654e4f61ff51466848e91c116b422d6010ea269fda0fbe1af7e9e742ce1" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73" + url: "https://pub.dev" + source: hosted + version: "2.3.14" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 + url: "https://pub.dev" + source: hosted + version: "0.2.4" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2 + url: "https://pub.dev" + source: hosted + version: "4.2.8" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" get_it: dependency: "direct main" description: @@ -245,6 +333,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" hooks: dependency: transitive description: @@ -253,6 +349,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_parser: dependency: transitive description: @@ -261,6 +365,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + intaleq_maps: + dependency: "direct main" + description: + name: intaleq_maps + sha256: "755ed2f28350cbef80deb055f4d98f94a8a23370f8dbf77d7eb4cd91a1ffd76b" + url: "https://pub.dev" + source: hosted + version: "2.3.0" intl: dependency: transitive description: @@ -333,6 +453,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + maplibre_gl: + dependency: transitive + description: + name: maplibre_gl + sha256: d9773555ae4ebab94bbc3ae2176b077cfda486ec729eefe01e1613f164cb8410 + url: "https://pub.dev" + source: hosted + version: "0.25.0" + maplibre_gl_platform_interface: + dependency: transitive + description: + name: maplibre_gl_platform_interface + sha256: bd7de401dea24dd7e8a6f2fa736ddee7dbbee3e24a9027f0afdd619994702047 + url: "https://pub.dev" + source: hosted + version: "0.25.0" + maplibre_gl_web: + dependency: transitive + description: + name: maplibre_gl_web + sha256: af0e48bf96e8dd99f8b958a1953126971eb8a0527b9735441d4f24df3913f5a2 + url: "https://pub.dev" + source: hosted + version: "0.25.0" matcher: dependency: transitive description: @@ -389,6 +533,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -493,6 +653,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -509,6 +677,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" provider: dependency: transitive description: @@ -594,6 +770,22 @@ packages: description: flutter source: sdk version: "0.0.0" + socket_io_client: + dependency: "direct main" + description: + name: socket_io_client + sha256: f5990ff303d385e7b2150f0e57cca097a8d20c6a2ae0a22cb2d496661ea8ed9b + url: "https://pub.dev" + source: hosted + version: "3.1.6" + socket_io_common: + dependency: transitive + description: + name: socket_io_common + sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b" + url: "https://pub.dev" + source: hosted + version: "3.1.1" source_span: dependency: transitive description: @@ -650,6 +842,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" vector_math: dependency: transitive description: @@ -674,6 +874,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" win32: dependency: transitive description: @@ -698,6 +906,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: diff --git a/apps/driver_new/pubspec.yaml b/apps/driver_new/pubspec.yaml index ee743bf..a33f417 100644 --- a/apps/driver_new/pubspec.yaml +++ b/apps/driver_new/pubspec.yaml @@ -29,6 +29,11 @@ dependencies: flutter_secure_storage: ^10.0.0 shared_preferences: ^2.3.3 + # م4: الخريطة والموقع والواقع اللحظي + intaleq_maps: ^2.3.0 + geolocator: ^14.0.2 + socket_io_client: ^3.0.2 + # م3: الأذونات وبصمة الجهاز permission_handler: ^12.0.1 device_info_plus: ^12.3.0 diff --git a/apps/rider_new/lib/core/api/antlaq_api.dart b/apps/rider_new/lib/core/api/antlaq_api.dart new file mode 100644 index 0000000..9470484 --- /dev/null +++ b/apps/rider_new/lib/core/api/antlaq_api.dart @@ -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 get(String path, {Map? query}) async { + try { + final res = await _dio.get(path, queryParameters: query); + return res.data as T; + } on DioException catch (e) { + throw ApiException.from(e); + } + } +} diff --git a/apps/rider_new/lib/core/config.dart b/apps/rider_new/lib/core/config.dart index 480bb0a..49020f1 100644 --- a/apps/rider_new/lib/core/config.dart +++ b/apps/rider_new/lib/core/config.dart @@ -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; diff --git a/apps/rider_new/lib/core/di.dart b/apps/rider_new/lib/core/di.dart index f042280..4bd1b1c 100644 --- a/apps/rider_new/lib/core/di.dart +++ b/apps/rider_new/lib/core/di.dart @@ -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 setupInjector() async { // تدفّق الدخول قصير العمر: نسخة جديدة لكل دخول، لا نسخة واحدة أبدية. sl.registerFactory(() => LoginCubit(sl(), sl())); + + // الخرائط عميل منفصل بلا توكن تريبز — map-saas لا شأن له بجلستنا. + sl.registerSingleton(AntlaqApi.create()); + sl.registerSingleton(MapsRepository(sl())); + sl.registerSingleton(TripRepository(sl())); + sl.registerSingleton(LocationService()); + sl.registerSingleton(RealtimeService(tokens)); + + sl.registerFactory(() => RideCubit( + trips: sl(), + maps: sl(), + location: sl(), + realtime: sl(), + )); } /// بصمة الجهاز (`x-device-id`) — يفرضها الخادم عند تفعيل diff --git a/apps/rider_new/lib/core/l10n/app_ar.arb b/apps/rider_new/lib/core/l10n/app_ar.arb index 88d7445..d4059f9 100644 --- a/apps/rider_new/lib/core/l10n/app_ar.arb +++ b/apps/rider_new/lib/core/l10n/app_ar.arb @@ -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": "في الطريق للراكب" +} \ No newline at end of file diff --git a/apps/rider_new/lib/core/l10n/app_en.arb b/apps/rider_new/lib/core/l10n/app_en.arb index 79404c2..522f627 100644 --- a/apps/rider_new/lib/core/l10n/app_en.arb +++ b/apps/rider_new/lib/core/l10n/app_en.arb @@ -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" +} \ No newline at end of file diff --git a/apps/rider_new/lib/core/location/location_service.dart b/apps/rider_new/lib/core/location/location_service.dart new file mode 100644 index 0000000..83e0be3 --- /dev/null +++ b/apps/rider_new/lib/core/location/location_service.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:geolocator/geolocator.dart'; + +import '../config.dart'; + +/// الموقع — نقطة واحدة. لا `Geolocator` منثوراً في الشاشات. +class LocationService { + StreamSubscription? _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 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 stop() async { + await _sub?.cancel(); + _sub = null; + } +} diff --git a/apps/rider_new/lib/core/realtime/realtime_service.dart b/apps/rider_new/lib/core/realtime/realtime_service.dart new file mode 100644 index 0000000..48c8817 --- /dev/null +++ b/apps/rider_new/lib/core/realtime/realtime_service.dart @@ -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 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.broadcast(); + + Stream get events => _events.stream; + bool get isConnected => _socket?.connected ?? false; + + Future 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.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 reconnect() async { + disconnect(); + await connect(); + } + + void disconnect() { + _socket?.dispose(); + _socket = null; + } + + Future dispose() async { + disconnect(); + await _events.close(); + } +} diff --git a/apps/rider_new/lib/core/router.dart b/apps/rider_new/lib/core/router.dart index 8284422..b2c4384 100644 --- a/apps/rider_new/lib/core/router.dart +++ b/apps/rider_new/lib/core/router.dart @@ -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(), ), ], ); diff --git a/apps/rider_new/lib/features/home/view/home_page.dart b/apps/rider_new/lib/features/home/view/home_page.dart deleted file mode 100644 index 04a9fac..0000000 --- a/apps/rider_new/lib/features/home/view/home_page.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../../core/design/tokens.dart'; -import '../../../core/design/tripz_colors.dart'; -import '../../../core/l10n/l10n.dart'; -import '../../../core/ui/tripz_button.dart'; -import '../../../core/ui/tripz_card.dart'; -import '../../../core/ui/tripz_scaffold.dart'; -import '../../../core/session/session_cubit.dart'; -import '../../../core/session/session_state.dart'; - -/// هيكل مؤقّت — تحلّ محلّه شاشة الخريطة وطلب الرحلة في م4 (docs/37). -/// الغرض الآن: إثبات أن سلسلة الدخول والجلسة والتوجيه تعمل طرفاً لطرف. -class HomePage extends StatelessWidget { - const HomePage({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, session) { - final user = session.user; - return TripzScaffold( - title: context.l10n.appTitle, - child: ListView( - children: [ - TripzCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user?.name ?? '', - style: context.texts.titleMedium, - ), - const SizedBox(height: Space.xxs), - Text( - user?.phone ?? '', - textDirection: TextDirection.ltr, - style: context.texts.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), - ), - const SizedBox(height: Space.lg), - TripzButton.secondary( - label: context.l10n.actionCancel, - expanded: true, - onPressed: context.read().logout, - ), - ], - ), - ); - }, - ); - } -} diff --git a/apps/rider_new/lib/features/trip/cubit/ride_cubit.dart b/apps/rider_new/lib/features/trip/cubit/ride_cubit.dart new file mode 100644 index 0000000..47811fe --- /dev/null +++ b/apps/rider_new/lib/features/trip/cubit/ride_cubit.dart @@ -0,0 +1,372 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/api/api_exception.dart'; +import '../../../core/config.dart'; +import '../../../core/location/location_service.dart'; +import '../../../core/realtime/realtime_service.dart'; +import '../data/maps_repository.dart'; +import '../data/models/geo_point.dart'; +import '../data/models/place.dart'; +import '../data/models/ride_type.dart'; +import '../data/models/trip.dart'; +import '../data/trip_repository.dart'; +import 'ride_state.dart'; + +/// شاشة الراكب كاملة: التخطيط ← التسعيرة ← الطلب ← البحث ← الرحلة الجارية. +/// +/// لا حساب أجرة ولا قرار مطابقة هنا — الخادم مصدر الحقيقة (docs/23 §0.2). +/// دور الكيوبت جمع المدخلات وعرض ما يُعطى. +class RideCubit extends Cubit { + RideCubit({ + required TripRepository trips, + required MapsRepository maps, + required LocationService location, + required RealtimeService realtime, + }) : _trips = trips, + _maps = maps, + _location = location, + _realtime = realtime, + super(const RideState()); + + final TripRepository _trips; + final MapsRepository _maps; + final LocationService _location; + final RealtimeService _realtime; + + StreamSubscription? _events; + Timer? _searchTimeout; + Timer? _searchPoll; + Timer? _debounce; + + @override + Future close() { + _events?.cancel(); + _searchTimeout?.cancel(); + _searchPoll?.cancel(); + _debounce?.cancel(); + return super.close(); + } + + // ── الإقلاع ──────────────────────────────────────────────────────────── + + Future init() async { + _events = _realtime.events.listen(_onRealtime); + await _realtime.connect(); + + // الكاميرا تتوسّط الموقع **فوراً**، ولا تنتظر العنوان — انتظارها كان + // باگ سيرو المُصلَح 2026-07-20. + unawaited(_locateMe()); + unawaited(_loadRideTypes()); + unawaited(_resumeLiveTrip()); + } + + Future _locateMe() async { + final pos = await _location.current(); + if (pos == null || isClosed) return; + final point = GeoPoint(pos.lat, pos.lng); + emit(state.copyWith(origin: point, cameraTarget: point)); + + final place = await _maps.reverse(point); + if (isClosed || place == null) return; + emit(state.copyWith(originLabel: place.label)); + } + + Future _loadRideTypes() async { + try { + final types = await _trips.rideTypes(); + if (isClosed || types.isEmpty) return; + emit(state.copyWith( + rideTypes: types, + selectedRideType: state.selectedRideType ?? types.first, + )); + } on ApiException { + // قائمة الأنواع ليست حاجزاً للخريطة — تُعاد المحاولة عند التأكيد. + } + } + + /// رحلة جارية من جلسة سابقة (التطبيق أُغلق أثناءها) تُستأنف بلا تدخّل. + Future _resumeLiveTrip() async { + try { + final live = (await _trips.mine()).where((t) => t.status.isLive); + if (isClosed || live.isEmpty) return; + _attachTrip(live.first); + } on ApiException { + // لا شيء يُستأنف — تجربة عادية لا خطأ يُعرض. + } + } + + // ── التخطيط ──────────────────────────────────────────────────────────── + + void openPlanner() => emit(state.copyWith(phase: RidePhase.planning)); + + void collapsePlanner() => emit(state.copyWith(phase: RidePhase.idle)); + + void selectRideType(RideType type) { + emit(state.copyWith(selectedRideType: type)); + unawaited(_priceIt()); + } + + /// بحث نصّي عن مكان، بمهلة ارتداد: كل ضغطة زر لا تُطلق نداءً. + void searchPlaces(String query) { + _debounce?.cancel(); + if (query.trim().length < 2) { + emit(state.copyWith(searchResults: const [], searching: false)); + return; + } + emit(state.copyWith(searching: true)); + _debounce = Timer(const Duration(milliseconds: 350), () async { + try { + final results = await _maps.search(query, near: state.origin); + if (!isClosed) { + emit(state.copyWith(searchResults: results, searching: false)); + } + } on ApiException { + if (!isClosed) { + emit(state.copyWith(searchResults: const [], searching: false)); + } + } + }); + } + + Future pickPlace(Place place, PickTarget target) async { + emit(state.copyWith( + origin: target == PickTarget.origin ? place.point : null, + originLabel: target == PickTarget.origin ? place.label : null, + destination: target == PickTarget.destination ? place.point : null, + destinationLabel: + target == PickTarget.destination ? place.label : null, + searchResults: const [], + cameraTarget: place.point, + )); + await _routeAndPrice(); + } + + /// اختيار من الخريطة: المستخدم يحرّك **الخريطة** لا الدبّوس (docs/39 §3). + void startMapPick(PickTarget target) => emit(state.copyWith( + phase: RidePhase.pickingOnMap, + pickTarget: target, + )); + + Future confirmMapPick(GeoPoint point) async { + final isOrigin = state.pickTarget == PickTarget.origin; + emit(state.copyWith( + phase: RidePhase.planning, + origin: isOrigin ? point : null, + destination: isOrigin ? null : point, + )); + + final place = await _maps.reverse(point); + if (isClosed) return; + emit(state.copyWith( + originLabel: isOrigin ? (place?.label ?? '') : null, + destinationLabel: isOrigin ? null : (place?.label ?? ''), + )); + await _routeAndPrice(); + } + + void addStop(GeoPoint stop) => + emit(state.copyWith(stops: [...state.stops, stop])); + + void removeStop(int index) { + final next = [...state.stops]..removeAt(index); + emit(state.copyWith(stops: next)); + } + + void clearDestination() => emit(state.copyWith( + clearDestination: true, + clearRoute: true, + phase: RidePhase.planning, + )); + + // ── المسار والتسعيرة ─────────────────────────────────────────────────── + + Future _routeAndPrice() async { + final from = state.origin; + final to = state.destination; + if (from == null || to == null) return; + + emit(state.copyWith(busy: true, error: RideError.none)); + try { + final route = await _maps.route(from, to); + if (isClosed) return; + emit(state.copyWith(route: route, phase: RidePhase.confirming)); + await _priceIt(); + } on ApiException { + if (!isClosed) { + emit(state.copyWith(busy: false, error: RideError.routeFailed)); + } + } + } + + /// التعرفة تحتاج مسافة ومدة محسوبتين — لا إحداثيات (docs/38 §7). + Future _priceIt() async { + final route = state.route; + final type = state.selectedRideType; + if (route == null || type == null) return; + + emit(state.copyWith(busy: true)); + try { + final quote = await _trips.quote( + city: '', + serviceClass: type.code, + distanceKm: route.distanceKm, + durationMin: route.durationMin, + ); + if (isClosed) return; + // 200 بقيم `null` ليس نجاحاً — لا يُعرض رقم غير صالح للمستخدم. + emit(state.copyWith( + quote: quote, + busy: false, + error: quote.isUsable ? RideError.none : RideError.quoteFailed, + )); + } on ApiException { + if (!isClosed) { + emit(state.copyWith(busy: false, error: RideError.quoteFailed)); + } + } + } + + // ── الطلب والبحث ─────────────────────────────────────────────────────── + + Future requestTrip() async { + final from = state.origin; + final to = state.destination; + final type = state.selectedRideType; + if (from == null || to == null || type == null) return; + + emit(state.copyWith(busy: true, error: RideError.none)); + try { + final res = await _trips.request( + origin: from, + destination: to, + serviceClass: type.code, + stops: state.stops, + ); + if (isClosed) return; + + // صفر سائقين = جواب نهائي فوري، لا داعي لانتظار مهلة كاملة. + if (res.offeredDrivers == 0) { + emit(state.copyWith( + busy: false, + error: RideError.noDrivers, + phase: RidePhase.confirming, + )); + unawaited(_trips.cancel(res.trip.id)); + return; + } + + _attachTrip(res.trip); + _startSearchGuards(res.trip.id); + } on ApiException { + if (!isClosed) { + emit(state.copyWith(busy: false, error: RideError.requestFailed)); + } + } + } + + /// حارسان أثناء البحث: + /// 1. **مهلة محليّة** — الخادم قد لا يُطلق `expired`/`no_drivers` أبداً + /// (ثغرة R1، docs/38 §4)، فبلا هذه المهلة تدور الشاشة إلى الأبد. + /// 2. **استطلاع دوري** — يلتقط القبول لو ضاع حدث `trip:update`. + void _startSearchGuards(String tripId) { + _searchTimeout?.cancel(); + _searchPoll?.cancel(); + + _searchTimeout = Timer(AppConfig.searchingTimeout, () async { + if (isClosed || state.phase != RidePhase.searching) return; + await cancelTrip(); + if (!isClosed) emit(state.copyWith(error: RideError.noDrivers)); + }); + + _searchPoll = Timer.periodic(const Duration(seconds: 5), (_) async { + if (isClosed || state.phase != RidePhase.searching) return; + try { + _attachTrip(await _trips.get(tripId)); + } on ApiException { + // الاستطلاع شبكة أمان لا مصدر حقيقة — فشلُه لا يُعرض. + } + }); + } + + Future cancelTrip() async { + final trip = state.trip; + if (trip == null) return; + _stopSearchGuards(); + try { + await _trips.cancel(trip.id); + } on ApiException { + // حتى لو فشل الإلغاء على الخادم، المستخدم خرج من الشاشة — وحالة + // الرحلة تُصحَّح عند الاستئناف التالي. + } + if (!isClosed) _resetToIdle(); + } + + void _stopSearchGuards() { + _searchTimeout?.cancel(); + _searchPoll?.cancel(); + _searchTimeout = null; + _searchPoll = null; + } + + void _resetToIdle() { + emit(state.copyWith( + phase: RidePhase.idle, + clearTrip: true, + clearRoute: true, + clearDestination: true, + busy: false, + stops: const [], + )); + } + + // ── الواقع اللحظي ────────────────────────────────────────────────────── + + void _attachTrip(Trip trip) { + // الانضمام قبل أي انتقال حالة، وإلا فاتت الأحداث (docs/38 §9). + _realtime.joinTrip(trip.id); + + if (trip.status.isOver) { + _stopSearchGuards(); + emit(state.copyWith( + trip: trip, + busy: false, + phase: RidePhase.idle, + error: trip.status == TripStatus.noDrivers || + trip.status == TripStatus.expired + ? RideError.noDrivers + : RideError.none, + )); + return; + } + + if (trip.status != TripStatus.searching) _stopSearchGuards(); + + emit(state.copyWith( + trip: trip, + busy: false, + phase: trip.status == TripStatus.searching + ? RidePhase.searching + : RidePhase.active, + )); + } + + void _onRealtime(RealtimeEvent event) { + switch (event.name) { + case RealtimeEvent.tripUpdate: + final id = event.data['id'] ?? event.data['tripId']; + if (id != null && id != state.trip?.id) return; + _attachTrip(Trip.fromJson(event.data)); + + case RealtimeEvent.driverLocation: + final lat = event.data['lat']; + final lng = event.data['lng']; + if (lat is num && lng is num) { + emit(state.copyWith( + driverLocation: GeoPoint(lat.toDouble(), lng.toDouble()), + )); + } + } + } +} diff --git a/apps/rider_new/lib/features/trip/cubit/ride_state.dart b/apps/rider_new/lib/features/trip/cubit/ride_state.dart new file mode 100644 index 0000000..a90a9ba --- /dev/null +++ b/apps/rider_new/lib/features/trip/cubit/ride_state.dart @@ -0,0 +1,165 @@ +import 'package:equatable/equatable.dart'; + +import '../data/models/fare_quote.dart'; +import '../data/models/geo_point.dart'; +import '../data/models/place.dart'; +import '../data/models/ride_type.dart'; +import '../data/models/route_info.dart'; +import '../data/models/trip.dart'; + +/// مراحل شاشة الراكب. المرحلة تحدّد ما يُعرض على الخريطة وما في الورقة +/// السفلية — «الشاشة مهمة واحدة» (docs/26 §8.1). +enum RidePhase { + /// الخريطة والبطاقة المطوية «إلى أين؟». + idle, + + /// المخطّط الموسّع: المصدر والوجهة والمحطات. + planning, + + /// المستخدم يحرّك الخريطة والدبّوس ثابت في المنتصف. + pickingOnMap, + + /// المسار والتسعيرة معروضان بانتظار التأكيد. + confirming, + + /// البحث عن سائق. + searching, + + /// رحلة جارية. + active, +} + +/// أيّ حقلٍ يلتقط اختيار الخريطة. +enum PickTarget { origin, destination } + +enum RideError { none, noDrivers, quoteFailed, routeFailed, requestFailed } + +class RideState extends Equatable { + const RideState({ + this.phase = RidePhase.idle, + this.origin, + this.destination, + this.originLabel = '', + this.destinationLabel = '', + this.stops = const [], + this.rideTypes = const [], + this.selectedRideType, + this.route, + this.quote, + this.trip, + this.driverLocation, + this.searchResults = const [], + this.searching = false, + this.busy = false, + this.error = RideError.none, + this.pickTarget = PickTarget.destination, + this.cameraTarget, + }); + + final RidePhase phase; + + final GeoPoint? origin; + final GeoPoint? destination; + final String originLabel; + final String destinationLabel; + final List stops; + + final List rideTypes; + final RideType? selectedRideType; + + final RouteInfo? route; + final FareQuote? quote; + + final Trip? trip; + + /// موقع السائق الحيّ من `driver:location`. + final GeoPoint? driverLocation; + + final List searchResults; + + /// بحث نصّي جارٍ — منفصل عن [busy] كي لا يقفل مؤشّرُ البحثِ الشاشةَ كلها. + final bool searching; + final bool busy; + + final RideError error; + final PickTarget pickTarget; + + /// وجهة الكاميرا حين يقودها المنطق لا المستخدم. + final GeoPoint? cameraTarget; + + bool get canConfirm => + origin != null && + destination != null && + selectedRideType != null && + quote?.isUsable == true; + + RideState copyWith({ + RidePhase? phase, + GeoPoint? origin, + GeoPoint? destination, + String? originLabel, + String? destinationLabel, + List? stops, + List? rideTypes, + RideType? selectedRideType, + RouteInfo? route, + FareQuote? quote, + Trip? trip, + GeoPoint? driverLocation, + List? searchResults, + bool? searching, + bool? busy, + RideError? error, + PickTarget? pickTarget, + GeoPoint? cameraTarget, + bool clearDestination = false, + bool clearRoute = false, + bool clearTrip = false, + bool clearCamera = false, + }) { + return RideState( + phase: phase ?? this.phase, + origin: origin ?? this.origin, + destination: clearDestination ? null : (destination ?? this.destination), + originLabel: originLabel ?? this.originLabel, + destinationLabel: clearDestination + ? '' + : (destinationLabel ?? this.destinationLabel), + stops: stops ?? this.stops, + rideTypes: rideTypes ?? this.rideTypes, + selectedRideType: selectedRideType ?? this.selectedRideType, + route: clearRoute ? null : (route ?? this.route), + quote: clearRoute ? null : (quote ?? this.quote), + trip: clearTrip ? null : (trip ?? this.trip), + driverLocation: driverLocation ?? this.driverLocation, + searchResults: searchResults ?? this.searchResults, + searching: searching ?? this.searching, + busy: busy ?? this.busy, + error: error ?? this.error, + pickTarget: pickTarget ?? this.pickTarget, + cameraTarget: clearCamera ? null : (cameraTarget ?? this.cameraTarget), + ); + } + + @override + List get props => [ + phase, + origin, + destination, + originLabel, + destinationLabel, + stops, + rideTypes, + selectedRideType, + route, + quote, + trip, + driverLocation, + searchResults, + searching, + busy, + error, + pickTarget, + cameraTarget, + ]; +} diff --git a/apps/rider_new/lib/features/trip/data/maps_repository.dart b/apps/rider_new/lib/features/trip/data/maps_repository.dart new file mode 100644 index 0000000..f3dff78 --- /dev/null +++ b/apps/rider_new/lib/features/trip/data/maps_repository.dart @@ -0,0 +1,52 @@ +import '../../../core/api/antlaq_api.dart'; +import 'models/geo_point.dart'; +import 'models/place.dart'; +import 'models/route_info.dart'; + +/// كل الخرائط من map-saas مباشرة (قرار 2026-07-20). نقاط `/maps/*` في باك إند +/// تريبز **لا تُستعمل** — إحداها (`geocode`) معطوبة على المنشور أصلاً. +class MapsRepository { + MapsRepository(this._api); + + final AntlaqApi _api; + + Future> search(String query, {GeoPoint? near}) async { + if (query.trim().isEmpty) return const []; + final res = await _api.get>( + '/geocoding/search', + query: { + 'q': query.trim(), + if (near != null) 'lat': near.lat, + if (near != null) 'lng': near.lng, + }, + ); + return res + .whereType>() + .map(Place.fromJson) + .toList(growable: false); + } + + /// يرجّع **مصفوفة** مرتّبة بالأقرب — الأول هو المطلوب. تفكيكها ككائن مفرد + /// كان باگ سيرو الذي أُصلح 2026-07-20. + Future reverse(GeoPoint point) async { + final res = await _api.get>( + '/geocoding/reverse', + query: {'lat': point.lat, 'lng': point.lng}, + ); + final first = res.whereType>().firstOrNull; + return first == null ? null : Place.fromJson(first); + } + + Future route(GeoPoint from, GeoPoint to) async { + final res = await _api.get>( + '/maps/route', + query: { + 'fromLat': from.lat, + 'fromLng': from.lng, + 'toLat': to.lat, + 'toLng': to.lng, + }, + ); + return RouteInfo.fromJson(res); + } +} diff --git a/apps/rider_new/lib/features/trip/data/models/fare_quote.dart b/apps/rider_new/lib/features/trip/data/models/fare_quote.dart new file mode 100644 index 0000000..898993c --- /dev/null +++ b/apps/rider_new/lib/features/trip/data/models/fare_quote.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; + +/// تسعيرة مسبقة من `POST /tariff/quote`. +/// +/// ⚠️ مصيدتان مثبتتان (docs/38 §7): +/// 1. هذه النقطة **camelCase وحدها** بخلاف كل الـAPI. +/// 2. بالمعاملات الخطأ ترجّع **200 بقيم `null` صامتة** لا خطأً — لذلك +/// [isUsable] شرطٌ قبل عرض أي رقم للمستخدم. +class FareQuote extends Equatable { + const FareQuote({ + required this.total, + required this.currency, + this.subtotal, + this.surgeMultiplier = 1, + this.window = '', + }); + + final double? total; + final String currency; + final double? subtotal; + final double surgeMultiplier; + final String window; + + factory FareQuote.fromJson(Map json) { + final quote = (json['quote'] as Map?) ?? const {}; + return FareQuote( + total: (quote['total'] as num?)?.toDouble(), + currency: quote['currency'] as String? ?? '', + subtotal: (quote['subtotal'] as num?)?.toDouble(), + surgeMultiplier: (quote['surgeMultiplier'] as num?)?.toDouble() ?? 1, + window: quote['window'] as String? ?? '', + ); + } + + bool get isUsable => total != null && total! > 0; + + /// تسعير مرتفع — يُعرض للمستخدم صراحةً قبل التأكيد لا بعده. + bool get hasSurge => surgeMultiplier > 1; + + @override + List get props => [total, currency, subtotal, surgeMultiplier]; +} diff --git a/apps/rider_new/lib/features/trip/data/models/geo_point.dart b/apps/rider_new/lib/features/trip/data/models/geo_point.dart new file mode 100644 index 0000000..b9a5f3a --- /dev/null +++ b/apps/rider_new/lib/features/trip/data/models/geo_point.dart @@ -0,0 +1,22 @@ +import 'package:equatable/equatable.dart'; + +/// إحداثية في طبقة النطاق — **مستقلة عن حزمة الخريطة** عمداً. +/// +/// الكيوبت والمستودع لا يعرفان `intaleq_maps`؛ التحويل يقع في الواجهة وحدها. +/// تغيير محرّك الخريطة لاحقاً لا يلمس منطقاً. +class GeoPoint extends Equatable { + const GeoPoint(this.lat, this.lng); + + final double lat; + final double lng; + + factory GeoPoint.fromJson(Map json) => GeoPoint( + (json['lat'] as num).toDouble(), + (json['lng'] as num).toDouble(), + ); + + Map toJson() => {'lat': lat, 'lng': lng}; + + @override + List get props => [lat, lng]; +} diff --git a/apps/rider_new/lib/features/trip/data/models/place.dart b/apps/rider_new/lib/features/trip/data/models/place.dart new file mode 100644 index 0000000..a1f7e23 --- /dev/null +++ b/apps/rider_new/lib/features/trip/data/models/place.dart @@ -0,0 +1,43 @@ +import 'package:equatable/equatable.dart'; + +import 'geo_point.dart'; + +/// مكان من map-saas. حقول الاسم `name`/`name_ar`/`address` — **لا +/// `formattedAddress`** (عقد مثبت بـcurl، راجع قرار الخرائط 2026-07-20). +class Place extends Equatable { + const Place({ + required this.id, + required this.name, + required this.point, + this.address = '', + this.category = '', + }); + + final String id; + final String name; + final GeoPoint point; + final String address; + final String category; + + factory Place.fromJson(Map json) { + return Place( + id: json['id']?.toString() ?? '', + // العربي أولاً — الواجهة عربية افتراضاً، والإنجليزي احتياط. + name: (json['name_ar'] as String?)?.trim().isNotEmpty == true + ? json['name_ar'] as String + : (json['name'] as String? ?? ''), + address: json['address'] as String? ?? '', + category: json['category'] as String? ?? '', + point: GeoPoint( + (json['latitude'] as num).toDouble(), + (json['longitude'] as num).toDouble(), + ), + ); + } + + /// ما يُعرض في حقل العنوان: الاسم، وإن غاب فالعنوان. + String get label => name.isNotEmpty ? name : address; + + @override + List get props => [id, name, point, address]; +} diff --git a/apps/rider_new/lib/features/trip/data/models/ride_type.dart b/apps/rider_new/lib/features/trip/data/models/ride_type.dart new file mode 100644 index 0000000..eaf80e5 --- /dev/null +++ b/apps/rider_new/lib/features/trip/data/models/ride_type.dart @@ -0,0 +1,47 @@ +import 'package:equatable/equatable.dart'; + +/// نوع الرحلة كما يرجّعه `GET /ride-types` (شكل مُتحقَّق حيّاً، docs/38 §7). +/// **شاشة الاختيار تُبنى من هنا** — لا قائمة مكتوبة في التطبيق. +class RideType extends Equatable { + const RideType({ + required this.id, + required this.code, + required this.nameAr, + required this.nameEn, + required this.vehicleKind, + required this.womenOnly, + required this.roundTripSupported, + required this.sort, + this.icon, + }); + + final String id; + final String code; + final String nameAr; + final String nameEn; + final String vehicleKind; + final bool womenOnly; + final bool roundTripSupported; + final int sort; + final String? icon; + + factory RideType.fromJson(Map json) { + return RideType( + id: json['id'] as String, + code: json['code'] as String, + nameAr: json['name_ar'] as String? ?? '', + nameEn: json['name_en'] as String? ?? '', + vehicleKind: json['vehicle_kind'] as String? ?? 'car', + womenOnly: json['women_only'] as bool? ?? false, + roundTripSupported: json['round_trip_supported'] as bool? ?? false, + sort: (json['sort'] as num?)?.toInt() ?? 0, + icon: json['icon'] as String?, + ); + } + + String nameFor(String languageCode) => + languageCode == 'ar' ? nameAr : (nameEn.isEmpty ? nameAr : nameEn); + + @override + List get props => [id, code, sort]; +} diff --git a/apps/rider_new/lib/features/trip/data/models/route_info.dart b/apps/rider_new/lib/features/trip/data/models/route_info.dart new file mode 100644 index 0000000..b913802 --- /dev/null +++ b/apps/rider_new/lib/features/trip/data/models/route_info.dart @@ -0,0 +1,33 @@ +import 'package:equatable/equatable.dart'; + +/// مسار من map-saas. المسار نفسه **encoded polyline** في حقل `points`، +/// ويُفكّ في الواجهة لا هنا (طبقة النطاق لا تعرف حزمة الخريطة). +class RouteInfo extends Equatable { + const RouteInfo({ + required this.distanceMeters, + required this.durationSeconds, + required this.encodedPoints, + this.name = '', + }); + + final double distanceMeters; + final int durationSeconds; + final String encodedPoints; + final String name; + + factory RouteInfo.fromJson(Map json) { + return RouteInfo( + distanceMeters: (json['distance'] as num?)?.toDouble() ?? 0, + durationSeconds: (json['duration'] as num?)?.round() ?? 0, + encodedPoints: json['points'] as String? ?? '', + name: json['routeName'] as String? ?? '', + ); + } + + /// التعرفة تُطلب بالكيلومترات والدقائق (docs/38 §7). + double get distanceKm => distanceMeters / 1000; + double get durationMin => durationSeconds / 60; + + @override + List get props => [distanceMeters, durationSeconds, encodedPoints]; +} diff --git a/apps/rider_new/lib/features/trip/data/models/trip.dart b/apps/rider_new/lib/features/trip/data/models/trip.dart new file mode 100644 index 0000000..5a7ba5b --- /dev/null +++ b/apps/rider_new/lib/features/trip/data/models/trip.dart @@ -0,0 +1,125 @@ +import 'package:equatable/equatable.dart'; + +import 'geo_point.dart'; + +/// حالات الرحلة كما يعرّفها الخادم (docs/38 §4). +enum TripStatus { + searching, + assigned, + driverArriving, + driverArrived, + inProgress, + completed, + paid, + cancelled, + expired, + noDrivers, + unknown; + + static TripStatus parse(String? raw) => switch (raw) { + 'searching' => TripStatus.searching, + 'assigned' => TripStatus.assigned, + 'driver_arriving' => TripStatus.driverArriving, + 'driver_arrived' => TripStatus.driverArrived, + 'in_progress' => TripStatus.inProgress, + 'completed' => TripStatus.completed, + 'paid' => TripStatus.paid, + 'cancelled' => TripStatus.cancelled, + 'expired' => TripStatus.expired, + 'no_drivers' => TripStatus.noDrivers, + _ => TripStatus.unknown, + }; + + String get wire => switch (this) { + TripStatus.driverArriving => 'driver_arriving', + TripStatus.driverArrived => 'driver_arrived', + TripStatus.inProgress => 'in_progress', + TripStatus.noDrivers => 'no_drivers', + _ => name, + }; + + /// رحلة جارية تُستأنف عند فتح التطبيق. + bool get isLive => const { + TripStatus.searching, + TripStatus.assigned, + TripStatus.driverArriving, + TripStatus.driverArrived, + TripStatus.inProgress, + }.contains(this); + + bool get isOver => const { + TripStatus.completed, + TripStatus.paid, + TripStatus.cancelled, + TripStatus.expired, + TripStatus.noDrivers, + }.contains(this); +} + +class Trip extends Equatable { + const Trip({ + required this.id, + required this.status, + this.origin, + this.destination, + this.quotedFare, + this.priceForPassenger, + this.priceForDriver, + this.currency = '', + this.driverName, + this.driverPhone, + this.vehiclePlate, + this.vehicleModel, + this.driverRating, + }); + + final String id; + final TripStatus status; + final GeoPoint? origin; + final GeoPoint? destination; + + /// أجرة مقفولة عند الطلب. + final String? quotedFare; + + /// **حقلان منفصلان** لا حقل واحد — الفرق هو العمولة (docs/38 §4). + final String? priceForPassenger; + final String? priceForDriver; + + final String currency; + final String? driverName; + final String? driverPhone; + final String? vehiclePlate; + final String? vehicleModel; + final String? driverRating; + + factory Trip.fromJson(Map json) { + GeoPoint? point(String key) { + final v = json[key]; + return v is Map ? GeoPoint.fromJson(v) : null; + } + + final driver = json['driver'] as Map?; + return Trip( + id: json['id'] as String, + status: TripStatus.parse(json['status'] as String?), + origin: point('origin'), + destination: point('destination'), + // كل المبالغ نصوص (docs/38 §12.2) — تُحفظ كما وصلت ولا تُحوَّل هنا. + quotedFare: json['quoted_fare']?.toString(), + priceForPassenger: json['price_for_passenger']?.toString(), + priceForDriver: json['price_for_driver']?.toString(), + currency: json['currency'] as String? ?? '', + driverName: driver?['name'] as String? ?? json['driver_name'] as String?, + driverPhone: + driver?['phone'] as String? ?? json['driver_phone'] as String?, + vehiclePlate: + driver?['vehicle_plate'] as String? ?? json['vehicle_plate'] as String?, + vehicleModel: + driver?['vehicle_model'] as String? ?? json['vehicle_model'] as String?, + driverRating: driver?['rating']?.toString(), + ); + } + + @override + List get props => [id, status, priceForPassenger, driverName]; +} diff --git a/apps/rider_new/lib/features/trip/data/trip_repository.dart b/apps/rider_new/lib/features/trip/data/trip_repository.dart new file mode 100644 index 0000000..9b1afe9 --- /dev/null +++ b/apps/rider_new/lib/features/trip/data/trip_repository.dart @@ -0,0 +1,128 @@ +import '../../../core/api/api_client.dart'; +import 'models/fare_quote.dart'; +import 'models/geo_point.dart'; +import 'models/ride_type.dart'; +import 'models/trip.dart'; + +class TripRepository { + TripRepository(this._api); + + final ApiClient _api; + + Future> rideTypes() async { + final res = await _api.get>('/ride-types'); + return res + .whereType>() + .map(RideType.fromJson) + .where((t) => true) + .toList(growable: false) + ..sort((a, b) => a.sort.compareTo(b.sort)); + } + + /// ⚠️ camelCase وحدها في كل الـAPI، وتحتاج **مسافة ومدة محسوبتين مسبقاً** + /// من `/maps/route` — إرسال الإحداثيات يرجّع 200 بقيم `null` صامتة + /// (docs/38 §7). + Future quote({ + required String city, + required String serviceClass, + required double distanceKm, + required double durationMin, + }) async { + final res = await _api.post>( + '/tariff/quote', + body: { + 'city': city, + 'serviceClass': serviceClass, + 'distanceKm': distanceKm, + 'durationMin': durationMin, + }, + ); + return FareQuote.fromJson(res); + } + + /// يرجّع الرحلة وعدد السائقين الذين عُرضت عليهم — الصفر يعني «لا سائقين + /// قريبين» فوراً، بلا انتظار مهلة. + Future<({Trip trip, int offeredDrivers})> request({ + required GeoPoint origin, + required GeoPoint destination, + required String serviceClass, + String paymentMethod = 'wallet', + List stops = const [], + String? couponCode, + bool isRoundTrip = false, + }) async { + final res = await _api.post>('/trips', body: { + 'origin': origin.toJson(), + 'destination': destination.toJson(), + 'service_class': serviceClass, + 'payment_method': paymentMethod, + 'is_round_trip': isRoundTrip, + if (stops.isNotEmpty) + 'stops': stops.map((s) => s.toJson()).toList(growable: false), + 'coupon_code': ?couponCode, + }); + return ( + trip: Trip.fromJson(res['trip'] as Map), + offeredDrivers: (res['offeredDrivers'] as num?)?.toInt() ?? 0, + ); + } + + Future get(String tripId) async { + final res = await _api.get>('/trips/$tripId'); + return Trip.fromJson(res); + } + + Future> mine() async { + final res = await _api.get>('/trips/mine'); + return res + .whereType>() + .map(Trip.fromJson) + .toList(growable: false); + } + + Future cancel(String tripId) => + _api.post>('/trips/$tripId/cancel'); + + // ── طرف السائق ───────────────────────────────────────────────────────── + + Future> available() async { + final res = await _api.get>('/trips/available'); + return res + .whereType>() + .map(Trip.fromJson) + .toList(growable: false); + } + + Future accept(String tripId) => + _api.post>('/trips/$tripId/accept'); + + Future setOnline(bool online) => _api.patch>( + '/drivers/status', + body: {'online': online}, + ); + + /// نبضة الموقع إلى Redis — أساس المطابقة. مرشّح المسافة في + /// `LocationService` يمنع إغراقها. + Future pushLocation(GeoPoint point, {double? heading, double? speed}) => + _api.post>('/drivers/location', body: { + ...point.toJson(), + 'heading': ?heading, + 'speed': ?speed, + }); + + /// الرصيد التشغيلي. للراكب يرجّع 403 `Not a driver` — لا يُنادى إلا من + /// تطبيق السائق (docs/38 §5). + Future<({String balance, bool blocked})> credit() async { + final res = await _api.get>('/credit'); + return ( + balance: res['balance']?.toString() ?? '0', + blocked: res['blocked'] as bool? ?? false, + ); + } + + Future updateStatus(String tripId, TripStatus status) => + _api.patch>( + '/trips/$tripId/status', + body: {'status': status.wire}, + ); +} diff --git a/apps/rider_new/lib/features/trip/view/ride_page.dart b/apps/rider_new/lib/features/trip/view/ride_page.dart new file mode 100644 index 0000000..24efd5d --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/ride_page.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/design/tokens.dart'; +import '../../../core/design/tripz_colors.dart'; +import '../../../core/di.dart'; +import '../../../core/l10n/l10n.dart'; +import '../../../core/ui/tripz_button.dart'; +import '../cubit/ride_cubit.dart'; +import '../cubit/ride_state.dart'; +import '../data/models/geo_point.dart'; +import 'widgets/active_trip_sheet.dart'; +import 'widgets/center_pin.dart'; +import 'widgets/confirm_sheet.dart'; +import 'widgets/planner_sheet.dart'; +import 'widgets/ride_map.dart'; +import 'widgets/searching_sheet.dart'; + +/// شاشة الراكب: خريطة ثابتة + ورقة سفلية تتبدّل بالمرحلة (docs/39 §3). +/// +/// الخريطة **لا تُعاد بناؤها** عند تبدّل الورقة: هي خارج الـ`BlocBuilder` +/// الذي يبني الورقة، ولها `BlocSelector` خاص بما تحتاجه وحدها. هذا هو الفرق +/// بين خريطة سلسة وأخرى تهتزّ عند كل تحديث حالة. +class RidePage extends StatefulWidget { + const RidePage({super.key}); + + @override + State createState() => _RidePageState(); +} + +class _RidePageState extends State { + late final RideCubit _cubit = sl()..init(); + GeoPoint? _mapCenter; + + @override + void dispose() { + _cubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cubit, + child: Scaffold( + body: Stack( + children: [ + // ── الخريطة ──────────────────────────────────────────────── + BlocSelector( + selector: (s) => _MapData( + origin: s.origin, + destination: s.destination, + driver: s.driverLocation, + route: s.route, + camera: s.cameraTarget, + ), + builder: (context, data) => RideMap( + origin: data.origin, + destination: data.destination, + driver: data.driver, + route: data.route, + cameraTarget: data.camera, + onCameraIdle: (p) => _mapCenter = p, + ), + ), + + // ── دبّوس الاختيار ───────────────────────────────────────── + BlocSelector( + selector: (s) => s.phase == RidePhase.pickingOnMap, + builder: (context, picking) => + picking ? const CenterPin() : const SizedBox.shrink(), + ), + + // ── الورقة السفلية ───────────────────────────────────────── + Align( + alignment: Alignment.bottomCenter, + child: BlocBuilder( + builder: (context, state) => _Sheet( + state: state, + onConfirmPick: () { + final center = _mapCenter; + if (center != null) _cubit.confirmMapPick(center); + }, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// ما تحتاجه الخريطة وحدها — تغيّرُ أي شيء آخر في الحالة لا يعيد بناءها. +class _MapData { + const _MapData({ + this.origin, + this.destination, + this.driver, + this.route, + this.camera, + }); + + final GeoPoint? origin; + final GeoPoint? destination; + final GeoPoint? driver; + final dynamic route; + final GeoPoint? camera; + + @override + bool operator ==(Object other) => + other is _MapData && + other.origin == origin && + other.destination == destination && + other.driver == driver && + other.route == route && + other.camera == camera; + + @override + int get hashCode => Object.hash(origin, destination, driver, route, camera); +} + +class _Sheet extends StatelessWidget { + const _Sheet({required this.state, required this.onConfirmPick}); + + final RideState state; + final VoidCallback onConfirmPick; + + @override + Widget build(BuildContext context) { + final child = switch (state.phase) { + RidePhase.idle => const _Collapsed(), + RidePhase.planning => PlannerSheet(state: state), + RidePhase.pickingOnMap => _PickConfirm(onConfirm: onConfirmPick), + RidePhase.confirming => ConfirmSheet(state: state), + RidePhase.searching => const SearchingSheet(), + RidePhase.active => ActiveTripSheet(trip: state.trip!), + }; + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: Radii.sheetTop, + ), + child: SafeArea( + top: false, + child: Padding( + padding: Space.page, + child: AnimatedSize( + duration: Motion.inScreen, + curve: Motion.curve, + alignment: Alignment.bottomCenter, + child: child, + ), + ), + ), + ); + } +} + +class _Collapsed extends StatelessWidget { + const _Collapsed(); + + @override + Widget build(BuildContext context) { + return TripzButton.secondary( + label: context.l10n.rideWhereTo, + icon: Icons.search_rounded, + expanded: true, + onPressed: context.read().openPlanner, + ); + } +} + +class _PickConfirm extends StatelessWidget { + const _PickConfirm({required this.onConfirm}); + + final VoidCallback onConfirm; + + @override + Widget build(BuildContext context) { + return TripzButton.primary( + label: context.l10n.rideConfirmPoint, + onPressed: onConfirm, + ); + } +} diff --git a/apps/rider_new/lib/features/trip/view/widgets/active_trip_sheet.dart b/apps/rider_new/lib/features/trip/view/widgets/active_trip_sheet.dart new file mode 100644 index 0000000..a286c22 --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/widgets/active_trip_sheet.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/design/typography.dart'; +import '../../../../core/l10n/l10n.dart'; +import '../../../../core/ui/tripz_button.dart'; +import '../../cubit/ride_cubit.dart'; +import '../../data/models/trip.dart'; +import 'driver_badge.dart'; + +/// بطاقة الرحلة الجارية: حالة واضحة ثم بيانات السائق ثم فعل واحد. +class ActiveTripSheet extends StatelessWidget { + const ActiveTripSheet({super.key, required this.trip}); + + final Trip trip; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final canCancel = const { + TripStatus.assigned, + TripStatus.driverArriving, + }.contains(trip.status); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(_statusText(context), style: context.texts.titleMedium), + const SizedBox(height: Space.md), + DriverBadge(trip: trip), + if (trip.priceForPassenger != null) ...[ + const SizedBox(height: Space.md), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + l10n.rideConfirmTrip, + style: context.texts.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + Text( + '${trip.priceForPassenger} ${trip.currency}', + style: numericStyle(context.texts.titleLarge), + ), + ], + ), + ], + if (canCancel) ...[ + const SizedBox(height: Space.md), + TripzButton.text( + label: l10n.rideCancelTrip, + expanded: true, + onPressed: context.read().cancelTrip, + ), + ], + ], + ); + } + + String _statusText(BuildContext context) { + final l10n = context.l10n; + return switch (trip.status) { + TripStatus.assigned => l10n.tripAssigned, + TripStatus.driverArriving => l10n.tripDriverArriving, + TripStatus.driverArrived => l10n.tripDriverArrived, + TripStatus.inProgress => l10n.tripInProgress, + TripStatus.completed || TripStatus.paid => l10n.tripCompleted, + _ => l10n.rideSearchingTitle, + }; + } +} diff --git a/apps/rider_new/lib/features/trip/view/widgets/center_pin.dart b/apps/rider_new/lib/features/trip/view/widgets/center_pin.dart new file mode 100644 index 0000000..a047591 --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/widgets/center_pin.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; + +/// دبّوس ثابت في منتصف الشاشة أثناء الاختيار من الخريطة. +/// +/// النمط الصحيح المثبت ميدانياً: **المستخدم يحرّك الخريطة لا الدبّوس** +/// (docs/39 §3) — الدبّوس لا يُسحب، فلا يضيع تحت الإصبع. +class CenterPin extends StatelessWidget { + const CenterPin({super.key, this.isOrigin = false}); + + final bool isOrigin; + + @override + Widget build(BuildContext context) { + final color = + isOrigin ? context.tripzColors.mapOrigin : context.tripzColors.mapDestination; + return IgnorePointer( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.location_on, size: Sizes.iconLg, color: color), + // ظلّ صغير يثبّت الإحساس بموضع النقطة على الأرض. + Container( + width: 8, + height: 4, + margin: const EdgeInsets.only(top: 2), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: Sizes.iconLg), + ], + ), + ), + ); + } +} diff --git a/apps/rider_new/lib/features/trip/view/widgets/confirm_sheet.dart b/apps/rider_new/lib/features/trip/view/widgets/confirm_sheet.dart new file mode 100644 index 0000000..d977676 --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/widgets/confirm_sheet.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/design/typography.dart'; +import '../../../../core/l10n/l10n.dart'; +import '../../../../core/ui/status_banner.dart'; +import '../../../../core/ui/tripz_button.dart'; +import '../../cubit/ride_cubit.dart'; +import '../../cubit/ride_state.dart'; +import 'ride_type_picker.dart'; +import 'route_timeline.dart'; + +/// التأكيد: نوع الرحلة والأجرة والمسافة والمدة — ثم زر واحد. +class ConfirmSheet extends StatelessWidget { + const ConfirmSheet({super.key, required this.state}); + + final RideState state; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final cubit = context.read(); + final route = state.route; + final quote = state.quote; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RouteTimeline( + originLabel: state.originLabel, + destinationLabel: state.destinationLabel, + stopCount: state.stops.length, + onTapDestination: cubit.clearDestination, + ), + const SizedBox(height: Space.md), + RideTypePicker( + types: state.rideTypes, + selected: state.selectedRideType, + onSelect: cubit.selectRideType, + ), + const SizedBox(height: Space.md), + if (route != null) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _Metric( + icon: Icons.route_outlined, + value: l10n.rideDistanceKm(route.distanceKm.toStringAsFixed(1)), + ), + _Metric( + icon: Icons.schedule_rounded, + value: l10n.rideEta(route.durationMin.round().toString()), + ), + // الأجرة لا تُعرض إلا إن كانت صالحة: النقطة ترجّع 200 بقيم + // `null` عند المدخلات الخطأ (docs/38 §7). + if (quote != null && quote.isUsable) + Text( + '${quote.total!.toStringAsFixed(2)} ${quote.currency}', + style: numericStyle(context.texts.titleLarge), + ), + ], + ), + if (quote?.hasSurge == true) ...[ + const SizedBox(height: Space.sm), + StatusBanner( + message: l10n.rideSurge, + tone: BannerTone.warning, + icon: Icons.trending_up_rounded, + ), + ], + if (state.error == RideError.quoteFailed) ...[ + const SizedBox(height: Space.sm), + StatusBanner( + message: l10n.rideQuoteFailed, + tone: BannerTone.danger, + ), + ], + if (state.error == RideError.noDrivers) ...[ + const SizedBox(height: Space.sm), + StatusBanner(message: l10n.rideNoDrivers, tone: BannerTone.warning), + ], + const SizedBox(height: Space.md), + TripzButton.primary( + label: l10n.rideConfirmTrip, + loading: state.busy, + onPressed: state.canConfirm ? cubit.requestTrip : null, + ), + ], + ); + } +} + +class _Metric extends StatelessWidget { + const _Metric({required this.icon, required this.value}); + + final IconData icon; + final String value; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: Sizes.iconSm, color: context.colors.onSurfaceVariant), + const SizedBox(width: Space.xxs), + Text(value, style: numericStyle(context.texts.bodyMedium)), + ], + ); + } +} diff --git a/apps/rider_new/lib/features/trip/view/widgets/driver_badge.dart b/apps/rider_new/lib/features/trip/view/widgets/driver_badge.dart new file mode 100644 index 0000000..cabb7ac --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/widgets/driver_badge.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/design/typography.dart'; +import '../../data/models/trip.dart'; + +/// بطاقة السائق ولوحة المركبة — أهم ما يبحث عنه الراكب في الشارع، فيأخذ +/// أوضح موضع وأكبر تباين. +class DriverBadge extends StatelessWidget { + const DriverBadge({super.key, required this.trip}); + + final Trip trip; + + @override + Widget build(BuildContext context) { + if (trip.driverName == null) return const SizedBox.shrink(); + return Row( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: context.colors.primaryContainer, + child: Icon( + Icons.person_rounded, + color: context.colors.onPrimaryContainer, + ), + ), + const SizedBox(width: Space.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(trip.driverName!, style: context.texts.titleSmall), + if (trip.vehicleModel != null) + Text( + trip.vehicleModel!, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + if (trip.driverRating != null) + Row( + children: [ + Icon( + Icons.star_rounded, + size: Sizes.iconSm, + color: context.tripzColors.warning, + ), + const SizedBox(width: Space.xxs), + Text( + trip.driverRating!, + style: numericStyle(context.texts.bodySmall), + ), + ], + ), + ], + ), + ), + if (trip.vehiclePlate != null) _Plate(plate: trip.vehiclePlate!), + ], + ); + } +} + +/// لوحة مرسومة لا نصّ عادي — تُقرأ من بعيد ومن زاوية. +class _Plate extends StatelessWidget { + const _Plate({required this.plate}); + + final String plate; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: Space.sm, + vertical: Space.xxs, + ), + decoration: BoxDecoration( + borderRadius: Radii.field_, + border: Border.all(color: context.colors.outline, width: 1.5), + color: context.tripzColors.surfaceRaised, + ), + child: Text( + plate, + textDirection: TextDirection.ltr, + style: numericStyle(context.texts.titleMedium).copyWith( + letterSpacing: 1.5, + ), + ), + ); + } +} diff --git a/apps/rider_new/lib/features/trip/view/widgets/planner_sheet.dart b/apps/rider_new/lib/features/trip/view/widgets/planner_sheet.dart new file mode 100644 index 0000000..a72fe32 --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/widgets/planner_sheet.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/l10n/l10n.dart'; +import '../../../../core/ui/tripz_button.dart'; +import '../../../../core/ui/tripz_text_field.dart'; +import '../../cubit/ride_cubit.dart'; +import '../../cubit/ride_state.dart'; +import 'route_timeline.dart'; + +/// المخطّط الموسّع بترتيبه المثبت ميدانياً (docs/39 §3): +/// الخط الزمني للمسار ← البحث ← تحديد من الخريطة. +class PlannerSheet extends StatefulWidget { + const PlannerSheet({super.key, required this.state}); + + final RideState state; + + @override + State createState() => _PlannerSheetState(); +} + +class _PlannerSheetState extends State { + final _query = TextEditingController(); + + @override + void dispose() { + _query.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final cubit = context.read(); + final state = widget.state; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RouteTimeline( + originLabel: state.originLabel, + destinationLabel: state.destinationLabel, + stopCount: state.stops.length, + onTapOrigin: () => cubit.startMapPick(PickTarget.origin), + onTapDestination: () => cubit.startMapPick(PickTarget.destination), + ), + const SizedBox(height: Space.md), + TripzTextField( + controller: _query, + hint: l10n.rideSearchHint, + prefixIcon: Icons.search_rounded, + onChanged: cubit.searchPlaces, + ), + if (state.searching) + const Padding( + padding: EdgeInsets.only(top: Space.md), + child: LinearProgressIndicator(minHeight: 2), + ), + if (state.searchResults.isNotEmpty) + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.only(top: Space.xs), + itemCount: state.searchResults.length, + separatorBuilder: (context, i) => const Divider(height: 1), + itemBuilder: (context, i) { + final place = state.searchResults[i]; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.place_outlined), + title: Text(place.label), + subtitle: place.address.isEmpty ? null : Text(place.address), + onTap: () { + _query.clear(); + cubit.pickPlace(place, PickTarget.destination); + }, + ); + }, + ), + ), + const SizedBox(height: Space.md), + TripzButton.secondary( + label: l10n.ridePickOnMap, + icon: Icons.map_outlined, + expanded: true, + onPressed: () => cubit.startMapPick(PickTarget.destination), + ), + ], + ); + } +} diff --git a/apps/rider_new/lib/features/trip/view/widgets/ride_map.dart b/apps/rider_new/lib/features/trip/view/widgets/ride_map.dart new file mode 100644 index 0000000..65956ec --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/widgets/ride_map.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:intaleq_maps/intaleq_maps.dart' as im; + +import '../../../../core/config.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../data/models/geo_point.dart'; +import '../../data/models/route_info.dart'; + +/// الخريطة — **الطبقة الوحيدة التي تعرف `intaleq_maps`**. ما فوقها يتعامل +/// مع `GeoPoint` وحده، فتبديل محرّك الخريطة لاحقاً لا يلمس منطقاً. +/// +/// البلاطات تُجلب مباشرة من map-saas بمفتاح مقيّد ببصمة التطبيق +/// (قرار 2026-07-20). +class RideMap extends StatefulWidget { + const RideMap({ + super.key, + this.origin, + this.destination, + this.driver, + this.route, + this.cameraTarget, + this.onCameraIdle, + }); + + final GeoPoint? origin; + final GeoPoint? destination; + final GeoPoint? driver; + final RouteInfo? route; + final GeoPoint? cameraTarget; + + /// مركز الخريطة عند استقرارها — يغذّي اختيار النقطة بالدبّوس الثابت. + final ValueChanged? onCameraIdle; + + @override + State createState() => _RideMapState(); +} + +class _RideMapState extends State { + im.IntaleqMapController? _controller; + im.LatLng? _center; + + static const _fallback = im.LatLng(31.9539, 35.9106); // عمّان + + @override + void didUpdateWidget(RideMap old) { + super.didUpdateWidget(old); + final target = widget.cameraTarget; + if (target != null && target != old.cameraTarget) { + _controller?.animateCamera( + im.CameraUpdate.newLatLng(im.LatLng(target.lat, target.lng)), + ); + } + } + + @override + Widget build(BuildContext context) { + final colors = context.tripzColors; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return im.IntaleqMap( + apiKey: AppConfig.mapApiKey, + initialCameraPosition: im.CameraPosition( + target: widget.origin == null + ? _fallback + : im.LatLng(widget.origin!.lat, widget.origin!.lng), + zoom: 15, + ), + // الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر + // الغرض (docs/26 §2). + styleUrl: isDark + ? im.IntaleqStyles.obsidian(AppConfig.mapApiKey) + : im.IntaleqStyles.light(AppConfig.mapApiKey), + myLocationEnabled: true, + compassEnabled: false, + zoomControlsEnabled: false, + onMapCreated: (c) => _controller = c, + onCameraMove: (position) => _center = position.target, + onCameraIdle: () { + final c = _center; + if (c != null) widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude)); + }, + markers: { + if (widget.origin != null) + _marker('origin', widget.origin!), + if (widget.destination != null) + _marker('destination', widget.destination!), + if (widget.driver != null) _marker('driver', widget.driver!), + }, + polylines: { + if (widget.route != null && widget.route!.encodedPoints.isNotEmpty) + im.Polyline( + polylineId: const im.PolylineId('route'), + points: im.PolylineUtils.decode(widget.route!.encodedPoints), + color: colors.mapRoute, + width: 5, + ), + }, + ); + } + + im.Marker _marker(String id, GeoPoint p) => im.Marker( + markerId: im.MarkerId(id), + position: im.LatLng(p.lat, p.lng), + ); +} diff --git a/apps/rider_new/lib/features/trip/view/widgets/ride_type_picker.dart b/apps/rider_new/lib/features/trip/view/widgets/ride_type_picker.dart new file mode 100644 index 0000000..ed35bd1 --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/widgets/ride_type_picker.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../data/models/ride_type.dart'; + +/// قائمة الأنواع تُبنى من `GET /ride-types` لا من قائمة مكتوبة (docs/38 §7). +/// `women_only` علامة مرئية لا فلتر صامت. +class RideTypePicker extends StatelessWidget { + const RideTypePicker({ + super.key, + required this.types, + required this.selected, + required this.onSelect, + }); + + final List types; + final RideType? selected; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + if (types.isEmpty) return const SizedBox.shrink(); + final lang = Localizations.localeOf(context).languageCode; + + return SizedBox( + height: 44, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: types.length, + separatorBuilder: (context, i) => const SizedBox(width: Space.xs), + itemBuilder: (context, i) { + final type = types[i]; + final isSelected = type.id == selected?.id; + return ChoiceChip( + selected: isSelected, + onSelected: (_) => onSelect(type), + avatar: type.womenOnly + ? const Icon(Icons.female_rounded, size: Sizes.iconSm) + : Icon( + type.vehicleKind == 'moto' + ? Icons.two_wheeler_rounded + : Icons.directions_car_rounded, + size: Sizes.iconSm, + ), + label: Text(type.nameFor(lang)), + labelStyle: context.texts.labelLarge, + ); + }, + ), + ); + } +} diff --git a/apps/rider_new/lib/features/trip/view/widgets/route_timeline.dart b/apps/rider_new/lib/features/trip/view/widgets/route_timeline.dart new file mode 100644 index 0000000..e2c0894 --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/widgets/route_timeline.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/l10n/l10n.dart'; + +/// الخط الزمني للمسار: المصدر ← المحطات ← الوجهة، بنقاط ملوّنة تطابق +/// علامات الخريطة كي يربط المستخدم بينهما بلا شرح. +class RouteTimeline extends StatelessWidget { + const RouteTimeline({ + super.key, + required this.originLabel, + required this.destinationLabel, + this.stopCount = 0, + this.onTapOrigin, + this.onTapDestination, + }); + + final String originLabel; + final String destinationLabel; + final int stopCount; + final VoidCallback? onTapOrigin; + final VoidCallback? onTapDestination; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final colors = context.tripzColors; + return Column( + children: [ + _Row( + color: colors.mapOrigin, + label: l10n.rideOrigin, + value: originLabel, + onTap: onTapOrigin, + ), + if (stopCount > 0) + for (var i = 0; i < stopCount; i++) + _Row( + color: colors.info, + label: l10n.rideStop(i + 1), + value: '', + ), + _Row( + color: colors.mapDestination, + label: l10n.rideDestination, + value: destinationLabel, + onTap: onTapDestination, + ), + ], + ); + } +} + +class _Row extends StatelessWidget { + const _Row({ + required this.color, + required this.label, + required this.value, + this.onTap, + }); + + final Color color; + final String label; + final String value; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: Radii.field_, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Space.sm), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: Space.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: context.texts.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + Text( + value.isEmpty ? '—' : value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.texts.bodyMedium, + ), + ], + ), + ), + if (onTap != null) + Icon( + Icons.chevron_left_rounded, + color: context.colors.onSurfaceVariant, + ), + ], + ), + ), + ); + } +} diff --git a/apps/rider_new/lib/features/trip/view/widgets/searching_sheet.dart b/apps/rider_new/lib/features/trip/view/widgets/searching_sheet.dart new file mode 100644 index 0000000..d8e2830 --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/widgets/searching_sheet.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/l10n/l10n.dart'; +import '../../../../core/ui/tripz_button.dart'; +import '../../cubit/ride_cubit.dart'; + +/// نافذة البحث عن سائق. +/// +/// **لا تُغلق بالسحب — بزر الإلغاء وحده** (قرار تصميمي مثبت ميدانياً، +/// docs/39 §3): المستخدم لا يزيح شاشة البحث بالخطأ فيفقد أثر رحلته. +/// الويدجت «غبي» بلا منطق — المهلة والاستطلاع في الكيوبت. +class SearchingSheet extends StatefulWidget { + const SearchingSheet({super.key}); + + @override + State createState() => _SearchingSheetState(); +} + +class _SearchingSheetState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _radar = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + )..repeat(); + + @override + void dispose() { + _radar.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: 120, + child: Center( + child: RepaintBoundary( + child: AnimatedBuilder( + animation: _radar, + builder: (context, _) => CustomPaint( + size: const Size(120, 120), + painter: _RadarPainter( + progress: _radar.value, + color: context.colors.primary, + ), + ), + ), + ), + ), + ), + const SizedBox(height: Space.md), + Text( + l10n.rideSearchingTitle, + textAlign: TextAlign.center, + style: context.texts.titleMedium, + ), + const SizedBox(height: Space.xxs), + Text( + l10n.rideSearchingLead, + textAlign: TextAlign.center, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Space.lg), + TripzButton.secondary( + label: l10n.rideCancelSearch, + expanded: true, + onPressed: context.read().cancelTrip, + ), + ], + ); + } +} + +class _RadarPainter extends CustomPainter { + const _RadarPainter({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final center = size.center(Offset.zero); + final maxRadius = size.width / 2; + + // ثلاث حلقات متتابعة الطور — إحساس بالبثّ المستمر لا بنبضة واحدة. + for (var i = 0; i < 3; i++) { + final t = (progress + i / 3) % 1.0; + canvas.drawCircle( + center, + maxRadius * t, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = color.withValues(alpha: (1 - t) * 0.6), + ); + } + canvas.drawCircle(center, 8, Paint()..color = color); + } + + @override + bool shouldRepaint(_RadarPainter old) => old.progress != progress; +} diff --git a/apps/rider_new/pubspec.lock b/apps/rider_new/pubspec.lock index 9eb5b8c..a8ef420 100644 --- a/apps/rider_new/pubspec.lock +++ b/apps/rider_new/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -81,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" device_info_plus: dependency: "direct main" description: @@ -145,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -229,6 +253,70 @@ packages: description: flutter source: sdk version: "0.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" + url: "https://pub.dev" + source: hosted + version: "14.0.2" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: "86ea1654e4f61ff51466848e91c116b422d6010ea269fda0fbe1af7e9e742ce1" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73" + url: "https://pub.dev" + source: hosted + version: "2.3.14" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 + url: "https://pub.dev" + source: hosted + version: "0.2.4" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2 + url: "https://pub.dev" + source: hosted + version: "4.2.8" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" get_it: dependency: "direct main" description: @@ -245,6 +333,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" hooks: dependency: transitive description: @@ -253,6 +349,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_parser: dependency: transitive description: @@ -261,6 +365,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + intaleq_maps: + dependency: "direct main" + description: + name: intaleq_maps + sha256: "755ed2f28350cbef80deb055f4d98f94a8a23370f8dbf77d7eb4cd91a1ffd76b" + url: "https://pub.dev" + source: hosted + version: "2.3.0" intl: dependency: transitive description: @@ -333,6 +453,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + maplibre_gl: + dependency: transitive + description: + name: maplibre_gl + sha256: d9773555ae4ebab94bbc3ae2176b077cfda486ec729eefe01e1613f164cb8410 + url: "https://pub.dev" + source: hosted + version: "0.25.0" + maplibre_gl_platform_interface: + dependency: transitive + description: + name: maplibre_gl_platform_interface + sha256: bd7de401dea24dd7e8a6f2fa736ddee7dbbee3e24a9027f0afdd619994702047 + url: "https://pub.dev" + source: hosted + version: "0.25.0" + maplibre_gl_web: + dependency: transitive + description: + name: maplibre_gl_web + sha256: af0e48bf96e8dd99f8b958a1953126971eb8a0527b9735441d4f24df3913f5a2 + url: "https://pub.dev" + source: hosted + version: "0.25.0" matcher: dependency: transitive description: @@ -389,6 +533,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -493,6 +653,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -509,6 +677,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" provider: dependency: transitive description: @@ -594,6 +770,22 @@ packages: description: flutter source: sdk version: "0.0.0" + socket_io_client: + dependency: "direct main" + description: + name: socket_io_client + sha256: f5990ff303d385e7b2150f0e57cca097a8d20c6a2ae0a22cb2d496661ea8ed9b + url: "https://pub.dev" + source: hosted + version: "3.1.6" + socket_io_common: + dependency: transitive + description: + name: socket_io_common + sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b" + url: "https://pub.dev" + source: hosted + version: "3.1.1" source_span: dependency: transitive description: @@ -650,6 +842,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" vector_math: dependency: transitive description: @@ -674,6 +874,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" win32: dependency: transitive description: @@ -698,6 +906,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: diff --git a/apps/rider_new/pubspec.yaml b/apps/rider_new/pubspec.yaml index 7d2af6f..eecc883 100644 --- a/apps/rider_new/pubspec.yaml +++ b/apps/rider_new/pubspec.yaml @@ -29,6 +29,11 @@ dependencies: flutter_secure_storage: ^10.0.0 shared_preferences: ^2.3.3 + # م4: الخريطة والموقع والواقع اللحظي + intaleq_maps: ^2.3.0 + geolocator: ^14.0.2 + socket_io_client: ^3.0.2 + # م3: الأذونات وبصمة الجهاز permission_handler: ^12.0.1 device_info_plus: ^12.3.0 diff --git a/docs/37-newapps-cubit-plan.md b/docs/37-newapps-cubit-plan.md index 53d7632..8cf31b6 100644 --- a/docs/37-newapps-cubit-plan.md +++ b/docs/37-newapps-cubit-plan.md @@ -200,7 +200,45 @@ 16. **بوابة**: دخول حقيقي من التطبيقين على NestJS المشتغل + لقطات شاشة تُقارن بمرجع سيرو، ولا تُقبل إن كانت أدنى منه. -### المرحلة 4 — الخريطة وطلب الرحلة +### المرحلة 4 — الخريطة وطلب الرحلة — **نُفّذت 2026-08-04، البوابة معلّقة** + +**حُسم تعارض الخرائط المسجّل في `docs/38 §7`** لصالح القرار الأحدث +(2026-07-20): كل الخرائط — البلاطات والبحث والعكسي والمسار — **مباشرة إلى +`map-saas.intaleqapp.com`** بترويسة `x-api-key`، بلا مرور بباك إند تريبز. +نقاط `/maps/*` في الباك إند **لا تُستعمل**، وإحداها (`geocode`) معطوبة على +المنشور أصلاً. تُحقّق العقد حيّاً بـcurl قبل الكتابة. + +المنجز: +- `core/api/antlaq_api.dart` — عميل منفصل **بلا** `AuthInterceptor`: توكن + تريبز لا شأن لخادم الخرائط به، وإرساله إليه تسريبٌ بلا مقابل. +- `core/realtime/realtime_service.dart` — Socket.IO واحد للتطبيق: + `trip:update` · `driver:location` · `trip:offer` · `trip:offer_taken`. +- `core/location/location_service.dart` — نقطة الموقع الوحيدة، بمرشّح 25م. +- `features/trip/data/` — سبعة نماذج + `MapsRepository` + `TripRepository` + (متطابق حرفياً بين التوأمين). +- **الراكب**: `RideCubit` بستّ مراحل + `RidePage` وثمانية ويدجت + (الخريطة · دبّوس المنتصف · المخطّط · الخط الزمني · اختيار النوع · التأكيد · + البحث عن سائق · الرحلة الجارية). +- **السائق**: `DutyCubit` بأربع مراحل + `DutyPage` وأربعة ويدجت + (مفتاح الاتصال · العرض بمؤقّت · الرحلة الجارية · بطاقة الراكب). + +قرارات تصميمية مقصودة: +1. **`GeoPoint` مستقلّ عن حزمة الخريطة**؛ `intaleq_maps` مستوردة في **ملف + واحد فقط** لكل تطبيق (`ride_map.dart`). تبديل محرّك الخريطة لا يلمس منطقاً. +2. **الخريطة خارج الـ`BlocBuilder`** الذي يبني الورقة، ولها `BlocSelector` + بما تحتاجه وحدها — نبضة موقع كل 25م لا تعيد بناء الشاشة. هذا تطبيق مباشر + لميزانية الأداء (م2.12). +3. **مهلة بحث محليّة 90 ثانية + استطلاع كل 5ث**: الخادم قد لا يُطلق + `expired`/`no_drivers` أبداً (ثغرة R1)، وانتظار حدثٍ قد لا يصل = شاشة + بحث أبدية. و`offeredDrivers == 0` يُعالَج فوراً بلا انتظار المهلة. +4. **الأجرة لا تُعرض إلا إن كانت صالحة**: `/tariff/quote` يرجّع 200 بقيم + `null` صامتة عند المدخلات الخطأ (`docs/38 §7`). +5. موقع السائق يُرسَل بمسارين عمداً: REST يغذّي مطابقة Redis، والسوكت يغذّي + خريطة الراكب — سقوط أحدهما لا يُعمي الآخر. + +`flutter analyze` نظيف · الراكب 70 ملف/5,294 سطر · السائق 67 ملف/4,771 سطر. + +### (الأصل المعتمد للمرحلة 4) 17. الخريطة مباشرة لـ map-saas بمفتاح `x-api-key` مقيّد ببصمة التطبيق (قرار `maps-direct-decision`) — لا تمرير عبر الباك إند. 18. الراكب: اختيار المصدر/الوجهة · التسعير المسبق · تأكيد الطلب · انتظار سائق.