From bb2c0de478987229ee08926fcdeeb55575331919 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Wed, 5 Aug 2026 00:04:28 +0300 Subject: [PATCH] =?UTF-8?q?feat(apps):=20=D8=AA=D8=B3=D8=AC=D9=8A=D9=84=20?= =?UTF-8?q?=D8=A7=D9=84=D8=B3=D8=A7=D8=A6=D9=82=20=D9=88=D8=A7=D9=84=D8=AF?= =?UTF-8?q?=D8=B1=D8=AF=D8=B4=D8=A9=20=D9=88=D8=A7=D9=84=D9=83=D9=88=D8=A8?= =?UTF-8?q?=D9=88=D9=86=20=E2=80=94=20=D8=A5=D9=83=D9=85=D8=A7=D9=84=20?= =?UTF-8?q?=D8=A7=D9=84=D8=AA=D8=BA=D8=B7=D9=8A=D8=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit فحص آليّ لنقاط docs/38 مقابل الكود كشف ثغرات لم تظهر بالقراءة. ## تسجيل السائق — أخطر ثغرة لم يكن موجوداً أصلاً: سائق جديد يسجّل دخوله ثم يقف. بُنيت features/onboarding كاملة — تقديم ← ملف ← مركبة ← وثائق ← انتظار الاعتماد. - الخطوة تُشتقّ من حالة الخادم لا من تقدّم محلّي: سائق يعيد تثبيت التطبيق يعود إلى حيث وقف لا إلى البداية - الاعتماد يقع على الخادم تلقائياً حين تكتمل الوثائق؛ التطبيق لا يعتمد أحداً - عند الاعتماد خروج إجباري: الدور يتغيّر على الخادم والتوكن القديم يحمل القديم، فتفشل نقاط السائق بـ403 (مصيدة docs/38 §5) - بوابة توجيه: مستخدم دوره ليس driver يُحجز في /onboarding - الوثائق تُصوَّر بالكاميرا لا من المعرض: أصعب تزويراً ## الدردشة والكوبون - Features.chat كان مفعّلاً بلا ميزة. features/chat باستطلاع كل خمس ثوان — قائمة أحداث الخادم لا تتضمّن الرسائل (docs/38 §9)، فالاستطلاع قيد خادم لا اختيار تصميمي - الكوبون: حقل في ورقة التأكيد خلف طبقتَي الميزات، يُقيَّم على الخادم ## Features.calls أُطفئ صراحةً الخادم يدعم WebRTC والتطبيق لا. عَلَم مفعّل بلا ميزة كذبٌ على القارئ التالي. ## بنية - ApiClient.upload للرفع متعدّد الأجزاء - AuthFailure → ApiFailure في core/api: يخدم المصادقة والملف والتسجيل - tool/sync_from_rider.sh: المزامنة اليدوية بين التوأمين انكسرت أربع مرات، فصارت سكربتاً واحداً يعرّف ما يملكه كل تطبيق flutter analyze نظيف · الراكب 101 ملف/7,809 سطر · السائق 110 ملف/8,116 سطر. فحوص آلية: صفر استيراد بين ميزتين · صفر عَلَم مفعّل بلا ميزة · كل نقاط العقد المخصّصة للتطبيقين مستهلكة. Co-Authored-By: Claude Opus 5 --- apps/driver_new/lib/core/api/api_client.dart | 16 +++ apps/driver_new/lib/core/api/api_failure.dart | 18 +-- apps/driver_new/lib/core/build_config.dart | 4 +- apps/driver_new/lib/core/di.dart | 13 ++ apps/driver_new/lib/core/l10n/app_ar.arb | 38 +++++- apps/driver_new/lib/core/l10n/app_en.arb | 38 +++++- apps/driver_new/lib/core/router.dart | 18 +++ apps/driver_new/lib/core/ui/failure_text.dart | 20 +-- .../lib/features/auth/cubit/login_cubit.dart | 6 +- .../lib/features/auth/cubit/login_state.dart | 4 +- .../lib/features/auth/view/otp_view.dart | 2 +- .../lib/features/auth/view/phone_view.dart | 4 +- .../lib/features/chat/cubit/chat_cubit.dart | 81 ++++++++++++ .../features/chat/data/chat_repository.dart | 46 +++++++ .../lib/features/chat/view/chat_page.dart | 114 ++++++++++++++++ .../onboarding/cubit/onboarding_cubit.dart | 123 ++++++++++++++++++ .../onboarding/cubit/onboarding_state.dart | 51 ++++++++ .../onboarding/data/driver_repository.dart | 108 +++++++++++++++ .../data/models/document_requirement.dart | 39 ++++++ .../data/models/driver_profile.dart | 48 +++++++ .../onboarding/data/models/vehicle.dart | 45 +++++++ .../onboarding/view/onboarding_page.dart | 54 ++++++++ .../onboarding/view/steps/apply_step.dart | 64 +++++++++ .../onboarding/view/steps/documents_step.dart | 114 ++++++++++++++++ .../onboarding/view/steps/pending_step.dart | 58 +++++++++ .../onboarding/view/steps/profile_step.dart | 95 ++++++++++++++ .../onboarding/view/steps/vehicle_step.dart | 82 ++++++++++++ .../features/profile/cubit/profile_cubit.dart | 6 +- .../profile/view/profile_setup_page.dart | 2 +- .../trip/view/widgets/active_duty_sheet.dart | 14 ++ apps/driver_new/pubspec.lock | 112 ++++++++++++++++ apps/driver_new/pubspec.yaml | 3 + apps/driver_new/tool/driver_l10n_extra.py | 93 +++++++++++++ apps/driver_new/tool/sync_from_rider.sh | 56 ++++++++ apps/rider_new/lib/core/api/api_client.dart | 16 +++ apps/rider_new/lib/core/api/api_failure.dart | 18 +-- apps/rider_new/lib/core/build_config.dart | 5 +- apps/rider_new/lib/core/di.dart | 8 ++ apps/rider_new/lib/core/l10n/app_ar.arb | 7 +- apps/rider_new/lib/core/l10n/app_en.arb | 7 +- apps/rider_new/lib/core/router.dart | 7 + apps/rider_new/lib/core/ui/failure_text.dart | 20 +-- .../lib/features/auth/cubit/login_cubit.dart | 6 +- .../lib/features/auth/cubit/login_state.dart | 4 +- .../lib/features/auth/view/otp_view.dart | 2 +- .../lib/features/auth/view/phone_view.dart | 4 +- .../lib/features/chat/cubit/chat_cubit.dart | 81 ++++++++++++ .../features/chat/data/chat_repository.dart | 46 +++++++ .../lib/features/chat/view/chat_page.dart | 114 ++++++++++++++++ .../features/profile/cubit/profile_cubit.dart | 6 +- .../profile/view/profile_setup_page.dart | 2 +- .../lib/features/trip/cubit/ride_cubit.dart | 5 + .../lib/features/trip/cubit/ride_state.dart | 7 + .../trip/view/widgets/active_trip_sheet.dart | 13 ++ .../trip/view/widgets/confirm_sheet.dart | 12 ++ docs/37-newapps-cubit-plan.md | 25 ++++ 56 files changed, 1936 insertions(+), 68 deletions(-) create mode 100644 apps/driver_new/lib/features/chat/cubit/chat_cubit.dart create mode 100644 apps/driver_new/lib/features/chat/data/chat_repository.dart create mode 100644 apps/driver_new/lib/features/chat/view/chat_page.dart create mode 100644 apps/driver_new/lib/features/onboarding/cubit/onboarding_cubit.dart create mode 100644 apps/driver_new/lib/features/onboarding/cubit/onboarding_state.dart create mode 100644 apps/driver_new/lib/features/onboarding/data/driver_repository.dart create mode 100644 apps/driver_new/lib/features/onboarding/data/models/document_requirement.dart create mode 100644 apps/driver_new/lib/features/onboarding/data/models/driver_profile.dart create mode 100644 apps/driver_new/lib/features/onboarding/data/models/vehicle.dart create mode 100644 apps/driver_new/lib/features/onboarding/view/onboarding_page.dart create mode 100644 apps/driver_new/lib/features/onboarding/view/steps/apply_step.dart create mode 100644 apps/driver_new/lib/features/onboarding/view/steps/documents_step.dart create mode 100644 apps/driver_new/lib/features/onboarding/view/steps/pending_step.dart create mode 100644 apps/driver_new/lib/features/onboarding/view/steps/profile_step.dart create mode 100644 apps/driver_new/lib/features/onboarding/view/steps/vehicle_step.dart create mode 100644 apps/driver_new/tool/driver_l10n_extra.py create mode 100755 apps/driver_new/tool/sync_from_rider.sh create mode 100644 apps/rider_new/lib/features/chat/cubit/chat_cubit.dart create mode 100644 apps/rider_new/lib/features/chat/data/chat_repository.dart create mode 100644 apps/rider_new/lib/features/chat/view/chat_page.dart diff --git a/apps/driver_new/lib/core/api/api_client.dart b/apps/driver_new/lib/core/api/api_client.dart index 04943f9..7bd8bcc 100644 --- a/apps/driver_new/lib/core/api/api_client.dart +++ b/apps/driver_new/lib/core/api/api_client.dart @@ -47,6 +47,22 @@ class ApiClient { Future delete(String path) => _send(() => _dio.delete(path)); + /// رفع ملف — الحقل اسمه `file` كما يتوقّعه `FileInterceptor` على الخادم. + /// يمرّ بنفس الـinterceptor فيحمل التوكن والمستأجر والجهاز. + Future upload( + String path, { + required String filePath, + Map fields = const {}, + }) { + return _send(() async { + final form = FormData.fromMap({ + ...fields, + 'file': await MultipartFile.fromFile(filePath), + }); + return _dio.post(path, data: form); + }); + } + Future _send(Future> Function() call) async { try { final res = await call(); diff --git a/apps/driver_new/lib/core/api/api_failure.dart b/apps/driver_new/lib/core/api/api_failure.dart index ba6d221..4fb19da 100644 --- a/apps/driver_new/lib/core/api/api_failure.dart +++ b/apps/driver_new/lib/core/api/api_failure.dart @@ -7,7 +7,7 @@ import 'api_exception.dart'; /// كل نص مرئي بطبقة الترجمة. والـCubit لا يعرف `BuildContext` (docs/23 §3). /// الجمع بينها: الـCubit يُصدر الرمز، والواجهة تترجمه سطراً واحداً — فتبقى /// الشاشة بلا منطق، والنص بلغتين. -enum AuthFailure { +enum ApiFailure { /// رمز خاطئ أو منتهي الصلاحية. invalidCode, @@ -22,17 +22,17 @@ enum AuthFailure { server, } -extension AuthFailureX on ApiException { - AuthFailure toAuthFailure() { - if (isRateLimited) return AuthFailure.rateLimited; - if (kind == ApiErrorKind.network) return AuthFailure.network; - if (kind == ApiErrorKind.timeout) return AuthFailure.timeout; +extension ApiFailureX on ApiException { + ApiFailure toApiFailure() { + if (isRateLimited) return ApiFailure.rateLimited; + if (kind == ApiErrorKind.network) return ApiFailure.network; + if (kind == ApiErrorKind.timeout) return ApiFailure.timeout; if (isUnauthorized) { // الخادم يفرّق بين «رمز خاطئ» و«محاولات كثيرة» بالنصّ وحده. return message.contains('Too many') - ? AuthFailure.tooManyAttempts - : AuthFailure.invalidCode; + ? ApiFailure.tooManyAttempts + : ApiFailure.invalidCode; } - return AuthFailure.server; + return ApiFailure.server; } } diff --git a/apps/driver_new/lib/core/build_config.dart b/apps/driver_new/lib/core/build_config.dart index cb9a611..e9aaf9a 100644 --- a/apps/driver_new/lib/core/build_config.dart +++ b/apps/driver_new/lib/core/build_config.dart @@ -31,7 +31,9 @@ class Features { static const bool wallet = true; static const bool chat = true; - static const bool calls = true; + /// WebRTC غير مبنيّ بعد — الخادم يدعمه (`call:*` في docs/38 §9) لكن + /// التطبيق لا. يبقى مطفأً حتى يُبنى ويُختبر على جهاز حقيقي. + static const bool calls = false; static const bool rideTypes = true; static const bool coupons = true; static const bool geofence = false; diff --git a/apps/driver_new/lib/core/di.dart b/apps/driver_new/lib/core/di.dart index 426585b..ec52c49 100644 --- a/apps/driver_new/lib/core/di.dart +++ b/apps/driver_new/lib/core/di.dart @@ -21,6 +21,10 @@ import '../features/settings/cubit/settings_cubit.dart'; import '../features/trip/cubit/history_cubit.dart'; +import '../features/chat/cubit/chat_cubit.dart'; +import '../features/chat/data/chat_repository.dart'; +import '../features/onboarding/cubit/onboarding_cubit.dart'; +import '../features/onboarding/data/driver_repository.dart'; import '../features/trip/cubit/duty_cubit.dart'; import '../features/trip/data/history_repository.dart'; @@ -99,12 +103,21 @@ Future setupInjector() async { sl.registerSingleton(TenantCubit(sl(), prefs)); sl.registerSingleton(PushService(sl())); + sl.registerSingleton(DriverRepository(sl())); + sl.registerFactory(() => OnboardingCubit(sl())); + sl.registerSingleton(HistoryRepository(sl())); sl.registerFactory(() => HistoryCubit(sl())); sl.registerSingleton(RatingRepository(sl())); sl.registerFactory(() => RatingCubit(sl())); + // الدردشة خلف علمها: إطفاؤه يحذف كودها من الـbinary (docs/23 §5). + if (Features.chat) { + sl.registerSingleton(ChatRepository(sl())); + sl.registerFactory(() => ChatCubit(sl())); + } + // المحفظة خلف علم الاستحقاق: الميزة المطفأة لا تُسجَّل أصلاً (docs/23 §5). if (Features.wallet) { sl.registerSingleton(WalletRepository(sl())); diff --git a/apps/driver_new/lib/core/l10n/app_ar.arb b/apps/driver_new/lib/core/l10n/app_ar.arb index 11df7c7..8159cea 100644 --- a/apps/driver_new/lib/core/l10n/app_ar.arb +++ b/apps/driver_new/lib/core/l10n/app_ar.arb @@ -188,5 +188,41 @@ "aboutVersion": "الإصدار", "notifTitle": "الإشعارات", "notifEnable": "تفعيل الإشعارات", - "notifDenied": "الإشعارات موقوفة من إعدادات النظام" + "notifDenied": "الإشعارات موقوفة من إعدادات النظام", + "chatTitle": "المحادثة", + "chatHint": "اكتب رسالة", + "chatEmpty": "لا رسائل بعد", + "rideCoupon": "كود خصم", + "rideCouponApply": "تطبيق", + "obApplyTitle": "سجّل كسائق", + "obApplyLead": "خطوات قصيرة ثم تبدأ العمل", + "obVehicleMake": "شركة السيارة", + "obServiceClass": "نوع الخدمة", + "obApplySubmit": "ابدأ التسجيل", + "obProfileTitle": "بياناتك", + "obNameArabic": "الاسم كما في الهوية", + "obNationalNumber": "الرقم الوطني", + "obGender": "الجنس", + "obGenderMale": "ذكر", + "obGenderFemale": "أنثى", + "obVehicleTitle": "مركبتك", + "obVehicleModel": "الطراز", + "obVehicleYear": "سنة الصنع", + "obVehicleColor": "اللون", + "obVehiclePlate": "رقم اللوحة", + "obDocsTitle": "وثائقك", + "obDocsLead": "صوّر كل وثيقة بوضوح — الاعتماد يتم بعد مراجعتها", + "obDocMissing": "مطلوبة", + "obDocPending": "قيد المراجعة", + "obDocApproved": "مقبولة", + "obDocRejected": "مرفوضة — أعد الرفع", + "obDocSideFront": "الوجه الأمامي", + "obDocSideBack": "الوجه الخلفي", + "obDocUpload": "رفع", + "obPendingTitle": "طلبك قيد المراجعة", + "obPendingLead": "سنُعلمك فور اعتماد حسابك. عادةً خلال يوم عمل.", + "obPendingRefresh": "تحديث الحالة", + "obApprovedTitle": "تم اعتمادك", + "obApprovedLead": "سجّل الدخول من جديد لتفعيل صلاحيات السائق", + "obRelogin": "إعادة تسجيل الدخول" } \ 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 ffdb654..d223628 100644 --- a/apps/driver_new/lib/core/l10n/app_en.arb +++ b/apps/driver_new/lib/core/l10n/app_en.arb @@ -188,5 +188,41 @@ "aboutVersion": "Version", "notifTitle": "Notifications", "notifEnable": "Enable notifications", - "notifDenied": "Notifications are disabled in system settings" + "notifDenied": "Notifications are disabled in system settings", + "chatTitle": "Chat", + "chatHint": "Type a message", + "chatEmpty": "No messages yet", + "rideCoupon": "Promo code", + "rideCouponApply": "Apply", + "obApplyTitle": "Register as a driver", + "obApplyLead": "A few short steps, then you're working", + "obVehicleMake": "Car make", + "obServiceClass": "Service class", + "obApplySubmit": "Start registration", + "obProfileTitle": "Your details", + "obNameArabic": "Name as on your ID", + "obNationalNumber": "National ID number", + "obGender": "Gender", + "obGenderMale": "Male", + "obGenderFemale": "Female", + "obVehicleTitle": "Your vehicle", + "obVehicleModel": "Model", + "obVehicleYear": "Year", + "obVehicleColor": "Colour", + "obVehiclePlate": "Plate number", + "obDocsTitle": "Your documents", + "obDocsLead": "Photograph each document clearly — approval follows review", + "obDocMissing": "Required", + "obDocPending": "Under review", + "obDocApproved": "Approved", + "obDocRejected": "Rejected — upload again", + "obDocSideFront": "Front", + "obDocSideBack": "Back", + "obDocUpload": "Upload", + "obPendingTitle": "Your application is under review", + "obPendingLead": "We'll notify you as soon as it's approved. Usually within a business day.", + "obPendingRefresh": "Refresh status", + "obApprovedTitle": "You're approved", + "obApprovedLead": "Sign in again to activate your driver permissions", + "obRelogin": "Sign in again" } \ No newline at end of file diff --git a/apps/driver_new/lib/core/router.dart b/apps/driver_new/lib/core/router.dart index 1135f09..ae5744c 100644 --- a/apps/driver_new/lib/core/router.dart +++ b/apps/driver_new/lib/core/router.dart @@ -22,6 +22,8 @@ import '../features/support/view/support_page.dart'; import '../features/trip/view/history_page.dart'; +import '../features/chat/view/chat_page.dart'; +import '../features/onboarding/view/onboarding_page.dart'; import '../features/trip/view/duty_page.dart'; import '../features/wallet/view/earnings_page.dart'; @@ -44,6 +46,8 @@ class Routes { static const profileEdit = '/account'; static const settings = '/settings'; static const support = '/support'; + static const chat = '/chat'; + static const onboarding = '/onboarding'; } /// التوجيه يُشتقّ من `SessionCubit` وحده — لا `Navigator.push` بعد الدخول @@ -66,6 +70,11 @@ final appRouter = GoRouter( // (docs/38 §7 — يُفرض عند الفتح). SessionStatus.authenticated when session.pendingRating != null => loc == Routes.rating ? null : Routes.rating, + // السائق غير المعتمد يُحجز في التسجيل: دوره لا يزال `rider` على + // الخادم، فكل نقاط السائق ترجّع 403 (docs/38 §5). + SessionStatus.authenticated + when session.user?.role != 'driver' && loc != Routes.onboarding => + Routes.onboarding, SessionStatus.authenticated => (loc == Routes.login || loc == Routes.profile || loc == Routes.splash || @@ -117,6 +126,15 @@ final appRouter = GoRouter( path: Routes.settings, builder: (context, state) => const SettingsPage(), ), + GoRoute( + path: Routes.chat, + builder: (context, state) => + ChatPage(tripId: state.uri.queryParameters['trip'] ?? ''), + ), + GoRoute( + path: Routes.onboarding, + builder: (context, state) => const OnboardingPage(), + ), GoRoute( path: Routes.support, builder: (context, state) => const SupportPage(), diff --git a/apps/driver_new/lib/core/ui/failure_text.dart b/apps/driver_new/lib/core/ui/failure_text.dart index 2b7cd48..d13b933 100644 --- a/apps/driver_new/lib/core/ui/failure_text.dart +++ b/apps/driver_new/lib/core/ui/failure_text.dart @@ -12,20 +12,20 @@ import '../l10n/l10n.dart'; /// النقطة **الوحيدة** التي تُترجَم فيها رموز الفشل إلى نصّ. /// الشاشات لا تكتب رسائل خطأ، والـCubit لا يعرف اللغة (راجع `auth_failure.dart`). -class AuthFailureText extends StatelessWidget { - const AuthFailureText({super.key, required this.failure}); +class ApiFailureText extends StatelessWidget { + const ApiFailureText({super.key, required this.failure}); - final AuthFailure failure; + final ApiFailure failure; - static String messageOf(BuildContext context, AuthFailure failure) { + static String messageOf(BuildContext context, ApiFailure failure) { final l10n = context.l10n; return switch (failure) { - AuthFailure.invalidCode => l10n.otpErrInvalid, - AuthFailure.tooManyAttempts => l10n.otpErrTooManyAttempts, - AuthFailure.rateLimited => l10n.otpErrRateLimited, - AuthFailure.network => l10n.errorNetwork, - AuthFailure.timeout => l10n.errorTimeout, - AuthFailure.server => l10n.errorGeneric, + ApiFailure.invalidCode => l10n.otpErrInvalid, + ApiFailure.tooManyAttempts => l10n.otpErrTooManyAttempts, + ApiFailure.rateLimited => l10n.otpErrRateLimited, + ApiFailure.network => l10n.errorNetwork, + ApiFailure.timeout => l10n.errorTimeout, + ApiFailure.server => l10n.errorGeneric, }; } diff --git a/apps/driver_new/lib/features/auth/cubit/login_cubit.dart b/apps/driver_new/lib/features/auth/cubit/login_cubit.dart index cf11776..90497ab 100644 --- a/apps/driver_new/lib/features/auth/cubit/login_cubit.dart +++ b/apps/driver_new/lib/features/auth/cubit/login_cubit.dart @@ -110,7 +110,7 @@ class LoginCubit extends Cubit { } on ApiException catch (e) { emit(state.copyWith( status: LoginStatus.failure, - failure: e.toAuthFailure(), + failure: e.toApiFailure(), )); } } @@ -137,7 +137,7 @@ class LoginCubit extends Cubit { } on ApiException catch (e) { emit(state.copyWith( status: LoginStatus.failure, - failure: e.toAuthFailure(), + failure: e.toApiFailure(), )); // حتى عند الرفض بحدّ المعدّل نبدأ العدّاد — وإلا ضغط المستخدم مجدداً // فوراً وعمّق الحظر. @@ -159,7 +159,7 @@ class LoginCubit extends Cubit { } on ApiException catch (e) { emit(state.copyWith( status: LoginStatus.failure, - failure: e.toAuthFailure(), + failure: e.toApiFailure(), )); } } diff --git a/apps/driver_new/lib/features/auth/cubit/login_state.dart b/apps/driver_new/lib/features/auth/cubit/login_state.dart index 79f38ef..c778d23 100644 --- a/apps/driver_new/lib/features/auth/cubit/login_state.dart +++ b/apps/driver_new/lib/features/auth/cubit/login_state.dart @@ -32,7 +32,7 @@ class LoginState extends Equatable { final String phone; final bool agreed; - final AuthFailure? failure; + final ApiFailure? failure; final PhoneError? phoneError; /// ثوانٍ متبقّية قبل السماح بإعادة الإرسال. الخادم يحدّ بثلاثة طلبات كل @@ -52,7 +52,7 @@ class LoginState extends Equatable { LoginStatus? status, String? phone, bool? agreed, - AuthFailure? failure, + ApiFailure? failure, PhoneError? phoneError, int? resendIn, bool? permissionPermanentlyDenied, diff --git a/apps/driver_new/lib/features/auth/view/otp_view.dart b/apps/driver_new/lib/features/auth/view/otp_view.dart index de0d752..8a7c883 100644 --- a/apps/driver_new/lib/features/auth/view/otp_view.dart +++ b/apps/driver_new/lib/features/auth/view/otp_view.dart @@ -67,7 +67,7 @@ class _OtpViewState extends State { onCompleted: cubit.submitOtp, ), if (state.failure != null) - AuthFailureText(failure: state.failure!), + ApiFailureText(failure: state.failure!), const SizedBox(height: Space.md), // العدّاد التنازلي ليس تجميلاً: بلا حارس تُحظر ثلاث ضغطات // المستخدمَ خمس دقائق (docs/38 §2). diff --git a/apps/driver_new/lib/features/auth/view/phone_view.dart b/apps/driver_new/lib/features/auth/view/phone_view.dart index 5752888..93d294a 100644 --- a/apps/driver_new/lib/features/auth/view/phone_view.dart +++ b/apps/driver_new/lib/features/auth/view/phone_view.dart @@ -57,10 +57,10 @@ class PhoneView extends StatelessWidget { onSubmitted: (_) => cubit.submitPhone(), errorText: state.phoneError == null ? null - : AuthFailureText.phoneMessageOf(context, state.phoneError!), + : ApiFailureText.phoneMessageOf(context, state.phoneError!), ), if (state.failure != null) - AuthFailureText(failure: state.failure!), + ApiFailureText(failure: state.failure!), ], ), ); diff --git a/apps/driver_new/lib/features/chat/cubit/chat_cubit.dart b/apps/driver_new/lib/features/chat/cubit/chat_cubit.dart new file mode 100644 index 0000000..a71266a --- /dev/null +++ b/apps/driver_new/lib/features/chat/cubit/chat_cubit.dart @@ -0,0 +1,81 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/api/api_exception.dart'; +import '../../../core/ui/view_status.dart'; +import '../data/chat_repository.dart'; + +class ChatState { + const ChatState({ + this.status = ViewStatus.idle, + this.messages = const [], + this.sending = false, + this.error, + }); + + final ViewStatus status; + final List messages; + final bool sending; + final String? error; +} + +class ChatCubit extends Cubit { + ChatCubit(this._repo) : super(const ChatState()); + + final ChatRepository _repo; + Timer? _poll; + String? _tripId; + + @override + Future close() { + _poll?.cancel(); + return super.close(); + } + + /// استطلاع كل خمس ثوانٍ — الخادم لا يبثّ الرسائل عبر السوكت + /// (راجع `ChatRepository`). يتوقّف مع إغلاق الشاشة. + Future open(String tripId) async { + _tripId = tripId; + emit(const ChatState(status: ViewStatus.loading)); + await _refresh(); + _poll = Timer.periodic(const Duration(seconds: 5), (_) => _refresh()); + } + + Future _refresh() async { + final tripId = _tripId; + if (tripId == null || isClosed) return; + try { + final messages = await _repo.messages(tripId); + if (isClosed) return; + emit(ChatState( + status: ViewStatus.success, + messages: messages, + sending: state.sending, + )); + } on ApiException catch (e) { + // فشل الاستطلاع لا يمسح ما وصل: الرسائل المعروضة تبقى. + if (!isClosed && state.messages.isEmpty) { + emit(ChatState(status: ViewStatus.error, error: e.message)); + } + } + } + + Future send(String body) async { + final tripId = _tripId; + if (tripId == null || body.trim().isEmpty) return; + emit(ChatState( + status: state.status, + messages: state.messages, + sending: true, + )); + try { + await _repo.send(tripId, body.trim()); + await _refresh(); + } on ApiException { + if (!isClosed) { + emit(ChatState(status: state.status, messages: state.messages)); + } + } + } +} diff --git a/apps/driver_new/lib/features/chat/data/chat_repository.dart b/apps/driver_new/lib/features/chat/data/chat_repository.dart new file mode 100644 index 0000000..a54749a --- /dev/null +++ b/apps/driver_new/lib/features/chat/data/chat_repository.dart @@ -0,0 +1,46 @@ +import '../../../core/api/api_client.dart'; + +class ChatMessage { + const ChatMessage({ + required this.id, + required this.body, + required this.senderId, + this.createdAt, + }); + + final String id; + final String body; + final String senderId; + final DateTime? createdAt; + + factory ChatMessage.fromJson(Map json) => ChatMessage( + id: json['id']?.toString() ?? '', + body: json['body'] as String? ?? json['message'] as String? ?? '', + senderId: json['sender_id']?.toString() ?? '', + createdAt: DateTime.tryParse(json['created_at']?.toString() ?? ''), + ); +} + +/// دردشة الرحلة (docs/38 §11). +/// +/// ⚠️ **لا حدث سوكت للرسائل**: قائمة أحداث الخادم لا تتضمّن الدردشة +/// (docs/38 §9)، فالتحديث **باستطلاع دوري** لا ببثّ. هذا قيد الخادم لا +/// اختيار تصميمي — ويُلغى فور إضافة الحدث. +class ChatRepository { + ChatRepository(this._api); + + final ApiClient _api; + + Future> messages(String tripId) async { + final res = await _api.get>('/trips/$tripId/messages'); + return res + .whereType>() + .map(ChatMessage.fromJson) + .toList(growable: false); + } + + Future send(String tripId, String body) => _api.post>( + '/trips/$tripId/messages', + body: {'body': body}, + ); +} diff --git a/apps/driver_new/lib/features/chat/view/chat_page.dart b/apps/driver_new/lib/features/chat/view/chat_page.dart new file mode 100644 index 0000000..1488e4b --- /dev/null +++ b/apps/driver_new/lib/features/chat/view/chat_page.dart @@ -0,0 +1,114 @@ +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/session/session_cubit.dart'; +import '../../../core/ui/empty_view.dart'; +import '../../../core/ui/tripz_scaffold.dart'; +import '../../../core/ui/tripz_text_field.dart'; +import '../cubit/chat_cubit.dart'; +import '../data/chat_repository.dart'; + +class ChatPage extends StatefulWidget { + const ChatPage({super.key, required this.tripId}); + + final String tripId; + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State { + late final ChatCubit _cubit = sl()..open(widget.tripId); + final _input = TextEditingController(); + + @override + void dispose() { + _input.dispose(); + _cubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final myId = sl().state.user?.id; + + return BlocProvider.value( + value: _cubit, + child: BlocBuilder( + builder: (context, state) => TripzScaffold( + title: l10n.chatTitle, + status: state.status, + error: state.error, + bottomAction: TripzTextField( + controller: _input, + hint: l10n.chatHint, + textInputAction: TextInputAction.send, + onSubmitted: (v) { + _cubit.send(v); + _input.clear(); + }, + suffix: IconButton( + icon: const Icon(Icons.send_rounded), + onPressed: () { + _cubit.send(_input.text); + _input.clear(); + }, + ), + ), + child: state.messages.isEmpty + ? EmptyView( + icon: Icons.forum_outlined, + message: l10n.chatEmpty, + ) + : ListView.builder( + // الأحدث أسفل، والقائمة مقلوبة كي تبقى عند آخر رسالة. + reverse: true, + itemCount: state.messages.length, + itemBuilder: (context, i) { + final msg = state.messages[state.messages.length - 1 - i]; + return _Bubble(msg: msg, mine: msg.senderId == myId); + }, + ), + ), + ), + ); + } +} + +class _Bubble extends StatelessWidget { + const _Bubble({required this.msg, required this.mine}); + + final ChatMessage msg; + final bool mine; + + @override + Widget build(BuildContext context) { + return Align( + alignment: + mine ? AlignmentDirectional.centerEnd : AlignmentDirectional.centerStart, + child: Container( + margin: const EdgeInsets.symmetric(vertical: Space.xxs), + padding: const EdgeInsets.symmetric( + horizontal: Space.sm, + vertical: Space.xs, + ), + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.75, + ), + decoration: BoxDecoration( + color: mine + ? context.colors.primaryContainer + : context.tripzColors.surfaceRaised, + borderRadius: Radii.card_, + border: mine ? null : Border.all(color: context.colors.outlineVariant), + ), + child: Text(msg.body, style: context.texts.bodyMedium), + ), + ); + } +} diff --git a/apps/driver_new/lib/features/onboarding/cubit/onboarding_cubit.dart b/apps/driver_new/lib/features/onboarding/cubit/onboarding_cubit.dart new file mode 100644 index 0000000..9822a6a --- /dev/null +++ b/apps/driver_new/lib/features/onboarding/cubit/onboarding_cubit.dart @@ -0,0 +1,123 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/api/api_exception.dart'; +import '../../../core/api/api_failure.dart'; +import '../data/driver_repository.dart'; +import 'onboarding_state.dart'; + +/// تسجيل السائق حتى الاعتماد. +/// +/// **الخطوة تُشتقّ من حالة الخادم لا من تقدّم محلّي**: سائق يعيد تثبيت +/// التطبيق يجب أن يعود إلى حيث وقف، لا أن يبدأ من الصفر. +class OnboardingCubit extends Cubit { + OnboardingCubit(this._repo) : super(const OnboardingState()); + + final DriverRepository _repo; + + Future load() async { + emit(state.copyWith(step: OnboardingStep.loading, clearFailure: true)); + try { + final driver = await _repo.me(); + if (isClosed) return; + + if (driver == null) { + emit(state.copyWith(step: OnboardingStep.apply, driver: null)); + return; + } + if (driver.isApproved) { + emit(state.copyWith(step: OnboardingStep.approved, driver: driver)); + return; + } + + // الملف ناقص ما لم يُدخل الاسم العربي — أول ما يطلبه الخادم. + if (driver.nameArabic == null || driver.nameArabic!.isEmpty) { + emit(state.copyWith(step: OnboardingStep.profile, driver: driver)); + return; + } + + final vehicles = await _repo.vehicles(); + if (isClosed) return; + if (vehicles.isEmpty) { + emit(state.copyWith( + step: OnboardingStep.vehicle, + driver: driver, + vehicles: vehicles, + )); + return; + } + + final reqs = await _repo.requirements(); + if (isClosed) return; + emit(state.copyWith( + // اكتملت الوثائق ولم يُعتمد بعد: مراجعة بشرية جارية. + step: reqs.complete ? OnboardingStep.pending : OnboardingStep.documents, + driver: driver, + vehicles: vehicles, + requirements: reqs.items, + )); + } on ApiException catch (e) { + if (!isClosed) { + emit(state.copyWith( + step: OnboardingStep.apply, + failure: e.toApiFailure(), + )); + } + } + } + + Future apply({required String vehicleMake, required String serviceClass}) => + _run(() => _repo.apply(vehicleMake: vehicleMake, serviceClass: serviceClass)); + + Future saveProfile({ + required String nameArabic, + required String nationalNumber, + String? gender, + }) => + _run(() => _repo.setProfile( + nameArabic: nameArabic, + nationalNumber: nationalNumber, + gender: gender, + )); + + Future addVehicle({ + required String make, + required String model, + required int year, + required String color, + required String plate, + }) => + _run(() => _repo.addVehicle( + make: make, + model: model, + year: year, + color: color, + plate: plate, + )); + + Future uploadDocument({ + required String filePath, + required String type, + required String side, + }) => + _run(() => _repo.uploadDocument( + filePath: filePath, + type: type, + side: side, + )); + + /// كل فعل ينتهي بإعادة قراءة الحالة من الخادم — فلا يتقدّم التطبيق خطوةً + /// لم يقبلها الخادم فعلاً. + Future _run(Future Function() action) async { + emit(state.copyWith(busy: true, clearFailure: true)); + try { + await action(); + if (isClosed) return; + emit(state.copyWith(busy: false)); + await load(); + } on ApiException catch (e) { + if (!isClosed) { + emit(state.copyWith(busy: false, failure: e.toApiFailure())); + } + } + } +} diff --git a/apps/driver_new/lib/features/onboarding/cubit/onboarding_state.dart b/apps/driver_new/lib/features/onboarding/cubit/onboarding_state.dart new file mode 100644 index 0000000..e3c99fa --- /dev/null +++ b/apps/driver_new/lib/features/onboarding/cubit/onboarding_state.dart @@ -0,0 +1,51 @@ +import 'package:equatable/equatable.dart'; + +import '../../../core/api/api_failure.dart'; +import '../data/models/document_requirement.dart'; +import '../data/models/driver_profile.dart'; +import '../data/models/vehicle.dart'; + +/// خطوات تسجيل السائق بالترتيب الميداني (docs/39 §2): تقديم ← ملف ← +/// مركبة ← وثائق ← انتظار الاعتماد. +enum OnboardingStep { loading, apply, profile, vehicle, documents, pending, approved } + +class OnboardingState extends Equatable { + const OnboardingState({ + this.step = OnboardingStep.loading, + this.driver, + this.vehicles = const [], + this.requirements = const [], + this.busy = false, + this.failure, + }); + + final OnboardingStep step; + final DriverProfile? driver; + final List vehicles; + final List requirements; + final bool busy; + final ApiFailure? failure; + + OnboardingState copyWith({ + OnboardingStep? step, + DriverProfile? driver, + List? vehicles, + List? requirements, + bool? busy, + ApiFailure? failure, + bool clearFailure = false, + }) { + return OnboardingState( + step: step ?? this.step, + driver: driver ?? this.driver, + vehicles: vehicles ?? this.vehicles, + requirements: requirements ?? this.requirements, + busy: busy ?? this.busy, + failure: clearFailure ? null : (failure ?? this.failure), + ); + } + + @override + List get props => + [step, driver, vehicles, requirements, busy, failure]; +} diff --git a/apps/driver_new/lib/features/onboarding/data/driver_repository.dart b/apps/driver_new/lib/features/onboarding/data/driver_repository.dart new file mode 100644 index 0000000..18a6042 --- /dev/null +++ b/apps/driver_new/lib/features/onboarding/data/driver_repository.dart @@ -0,0 +1,108 @@ +import '../../../core/api/api_client.dart'; +import '../../../core/api/api_exception.dart'; +import 'models/document_requirement.dart'; +import 'models/driver_profile.dart'; +import 'models/vehicle.dart'; + +class DriverRepository { + DriverRepository(this._api); + + final ApiClient _api; + + /// `null` حين لا سجلّ سائق بعد — 404 هنا حالة طبيعية لا عطل. + Future me() async { + try { + final res = await _api.get>('/drivers/me'); + return DriverProfile.fromJson(res); + } on ApiException catch (e) { + if (e.statusCode == 404) return null; + rethrow; + } + } + + Future apply({ + required String vehicleMake, + required String serviceClass, + }) async { + final res = await _api.post>('/drivers/apply', body: { + 'vehicle_make': vehicleMake, + 'service_class': serviceClass, + }); + return DriverProfile.fromJson(res); + } + + Future setProfile({ + required String nameArabic, + required String nationalNumber, + String? gender, + String? birthdate, + String? address, + String? licenseType, + String? licenseExpiry, + }) { + return _api.patch>('/drivers/profile', body: { + 'name_arabic': nameArabic, + 'national_number': nationalNumber, + 'gender': ?gender, + 'birthdate': ?birthdate, + 'address': ?address, + 'license_type': ?licenseType, + 'license_expiry': ?licenseExpiry, + }); + } + + Future> vehicles() async { + final res = await _api.get>('/vehicles/mine'); + return res + .whereType>() + .map(Vehicle.fromJson) + .toList(growable: false); + } + + Future addVehicle({ + required String make, + required String model, + required int year, + required String color, + required String plate, + }) async { + final res = await _api.post>('/vehicles', body: { + 'make': make, + 'model': model, + 'year': year, + 'color': color, + 'plate': plate, + }); + return Vehicle.fromJson(res); + } + + Future<({bool complete, List items})> requirements() async { + final res = await _api.get>( + '/drivers/documents/requirements', + ); + final raw = res['items']; + return ( + complete: res['complete'] as bool? ?? false, + items: raw is List + ? raw + .whereType>() + .map(DocumentRequirement.fromJson) + .toList(growable: false) + : const [], + ); + } + + /// رفع وثيقة. الاعتماد **يقع على الخادم تلقائياً** حين تكتمل كل الوثائق — + /// التطبيق لا يعتمد أحداً. + Future uploadDocument({ + required String filePath, + required String type, + required String side, + }) { + return _api.upload>( + '/drivers/documents', + filePath: filePath, + fields: {'type': type, 'side': side}, + ); + } +} diff --git a/apps/driver_new/lib/features/onboarding/data/models/document_requirement.dart b/apps/driver_new/lib/features/onboarding/data/models/document_requirement.dart new file mode 100644 index 0000000..59d4704 --- /dev/null +++ b/apps/driver_new/lib/features/onboarding/data/models/document_requirement.dart @@ -0,0 +1,39 @@ +import 'package:equatable/equatable.dart'; + +/// وثيقة مطلوبة وحالتها — من `GET /drivers/documents/requirements`. +/// الخادم يعتمد السائق **تلقائياً** حين تصير كلها `approved`. +class DocumentRequirement extends Equatable { + const DocumentRequirement({ + required this.type, + required this.nameAr, + required this.side, + required this.status, + this.documentId, + }); + + final String type; + final String nameAr; + + /// وجه المستند: `front` أو `back` — بعضها يحتاج الوجهين. + final String side; + + /// `missing` · `pending` · `approved` · `rejected`. + final String status; + + final String? documentId; + + factory DocumentRequirement.fromJson(Map json) => + DocumentRequirement( + type: json['type'] as String? ?? '', + nameAr: json['name_ar'] as String? ?? '', + side: json['side'] as String? ?? 'front', + status: json['status'] as String? ?? 'missing', + documentId: json['document_id'] as String?, + ); + + bool get isMissing => status == 'missing' || status == 'rejected'; + bool get isApproved => status == 'approved'; + + @override + List get props => [type, side, status]; +} diff --git a/apps/driver_new/lib/features/onboarding/data/models/driver_profile.dart b/apps/driver_new/lib/features/onboarding/data/models/driver_profile.dart new file mode 100644 index 0000000..8ba5f8f --- /dev/null +++ b/apps/driver_new/lib/features/onboarding/data/models/driver_profile.dart @@ -0,0 +1,48 @@ +import 'package:equatable/equatable.dart'; + +/// حالة اعتماد السائق كما يعرّفها الخادم: `pending` · `approved` · `rejected`. +enum VerificationStatus { + pending, + approved, + rejected, + + /// لا سجلّ سائق بعد — لم يقدّم أصلاً. + none; + + static VerificationStatus parse(String? raw) => switch (raw) { + 'approved' => VerificationStatus.approved, + 'rejected' => VerificationStatus.rejected, + 'pending' => VerificationStatus.pending, + _ => VerificationStatus.none, + }; +} + +class DriverProfile extends Equatable { + const DriverProfile({ + required this.id, + required this.verification, + this.serviceClass = 'economy', + this.nameArabic, + this.online = false, + }); + + final String id; + final VerificationStatus verification; + final String serviceClass; + final String? nameArabic; + final bool online; + + factory DriverProfile.fromJson(Map json) => DriverProfile( + id: json['id'] as String? ?? '', + verification: + VerificationStatus.parse(json['verification_status'] as String?), + serviceClass: json['service_class'] as String? ?? 'economy', + nameArabic: json['name_arabic'] as String?, + online: json['online'] as bool? ?? false, + ); + + bool get isApproved => verification == VerificationStatus.approved; + + @override + List get props => [id, verification, serviceClass, online]; +} diff --git a/apps/driver_new/lib/features/onboarding/data/models/vehicle.dart b/apps/driver_new/lib/features/onboarding/data/models/vehicle.dart new file mode 100644 index 0000000..7fb84c9 --- /dev/null +++ b/apps/driver_new/lib/features/onboarding/data/models/vehicle.dart @@ -0,0 +1,45 @@ +import 'package:equatable/equatable.dart'; + +class Vehicle extends Equatable { + const Vehicle({ + required this.id, + this.make, + this.model, + this.year, + this.color, + this.plate, + this.isDefault = false, + this.status = 'pending', + }); + + final String id; + final String? make; + final String? model; + final int? year; + final String? color; + final String? plate; + + /// أول مركبة تصير الافتراضية تلقائياً على الخادم (docs/38 §5). + final bool isDefault; + + final String status; + + factory Vehicle.fromJson(Map json) => Vehicle( + id: json['id'] as String? ?? '', + make: json['make'] as String?, + model: json['model'] as String?, + year: (json['year'] as num?)?.toInt(), + color: json['color'] as String?, + plate: json['plate'] as String?, + isDefault: json['is_default'] as bool? ?? false, + status: json['status'] as String? ?? 'pending', + ); + + String get label => [make, model, year?.toString()] + .whereType() + .where((s) => s.isNotEmpty) + .join(' '); + + @override + List get props => [id, plate, isDefault, status]; +} diff --git a/apps/driver_new/lib/features/onboarding/view/onboarding_page.dart b/apps/driver_new/lib/features/onboarding/view/onboarding_page.dart new file mode 100644 index 0000000..fe657b6 --- /dev/null +++ b/apps/driver_new/lib/features/onboarding/view/onboarding_page.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/di.dart'; +import '../../../core/ui/view_status.dart'; +import '../../../core/ui/tripz_scaffold.dart'; +import '../cubit/onboarding_cubit.dart'; +import '../cubit/onboarding_state.dart'; +import 'steps/apply_step.dart'; +import 'steps/documents_step.dart'; +import 'steps/pending_step.dart'; +import 'steps/profile_step.dart'; +import 'steps/vehicle_step.dart'; + +/// مضيف تسجيل السائق. الخطوة تُشتقّ من حالة الخادم في كل مرة — سائق يعيد +/// تثبيت التطبيق يعود إلى حيث وقف لا إلى البداية. +class OnboardingPage extends StatefulWidget { + const OnboardingPage({super.key}); + + @override + State createState() => _OnboardingPageState(); +} + +class _OnboardingPageState extends State { + late final OnboardingCubit _cubit = sl()..load(); + + @override + void dispose() { + _cubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cubit, + child: BlocBuilder( + builder: (context, state) => switch (state.step) { + OnboardingStep.loading => const TripzScaffold( + showAppBar: false, + status: ViewStatus.loading, + child: SizedBox.shrink(), + ), + OnboardingStep.apply => ApplyStep(state: state), + OnboardingStep.profile => ProfileStep(state: state), + OnboardingStep.vehicle => VehicleStep(state: state), + OnboardingStep.documents => DocumentsStep(state: state), + OnboardingStep.pending => const PendingStep(approved: false), + OnboardingStep.approved => const PendingStep(approved: true), + }, + ), + ); + } +} diff --git a/apps/driver_new/lib/features/onboarding/view/steps/apply_step.dart b/apps/driver_new/lib/features/onboarding/view/steps/apply_step.dart new file mode 100644 index 0000000..2e48f5c --- /dev/null +++ b/apps/driver_new/lib/features/onboarding/view/steps/apply_step.dart @@ -0,0 +1,64 @@ +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/form_header.dart'; +import '../../../../core/ui/tripz_button.dart'; +import '../../../../core/ui/tripz_scaffold.dart'; +import '../../../../core/ui/tripz_text_field.dart'; +import '../../cubit/onboarding_cubit.dart'; +import '../../cubit/onboarding_state.dart'; + +class ApplyStep extends StatefulWidget { + const ApplyStep({super.key, required this.state}); + + final OnboardingState state; + + @override + State createState() => _ApplyStepState(); +} + +class _ApplyStepState extends State { + final _make = TextEditingController(); + + @override + void dispose() { + _make.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final cubit = context.read(); + return TripzScaffold( + showAppBar: false, + bottomAction: TripzButton.primary( + label: l10n.obApplySubmit, + loading: widget.state.busy, + onPressed: () => cubit.apply( + vehicleMake: _make.text.trim(), + // فئة الخدمة الافتراضية؛ يعدّلها الأدمن لاحقاً حسب المركبة المعتمدة. + serviceClass: 'economy', + ), + ), + child: ListView( + children: [ + const SizedBox(height: Space.xl), + FormHeader( + icon: Icons.badge_outlined, + title: l10n.obApplyTitle, + lead: l10n.obApplyLead, + ), + const SizedBox(height: Space.lg), + TripzTextField( + controller: _make, + label: l10n.obVehicleMake, + autofocus: true, + ), + ], + ), + ); + } +} diff --git a/apps/driver_new/lib/features/onboarding/view/steps/documents_step.dart b/apps/driver_new/lib/features/onboarding/view/steps/documents_step.dart new file mode 100644 index 0000000..722f32b --- /dev/null +++ b/apps/driver_new/lib/features/onboarding/view/steps/documents_step.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/l10n/l10n.dart'; +import '../../../../core/ui/form_header.dart'; +import '../../../../core/ui/tripz_card.dart'; +import '../../../../core/ui/tripz_scaffold.dart'; +import '../../cubit/onboarding_cubit.dart'; +import '../../cubit/onboarding_state.dart'; +import '../../data/models/document_requirement.dart'; + +/// قائمة الوثائق المطلوبة وحالتها. **الخادم يعتمد السائق تلقائياً** حين +/// تصير كلها `approved` — التطبيق لا يعتمد أحداً ولا يخمّن الاكتمال. +class DocumentsStep extends StatelessWidget { + const DocumentsStep({super.key, required this.state}); + + final OnboardingState state; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return TripzScaffold( + showAppBar: false, + child: ListView( + children: [ + const SizedBox(height: Space.xl), + FormHeader( + icon: Icons.folder_open_outlined, + title: l10n.obDocsTitle, + lead: l10n.obDocsLead, + ), + const SizedBox(height: Space.lg), + for (final req in state.requirements) ...[ + _DocTile(req: req, busy: state.busy), + const SizedBox(height: Space.xs), + ], + ], + ), + ); + } +} + +class _DocTile extends StatelessWidget { + const _DocTile({required this.req, required this.busy}); + + final DocumentRequirement req; + final bool busy; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final colors = context.tripzColors; + + final (label, color) = switch (req.status) { + 'approved' => (l10n.obDocApproved, colors.success), + 'pending' => (l10n.obDocPending, colors.warning), + 'rejected' => (l10n.obDocRejected, colors.danger), + _ => (l10n.obDocMissing, context.colors.onSurfaceVariant), + }; + + return TripzCard( + onTap: req.isMissing && !busy ? () => _pick(context) : null, + child: Row( + children: [ + Icon( + req.isApproved + ? Icons.check_circle_rounded + : Icons.upload_file_outlined, + color: color, + ), + const SizedBox(width: Space.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(req.nameAr, style: context.texts.bodyMedium), + Text( + '${req.side == 'back' ? l10n.obDocSideBack : l10n.obDocSideFront} · $label', + style: context.texts.bodySmall?.copyWith(color: color), + ), + ], + ), + ), + if (req.isMissing) + Text( + l10n.obDocUpload, + style: context.texts.labelLarge?.copyWith( + color: context.colors.primary, + ), + ), + ], + ), + ); + } + + Future _pick(BuildContext context) async { + final cubit = context.read(); + // الكاميرا لا المعرض: صورة وثيقة تُلتقط الآن أصعب تزويراً من ملف محفوظ. + final file = await ImagePicker().pickImage( + source: ImageSource.camera, + imageQuality: 80, + maxWidth: 1600, + ); + if (file == null) return; + await cubit.uploadDocument( + filePath: file.path, + type: req.type, + side: req.side, + ); + } +} diff --git a/apps/driver_new/lib/features/onboarding/view/steps/pending_step.dart b/apps/driver_new/lib/features/onboarding/view/steps/pending_step.dart new file mode 100644 index 0000000..a60ad4b --- /dev/null +++ b/apps/driver_new/lib/features/onboarding/view/steps/pending_step.dart @@ -0,0 +1,58 @@ +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/session/session_cubit.dart'; +import '../../../../core/ui/form_header.dart'; +import '../../../../core/ui/tripz_button.dart'; +import '../../../../core/ui/tripz_scaffold.dart'; +import '../../cubit/onboarding_cubit.dart'; + +/// انتظار المراجعة، أو الاعتماد. +/// +/// عند الاعتماد **لا بد من إعادة دخول**: الدور يتغيّر على الخادم بعد +/// `approve`، والتوكن القديم ما زال يحمل الدور القديم — فتفشل نقاط السائق +/// بـ403 (مصيدة docs/38 §5). فالخروج هنا فعلٌ مقصود لا خطأ. +class PendingStep extends StatelessWidget { + const PendingStep({super.key, required this.approved}); + + final bool approved; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return TripzScaffold( + showAppBar: false, + bottomAction: approved + ? TripzButton.primary( + label: l10n.obRelogin, + onPressed: context.read().logout, + ) + : TripzButton.secondary( + label: l10n.obPendingRefresh, + expanded: true, + onPressed: context.read().load, + ), + child: ListView( + children: [ + const SizedBox(height: Space.xxl), + Icon( + approved ? Icons.verified_rounded : Icons.hourglass_top_rounded, + size: 56, + color: approved + ? context.tripzColors.success + : context.tripzColors.warning, + ), + const SizedBox(height: Space.md), + FormHeader( + icon: approved ? Icons.verified_outlined : Icons.schedule_rounded, + title: approved ? l10n.obApprovedTitle : l10n.obPendingTitle, + lead: approved ? l10n.obApprovedLead : l10n.obPendingLead, + ), + ], + ), + ); + } +} diff --git a/apps/driver_new/lib/features/onboarding/view/steps/profile_step.dart b/apps/driver_new/lib/features/onboarding/view/steps/profile_step.dart new file mode 100644 index 0000000..ed1d81f --- /dev/null +++ b/apps/driver_new/lib/features/onboarding/view/steps/profile_step.dart @@ -0,0 +1,95 @@ +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/form_header.dart'; +import '../../../../core/ui/tripz_button.dart'; +import '../../../../core/ui/tripz_scaffold.dart'; +import '../../../../core/ui/tripz_text_field.dart'; +import '../../cubit/onboarding_cubit.dart'; +import '../../cubit/onboarding_state.dart'; + +class ProfileStep extends StatefulWidget { + const ProfileStep({super.key, required this.state}); + + final OnboardingState state; + + @override + State createState() => _ProfileStepState(); +} + +class _ProfileStepState extends State { + final _name = TextEditingController(); + final _national = TextEditingController(); + String _gender = 'male'; + + @override + void dispose() { + _name.dispose(); + _national.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final cubit = context.read(); + return TripzScaffold( + showAppBar: false, + bottomAction: TripzButton.primary( + label: l10n.actionContinue, + loading: widget.state.busy, + onPressed: () => cubit.saveProfile( + nameArabic: _name.text.trim(), + nationalNumber: _national.text.trim(), + gender: _gender, + ), + ), + child: ListView( + children: [ + const SizedBox(height: Space.xl), + FormHeader( + icon: Icons.person_outline_rounded, + title: l10n.obProfileTitle, + lead: l10n.obApplyLead, + ), + const SizedBox(height: Space.lg), + TripzTextField( + controller: _name, + label: l10n.obNameArabic, + autofocus: true, + ), + const SizedBox(height: Space.md), + // الرقم الوطني يُشفَّر على الخادم مع فهرس أعمى — يُدخل مرة واحدة. + TripzTextField( + controller: _national, + label: l10n.obNationalNumber, + numeric: true, + keyboardType: TextInputType.number, + ), + const SizedBox(height: Space.md), + Text(l10n.obGender, style: Theme.of(context).textTheme.titleSmall), + RadioGroup( + groupValue: _gender, + onChanged: (v) => setState(() => _gender = v ?? 'male'), + child: Column( + children: [ + RadioListTile( + value: 'male', + contentPadding: EdgeInsets.zero, + title: Text(l10n.obGenderMale), + ), + RadioListTile( + value: 'female', + contentPadding: EdgeInsets.zero, + title: Text(l10n.obGenderFemale), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/driver_new/lib/features/onboarding/view/steps/vehicle_step.dart b/apps/driver_new/lib/features/onboarding/view/steps/vehicle_step.dart new file mode 100644 index 0000000..6981921 --- /dev/null +++ b/apps/driver_new/lib/features/onboarding/view/steps/vehicle_step.dart @@ -0,0 +1,82 @@ +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/form_header.dart'; +import '../../../../core/ui/tripz_button.dart'; +import '../../../../core/ui/tripz_scaffold.dart'; +import '../../../../core/ui/tripz_text_field.dart'; +import '../../cubit/onboarding_cubit.dart'; +import '../../cubit/onboarding_state.dart'; + +class VehicleStep extends StatefulWidget { + const VehicleStep({super.key, required this.state}); + + final OnboardingState state; + + @override + State createState() => _VehicleStepState(); +} + +class _VehicleStepState extends State { + final _make = TextEditingController(); + final _model = TextEditingController(); + final _year = TextEditingController(); + final _color = TextEditingController(); + final _plate = TextEditingController(); + + @override + void dispose() { + for (final c in [_make, _model, _year, _color, _plate]) { + c.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final cubit = context.read(); + return TripzScaffold( + showAppBar: false, + bottomAction: TripzButton.primary( + label: l10n.actionContinue, + loading: widget.state.busy, + onPressed: () => cubit.addVehicle( + make: _make.text.trim(), + model: _model.text.trim(), + year: int.tryParse(_year.text.trim()) ?? 0, + color: _color.text.trim(), + plate: _plate.text.trim(), + ), + ), + child: ListView( + children: [ + const SizedBox(height: Space.xl), + FormHeader( + icon: Icons.directions_car_outlined, + title: l10n.obVehicleTitle, + lead: l10n.obApplyLead, + ), + const SizedBox(height: Space.lg), + TripzTextField(controller: _make, label: l10n.obVehicleMake), + const SizedBox(height: Space.md), + TripzTextField(controller: _model, label: l10n.obVehicleModel), + const SizedBox(height: Space.md), + TripzTextField( + controller: _year, + label: l10n.obVehicleYear, + numeric: true, + keyboardType: TextInputType.number, + ), + const SizedBox(height: Space.md), + TripzTextField(controller: _color, label: l10n.obVehicleColor), + const SizedBox(height: Space.md), + // اللوحة تُشفَّر على الخادم — تُدخل كما هي على المركبة. + TripzTextField(controller: _plate, label: l10n.obVehiclePlate), + ], + ), + ); + } +} diff --git a/apps/driver_new/lib/features/profile/cubit/profile_cubit.dart b/apps/driver_new/lib/features/profile/cubit/profile_cubit.dart index abeb9d8..3fdfef9 100644 --- a/apps/driver_new/lib/features/profile/cubit/profile_cubit.dart +++ b/apps/driver_new/lib/features/profile/cubit/profile_cubit.dart @@ -20,13 +20,13 @@ class ProfileState { final ViewStatus status; final bool saving; - final AuthFailure? failure; + final ApiFailure? failure; final AppUser? user; ProfileState copyWith({ ViewStatus? status, bool? saving, - AuthFailure? failure, + ApiFailure? failure, AppUser? user, bool clearFailure = false, }) { @@ -53,7 +53,7 @@ class ProfileCubit extends Cubit { emit(state.copyWith(saving: false, user: user)); return user; } on ApiException catch (e) { - emit(state.copyWith(saving: false, failure: e.toAuthFailure())); + emit(state.copyWith(saving: false, failure: e.toApiFailure())); return null; } } diff --git a/apps/driver_new/lib/features/profile/view/profile_setup_page.dart b/apps/driver_new/lib/features/profile/view/profile_setup_page.dart index a5c5ca4..bcb34ab 100644 --- a/apps/driver_new/lib/features/profile/view/profile_setup_page.dart +++ b/apps/driver_new/lib/features/profile/view/profile_setup_page.dart @@ -74,7 +74,7 @@ class _ProfileSetupPageState extends State { onSubmitted: (_) => _submit(context), ), if (state.failure != null) - AuthFailureText(failure: state.failure!), + ApiFailureText(failure: state.failure!), ], ), ); 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 index 7e57233..da3ff32 100644 --- 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 @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import '../../../../core/design/tokens.dart'; import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/build_config.dart'; import '../../../../core/l10n/l10n.dart'; +import '../../../../core/router.dart'; +import '../../../../core/tenant/feature_gate.dart'; import '../../../../core/ui/tripz_button.dart'; import '../../cubit/duty_cubit.dart'; import '../../data/models/trip.dart'; @@ -19,6 +23,7 @@ class ActiveDutySheet extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; final cubit = context.read(); return Column( @@ -31,6 +36,15 @@ class ActiveDutySheet extends StatelessWidget { // السائق يرى **أرباحه** لا أجرة الراكب: حقلان منفصلان والفرق عمولة. FareSummary(trip: trip, forDriver: true), ], + if (Features.chat && context.tenantHas('chat')) ...[ + const SizedBox(height: Space.sm), + TripzButton.secondary( + label: l10n.chatTitle, + icon: Icons.forum_outlined, + expanded: true, + onPressed: () => context.push('${Routes.chat}?trip=\${trip.id}'), + ), + ], const SizedBox(height: Space.md), TripzButton.primary( label: _actionLabel(context, cubit), diff --git a/apps/driver_new/pubspec.lock b/apps/driver_new/pubspec.lock index 45028b2..d4e2a5f 100644 --- a/apps/driver_new/pubspec.lock +++ b/apps/driver_new/pubspec.lock @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" crypto: dependency: transitive description: @@ -169,6 +177,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" firebase_core: dependency: "direct main" description: @@ -251,6 +291,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_secure_storage: dependency: "direct main" description: @@ -429,6 +477,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f + url: "https://pub.dev" + source: hosted + version: "0.8.13+17" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intaleq_maps: dependency: "direct main" description: diff --git a/apps/driver_new/pubspec.yaml b/apps/driver_new/pubspec.yaml index 116e270..5d75541 100644 --- a/apps/driver_new/pubspec.yaml +++ b/apps/driver_new/pubspec.yaml @@ -40,6 +40,9 @@ dependencies: firebase_core: ^4.4.0 firebase_messaging: ^16.1.1 + # تسجيل السائق: تصوير الوثائق + image_picker: ^1.2.1 + # م3: الأذونات وبصمة الجهاز permission_handler: ^12.0.1 device_info_plus: ^12.3.0 diff --git a/apps/driver_new/tool/driver_l10n_extra.py b/apps/driver_new/tool/driver_l10n_extra.py new file mode 100644 index 0000000..603c509 --- /dev/null +++ b/apps/driver_new/tool/driver_l10n_extra.py @@ -0,0 +1,93 @@ +"""مفاتيح ترجمة تخصّ تطبيق السائق وحده. + +التطبيقان توأمان (docs/23 §0.1) ونصوصهما المشتركة تُزامَن بـ`rsync` من +الراكب — وهو ينسخ ملفَّي ARB كما هما فيمحو ما يخصّ السائق. لذلك يُعاد +تشغيل هذا السكربت بعد كل مزامنة، ثم `flutter gen-l10n`. +""" +import json +import os + +BASE = os.path.join(os.path.dirname(__file__), '..', 'lib', 'core', 'l10n') + +EXTRA = { + 'ar': { + "obApplyTitle": "سجّل كسائق", + "obApplyLead": "خطوات قصيرة ثم تبدأ العمل", + "obVehicleMake": "شركة السيارة", + "obServiceClass": "نوع الخدمة", + "obApplySubmit": "ابدأ التسجيل", + "obProfileTitle": "بياناتك", + "obNameArabic": "الاسم كما في الهوية", + "obNationalNumber": "الرقم الوطني", + "obGender": "الجنس", + "obGenderMale": "ذكر", + "obGenderFemale": "أنثى", + "obVehicleTitle": "مركبتك", + "obVehicleModel": "الطراز", + "obVehicleYear": "سنة الصنع", + "obVehicleColor": "اللون", + "obVehiclePlate": "رقم اللوحة", + "obDocsTitle": "وثائقك", + "obDocsLead": "صوّر كل وثيقة بوضوح — الاعتماد يتم بعد مراجعتها", + "obDocMissing": "مطلوبة", + "obDocPending": "قيد المراجعة", + "obDocApproved": "مقبولة", + "obDocRejected": "مرفوضة — أعد الرفع", + "obDocSideFront": "الوجه الأمامي", + "obDocSideBack": "الوجه الخلفي", + "obDocUpload": "رفع", + "obPendingTitle": "طلبك قيد المراجعة", + "obPendingLead": "سنُعلمك فور اعتماد حسابك. عادةً خلال يوم عمل.", + "obPendingRefresh": "تحديث الحالة", + "obApprovedTitle": "تم اعتمادك", + "obApprovedLead": "سجّل الدخول من جديد لتفعيل صلاحيات السائق", + "obRelogin": "إعادة تسجيل الدخول", + "phoneTitle": "أهلاً بك، كابتن", + "profileLead": "اسمك يظهر للراكب عند الرحلة.", + "historyEmpty": "لا رحلات بعد — اتصل لتصلك الطلبات", + }, + 'en': { + "obApplyTitle": "Register as a driver", + "obApplyLead": "A few short steps, then you're working", + "obVehicleMake": "Car make", + "obServiceClass": "Service class", + "obApplySubmit": "Start registration", + "obProfileTitle": "Your details", + "obNameArabic": "Name as on your ID", + "obNationalNumber": "National ID number", + "obGender": "Gender", + "obGenderMale": "Male", + "obGenderFemale": "Female", + "obVehicleTitle": "Your vehicle", + "obVehicleModel": "Model", + "obVehicleYear": "Year", + "obVehicleColor": "Colour", + "obVehiclePlate": "Plate number", + "obDocsTitle": "Your documents", + "obDocsLead": "Photograph each document clearly — approval follows review", + "obDocMissing": "Required", + "obDocPending": "Under review", + "obDocApproved": "Approved", + "obDocRejected": "Rejected — upload again", + "obDocSideFront": "Front", + "obDocSideBack": "Back", + "obDocUpload": "Upload", + "obPendingTitle": "Your application is under review", + "obPendingLead": "We'll notify you as soon as it's approved. Usually within a business day.", + "obPendingRefresh": "Refresh status", + "obApprovedTitle": "You're approved", + "obApprovedLead": "Sign in again to activate your driver permissions", + "obRelogin": "Sign in again", + "phoneTitle": "Welcome, captain", + "profileLead": "Your name is shown to the rider during a trip.", + "historyEmpty": "No trips yet — go online to receive requests", + }, +} + +for loc, extra in EXTRA.items(): + path = os.path.join(BASE, f'app_{loc}.arb') + data = json.load(open(path)) + data.update(extra) + json.dump(data, open(path, 'w'), ensure_ascii=False, indent=2) + +print(f"أُضيف {len(EXTRA['ar'])} مفتاحاً خاصاً بتطبيق السائق") diff --git a/apps/driver_new/tool/sync_from_rider.sh b/apps/driver_new/tool/sync_from_rider.sh new file mode 100755 index 0000000..8a7e86b --- /dev/null +++ b/apps/driver_new/tool/sync_from_rider.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# مزامنة الكود المشترك من rider_new إلى driver_new. +# +# التطبيقان توأمان في البنية ومختلفان في الميزات (docs/23 §0.1)، ولا حزمة +# مشتركة بينهما لأن docs/23 §1 و docs/26 §10 يفرضان core/ داخل كل تطبيق. +# البديل الوحيد إذن مزامنة منضبطة — وهذا الملف هو تعريفها الوحيد. +# +# التشغيل: bash tool/sync_from_rider.sh +set -euo pipefail +cd "$(dirname "$0")/.." + +# ملفات يملكها السائق ولا تُنسخ من الراكب أبداً +OWN=( + "core/config.dart" # appRole = driver + "core/build_config.dart" # اسم التطبيق + "core/di.dart" # DutyCubit + OnboardingCubit + PayoutCubit + "core/router.dart" # DutyPage + EarningsPage + بوابة التسجيل + "core/l10n/app_ar.arb" + "core/l10n/app_en.arb" + "core/l10n/gen" + "features/onboarding" # تسجيل السائق — لا مقابل له عند الراكب + "features/trip/cubit/duty_cubit.dart" + "features/trip/cubit/duty_state.dart" + "features/trip/view/duty_page.dart" + "features/trip/view/widgets/duty_toggle.dart" + "features/trip/view/widgets/offer_sheet.dart" + "features/trip/view/widgets/active_duty_sheet.dart" + "features/wallet/cubit/payout_cubit.dart" + "features/wallet/view/earnings_page.dart" + "features/wallet/view/widgets/payout_sheet.dart" +) + +# ملفات تخصّ الراكب ولا مكان لها عند السائق +RIDER_ONLY=( + "features/trip/cubit/ride_cubit.dart" + "features/trip/cubit/ride_state.dart" + "features/trip/view/ride_page.dart" + "features/trip/view/widgets/planner_sheet.dart" + "features/trip/view/widgets/confirm_sheet.dart" + "features/trip/view/widgets/searching_sheet.dart" + "features/trip/view/widgets/route_timeline.dart" + "features/trip/view/widgets/ride_type_picker.dart" + "features/trip/view/widgets/center_pin.dart" + "features/trip/view/widgets/active_trip_sheet.dart" + "features/trip/view/widgets/cancel_sheet.dart" + "features/wallet/view/wallet_page.dart" +) + +args=() +for p in "${OWN[@]}" "${RIDER_ONLY[@]}"; do args+=(--exclude "$p"); done + +rsync -a "${args[@]}" ../rider_new/lib/ lib/ + +# نصوص السائق تُعاد بعد كل مزامنة (ملفا ARB مستثنيان لكن قد يتغيّر المشترك) +python3 tool/driver_l10n_extra.py +echo "✅ تمّت المزامنة — شغّل: flutter gen-l10n && flutter analyze" diff --git a/apps/rider_new/lib/core/api/api_client.dart b/apps/rider_new/lib/core/api/api_client.dart index 04943f9..7bd8bcc 100644 --- a/apps/rider_new/lib/core/api/api_client.dart +++ b/apps/rider_new/lib/core/api/api_client.dart @@ -47,6 +47,22 @@ class ApiClient { Future delete(String path) => _send(() => _dio.delete(path)); + /// رفع ملف — الحقل اسمه `file` كما يتوقّعه `FileInterceptor` على الخادم. + /// يمرّ بنفس الـinterceptor فيحمل التوكن والمستأجر والجهاز. + Future upload( + String path, { + required String filePath, + Map fields = const {}, + }) { + return _send(() async { + final form = FormData.fromMap({ + ...fields, + 'file': await MultipartFile.fromFile(filePath), + }); + return _dio.post(path, data: form); + }); + } + Future _send(Future> Function() call) async { try { final res = await call(); diff --git a/apps/rider_new/lib/core/api/api_failure.dart b/apps/rider_new/lib/core/api/api_failure.dart index ba6d221..4fb19da 100644 --- a/apps/rider_new/lib/core/api/api_failure.dart +++ b/apps/rider_new/lib/core/api/api_failure.dart @@ -7,7 +7,7 @@ import 'api_exception.dart'; /// كل نص مرئي بطبقة الترجمة. والـCubit لا يعرف `BuildContext` (docs/23 §3). /// الجمع بينها: الـCubit يُصدر الرمز، والواجهة تترجمه سطراً واحداً — فتبقى /// الشاشة بلا منطق، والنص بلغتين. -enum AuthFailure { +enum ApiFailure { /// رمز خاطئ أو منتهي الصلاحية. invalidCode, @@ -22,17 +22,17 @@ enum AuthFailure { server, } -extension AuthFailureX on ApiException { - AuthFailure toAuthFailure() { - if (isRateLimited) return AuthFailure.rateLimited; - if (kind == ApiErrorKind.network) return AuthFailure.network; - if (kind == ApiErrorKind.timeout) return AuthFailure.timeout; +extension ApiFailureX on ApiException { + ApiFailure toApiFailure() { + if (isRateLimited) return ApiFailure.rateLimited; + if (kind == ApiErrorKind.network) return ApiFailure.network; + if (kind == ApiErrorKind.timeout) return ApiFailure.timeout; if (isUnauthorized) { // الخادم يفرّق بين «رمز خاطئ» و«محاولات كثيرة» بالنصّ وحده. return message.contains('Too many') - ? AuthFailure.tooManyAttempts - : AuthFailure.invalidCode; + ? ApiFailure.tooManyAttempts + : ApiFailure.invalidCode; } - return AuthFailure.server; + return ApiFailure.server; } } diff --git a/apps/rider_new/lib/core/build_config.dart b/apps/rider_new/lib/core/build_config.dart index ffa22ba..59edb3f 100644 --- a/apps/rider_new/lib/core/build_config.dart +++ b/apps/rider_new/lib/core/build_config.dart @@ -31,7 +31,10 @@ class Features { static const bool wallet = true; static const bool chat = true; - static const bool calls = true; + + /// WebRTC غير مبنيّ بعد — الخادم يدعمه (`call:*` في docs/38 §9) لكن + /// التطبيق لا. يبقى مطفأً حتى يُبنى ويُختبر على جهاز حقيقي. + static const bool calls = false; static const bool rideTypes = true; static const bool coupons = true; static const bool geofence = false; diff --git a/apps/rider_new/lib/core/di.dart b/apps/rider_new/lib/core/di.dart index c16f731..28ac171 100644 --- a/apps/rider_new/lib/core/di.dart +++ b/apps/rider_new/lib/core/di.dart @@ -11,6 +11,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../features/auth/cubit/login_cubit.dart'; import '../features/auth/data/auth_repository.dart'; +import '../features/chat/cubit/chat_cubit.dart'; +import '../features/chat/data/chat_repository.dart'; import '../features/profile/data/profile_repository.dart'; import '../features/rating/cubit/rating_cubit.dart'; @@ -104,6 +106,12 @@ Future setupInjector() async { sl.registerSingleton(RatingRepository(sl())); sl.registerFactory(() => RatingCubit(sl())); + // الدردشة خلف علمها: إطفاؤه يحذف كودها من الـbinary (docs/23 §5). + if (Features.chat) { + sl.registerSingleton(ChatRepository(sl())); + sl.registerFactory(() => ChatCubit(sl())); + } + // المحفظة خلف علم الاستحقاق: الميزة المطفأة لا تُسجَّل أصلاً (docs/23 §5). if (Features.wallet) { sl.registerSingleton(WalletRepository(sl())); diff --git a/apps/rider_new/lib/core/l10n/app_ar.arb b/apps/rider_new/lib/core/l10n/app_ar.arb index 3fd557d..5554570 100644 --- a/apps/rider_new/lib/core/l10n/app_ar.arb +++ b/apps/rider_new/lib/core/l10n/app_ar.arb @@ -188,5 +188,10 @@ "aboutVersion": "الإصدار", "notifTitle": "الإشعارات", "notifEnable": "تفعيل الإشعارات", - "notifDenied": "الإشعارات موقوفة من إعدادات النظام" + "notifDenied": "الإشعارات موقوفة من إعدادات النظام", + "chatTitle": "المحادثة", + "chatHint": "اكتب رسالة", + "chatEmpty": "لا رسائل بعد", + "rideCoupon": "كود خصم", + "rideCouponApply": "تطبيق" } \ 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 d84d7d7..0c70943 100644 --- a/apps/rider_new/lib/core/l10n/app_en.arb +++ b/apps/rider_new/lib/core/l10n/app_en.arb @@ -188,5 +188,10 @@ "aboutVersion": "Version", "notifTitle": "Notifications", "notifEnable": "Enable notifications", - "notifDenied": "Notifications are disabled in system settings" + "notifDenied": "Notifications are disabled in system settings", + "chatTitle": "Chat", + "chatHint": "Type a message", + "chatEmpty": "No messages yet", + "rideCoupon": "Promo code", + "rideCouponApply": "Apply" } \ No newline at end of file diff --git a/apps/rider_new/lib/core/router.dart b/apps/rider_new/lib/core/router.dart index da10e4b..eb9ae61 100644 --- a/apps/rider_new/lib/core/router.dart +++ b/apps/rider_new/lib/core/router.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import '../features/auth/view/login_page.dart'; +import '../features/chat/view/chat_page.dart'; import '../features/profile/view/profile_edit_page.dart'; import '../features/profile/view/profile_setup_page.dart'; @@ -44,6 +45,7 @@ class Routes { static const profileEdit = '/account'; static const settings = '/settings'; static const support = '/support'; + static const chat = '/chat'; } /// التوجيه يُشتقّ من `SessionCubit` وحده — لا `Navigator.push` بعد الدخول @@ -117,6 +119,11 @@ final appRouter = GoRouter( path: Routes.settings, builder: (context, state) => const SettingsPage(), ), + GoRoute( + path: Routes.chat, + builder: (context, state) => + ChatPage(tripId: state.uri.queryParameters['trip'] ?? ''), + ), GoRoute( path: Routes.support, builder: (context, state) => const SupportPage(), diff --git a/apps/rider_new/lib/core/ui/failure_text.dart b/apps/rider_new/lib/core/ui/failure_text.dart index 2b7cd48..d13b933 100644 --- a/apps/rider_new/lib/core/ui/failure_text.dart +++ b/apps/rider_new/lib/core/ui/failure_text.dart @@ -12,20 +12,20 @@ import '../l10n/l10n.dart'; /// النقطة **الوحيدة** التي تُترجَم فيها رموز الفشل إلى نصّ. /// الشاشات لا تكتب رسائل خطأ، والـCubit لا يعرف اللغة (راجع `auth_failure.dart`). -class AuthFailureText extends StatelessWidget { - const AuthFailureText({super.key, required this.failure}); +class ApiFailureText extends StatelessWidget { + const ApiFailureText({super.key, required this.failure}); - final AuthFailure failure; + final ApiFailure failure; - static String messageOf(BuildContext context, AuthFailure failure) { + static String messageOf(BuildContext context, ApiFailure failure) { final l10n = context.l10n; return switch (failure) { - AuthFailure.invalidCode => l10n.otpErrInvalid, - AuthFailure.tooManyAttempts => l10n.otpErrTooManyAttempts, - AuthFailure.rateLimited => l10n.otpErrRateLimited, - AuthFailure.network => l10n.errorNetwork, - AuthFailure.timeout => l10n.errorTimeout, - AuthFailure.server => l10n.errorGeneric, + ApiFailure.invalidCode => l10n.otpErrInvalid, + ApiFailure.tooManyAttempts => l10n.otpErrTooManyAttempts, + ApiFailure.rateLimited => l10n.otpErrRateLimited, + ApiFailure.network => l10n.errorNetwork, + ApiFailure.timeout => l10n.errorTimeout, + ApiFailure.server => l10n.errorGeneric, }; } diff --git a/apps/rider_new/lib/features/auth/cubit/login_cubit.dart b/apps/rider_new/lib/features/auth/cubit/login_cubit.dart index cf11776..90497ab 100644 --- a/apps/rider_new/lib/features/auth/cubit/login_cubit.dart +++ b/apps/rider_new/lib/features/auth/cubit/login_cubit.dart @@ -110,7 +110,7 @@ class LoginCubit extends Cubit { } on ApiException catch (e) { emit(state.copyWith( status: LoginStatus.failure, - failure: e.toAuthFailure(), + failure: e.toApiFailure(), )); } } @@ -137,7 +137,7 @@ class LoginCubit extends Cubit { } on ApiException catch (e) { emit(state.copyWith( status: LoginStatus.failure, - failure: e.toAuthFailure(), + failure: e.toApiFailure(), )); // حتى عند الرفض بحدّ المعدّل نبدأ العدّاد — وإلا ضغط المستخدم مجدداً // فوراً وعمّق الحظر. @@ -159,7 +159,7 @@ class LoginCubit extends Cubit { } on ApiException catch (e) { emit(state.copyWith( status: LoginStatus.failure, - failure: e.toAuthFailure(), + failure: e.toApiFailure(), )); } } diff --git a/apps/rider_new/lib/features/auth/cubit/login_state.dart b/apps/rider_new/lib/features/auth/cubit/login_state.dart index 79f38ef..c778d23 100644 --- a/apps/rider_new/lib/features/auth/cubit/login_state.dart +++ b/apps/rider_new/lib/features/auth/cubit/login_state.dart @@ -32,7 +32,7 @@ class LoginState extends Equatable { final String phone; final bool agreed; - final AuthFailure? failure; + final ApiFailure? failure; final PhoneError? phoneError; /// ثوانٍ متبقّية قبل السماح بإعادة الإرسال. الخادم يحدّ بثلاثة طلبات كل @@ -52,7 +52,7 @@ class LoginState extends Equatable { LoginStatus? status, String? phone, bool? agreed, - AuthFailure? failure, + ApiFailure? failure, PhoneError? phoneError, int? resendIn, bool? permissionPermanentlyDenied, diff --git a/apps/rider_new/lib/features/auth/view/otp_view.dart b/apps/rider_new/lib/features/auth/view/otp_view.dart index de0d752..8a7c883 100644 --- a/apps/rider_new/lib/features/auth/view/otp_view.dart +++ b/apps/rider_new/lib/features/auth/view/otp_view.dart @@ -67,7 +67,7 @@ class _OtpViewState extends State { onCompleted: cubit.submitOtp, ), if (state.failure != null) - AuthFailureText(failure: state.failure!), + ApiFailureText(failure: state.failure!), const SizedBox(height: Space.md), // العدّاد التنازلي ليس تجميلاً: بلا حارس تُحظر ثلاث ضغطات // المستخدمَ خمس دقائق (docs/38 §2). diff --git a/apps/rider_new/lib/features/auth/view/phone_view.dart b/apps/rider_new/lib/features/auth/view/phone_view.dart index 5752888..93d294a 100644 --- a/apps/rider_new/lib/features/auth/view/phone_view.dart +++ b/apps/rider_new/lib/features/auth/view/phone_view.dart @@ -57,10 +57,10 @@ class PhoneView extends StatelessWidget { onSubmitted: (_) => cubit.submitPhone(), errorText: state.phoneError == null ? null - : AuthFailureText.phoneMessageOf(context, state.phoneError!), + : ApiFailureText.phoneMessageOf(context, state.phoneError!), ), if (state.failure != null) - AuthFailureText(failure: state.failure!), + ApiFailureText(failure: state.failure!), ], ), ); diff --git a/apps/rider_new/lib/features/chat/cubit/chat_cubit.dart b/apps/rider_new/lib/features/chat/cubit/chat_cubit.dart new file mode 100644 index 0000000..a71266a --- /dev/null +++ b/apps/rider_new/lib/features/chat/cubit/chat_cubit.dart @@ -0,0 +1,81 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/api/api_exception.dart'; +import '../../../core/ui/view_status.dart'; +import '../data/chat_repository.dart'; + +class ChatState { + const ChatState({ + this.status = ViewStatus.idle, + this.messages = const [], + this.sending = false, + this.error, + }); + + final ViewStatus status; + final List messages; + final bool sending; + final String? error; +} + +class ChatCubit extends Cubit { + ChatCubit(this._repo) : super(const ChatState()); + + final ChatRepository _repo; + Timer? _poll; + String? _tripId; + + @override + Future close() { + _poll?.cancel(); + return super.close(); + } + + /// استطلاع كل خمس ثوانٍ — الخادم لا يبثّ الرسائل عبر السوكت + /// (راجع `ChatRepository`). يتوقّف مع إغلاق الشاشة. + Future open(String tripId) async { + _tripId = tripId; + emit(const ChatState(status: ViewStatus.loading)); + await _refresh(); + _poll = Timer.periodic(const Duration(seconds: 5), (_) => _refresh()); + } + + Future _refresh() async { + final tripId = _tripId; + if (tripId == null || isClosed) return; + try { + final messages = await _repo.messages(tripId); + if (isClosed) return; + emit(ChatState( + status: ViewStatus.success, + messages: messages, + sending: state.sending, + )); + } on ApiException catch (e) { + // فشل الاستطلاع لا يمسح ما وصل: الرسائل المعروضة تبقى. + if (!isClosed && state.messages.isEmpty) { + emit(ChatState(status: ViewStatus.error, error: e.message)); + } + } + } + + Future send(String body) async { + final tripId = _tripId; + if (tripId == null || body.trim().isEmpty) return; + emit(ChatState( + status: state.status, + messages: state.messages, + sending: true, + )); + try { + await _repo.send(tripId, body.trim()); + await _refresh(); + } on ApiException { + if (!isClosed) { + emit(ChatState(status: state.status, messages: state.messages)); + } + } + } +} diff --git a/apps/rider_new/lib/features/chat/data/chat_repository.dart b/apps/rider_new/lib/features/chat/data/chat_repository.dart new file mode 100644 index 0000000..a54749a --- /dev/null +++ b/apps/rider_new/lib/features/chat/data/chat_repository.dart @@ -0,0 +1,46 @@ +import '../../../core/api/api_client.dart'; + +class ChatMessage { + const ChatMessage({ + required this.id, + required this.body, + required this.senderId, + this.createdAt, + }); + + final String id; + final String body; + final String senderId; + final DateTime? createdAt; + + factory ChatMessage.fromJson(Map json) => ChatMessage( + id: json['id']?.toString() ?? '', + body: json['body'] as String? ?? json['message'] as String? ?? '', + senderId: json['sender_id']?.toString() ?? '', + createdAt: DateTime.tryParse(json['created_at']?.toString() ?? ''), + ); +} + +/// دردشة الرحلة (docs/38 §11). +/// +/// ⚠️ **لا حدث سوكت للرسائل**: قائمة أحداث الخادم لا تتضمّن الدردشة +/// (docs/38 §9)، فالتحديث **باستطلاع دوري** لا ببثّ. هذا قيد الخادم لا +/// اختيار تصميمي — ويُلغى فور إضافة الحدث. +class ChatRepository { + ChatRepository(this._api); + + final ApiClient _api; + + Future> messages(String tripId) async { + final res = await _api.get>('/trips/$tripId/messages'); + return res + .whereType>() + .map(ChatMessage.fromJson) + .toList(growable: false); + } + + Future send(String tripId, String body) => _api.post>( + '/trips/$tripId/messages', + body: {'body': body}, + ); +} diff --git a/apps/rider_new/lib/features/chat/view/chat_page.dart b/apps/rider_new/lib/features/chat/view/chat_page.dart new file mode 100644 index 0000000..1488e4b --- /dev/null +++ b/apps/rider_new/lib/features/chat/view/chat_page.dart @@ -0,0 +1,114 @@ +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/session/session_cubit.dart'; +import '../../../core/ui/empty_view.dart'; +import '../../../core/ui/tripz_scaffold.dart'; +import '../../../core/ui/tripz_text_field.dart'; +import '../cubit/chat_cubit.dart'; +import '../data/chat_repository.dart'; + +class ChatPage extends StatefulWidget { + const ChatPage({super.key, required this.tripId}); + + final String tripId; + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State { + late final ChatCubit _cubit = sl()..open(widget.tripId); + final _input = TextEditingController(); + + @override + void dispose() { + _input.dispose(); + _cubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final myId = sl().state.user?.id; + + return BlocProvider.value( + value: _cubit, + child: BlocBuilder( + builder: (context, state) => TripzScaffold( + title: l10n.chatTitle, + status: state.status, + error: state.error, + bottomAction: TripzTextField( + controller: _input, + hint: l10n.chatHint, + textInputAction: TextInputAction.send, + onSubmitted: (v) { + _cubit.send(v); + _input.clear(); + }, + suffix: IconButton( + icon: const Icon(Icons.send_rounded), + onPressed: () { + _cubit.send(_input.text); + _input.clear(); + }, + ), + ), + child: state.messages.isEmpty + ? EmptyView( + icon: Icons.forum_outlined, + message: l10n.chatEmpty, + ) + : ListView.builder( + // الأحدث أسفل، والقائمة مقلوبة كي تبقى عند آخر رسالة. + reverse: true, + itemCount: state.messages.length, + itemBuilder: (context, i) { + final msg = state.messages[state.messages.length - 1 - i]; + return _Bubble(msg: msg, mine: msg.senderId == myId); + }, + ), + ), + ), + ); + } +} + +class _Bubble extends StatelessWidget { + const _Bubble({required this.msg, required this.mine}); + + final ChatMessage msg; + final bool mine; + + @override + Widget build(BuildContext context) { + return Align( + alignment: + mine ? AlignmentDirectional.centerEnd : AlignmentDirectional.centerStart, + child: Container( + margin: const EdgeInsets.symmetric(vertical: Space.xxs), + padding: const EdgeInsets.symmetric( + horizontal: Space.sm, + vertical: Space.xs, + ), + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.75, + ), + decoration: BoxDecoration( + color: mine + ? context.colors.primaryContainer + : context.tripzColors.surfaceRaised, + borderRadius: Radii.card_, + border: mine ? null : Border.all(color: context.colors.outlineVariant), + ), + child: Text(msg.body, style: context.texts.bodyMedium), + ), + ); + } +} diff --git a/apps/rider_new/lib/features/profile/cubit/profile_cubit.dart b/apps/rider_new/lib/features/profile/cubit/profile_cubit.dart index abeb9d8..3fdfef9 100644 --- a/apps/rider_new/lib/features/profile/cubit/profile_cubit.dart +++ b/apps/rider_new/lib/features/profile/cubit/profile_cubit.dart @@ -20,13 +20,13 @@ class ProfileState { final ViewStatus status; final bool saving; - final AuthFailure? failure; + final ApiFailure? failure; final AppUser? user; ProfileState copyWith({ ViewStatus? status, bool? saving, - AuthFailure? failure, + ApiFailure? failure, AppUser? user, bool clearFailure = false, }) { @@ -53,7 +53,7 @@ class ProfileCubit extends Cubit { emit(state.copyWith(saving: false, user: user)); return user; } on ApiException catch (e) { - emit(state.copyWith(saving: false, failure: e.toAuthFailure())); + emit(state.copyWith(saving: false, failure: e.toApiFailure())); return null; } } diff --git a/apps/rider_new/lib/features/profile/view/profile_setup_page.dart b/apps/rider_new/lib/features/profile/view/profile_setup_page.dart index a5c5ca4..bcb34ab 100644 --- a/apps/rider_new/lib/features/profile/view/profile_setup_page.dart +++ b/apps/rider_new/lib/features/profile/view/profile_setup_page.dart @@ -74,7 +74,7 @@ class _ProfileSetupPageState extends State { onSubmitted: (_) => _submit(context), ), if (state.failure != null) - AuthFailureText(failure: state.failure!), + ApiFailureText(failure: state.failure!), ], ), ); diff --git a/apps/rider_new/lib/features/trip/cubit/ride_cubit.dart b/apps/rider_new/lib/features/trip/cubit/ride_cubit.dart index 5705b6d..78949ea 100644 --- a/apps/rider_new/lib/features/trip/cubit/ride_cubit.dart +++ b/apps/rider_new/lib/features/trip/cubit/ride_cubit.dart @@ -109,6 +109,8 @@ class RideCubit extends Cubit { // ── التخطيط ──────────────────────────────────────────────────────────── + void couponChanged(String code) => emit(state.copyWith(couponCode: code)); + void openPlanner() => emit(state.copyWith(phase: RidePhase.planning)); void collapsePlanner() => emit(state.copyWith(phase: RidePhase.idle)); @@ -253,6 +255,9 @@ class RideCubit extends Cubit { destination: to, serviceClass: type.code, stops: state.stops, + couponCode: state.couponCode.trim().isEmpty + ? null + : state.couponCode.trim(), ); if (isClosed) return; diff --git a/apps/rider_new/lib/features/trip/cubit/ride_state.dart b/apps/rider_new/lib/features/trip/cubit/ride_state.dart index 6d27bbd..3a42106 100644 --- a/apps/rider_new/lib/features/trip/cubit/ride_state.dart +++ b/apps/rider_new/lib/features/trip/cubit/ride_state.dart @@ -59,6 +59,7 @@ class RideState extends Equatable { this.error = RideError.none, this.pickTarget = PickTarget.destination, this.cameraTarget, + this.couponCode = '', }); final RidePhase phase; @@ -92,6 +93,9 @@ class RideState extends Equatable { /// وجهة الكاميرا حين يقودها المنطق لا المستخدم. final GeoPoint? cameraTarget; + /// كود الخصم — يُقيَّم على الخادم عند الطلب لا في التطبيق. + final String couponCode; + bool get canConfirm => origin != null && destination != null && @@ -117,6 +121,7 @@ class RideState extends Equatable { RideError? error, PickTarget? pickTarget, GeoPoint? cameraTarget, + String? couponCode, bool clearDestination = false, bool clearRoute = false, bool clearTrip = false, @@ -143,6 +148,7 @@ class RideState extends Equatable { error: error ?? this.error, pickTarget: pickTarget ?? this.pickTarget, cameraTarget: clearCamera ? null : (cameraTarget ?? this.cameraTarget), + couponCode: couponCode ?? this.couponCode, ); } @@ -166,5 +172,6 @@ class RideState extends Equatable { error, pickTarget, cameraTarget, + couponCode, ]; } 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 index b2b78ca..662d96c 100644 --- 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 @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import '../../../../core/design/tokens.dart'; import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/build_config.dart'; import '../../../../core/l10n/l10n.dart'; +import '../../../../core/router.dart'; +import '../../../../core/tenant/feature_gate.dart'; import '../../../../core/ui/tripz_button.dart'; @@ -41,6 +45,15 @@ class ActiveTripSheet extends StatelessWidget { Text(_statusText(context), style: context.texts.titleMedium), const SizedBox(height: Space.md), DriverBadge(trip: trip), + if (Features.chat && context.tenantHas('chat')) ...[ + const SizedBox(height: Space.sm), + TripzButton.secondary( + label: l10n.chatTitle, + icon: Icons.forum_outlined, + expanded: true, + onPressed: () => context.push('${Routes.chat}?trip=\${trip.id}'), + ), + ], if (trip.priceForPassenger != null) ...[ const SizedBox(height: Space.md), FareSummary(trip: trip), 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 index bc4eecb..4a9316a 100644 --- a/apps/rider_new/lib/features/trip/view/widgets/confirm_sheet.dart +++ b/apps/rider_new/lib/features/trip/view/widgets/confirm_sheet.dart @@ -12,7 +12,10 @@ import '../../../../core/l10n/l10n.dart'; import '../../../../core/ui/status_banner.dart'; +import '../../../../core/build_config.dart'; +import '../../../../core/tenant/feature_gate.dart'; import '../../../../core/ui/tripz_button.dart'; +import '../../../../core/ui/tripz_text_field.dart'; import '../../cubit/ride_cubit.dart'; @@ -92,6 +95,15 @@ class ConfirmSheet extends StatelessWidget { const SizedBox(height: Space.sm), StatusBanner(message: l10n.rideNoDrivers, tone: BannerTone.warning), ], + // الكوبون خلف طبقتَي الميزات: مبنيّ في الـbinary ومشترى للمستأجر. + if (Features.coupons && context.tenantHas('coupons')) ...[ + const SizedBox(height: Space.md), + TripzTextField( + hint: l10n.rideCoupon, + prefixIcon: Icons.local_offer_outlined, + onChanged: cubit.couponChanged, + ), + ], const SizedBox(height: Space.md), TripzButton.primary( label: l10n.rideConfirmTrip, diff --git a/docs/37-newapps-cubit-plan.md b/docs/37-newapps-cubit-plan.md index 74e7a9c..77c210d 100644 --- a/docs/37-newapps-cubit-plan.md +++ b/docs/37-newapps-cubit-plan.md @@ -315,6 +315,31 @@ 25. الوحدات المدفوعة السبع (`docs/33`) خلف أعلام `const` حسب نموذج lite/pro/max — بناء مولَّد، لا فروع. +## 3.7 إكمال التغطية (2026-08-05) + +فحصٌ آليّ لنقاط `docs/38` مقابل الكود كشف ثغرات لم تظهر بالقراءة: + +- **تسجيل السائق لم يكن موجوداً أصلاً** — أخطر ثغرة: سائق جديد يسجّل دخوله + ثم يقف. بُنيت `features/onboarding` كاملة: تقديم ← ملف ← مركبة ← وثائق ← + انتظار الاعتماد. **الخطوة تُشتقّ من حالة الخادم لا من تقدّم محلّي**، فسائقٌ + يعيد تثبيت التطبيق يعود إلى حيث وقف. والاعتماد يقع على الخادم تلقائياً حين + تكتمل الوثائق — التطبيق لا يعتمد أحداً. وعند الاعتماد **خروجٌ إجباري**: + الدور يتغيّر على الخادم والتوكن القديم يحمل القديم فتفشل نقاط السائق بـ403. +- **بوابة توجيه**: مستخدمٌ دوره ليس `driver` يُحجز في `/onboarding`. +- **الدردشة** (`Features.chat` كان مفعّلاً بلا ميزة): `features/chat` + باستطلاع كل خمس ثوانٍ — قائمة أحداث الخادم لا تتضمّن الرسائل + (`docs/38 §9`)، فالاستطلاع **قيد خادم لا اختيار تصميمي**. +- **الكوبون**: حقل في ورقة التأكيد خلف طبقتَي الميزات؛ يُقيَّم على الخادم. +- **`Features.calls` أُطفئ صراحةً**: الخادم يدعم WebRTC والتطبيق لا. عَلَمٌ + مفعّل بلا ميزة كذبٌ على القارئ التالي. +- `ApiClient.upload` للرفع متعدّد الأجزاء · `AuthFailure` صار `ApiFailure` + في `core/api` لأنه يخدم المصادقة والملف والتسجيل معاً. +- **`tool/sync_from_rider.sh`**: المزامنة اليدوية بين التوأمين انكسرت أربع + مرات، فصارت سكربتاً واحداً يعرّف ما يملكه كل تطبيق. تُشغَّل بعده + `flutter gen-l10n` و`flutter analyze`. + +`flutter analyze` نظيف · الراكب 101 ملف/7,809 سطر · السائق 110 ملف/8,116 سطر. + ## 4. مصير المجلدات القائمة | المجلد | المصير |