From 66e28acc58fa108c18cdc39a1e9dacb40ce09afb Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Tue, 4 Aug 2026 23:42:07 +0300 Subject: [PATCH] =?UTF-8?q?feat(apps):=20=D8=A7=D9=84=D8=AA=D9=82=D9=8A?= =?UTF-8?q?=D9=8A=D9=85=20=D9=88=D8=A7=D9=84=D9=85=D8=AD=D9=81=D8=B8=D8=AA?= =?UTF-8?q?=D8=A7=D9=86=20=D9=88=D9=85=D8=B3=D8=A7=D8=B1=20=D8=A7=D9=84?= =?UTF-8?q?=D8=B1=D8=AD=D9=84=D8=A9=20=E2=80=94=20=D8=A7=D9=84=D9=85=D8=B1?= =?UTF-8?q?=D8=AD=D9=84=D8=A9=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## التقييم مفروض لا اختياري SessionCubit يفحص GET /trips/rating/pending بعد إثبات الجلسة، ووجود رحلة معلّقة يحجز التطبيق كله عند /rating حتى تُحسم. دور المستخدم في الرحلة يأتي من الخادم لا من نوع التطبيق — نفس الرقم قد يكون له حسابان (docs/38 §1). التقييم المزدوج يرجّع 400، وهو نجاح من منظور المستخدم: الرحلة مُقيَّمة فعلاً فلا يُحبس في شاشة بلا مخرج. الجلسة تحتاج معرفة التقييم المعلّق، و core لا يجوز أن يستورد ميزة التقييم — فأُضيفت الطريقة لواجهة SessionSource ونفّذها AuthRepository. ## core/format/money.dart كل المبالغ تصل نصوصاً ("0.000")؛ التحويل والعرض من نقطة واحدة. عدد المنازل من العملة (JOD ثلاث)، والعملة من إعداد المستأجر لا من ثابت في التطبيق. ## المحفظتان لا تُوحَّدان - الراكب WalletPage: رصيد + شحن = التزام على المنصّة - السائق EarningsPage: أرباح + سحب = إيراد له شكلهما متقارب وحسابهما مختلف؛ دمجهما خطأ محاسبي لا اختصار (docs/39 §4). ## السحب بخطوتين request يرسل رمزاً بلا خصم، و confirm هو ما يخصم ويحجز. شاشة سيرو كانت خطوة واحدة ولم تُقلَّد. الرمز الخاطئ لا يُرجع المستخدم لخطوة المبلغ. الرصيد التشغيلي معروض منفصلاً عن الأرباح: منه تُخصم العمولة ونفاده يوقف وصول الطلبات — خلطه بالأرباح يخفي سبب توقّف العمل عن السائق. FareSummary يعرض رقم كلّ طرف: price_for_passenger للراكب و price_for_driver للسائق — حقلان منفصلان والفرق عمولة (docs/38 §4). الإلغاء بتأكيد صريح مع سبب؛ السبب لا يقبله عقد الخادم بعد لكن السؤال يخفض الإلغاء العرضي ويجهّز الحقل. flutter analyze نظيف · الراكب 86 ملف/6,280 سطر · السائق 83 ملف/5,818 سطر. Co-Authored-By: Claude Opus 5 --- apps/driver_new/lib/core/di.dart | 16 +++ apps/driver_new/lib/core/format/money.dart | 34 +++++ apps/driver_new/lib/core/l10n/app_ar.arb | 40 +++++- apps/driver_new/lib/core/l10n/app_en.arb | 40 +++++- apps/driver_new/lib/core/router.dart | 36 ++++- .../lib/core/session/session_cubit.dart | 24 ++++ .../lib/core/session/session_source.dart | 3 + .../lib/core/session/session_state.dart | 30 ++++- .../features/auth/data/auth_repository.dart | 12 ++ .../features/rating/cubit/rating_cubit.dart | 40 ++++++ .../features/rating/cubit/rating_state.dart | 38 ++++++ .../rating/data/rating_repository.dart | 40 ++++++ .../lib/features/rating/view/rating_page.dart | 123 ++++++++++++++++++ .../lib/features/rating/view/star_row.dart | 43 ++++++ .../trip/view/widgets/active_duty_sheet.dart | 21 +-- .../trip/view/widgets/fare_summary.dart | 51 ++++++++ .../features/wallet/cubit/payout_cubit.dart | 88 +++++++++++++ .../features/wallet/cubit/wallet_cubit.dart | 45 +++++++ .../features/wallet/cubit/wallet_state.dart | 48 +++++++ .../features/wallet/data/models/wallet.dart | 31 +++++ .../wallet/data/models/wallet_txn.dart | 29 +++++ .../wallet/data/wallet_repository.dart | 55 ++++++++ .../features/wallet/view/earnings_page.dart | 103 +++++++++++++++ .../wallet/view/widgets/balance_card.dart | 67 ++++++++++ .../wallet/view/widgets/payout_sheet.dart | 121 +++++++++++++++++ .../wallet/view/widgets/txn_tile.dart | 49 +++++++ apps/driver_new/pubspec.lock | 2 +- apps/driver_new/pubspec.yaml | 2 + apps/rider_new/lib/core/di.dart | 14 ++ apps/rider_new/lib/core/format/money.dart | 34 +++++ apps/rider_new/lib/core/l10n/app_ar.arb | 40 +++++- apps/rider_new/lib/core/l10n/app_en.arb | 40 +++++- apps/rider_new/lib/core/router.dart | 36 ++++- .../lib/core/session/session_cubit.dart | 24 ++++ .../lib/core/session/session_source.dart | 3 + .../lib/core/session/session_state.dart | 30 ++++- .../features/auth/data/auth_repository.dart | 12 ++ .../features/rating/cubit/rating_cubit.dart | 40 ++++++ .../features/rating/cubit/rating_state.dart | 38 ++++++ .../rating/data/rating_repository.dart | 40 ++++++ .../lib/features/rating/view/rating_page.dart | 123 ++++++++++++++++++ .../lib/features/rating/view/star_row.dart | 43 ++++++ .../trip/view/widgets/active_trip_sheet.dart | 25 ++-- .../trip/view/widgets/cancel_sheet.dart | 60 +++++++++ .../trip/view/widgets/fare_summary.dart | 51 ++++++++ .../features/wallet/cubit/wallet_cubit.dart | 45 +++++++ .../features/wallet/cubit/wallet_state.dart | 48 +++++++ .../features/wallet/data/models/wallet.dart | 31 +++++ .../wallet/data/models/wallet_txn.dart | 29 +++++ .../wallet/data/wallet_repository.dart | 55 ++++++++ .../lib/features/wallet/view/wallet_page.dart | 113 ++++++++++++++++ .../wallet/view/widgets/balance_card.dart | 67 ++++++++++ .../wallet/view/widgets/txn_tile.dart | 49 +++++++ apps/rider_new/pubspec.lock | 2 +- apps/rider_new/pubspec.yaml | 2 + docs/37-newapps-cubit-plan.md | 28 +++- 56 files changed, 2301 insertions(+), 52 deletions(-) create mode 100644 apps/driver_new/lib/core/format/money.dart create mode 100644 apps/driver_new/lib/features/rating/cubit/rating_cubit.dart create mode 100644 apps/driver_new/lib/features/rating/cubit/rating_state.dart create mode 100644 apps/driver_new/lib/features/rating/data/rating_repository.dart create mode 100644 apps/driver_new/lib/features/rating/view/rating_page.dart create mode 100644 apps/driver_new/lib/features/rating/view/star_row.dart create mode 100644 apps/driver_new/lib/features/trip/view/widgets/fare_summary.dart create mode 100644 apps/driver_new/lib/features/wallet/cubit/payout_cubit.dart create mode 100644 apps/driver_new/lib/features/wallet/cubit/wallet_cubit.dart create mode 100644 apps/driver_new/lib/features/wallet/cubit/wallet_state.dart create mode 100644 apps/driver_new/lib/features/wallet/data/models/wallet.dart create mode 100644 apps/driver_new/lib/features/wallet/data/models/wallet_txn.dart create mode 100644 apps/driver_new/lib/features/wallet/data/wallet_repository.dart create mode 100644 apps/driver_new/lib/features/wallet/view/earnings_page.dart create mode 100644 apps/driver_new/lib/features/wallet/view/widgets/balance_card.dart create mode 100644 apps/driver_new/lib/features/wallet/view/widgets/payout_sheet.dart create mode 100644 apps/driver_new/lib/features/wallet/view/widgets/txn_tile.dart create mode 100644 apps/rider_new/lib/core/format/money.dart create mode 100644 apps/rider_new/lib/features/rating/cubit/rating_cubit.dart create mode 100644 apps/rider_new/lib/features/rating/cubit/rating_state.dart create mode 100644 apps/rider_new/lib/features/rating/data/rating_repository.dart create mode 100644 apps/rider_new/lib/features/rating/view/rating_page.dart create mode 100644 apps/rider_new/lib/features/rating/view/star_row.dart create mode 100644 apps/rider_new/lib/features/trip/view/widgets/cancel_sheet.dart create mode 100644 apps/rider_new/lib/features/trip/view/widgets/fare_summary.dart create mode 100644 apps/rider_new/lib/features/wallet/cubit/wallet_cubit.dart create mode 100644 apps/rider_new/lib/features/wallet/cubit/wallet_state.dart create mode 100644 apps/rider_new/lib/features/wallet/data/models/wallet.dart create mode 100644 apps/rider_new/lib/features/wallet/data/models/wallet_txn.dart create mode 100644 apps/rider_new/lib/features/wallet/data/wallet_repository.dart create mode 100644 apps/rider_new/lib/features/wallet/view/wallet_page.dart create mode 100644 apps/rider_new/lib/features/wallet/view/widgets/balance_card.dart create mode 100644 apps/rider_new/lib/features/wallet/view/widgets/txn_tile.dart diff --git a/apps/driver_new/lib/core/di.dart b/apps/driver_new/lib/core/di.dart index 13610d5..1839812 100644 --- a/apps/driver_new/lib/core/di.dart +++ b/apps/driver_new/lib/core/di.dart @@ -6,8 +6,13 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../features/auth/cubit/login_cubit.dart'; import '../features/auth/data/auth_repository.dart'; +import '../features/rating/cubit/rating_cubit.dart'; +import '../features/rating/data/rating_repository.dart'; import '../features/settings/cubit/settings_cubit.dart'; import '../features/trip/cubit/duty_cubit.dart'; +import '../features/wallet/cubit/payout_cubit.dart'; +import '../features/wallet/cubit/wallet_cubit.dart'; +import '../features/wallet/data/wallet_repository.dart'; import '../features/trip/data/maps_repository.dart'; import '../features/trip/data/trip_repository.dart'; import 'api/antlaq_api.dart'; @@ -15,6 +20,7 @@ import 'api/api_client.dart'; import 'location/location_service.dart'; import 'realtime/realtime_service.dart'; import 'session/session_cubit.dart'; +import 'build_config.dart'; import 'storage/token_store.dart'; final sl = GetIt.instance; @@ -58,6 +64,16 @@ Future setupInjector() async { sl.registerSingleton(LocationService()); sl.registerSingleton(RealtimeService(tokens)); + sl.registerSingleton(RatingRepository(sl())); + sl.registerFactory(() => RatingCubit(sl())); + + // المحفظة خلف علم الاستحقاق: الميزة المطفأة لا تُسجَّل أصلاً (docs/23 §5). + if (Features.wallet) { + sl.registerSingleton(WalletRepository(sl())); + sl.registerFactory(() => WalletCubit(sl())); + sl.registerFactory(() => PayoutCubit(sl())); + } + sl.registerFactory(() => DutyCubit( trips: sl(), location: sl(), diff --git a/apps/driver_new/lib/core/format/money.dart b/apps/driver_new/lib/core/format/money.dart new file mode 100644 index 0000000..69ae013 --- /dev/null +++ b/apps/driver_new/lib/core/format/money.dart @@ -0,0 +1,34 @@ +import 'package:intl/intl.dart'; + +/// كل المبالغ تصل من الخادم **نصوصاً** لا أرقاماً (`"0.000"` — docs/38 §12.2). +/// تمريرها لعملية حسابية بلا تحويل يعطي نتائج صامتة خاطئة، لذلك التحويل +/// والعرض يمرّان من هنا وحدهما. +class Money { + const Money._(); + + /// `null` عند نصّ غير صالح — لا صفر: الصفر رصيدٌ حقيقي، والغياب ليس رصيداً. + static double? parse(String? raw) => + raw == null ? null : double.tryParse(raw); + + /// العملة ورمزها من إعداد المستأجر (country pack)، لا ثابتة في التطبيق + /// (docs/26 §5). الأرقام لاتينية في اللغتين. + static String format(String? raw, String currency, {String locale = 'en'}) { + final value = parse(raw); + if (value == null) return '—'; + final digits = currency == 'JOD' ? 3 : 2; + final text = NumberFormat.decimalPatternDigits( + locale: locale, + decimalDigits: digits, + ).format(value); + return currency.isEmpty ? text : '$text $currency'; + } + + static String formatNum(num value, String currency, {int? decimalDigits}) { + final digits = decimalDigits ?? (currency == 'JOD' ? 3 : 2); + final text = NumberFormat.decimalPatternDigits( + locale: 'en', + decimalDigits: digits, + ).format(value); + return currency.isEmpty ? text : '$text $currency'; + } +} diff --git a/apps/driver_new/lib/core/l10n/app_ar.arb b/apps/driver_new/lib/core/l10n/app_ar.arb index 5b99602..9f4f78e 100644 --- a/apps/driver_new/lib/core/l10n/app_ar.arb +++ b/apps/driver_new/lib/core/l10n/app_ar.arb @@ -123,5 +123,43 @@ "driverArrivedAction": "وصلت لموقع الراكب", "driverStartTrip": "بدء الرحلة", "driverEndTrip": "إنهاء الرحلة", - "driverOnTheWay": "في الطريق للراكب" + "driverOnTheWay": "في الطريق للراكب", + "rateTitle": "كيف كانت رحلتك؟", + "rateTitleDriver": "كيف كان الراكب؟", + "rateLead": "تقييمك يساعدنا على تحسين الخدمة", + "rateComment": "أضف ملاحظة (اختياري)", + "rateSubmit": "إرسال التقييم", + "rateSkip": "لاحقاً", + "rateAlready": "سبق أن قيّمت هذه الرحلة", + "tripFareTitle": "تفاصيل الأجرة", + "tripFare": "الأجرة", + "tripEarnings": "أرباحك", + "tripCommission": "العمولة", + "tripCancelTitle": "سبب الإلغاء", + "tripCancelReasonWait": "الانتظار طويل", + "tripCancelReasonMistake": "طلبت بالخطأ", + "tripCancelReasonOther": "سبب آخر", + "tripCancelConfirm": "تأكيد الإلغاء", + "tripCancelKeep": "متابعة الرحلة", + "walletTitle": "المحفظة", + "walletBalance": "الرصيد", + "walletTopup": "شحن الرصيد", + "walletTopupAmount": "المبلغ", + "walletTransactions": "الحركات", + "walletEmpty": "لا حركات بعد", + "walletTopupFailed": "تعذّر الشحن", + "creditTitle": "الرصيد التشغيلي", + "creditLead": "تُخصم منه العمولة عند إنهاء كل رحلة", + "creditBlocked": "رصيدك التشغيلي نفد — اشحنه لتستقبل الطلبات", + "payoutTitle": "سحب الأرباح", + "payoutAmount": "مبلغ السحب", + "payoutChannel": "قناة الاستلام", + "payoutRequest": "أرسل رمز التأكيد", + "payoutOtpTitle": "أكّد السحب", + "payoutOtpLead": "أرسلنا رمزاً إلى رقمك لتأكيد السحب", + "payoutConfirm": "تأكيد السحب", + "payoutDone": "تم إرسال طلب السحب", + "payoutFailed": "تعذّر إتمام السحب", + "payoutInsufficient": "الرصيد لا يكفي", + "payoutHistory": "طلبات السحب" } \ 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 7e1cb12..cc8c01e 100644 --- a/apps/driver_new/lib/core/l10n/app_en.arb +++ b/apps/driver_new/lib/core/l10n/app_en.arb @@ -123,5 +123,43 @@ "driverArrivedAction": "I've arrived", "driverStartTrip": "Start trip", "driverEndTrip": "End trip", - "driverOnTheWay": "Heading to rider" + "driverOnTheWay": "Heading to rider", + "rateTitle": "How was your ride?", + "rateTitleDriver": "How was your rider?", + "rateLead": "Your rating helps us improve", + "rateComment": "Add a note (optional)", + "rateSubmit": "Submit rating", + "rateSkip": "Later", + "rateAlready": "You already rated this trip", + "tripFareTitle": "Fare breakdown", + "tripFare": "Fare", + "tripEarnings": "Your earnings", + "tripCommission": "Commission", + "tripCancelTitle": "Why are you cancelling?", + "tripCancelReasonWait": "The wait is too long", + "tripCancelReasonMistake": "I requested by mistake", + "tripCancelReasonOther": "Another reason", + "tripCancelConfirm": "Confirm cancellation", + "tripCancelKeep": "Keep the ride", + "walletTitle": "Wallet", + "walletBalance": "Balance", + "walletTopup": "Top up", + "walletTopupAmount": "Amount", + "walletTransactions": "Transactions", + "walletEmpty": "No transactions yet", + "walletTopupFailed": "Top-up failed", + "creditTitle": "Operating credit", + "creditLead": "Commission is deducted from it when each trip ends", + "creditBlocked": "Your operating credit ran out — top it up to receive requests", + "payoutTitle": "Withdraw earnings", + "payoutAmount": "Amount", + "payoutChannel": "Payout channel", + "payoutRequest": "Send confirmation code", + "payoutOtpTitle": "Confirm withdrawal", + "payoutOtpLead": "We sent a code to your number to confirm this withdrawal", + "payoutConfirm": "Confirm withdrawal", + "payoutDone": "Withdrawal request sent", + "payoutFailed": "Withdrawal failed", + "payoutInsufficient": "Insufficient balance", + "payoutHistory": "Withdrawal requests" } \ 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 083cb5c..c727bc8 100644 --- a/apps/driver_new/lib/core/router.dart +++ b/apps/driver_new/lib/core/router.dart @@ -7,7 +7,10 @@ import 'session/session_cubit.dart'; import 'session/session_state.dart'; import '../features/auth/view/login_page.dart'; import '../features/auth/view/profile_page.dart'; +import '../features/rating/data/rating_repository.dart'; +import '../features/rating/view/rating_page.dart'; import '../features/trip/view/duty_page.dart'; +import '../features/wallet/view/earnings_page.dart'; import '../features/splash/view/splash_page.dart'; import 'di.dart'; @@ -17,6 +20,8 @@ class Routes { static const login = '/login'; static const profile = '/profile'; static const home = '/home'; + static const rating = '/rating'; + static const wallet = '/wallet'; } /// التوجيه يُشتقّ من `SessionCubit` وحده — لا `Navigator.push` بعد الدخول @@ -35,10 +40,16 @@ final appRouter = GoRouter( SessionStatus.unauthenticated => loc == Routes.login ? null : Routes.login, SessionStatus.needsProfile => loc == Routes.profile ? null : Routes.profile, - SessionStatus.authenticated => - (loc == Routes.login || loc == Routes.profile || loc == Routes.splash) - ? Routes.home - : null, + // التقييم المعلّق يحجز التطبيق كله: لا شاشة أخرى قبل حسمه + // (docs/38 §7 — يُفرض عند الفتح). + SessionStatus.authenticated when session.pendingRating != null => + loc == Routes.rating ? null : Routes.rating, + SessionStatus.authenticated => (loc == Routes.login || + loc == Routes.profile || + loc == Routes.splash || + loc == Routes.rating) + ? Routes.home + : null, SessionStatus.unknown => Routes.splash, }; }, @@ -55,6 +66,23 @@ final appRouter = GoRouter( path: Routes.profile, builder: (context, state) => const ProfilePage(), ), + GoRoute( + path: Routes.rating, + builder: (context, state) { + final pending = sl().state.pendingRating!; + return RatingPage( + pending: PendingRating( + tripId: pending.tripId, + role: pending.role, + ), + onDone: sl().onRatingResolved, + ); + }, + ), + GoRoute( + path: Routes.wallet, + builder: (context, state) => const EarningsPage(), + ), GoRoute( path: Routes.home, builder: (context, state) => const DutyPage(), diff --git a/apps/driver_new/lib/core/session/session_cubit.dart b/apps/driver_new/lib/core/session/session_cubit.dart index 51ef5a5..3a135c2 100644 --- a/apps/driver_new/lib/core/session/session_cubit.dart +++ b/apps/driver_new/lib/core/session/session_cubit.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_bloc/flutter_bloc.dart'; import '../api/api_exception.dart'; @@ -46,12 +48,34 @@ class SessionCubit extends Cubit { emit(const SessionState(status: SessionStatus.unauthenticated)); } + /// يُنادى بعد التقييم أو تخطّيه — يرفع الحجز عن بقية التطبيق. + void onRatingResolved() => + emit(state.copyWith(clearPendingRating: true)); + void _apply(AppUser user) { emit(SessionState( status: user.needsProfile ? SessionStatus.needsProfile : SessionStatus.authenticated, user: user, + pendingRating: state.pendingRating, )); + // التقييم المعلّق يُفحص بعد إثبات الجلسة لا قبلها. + if (!user.needsProfile) unawaited(_checkPendingRating()); + } + + Future _checkPendingRating() async { + try { + final pending = await _repo.pendingRating(); + if (isClosed || pending == null) return; + emit(state.copyWith( + pendingRating: PendingRatingRef( + tripId: pending.tripId, + role: pending.role, + ), + )); + } on ApiException { + // فحصٌ مساعد لا حاجز دخول. + } } } diff --git a/apps/driver_new/lib/core/session/session_source.dart b/apps/driver_new/lib/core/session/session_source.dart index 8366f5c..6e2e0b3 100644 --- a/apps/driver_new/lib/core/session/session_source.dart +++ b/apps/driver_new/lib/core/session/session_source.dart @@ -8,6 +8,9 @@ import 'app_user.dart'; /// ويُنفّذها `AuthRepository` في ميزة المصادقة. abstract interface class SessionSource { Future me(); + + /// رحلة تنتظر تقييماً، أو `null`. يُنفّذها `RatingRepository`. + Future<({String tripId, String role})?> pendingRating(); Future logout(); bool get hasSession; } diff --git a/apps/driver_new/lib/core/session/session_state.dart b/apps/driver_new/lib/core/session/session_state.dart index a6567f3..9d0b92d 100644 --- a/apps/driver_new/lib/core/session/session_state.dart +++ b/apps/driver_new/lib/core/session/session_state.dart @@ -2,6 +2,16 @@ import 'package:equatable/equatable.dart'; import 'app_user.dart'; +/// رحلة تنتظر تقييماً — تُفحص عند كل فتح للتطبيق (docs/38 §7). +class PendingRatingRef { + const PendingRatingRef({required this.tripId, required this.role}); + + final String tripId; + final String role; + + bool get isDriver => role == 'driver'; +} + enum SessionStatus { /// قبل أن نعرف — تُعرض شاشة الإقلاع، ولا يُتّخذ قرار توجيه. unknown, @@ -13,20 +23,34 @@ enum SessionStatus { } class SessionState extends Equatable { - const SessionState({this.status = SessionStatus.unknown, this.user}); + const SessionState({ + this.status = SessionStatus.unknown, + this.user, + this.pendingRating, + }); final SessionStatus status; final AppUser? user; + /// وجودها يفرض شاشة التقييم قبل أي شاشة أخرى. + final PendingRatingRef? pendingRating; + bool get isReady => status != SessionStatus.unknown; - SessionState copyWith({SessionStatus? status, AppUser? user}) { + SessionState copyWith({ + SessionStatus? status, + AppUser? user, + PendingRatingRef? pendingRating, + bool clearPendingRating = false, + }) { return SessionState( status: status ?? this.status, user: user ?? this.user, + pendingRating: + clearPendingRating ? null : (pendingRating ?? this.pendingRating), ); } @override - List get props => [status, user]; + List get props => [status, user, pendingRating?.tripId]; } diff --git a/apps/driver_new/lib/features/auth/data/auth_repository.dart b/apps/driver_new/lib/features/auth/data/auth_repository.dart index 5af7e4f..e8ac180 100644 --- a/apps/driver_new/lib/features/auth/data/auth_repository.dart +++ b/apps/driver_new/lib/features/auth/data/auth_repository.dart @@ -63,6 +63,18 @@ class AuthRepository implements SessionSource { return AppUser.fromJson(res); } + /// من عقد `SessionSource`: الجلسة تحتاج معرفة التقييم المعلّق، ولا يجوز + /// أن يستورد `core` ميزةَ التقييم — فتمرّ عبر الواجهة نفسها. + @override + Future<({String tripId, String role})?> pendingRating() async { + final res = await _api.get>('/trips/rating/pending'); + final pending = res['pending']; + if (pending is! Map) return null; + final tripId = pending['tripId'] as String?; + if (tripId == null) return null; + return (tripId: tripId, role: pending['role'] as String? ?? 'rider'); + } + @override Future logout() => _tokens.clear(); diff --git a/apps/driver_new/lib/features/rating/cubit/rating_cubit.dart b/apps/driver_new/lib/features/rating/cubit/rating_cubit.dart new file mode 100644 index 0000000..05c47e0 --- /dev/null +++ b/apps/driver_new/lib/features/rating/cubit/rating_cubit.dart @@ -0,0 +1,40 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/api/api_exception.dart'; +import '../data/rating_repository.dart'; +import 'rating_state.dart'; + +class RatingCubit extends Cubit { + RatingCubit(this._repo) : super(const RatingState()); + + final RatingRepository _repo; + + void attach(PendingRating pending) => emit(RatingState(pending: pending)); + + void setStars(int stars) => emit(state.copyWith(stars: stars)); + + void setComment(String comment) => emit(state.copyWith(comment: comment)); + + Future submit() async { + final pending = state.pending; + if (pending == null || !state.canSubmit) return; + + emit(state.copyWith(status: RatingStatus.submitting)); + try { + await _repo.rate( + pending.tripId, + state.stars, + comment: state.comment.trim().isEmpty ? null : state.comment.trim(), + ); + emit(state.copyWith(status: RatingStatus.done)); + } on ApiException catch (e) { + // 400 هنا = تقييم مزدوج، وهو نجاحٌ من منظور المستخدم: الرحلة مُقيَّمة + // فعلاً، فلا يُحبس في شاشة لا مخرج منها. + emit(state.copyWith( + status: e.statusCode == 400 + ? RatingStatus.alreadyRated + : RatingStatus.failed, + )); + } + } +} diff --git a/apps/driver_new/lib/features/rating/cubit/rating_state.dart b/apps/driver_new/lib/features/rating/cubit/rating_state.dart new file mode 100644 index 0000000..3053676 --- /dev/null +++ b/apps/driver_new/lib/features/rating/cubit/rating_state.dart @@ -0,0 +1,38 @@ +import 'package:equatable/equatable.dart'; + +import '../data/rating_repository.dart'; + +enum RatingStatus { idle, submitting, done, alreadyRated, failed } + +class RatingState extends Equatable { + const RatingState({ + this.status = RatingStatus.idle, + this.pending, + this.stars = 0, + this.comment = '', + }); + + final RatingStatus status; + final PendingRating? pending; + final int stars; + final String comment; + + bool get canSubmit => stars > 0 && status != RatingStatus.submitting; + + RatingState copyWith({ + RatingStatus? status, + PendingRating? pending, + int? stars, + String? comment, + }) { + return RatingState( + status: status ?? this.status, + pending: pending ?? this.pending, + stars: stars ?? this.stars, + comment: comment ?? this.comment, + ); + } + + @override + List get props => [status, pending?.tripId, stars, comment]; +} diff --git a/apps/driver_new/lib/features/rating/data/rating_repository.dart b/apps/driver_new/lib/features/rating/data/rating_repository.dart new file mode 100644 index 0000000..7ed24c4 --- /dev/null +++ b/apps/driver_new/lib/features/rating/data/rating_repository.dart @@ -0,0 +1,40 @@ +import '../../../core/api/api_client.dart'; + +/// رحلة تنتظر تقييماً — يرجّعها الخادم عند كل فتح للتطبيق (docs/38 §7). +class PendingRating { + const PendingRating({required this.tripId, required this.role}); + + final String tripId; + + /// الدور يحدّد نصّ السؤال: الراكب يقيّم السائق والعكس. + final String role; + + bool get isDriver => role == 'driver'; +} + +class RatingRepository { + RatingRepository(this._api); + + final ApiClient _api; + + /// `{"pending": null, "count": 0}` حين لا شيء — شكل مُتحقَّق حيّاً. + Future pending() async { + final res = await _api.get>('/trips/rating/pending'); + final pending = res['pending']; + if (pending is! Map) return null; + final tripId = pending['tripId'] as String?; + if (tripId == null) return null; + return PendingRating( + tripId: tripId, + role: pending['role'] as String? ?? 'rider', + ); + } + + /// التقييم المزدوج يرجّع **400** — تُعامَل كحالة معروفة لا كعطل. + Future rate(String tripId, int stars, {String? comment}) { + return _api.post>( + '/trips/$tripId/rate', + body: {'stars': stars, 'comment': ?comment}, + ); + } +} diff --git a/apps/driver_new/lib/features/rating/view/rating_page.dart b/apps/driver_new/lib/features/rating/view/rating_page.dart new file mode 100644 index 0000000..24074fc --- /dev/null +++ b/apps/driver_new/lib/features/rating/view/rating_page.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/design/tokens.dart'; +import '../../../core/design/tripz_colors.dart'; +import '../../../core/di.dart'; +import '../../../core/l10n/l10n.dart'; +import '../../../core/ui/tripz_button.dart'; +import '../../../core/ui/tripz_scaffold.dart'; +import '../../../core/ui/tripz_text_field.dart'; +import '../cubit/rating_cubit.dart'; +import '../cubit/rating_state.dart'; +import '../data/rating_repository.dart'; +import 'star_row.dart'; + +/// شاشة التقييم — **تُفرض عند فتح التطبيق** إن كانت هناك رحلة معلّقة +/// (`GET /trips/rating/pending`، docs/38 §7). لذلك بلا زرّ رجوع في الشريط: +/// الطريق الوحيد للخروج هو التقييم أو التخطّي الصريح. +class RatingPage extends StatefulWidget { + const RatingPage({super.key, required this.pending, required this.onDone}); + + final PendingRating pending; + final VoidCallback onDone; + + @override + State createState() => _RatingPageState(); +} + +class _RatingPageState extends State { + late final RatingCubit _cubit = sl()..attach(widget.pending); + final _comment = TextEditingController(); + + @override + void dispose() { + _comment.dispose(); + _cubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + // من يقيّم مَن: دور **هذا** المستخدم في الرحلة يأتي من الخادم، ولا + // يُستنتج من نوع التطبيق — الرقم نفسه قد يكون له حسابان (docs/38 §1). + final title = widget.pending.isDriver ? l10n.rateTitleDriver : l10n.rateTitle; + + return BlocProvider.value( + value: _cubit, + child: BlocConsumer( + listenWhen: (p, n) => p.status != n.status, + listener: (context, state) { + if (state.status == RatingStatus.done || + state.status == RatingStatus.alreadyRated) { + widget.onDone(); + } + }, + builder: (context, state) { + return TripzScaffold( + showAppBar: false, + bottomAction: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TripzButton.primary( + label: l10n.rateSubmit, + loading: state.status == RatingStatus.submitting, + onPressed: state.canSubmit ? _cubit.submit : null, + ), + TripzButton.text( + label: l10n.rateSkip, + onPressed: widget.onDone, + ), + ], + ), + child: ListView( + children: [ + const SizedBox(height: Space.xxl), + Icon( + Icons.star_rounded, + size: 56, + color: context.tripzColors.warning, + ), + const SizedBox(height: Space.md), + Text( + title, + textAlign: TextAlign.center, + style: context.texts.headlineSmall, + ), + const SizedBox(height: Space.xs), + Text( + l10n.rateLead, + textAlign: TextAlign.center, + style: context.texts.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Space.lg), + StarRow(value: state.stars, onChanged: _cubit.setStars), + const SizedBox(height: Space.lg), + TripzTextField( + controller: _comment, + hint: l10n.rateComment, + onChanged: _cubit.setComment, + ), + if (state.status == RatingStatus.failed) ...[ + const SizedBox(height: Space.sm), + Text( + l10n.errorGeneric, + textAlign: TextAlign.center, + style: context.texts.bodySmall?.copyWith( + color: context.tripzColors.danger, + ), + ), + ], + // مساحة للـCTA السفلي كي لا يغطّي الحقل عند فتح الكيبورد. + const SizedBox(height: Sizes.control), + ], + ), + ); + }, + ), + ); + } +} diff --git a/apps/driver_new/lib/features/rating/view/star_row.dart b/apps/driver_new/lib/features/rating/view/star_row.dart new file mode 100644 index 0000000..bd8f664 --- /dev/null +++ b/apps/driver_new/lib/features/rating/view/star_row.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import '../../../core/design/tokens.dart'; +import '../../../core/design/tripz_colors.dart'; + +/// خمس نجوم — أهداف لمس ≥ 48 لأن الشاشة تُستعمل عادةً والمستخدم مستعجل. +class StarRow extends StatelessWidget { + const StarRow({super.key, required this.value, required this.onChanged}); + + final int value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + // النجوم تُقرأ يساراً-ليميناً في اللغتين: ترتيبها ليس نصّاً. + textDirection: TextDirection.ltr, + children: List.generate(5, (i) { + final star = i + 1; + final filled = star <= value; + return IconButton( + onPressed: () => onChanged(star), + iconSize: Sizes.iconLg, + constraints: const BoxConstraints( + minWidth: Sizes.touchTarget, + minHeight: Sizes.touchTarget, + ), + icon: AnimatedSwitcher( + duration: Motion.tap, + child: Icon( + filled ? Icons.star_rounded : Icons.star_outline_rounded, + key: ValueKey(filled), + color: filled + ? context.tripzColors.warning + : context.colors.onSurfaceVariant, + ), + ), + ); + }), + ); + } +} 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 3859068..7e57233 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 @@ -3,11 +3,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../core/design/tokens.dart'; import '../../../../core/design/tripz_colors.dart'; -import '../../../../core/design/typography.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../core/ui/tripz_button.dart'; import '../../cubit/duty_cubit.dart'; import '../../data/models/trip.dart'; +import 'fare_summary.dart'; /// الرحلة من طرف السائق: **زر واحد** يدفع آلة الحالات خطوة واحدة. /// السائق لا يختار الحالة التالية — الكيوبت يشتقّها (docs/38 §4). @@ -25,19 +25,12 @@ class ActiveDutySheet extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(_statusText(context), style: context.texts.titleMedium), - if (trip.priceForDriver != null) - Text( - '${trip.priceForDriver} ${trip.currency}', - style: numericStyle(context.texts.titleLarge).copyWith( - color: context.tripzColors.success, - ), - ), - ], - ), + Text(_statusText(context), style: context.texts.titleMedium), + if (trip.priceForDriver != null) ...[ + const SizedBox(height: Space.sm), + // السائق يرى **أرباحه** لا أجرة الراكب: حقلان منفصلان والفرق عمولة. + FareSummary(trip: trip, forDriver: true), + ], const SizedBox(height: Space.md), TripzButton.primary( label: _actionLabel(context, cubit), diff --git a/apps/driver_new/lib/features/trip/view/widgets/fare_summary.dart b/apps/driver_new/lib/features/trip/view/widgets/fare_summary.dart new file mode 100644 index 0000000..baf4f83 --- /dev/null +++ b/apps/driver_new/lib/features/trip/view/widgets/fare_summary.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/design/typography.dart'; +import '../../../../core/format/money.dart'; +import '../../../../core/l10n/l10n.dart'; +import '../../data/models/trip.dart'; + +/// ملخّص الأجرة عند الإنهاء. +/// +/// ⚠️ `price_for_passenger` و`price_for_driver` **حقلان منفصلان** والفرق +/// بينهما هو العمولة (docs/38 §4). كلٌّ يرى رقمه: الراكب ما يدفع، والسائق ما +/// يكسب — عرض رقم واحد للطرفين خطأ لا اختصار. +class FareSummary extends StatelessWidget { + const FareSummary({super.key, required this.trip, this.forDriver = false}); + + final Trip trip; + final bool forDriver; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final amount = forDriver ? trip.priceForDriver : trip.priceForPassenger; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.tripFareTitle, style: context.texts.titleMedium), + const SizedBox(height: Space.sm), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + forDriver ? l10n.tripEarnings : l10n.tripFare, + style: context.texts.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + Text( + Money.format(amount, trip.currency), + style: numericStyle(context.texts.headlineSmall).copyWith( + color: forDriver ? context.tripzColors.success : null, + ), + ), + ], + ), + ], + ); + } +} diff --git a/apps/driver_new/lib/features/wallet/cubit/payout_cubit.dart b/apps/driver_new/lib/features/wallet/cubit/payout_cubit.dart new file mode 100644 index 0000000..69dda90 --- /dev/null +++ b/apps/driver_new/lib/features/wallet/cubit/payout_cubit.dart @@ -0,0 +1,88 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/api/api_exception.dart'; +import '../data/wallet_repository.dart'; + +/// خطوتا السحب (docs/38 §6): `request` يرسل رمزاً **بلا خصم**، و`confirm` +/// هو ما يخصم ويحجز. شاشة سيرو كانت خطوة واحدة — لا تُقلَّد (docs/39 §4). +enum PayoutStep { amount, code, done } + +enum PayoutFailure { none, insufficient, rejected, network } + +class PayoutState { + const PayoutState({ + this.step = PayoutStep.amount, + this.busy = false, + this.payoutId, + this.devCode, + this.failure = PayoutFailure.none, + }); + + final PayoutStep step; + final bool busy; + final String? payoutId; + + /// يعود من الخادم في وضع التطوير وحده — يملأ الحقل لتسريع الاختبار. + final String? devCode; + + final PayoutFailure failure; + + PayoutState copyWith({ + PayoutStep? step, + bool? busy, + String? payoutId, + String? devCode, + PayoutFailure? failure, + }) { + return PayoutState( + step: step ?? this.step, + busy: busy ?? this.busy, + payoutId: payoutId ?? this.payoutId, + devCode: devCode ?? this.devCode, + failure: failure ?? this.failure, + ); + } +} + +class PayoutCubit extends Cubit { + PayoutCubit(this._repo) : super(const PayoutState()); + + final WalletRepository _repo; + + Future request({required num amount, required String channel}) async { + if (amount <= 0) return; + emit(state.copyWith(busy: true, failure: PayoutFailure.none)); + try { + final res = await _repo.requestPayout(amount: amount, channel: channel); + emit(state.copyWith( + busy: false, + step: PayoutStep.code, + payoutId: res.payoutId, + devCode: res.devCode, + )); + } on ApiException catch (e) { + emit(state.copyWith( + busy: false, + failure: e.statusCode == 400 + ? PayoutFailure.insufficient + : PayoutFailure.network, + )); + } + } + + Future confirm(String code) async { + final id = state.payoutId; + if (id == null || code.isEmpty) return; + emit(state.copyWith(busy: true, failure: PayoutFailure.none)); + try { + await _repo.confirmPayout(id, code); + emit(state.copyWith(busy: false, step: PayoutStep.done)); + } on ApiException { + // الرمز الخاطئ لا يُرجع المستخدم لخطوة المبلغ: يبقى على الرمز ويعيد + // المحاولة — الطلب قائم على الخادم أصلاً. + emit(state.copyWith(busy: false, failure: PayoutFailure.rejected)); + } + } + + void reset() => emit(const PayoutState()); +} diff --git a/apps/driver_new/lib/features/wallet/cubit/wallet_cubit.dart b/apps/driver_new/lib/features/wallet/cubit/wallet_cubit.dart new file mode 100644 index 0000000..b8f743a --- /dev/null +++ b/apps/driver_new/lib/features/wallet/cubit/wallet_cubit.dart @@ -0,0 +1,45 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/api/api_exception.dart'; +import '../../../core/ui/view_status.dart'; +import '../data/wallet_repository.dart'; +import 'wallet_state.dart'; + +class WalletCubit extends Cubit { + WalletCubit(this._repo) : super(const WalletState()); + + final WalletRepository _repo; + + Future load() async { + emit(state.copyWith(status: ViewStatus.loading)); + try { + final wallet = await _repo.wallet(); + final txns = await _repo.transactions(); + if (isClosed) return; + emit(state.copyWith( + status: ViewStatus.success, + wallet: wallet, + transactions: txns, + )); + } on ApiException catch (e) { + if (!isClosed) { + emit(state.copyWith(status: ViewStatus.error, error: e.message)); + } + } + } + + Future topup(num amount) async { + if (amount <= 0) return; + emit(state.copyWith(action: WalletAction.toppingUp)); + try { + await _repo.topup(amount); + if (isClosed) return; + emit(state.copyWith(action: WalletAction.topupDone)); + // الرصيد يُعاد جلبه من الخادم لا يُحسب محلياً: المال لا يُقدَّر + // في التطبيق (docs/23 §0.2). + await load(); + } on ApiException { + if (!isClosed) emit(state.copyWith(action: WalletAction.topupFailed)); + } + } +} diff --git a/apps/driver_new/lib/features/wallet/cubit/wallet_state.dart b/apps/driver_new/lib/features/wallet/cubit/wallet_state.dart new file mode 100644 index 0000000..5ddedc6 --- /dev/null +++ b/apps/driver_new/lib/features/wallet/cubit/wallet_state.dart @@ -0,0 +1,48 @@ +import 'package:equatable/equatable.dart'; + +import '../../../core/ui/view_status.dart'; +import '../data/models/wallet.dart'; +import '../data/models/wallet_txn.dart'; + +enum WalletAction { none, toppingUp, topupFailed, topupDone } + +class WalletState extends Equatable { + const WalletState({ + this.status = ViewStatus.idle, + this.wallet, + this.transactions = const [], + this.action = WalletAction.none, + this.error, + }); + + final ViewStatus status; + final Wallet? wallet; + final List transactions; + + /// فعلٌ جارٍ داخل الشاشة — منفصل عن [status] كي لا يُخفي الشحنُ الرصيدَ + /// المعروض خلفه. + final WalletAction action; + + final String? error; + + String get currency => wallet?.currency ?? ''; + + WalletState copyWith({ + ViewStatus? status, + Wallet? wallet, + List? transactions, + WalletAction? action, + String? error, + }) { + return WalletState( + status: status ?? this.status, + wallet: wallet ?? this.wallet, + transactions: transactions ?? this.transactions, + action: action ?? this.action, + error: error ?? this.error, + ); + } + + @override + List get props => [status, wallet, transactions, action, error]; +} diff --git a/apps/driver_new/lib/features/wallet/data/models/wallet.dart b/apps/driver_new/lib/features/wallet/data/models/wallet.dart new file mode 100644 index 0000000..b10a0e1 --- /dev/null +++ b/apps/driver_new/lib/features/wallet/data/models/wallet.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; + +/// المحفظة كما يرجّعها `GET /wallet` (شكل مُتحقَّق حيّاً، docs/38 §6). +/// +/// ⚠️ **محفظتان لا واحدة**: محفظة الراكب **التزام** على المنصّة (رصيدٌ دفعه +/// ولم يستهلكه بعد)، ومحفظة السائق **إيراد** له. دمجهما في شاشة أو حساب +/// واحد خطأ محاسبي لا تجميلي — لذلك لا تُوحَّد شاشتاهما (docs/39 §4). +class Wallet extends Equatable { + const Wallet({ + required this.id, + required this.balance, + required this.currency, + }); + + final String id; + + /// نصّ لا رقم (`"0.000"`) — يُحوَّل عبر `Money` عند الحاجة فقط. + final String balance; + + /// من إعداد المستأجر: `JOD`/`EGP`/`SYP` — لا تُثبَّت في التطبيق. + final String currency; + + factory Wallet.fromJson(Map json) => Wallet( + id: json['id'] as String? ?? '', + balance: json['balance']?.toString() ?? '0', + currency: json['currency'] as String? ?? '', + ); + + @override + List get props => [id, balance, currency]; +} diff --git a/apps/driver_new/lib/features/wallet/data/models/wallet_txn.dart b/apps/driver_new/lib/features/wallet/data/models/wallet_txn.dart new file mode 100644 index 0000000..75e5204 --- /dev/null +++ b/apps/driver_new/lib/features/wallet/data/models/wallet_txn.dart @@ -0,0 +1,29 @@ +import 'package:equatable/equatable.dart'; + +class WalletTxn extends Equatable { + const WalletTxn({ + required this.id, + required this.amount, + required this.reason, + this.createdAt, + }); + + final String id; + + /// موجب = دخول، سالب = خروج. النصّ كما وصل، والإشارة تُقرأ منه. + final String amount; + final String reason; + final DateTime? createdAt; + + factory WalletTxn.fromJson(Map json) => WalletTxn( + id: json['id']?.toString() ?? '', + amount: json['amount']?.toString() ?? '0', + reason: json['reason'] as String? ?? json['type'] as String? ?? '', + createdAt: DateTime.tryParse(json['created_at']?.toString() ?? ''), + ); + + bool get isCredit => !amount.trim().startsWith('-'); + + @override + List get props => [id, amount, reason, createdAt]; +} diff --git a/apps/driver_new/lib/features/wallet/data/wallet_repository.dart b/apps/driver_new/lib/features/wallet/data/wallet_repository.dart new file mode 100644 index 0000000..2f7a121 --- /dev/null +++ b/apps/driver_new/lib/features/wallet/data/wallet_repository.dart @@ -0,0 +1,55 @@ +import '../../../core/api/api_client.dart'; +import 'models/wallet.dart'; +import 'models/wallet_txn.dart'; + +class WalletRepository { + WalletRepository(this._api); + + final ApiClient _api; + + Future wallet() async { + final res = await _api.get>('/wallet'); + return Wallet.fromJson(res); + } + + Future> transactions() async { + final res = await _api.get>('/wallet/transactions'); + return res + .whereType>() + .map(WalletTxn.fromJson) + .toList(growable: false); + } + + Future topup(num amount) => + _api.post>('/wallet/topup', body: {'amount': amount}); + + // ── السحب: خطوتان لا خطوة ──────────────────────────────────────────── + // `request` يرسل رمزاً **بلا خصم**، و`confirm` هو ما يخصم ويحجز + // (docs/38 §6). شاشة سيرو كانت خطوة واحدة — لا تُقلَّد. + + Future<({String payoutId, String? devCode})> requestPayout({ + required num amount, + required String channel, + }) async { + final res = await _api.post>( + '/payouts/request', + body: {'amount': amount, 'channel': channel}, + ); + final payout = res['payout'] as Map?; + return ( + payoutId: payout?['id'] as String? ?? '', + devCode: res['dev_code']?.toString(), + ); + } + + Future confirmPayout(String payoutId, String code) => + _api.post>( + '/payouts/$payoutId/confirm', + body: {'code': code}, + ); + + Future>> payouts() async { + final res = await _api.get>('/payouts/mine'); + return res.whereType>().toList(growable: false); + } +} diff --git a/apps/driver_new/lib/features/wallet/view/earnings_page.dart b/apps/driver_new/lib/features/wallet/view/earnings_page.dart new file mode 100644 index 0000000..ec8aff5 --- /dev/null +++ b/apps/driver_new/lib/features/wallet/view/earnings_page.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/design/tokens.dart'; +import '../../../core/design/tripz_colors.dart'; +import '../../../core/di.dart'; +import '../../../core/l10n/l10n.dart'; +import '../../../core/ui/empty_view.dart'; +import '../../../core/ui/status_banner.dart'; +import '../../../core/ui/tripz_button.dart'; +import '../../../core/ui/tripz_scaffold.dart'; +import '../../../core/ui/tripz_sheet.dart'; +import '../cubit/payout_cubit.dart'; +import '../cubit/wallet_cubit.dart'; +import '../cubit/wallet_state.dart'; +import 'widgets/balance_card.dart'; +import 'widgets/payout_sheet.dart'; +import 'widgets/txn_tile.dart'; + +/// شاشة السائق المالية — **مختلفة عن محفظة الراكب عمداً**. +/// +/// محفظة السائق **إيراد** له (أرباح تُسحب)، ومحفظة الراكب **التزام** على +/// المنصّة (رصيد مدفوع لم يُستهلك). دمجهما في شاشة واحدة خطأ محاسبي، ولذلك +/// لا تُوحَّد الشاشتان مهما تشابه شكلهما (docs/39 §4). +class EarningsPage extends StatefulWidget { + const EarningsPage({super.key}); + + @override + State createState() => _EarningsPageState(); +} + +class _EarningsPageState extends State { + late final WalletCubit _wallet = sl()..load(); + + @override + void dispose() { + _wallet.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return BlocProvider.value( + value: _wallet, + child: BlocBuilder( + builder: (context, state) { + return TripzScaffold( + title: l10n.payoutTitle, + status: state.status, + error: state.error, + onRetry: _wallet.load, + child: ListView( + children: [ + BalanceCard( + label: l10n.tripEarnings, + balance: state.wallet?.balance, + currency: state.currency, + action: TripzButton.primary( + label: l10n.payoutTitle, + onPressed: () => _openPayout(context), + ), + ), + const SizedBox(height: Space.md), + // الرصيد التشغيلي **ليس** أرباحاً: منه تُخصم العمولة، ونفاده + // يوقف وصول الطلبات. يُعرض منفصلاً كي لا يُخلط بالأرباح. + StatusBanner( + message: l10n.creditLead, + tone: BannerTone.info, + icon: Icons.account_balance_wallet_outlined, + ), + const SizedBox(height: Space.lg), + Text(l10n.walletTransactions, style: context.texts.titleMedium), + const SizedBox(height: Space.xs), + if (state.transactions.isEmpty) + Padding( + padding: const EdgeInsets.only(top: Space.xl), + child: EmptyView( + icon: Icons.receipt_long_outlined, + message: l10n.walletEmpty, + ), + ) + else + for (final txn in state.transactions) + TxnTile(txn: txn, currency: state.currency), + ], + ), + ); + }, + ), + ); + } + + void _openPayout(BuildContext context) { + TripzSheet.show( + context, + child: BlocProvider( + create: (_) => sl(), + child: PayoutSheet(onDone: _wallet.load), + ), + ); + } +} diff --git a/apps/driver_new/lib/features/wallet/view/widgets/balance_card.dart b/apps/driver_new/lib/features/wallet/view/widgets/balance_card.dart new file mode 100644 index 0000000..469815a --- /dev/null +++ b/apps/driver_new/lib/features/wallet/view/widgets/balance_card.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/design/typography.dart'; +import '../../../../core/format/money.dart'; + +/// بطاقة الرصيد — الرقم هو بطل الشاشة، فيأخذ أكبر حجم وأعلى تباين. +class BalanceCard extends StatelessWidget { + const BalanceCard({ + super.key, + required this.label, + required this.balance, + required this.currency, + this.hint, + this.action, + }); + + final String label; + final String? balance; + final String currency; + final String? hint; + final Widget? action; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: Space.page, + decoration: BoxDecoration( + color: context.colors.primaryContainer, + borderRadius: Radii.card_, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: context.texts.labelLarge?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + const SizedBox(height: Space.xs), + Text( + Money.format(balance, currency), + style: numericStyle(context.texts.displaySmall).copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + if (hint != null) ...[ + const SizedBox(height: Space.xxs), + Text( + hint!, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onPrimaryContainer.withValues(alpha: 0.8), + ), + ), + ], + if (action != null) ...[ + const SizedBox(height: Space.md), + action!, + ], + ], + ), + ); + } +} diff --git a/apps/driver_new/lib/features/wallet/view/widgets/payout_sheet.dart b/apps/driver_new/lib/features/wallet/view/widgets/payout_sheet.dart new file mode 100644 index 0000000..b440576 --- /dev/null +++ b/apps/driver_new/lib/features/wallet/view/widgets/payout_sheet.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/config.dart'; +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/l10n/l10n.dart'; +import '../../../../core/ui/tripz_button.dart'; +import '../../../../core/ui/tripz_otp_field.dart'; +import '../../../../core/ui/tripz_text_field.dart'; +import '../../cubit/payout_cubit.dart'; + +/// سحب الأرباح بخطوتيه. الخطوة الثانية رمز OTP — والمستخدم يرى صراحةً أن +/// المبلغ **لم يُخصم بعد** حتى يؤكّد. +class PayoutSheet extends StatefulWidget { + const PayoutSheet({super.key, required this.onDone}); + + final VoidCallback onDone; + + @override + State createState() => _PayoutSheetState(); +} + +class _PayoutSheetState extends State { + final _amount = TextEditingController(); + final _code = TextEditingController(); + + @override + void dispose() { + _amount.dispose(); + _code.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return BlocConsumer( + listenWhen: (p, n) => p.step != n.step, + listener: (context, state) { + if (state.step == PayoutStep.done) { + Navigator.of(context).pop(); + widget.onDone(); + } + // في وضع التطوير يصل الرمز في الرد — يُملأ لاختصار الاختبار. + if (state.step == PayoutStep.code && state.devCode != null) { + _code.text = state.devCode!; + } + }, + builder: (context, state) { + final cubit = context.read(); + final isCodeStep = state.step == PayoutStep.code; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + isCodeStep ? l10n.payoutOtpTitle : l10n.payoutTitle, + style: context.texts.titleLarge, + ), + const SizedBox(height: Space.xs), + Text( + isCodeStep ? l10n.payoutOtpLead : l10n.creditLead, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Space.lg), + if (isCodeStep) + TripzOtpField( + controller: _code, + length: AppConfig.otpLength, + hasError: state.failure == PayoutFailure.rejected, + onCompleted: cubit.confirm, + ) + else + TripzTextField( + controller: _amount, + label: l10n.payoutAmount, + numeric: true, + autofocus: true, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + ), + if (state.failure != PayoutFailure.none) ...[ + const SizedBox(height: Space.sm), + Text( + switch (state.failure) { + PayoutFailure.insufficient => l10n.payoutInsufficient, + PayoutFailure.rejected => l10n.otpErrInvalid, + _ => l10n.payoutFailed, + }, + style: context.texts.bodySmall?.copyWith( + color: context.tripzColors.danger, + ), + ), + ], + const SizedBox(height: Space.lg), + TripzButton.primary( + label: isCodeStep ? l10n.payoutConfirm : l10n.payoutRequest, + loading: state.busy, + onPressed: () { + if (isCodeStep) { + cubit.confirm(_code.text.trim()); + } else { + final amount = num.tryParse(_amount.text.trim()); + if (amount != null) { + cubit.request(amount: amount, channel: 'cliq'); + } + } + }, + ), + ], + ); + }, + ); + } +} diff --git a/apps/driver_new/lib/features/wallet/view/widgets/txn_tile.dart b/apps/driver_new/lib/features/wallet/view/widgets/txn_tile.dart new file mode 100644 index 0000000..dacd33f --- /dev/null +++ b/apps/driver_new/lib/features/wallet/view/widgets/txn_tile.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/design/typography.dart'; +import '../../../../core/format/money.dart'; +import '../../data/models/wallet_txn.dart'; + +class TxnTile extends StatelessWidget { + const TxnTile({super.key, required this.txn, required this.currency}); + + final WalletTxn txn; + final String currency; + + @override + Widget build(BuildContext context) { + final colors = context.tripzColors; + final isCredit = txn.isCredit; + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + backgroundColor: + (isCredit ? colors.success : colors.danger).withValues(alpha: 0.12), + child: Icon( + isCredit ? Icons.south_west_rounded : Icons.north_east_rounded, + size: Sizes.iconSm, + color: isCredit ? colors.success : colors.danger, + ), + ), + title: Text(txn.reason, style: context.texts.bodyMedium), + subtitle: txn.createdAt == null + ? null + : Text( + // التاريخ بصيغة الـlocale (docs/26 §5). + MaterialLocalizations.of(context).formatFullDate(txn.createdAt!), + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + trailing: Text( + Money.format(txn.amount, currency), + style: numericStyle(context.texts.titleSmall).copyWith( + color: isCredit ? colors.success : colors.danger, + ), + ), + ); + } +} diff --git a/apps/driver_new/pubspec.lock b/apps/driver_new/pubspec.lock index a8ef420..332ce46 100644 --- a/apps/driver_new/pubspec.lock +++ b/apps/driver_new/pubspec.lock @@ -382,7 +382,7 @@ packages: source: hosted version: "2.3.0" intl: - dependency: transitive + dependency: "direct main" description: name: intl sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" diff --git a/apps/driver_new/pubspec.yaml b/apps/driver_new/pubspec.yaml index a33f417..c764835 100644 --- a/apps/driver_new/pubspec.yaml +++ b/apps/driver_new/pubspec.yaml @@ -29,6 +29,8 @@ dependencies: flutter_secure_storage: ^10.0.0 shared_preferences: ^2.3.3 + intl: ^0.20.2 + # م4: الخريطة والموقع والواقع اللحظي intaleq_maps: ^2.3.0 geolocator: ^14.0.2 diff --git a/apps/rider_new/lib/core/di.dart b/apps/rider_new/lib/core/di.dart index 4bd1b1c..98679fc 100644 --- a/apps/rider_new/lib/core/di.dart +++ b/apps/rider_new/lib/core/di.dart @@ -6,8 +6,12 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../features/auth/cubit/login_cubit.dart'; import '../features/auth/data/auth_repository.dart'; +import '../features/rating/cubit/rating_cubit.dart'; +import '../features/rating/data/rating_repository.dart'; import '../features/settings/cubit/settings_cubit.dart'; import '../features/trip/cubit/ride_cubit.dart'; +import '../features/wallet/cubit/wallet_cubit.dart'; +import '../features/wallet/data/wallet_repository.dart'; import '../features/trip/data/maps_repository.dart'; import '../features/trip/data/trip_repository.dart'; import 'api/antlaq_api.dart'; @@ -15,6 +19,7 @@ import 'api/api_client.dart'; import 'location/location_service.dart'; import 'realtime/realtime_service.dart'; import 'session/session_cubit.dart'; +import 'build_config.dart'; import 'storage/token_store.dart'; final sl = GetIt.instance; @@ -58,6 +63,15 @@ Future setupInjector() async { sl.registerSingleton(LocationService()); sl.registerSingleton(RealtimeService(tokens)); + sl.registerSingleton(RatingRepository(sl())); + sl.registerFactory(() => RatingCubit(sl())); + + // المحفظة خلف علم الاستحقاق: الميزة المطفأة لا تُسجَّل أصلاً (docs/23 §5). + if (Features.wallet) { + sl.registerSingleton(WalletRepository(sl())); + sl.registerFactory(() => WalletCubit(sl())); + } + sl.registerFactory(() => RideCubit( trips: sl(), maps: sl(), diff --git a/apps/rider_new/lib/core/format/money.dart b/apps/rider_new/lib/core/format/money.dart new file mode 100644 index 0000000..69ae013 --- /dev/null +++ b/apps/rider_new/lib/core/format/money.dart @@ -0,0 +1,34 @@ +import 'package:intl/intl.dart'; + +/// كل المبالغ تصل من الخادم **نصوصاً** لا أرقاماً (`"0.000"` — docs/38 §12.2). +/// تمريرها لعملية حسابية بلا تحويل يعطي نتائج صامتة خاطئة، لذلك التحويل +/// والعرض يمرّان من هنا وحدهما. +class Money { + const Money._(); + + /// `null` عند نصّ غير صالح — لا صفر: الصفر رصيدٌ حقيقي، والغياب ليس رصيداً. + static double? parse(String? raw) => + raw == null ? null : double.tryParse(raw); + + /// العملة ورمزها من إعداد المستأجر (country pack)، لا ثابتة في التطبيق + /// (docs/26 §5). الأرقام لاتينية في اللغتين. + static String format(String? raw, String currency, {String locale = 'en'}) { + final value = parse(raw); + if (value == null) return '—'; + final digits = currency == 'JOD' ? 3 : 2; + final text = NumberFormat.decimalPatternDigits( + locale: locale, + decimalDigits: digits, + ).format(value); + return currency.isEmpty ? text : '$text $currency'; + } + + static String formatNum(num value, String currency, {int? decimalDigits}) { + final digits = decimalDigits ?? (currency == 'JOD' ? 3 : 2); + final text = NumberFormat.decimalPatternDigits( + locale: 'en', + decimalDigits: digits, + ).format(value); + return currency.isEmpty ? text : '$text $currency'; + } +} diff --git a/apps/rider_new/lib/core/l10n/app_ar.arb b/apps/rider_new/lib/core/l10n/app_ar.arb index d4059f9..f47527c 100644 --- a/apps/rider_new/lib/core/l10n/app_ar.arb +++ b/apps/rider_new/lib/core/l10n/app_ar.arb @@ -123,5 +123,43 @@ "driverArrivedAction": "وصلت لموقع الراكب", "driverStartTrip": "بدء الرحلة", "driverEndTrip": "إنهاء الرحلة", - "driverOnTheWay": "في الطريق للراكب" + "driverOnTheWay": "في الطريق للراكب", + "rateTitle": "كيف كانت رحلتك؟", + "rateTitleDriver": "كيف كان الراكب؟", + "rateLead": "تقييمك يساعدنا على تحسين الخدمة", + "rateComment": "أضف ملاحظة (اختياري)", + "rateSubmit": "إرسال التقييم", + "rateSkip": "لاحقاً", + "rateAlready": "سبق أن قيّمت هذه الرحلة", + "tripFareTitle": "تفاصيل الأجرة", + "tripFare": "الأجرة", + "tripEarnings": "أرباحك", + "tripCommission": "العمولة", + "tripCancelTitle": "سبب الإلغاء", + "tripCancelReasonWait": "الانتظار طويل", + "tripCancelReasonMistake": "طلبت بالخطأ", + "tripCancelReasonOther": "سبب آخر", + "tripCancelConfirm": "تأكيد الإلغاء", + "tripCancelKeep": "متابعة الرحلة", + "walletTitle": "المحفظة", + "walletBalance": "الرصيد", + "walletTopup": "شحن الرصيد", + "walletTopupAmount": "المبلغ", + "walletTransactions": "الحركات", + "walletEmpty": "لا حركات بعد", + "walletTopupFailed": "تعذّر الشحن", + "creditTitle": "الرصيد التشغيلي", + "creditLead": "تُخصم منه العمولة عند إنهاء كل رحلة", + "creditBlocked": "رصيدك التشغيلي نفد — اشحنه لتستقبل الطلبات", + "payoutTitle": "سحب الأرباح", + "payoutAmount": "مبلغ السحب", + "payoutChannel": "قناة الاستلام", + "payoutRequest": "أرسل رمز التأكيد", + "payoutOtpTitle": "أكّد السحب", + "payoutOtpLead": "أرسلنا رمزاً إلى رقمك لتأكيد السحب", + "payoutConfirm": "تأكيد السحب", + "payoutDone": "تم إرسال طلب السحب", + "payoutFailed": "تعذّر إتمام السحب", + "payoutInsufficient": "الرصيد لا يكفي", + "payoutHistory": "طلبات السحب" } \ 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 522f627..3ef16c1 100644 --- a/apps/rider_new/lib/core/l10n/app_en.arb +++ b/apps/rider_new/lib/core/l10n/app_en.arb @@ -123,5 +123,43 @@ "driverArrivedAction": "I've arrived", "driverStartTrip": "Start trip", "driverEndTrip": "End trip", - "driverOnTheWay": "Heading to rider" + "driverOnTheWay": "Heading to rider", + "rateTitle": "How was your ride?", + "rateTitleDriver": "How was your rider?", + "rateLead": "Your rating helps us improve", + "rateComment": "Add a note (optional)", + "rateSubmit": "Submit rating", + "rateSkip": "Later", + "rateAlready": "You already rated this trip", + "tripFareTitle": "Fare breakdown", + "tripFare": "Fare", + "tripEarnings": "Your earnings", + "tripCommission": "Commission", + "tripCancelTitle": "Why are you cancelling?", + "tripCancelReasonWait": "The wait is too long", + "tripCancelReasonMistake": "I requested by mistake", + "tripCancelReasonOther": "Another reason", + "tripCancelConfirm": "Confirm cancellation", + "tripCancelKeep": "Keep the ride", + "walletTitle": "Wallet", + "walletBalance": "Balance", + "walletTopup": "Top up", + "walletTopupAmount": "Amount", + "walletTransactions": "Transactions", + "walletEmpty": "No transactions yet", + "walletTopupFailed": "Top-up failed", + "creditTitle": "Operating credit", + "creditLead": "Commission is deducted from it when each trip ends", + "creditBlocked": "Your operating credit ran out — top it up to receive requests", + "payoutTitle": "Withdraw earnings", + "payoutAmount": "Amount", + "payoutChannel": "Payout channel", + "payoutRequest": "Send confirmation code", + "payoutOtpTitle": "Confirm withdrawal", + "payoutOtpLead": "We sent a code to your number to confirm this withdrawal", + "payoutConfirm": "Confirm withdrawal", + "payoutDone": "Withdrawal request sent", + "payoutFailed": "Withdrawal failed", + "payoutInsufficient": "Insufficient balance", + "payoutHistory": "Withdrawal requests" } \ 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 b2c4384..3fd091e 100644 --- a/apps/rider_new/lib/core/router.dart +++ b/apps/rider_new/lib/core/router.dart @@ -7,7 +7,10 @@ import 'session/session_cubit.dart'; import 'session/session_state.dart'; import '../features/auth/view/login_page.dart'; import '../features/auth/view/profile_page.dart'; +import '../features/rating/data/rating_repository.dart'; +import '../features/rating/view/rating_page.dart'; import '../features/trip/view/ride_page.dart'; +import '../features/wallet/view/wallet_page.dart'; import '../features/splash/view/splash_page.dart'; import 'di.dart'; @@ -17,6 +20,8 @@ class Routes { static const login = '/login'; static const profile = '/profile'; static const home = '/home'; + static const rating = '/rating'; + static const wallet = '/wallet'; } /// التوجيه يُشتقّ من `SessionCubit` وحده — لا `Navigator.push` بعد الدخول @@ -35,10 +40,16 @@ final appRouter = GoRouter( SessionStatus.unauthenticated => loc == Routes.login ? null : Routes.login, SessionStatus.needsProfile => loc == Routes.profile ? null : Routes.profile, - SessionStatus.authenticated => - (loc == Routes.login || loc == Routes.profile || loc == Routes.splash) - ? Routes.home - : null, + // التقييم المعلّق يحجز التطبيق كله: لا شاشة أخرى قبل حسمه + // (docs/38 §7 — يُفرض عند الفتح). + SessionStatus.authenticated when session.pendingRating != null => + loc == Routes.rating ? null : Routes.rating, + SessionStatus.authenticated => (loc == Routes.login || + loc == Routes.profile || + loc == Routes.splash || + loc == Routes.rating) + ? Routes.home + : null, SessionStatus.unknown => Routes.splash, }; }, @@ -55,6 +66,23 @@ final appRouter = GoRouter( path: Routes.profile, builder: (context, state) => const ProfilePage(), ), + GoRoute( + path: Routes.rating, + builder: (context, state) { + final pending = sl().state.pendingRating!; + return RatingPage( + pending: PendingRating( + tripId: pending.tripId, + role: pending.role, + ), + onDone: sl().onRatingResolved, + ); + }, + ), + GoRoute( + path: Routes.wallet, + builder: (context, state) => const WalletPage(), + ), GoRoute( path: Routes.home, builder: (context, state) => const RidePage(), diff --git a/apps/rider_new/lib/core/session/session_cubit.dart b/apps/rider_new/lib/core/session/session_cubit.dart index 51ef5a5..3a135c2 100644 --- a/apps/rider_new/lib/core/session/session_cubit.dart +++ b/apps/rider_new/lib/core/session/session_cubit.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_bloc/flutter_bloc.dart'; import '../api/api_exception.dart'; @@ -46,12 +48,34 @@ class SessionCubit extends Cubit { emit(const SessionState(status: SessionStatus.unauthenticated)); } + /// يُنادى بعد التقييم أو تخطّيه — يرفع الحجز عن بقية التطبيق. + void onRatingResolved() => + emit(state.copyWith(clearPendingRating: true)); + void _apply(AppUser user) { emit(SessionState( status: user.needsProfile ? SessionStatus.needsProfile : SessionStatus.authenticated, user: user, + pendingRating: state.pendingRating, )); + // التقييم المعلّق يُفحص بعد إثبات الجلسة لا قبلها. + if (!user.needsProfile) unawaited(_checkPendingRating()); + } + + Future _checkPendingRating() async { + try { + final pending = await _repo.pendingRating(); + if (isClosed || pending == null) return; + emit(state.copyWith( + pendingRating: PendingRatingRef( + tripId: pending.tripId, + role: pending.role, + ), + )); + } on ApiException { + // فحصٌ مساعد لا حاجز دخول. + } } } diff --git a/apps/rider_new/lib/core/session/session_source.dart b/apps/rider_new/lib/core/session/session_source.dart index 8366f5c..6e2e0b3 100644 --- a/apps/rider_new/lib/core/session/session_source.dart +++ b/apps/rider_new/lib/core/session/session_source.dart @@ -8,6 +8,9 @@ import 'app_user.dart'; /// ويُنفّذها `AuthRepository` في ميزة المصادقة. abstract interface class SessionSource { Future me(); + + /// رحلة تنتظر تقييماً، أو `null`. يُنفّذها `RatingRepository`. + Future<({String tripId, String role})?> pendingRating(); Future logout(); bool get hasSession; } diff --git a/apps/rider_new/lib/core/session/session_state.dart b/apps/rider_new/lib/core/session/session_state.dart index a6567f3..9d0b92d 100644 --- a/apps/rider_new/lib/core/session/session_state.dart +++ b/apps/rider_new/lib/core/session/session_state.dart @@ -2,6 +2,16 @@ import 'package:equatable/equatable.dart'; import 'app_user.dart'; +/// رحلة تنتظر تقييماً — تُفحص عند كل فتح للتطبيق (docs/38 §7). +class PendingRatingRef { + const PendingRatingRef({required this.tripId, required this.role}); + + final String tripId; + final String role; + + bool get isDriver => role == 'driver'; +} + enum SessionStatus { /// قبل أن نعرف — تُعرض شاشة الإقلاع، ولا يُتّخذ قرار توجيه. unknown, @@ -13,20 +23,34 @@ enum SessionStatus { } class SessionState extends Equatable { - const SessionState({this.status = SessionStatus.unknown, this.user}); + const SessionState({ + this.status = SessionStatus.unknown, + this.user, + this.pendingRating, + }); final SessionStatus status; final AppUser? user; + /// وجودها يفرض شاشة التقييم قبل أي شاشة أخرى. + final PendingRatingRef? pendingRating; + bool get isReady => status != SessionStatus.unknown; - SessionState copyWith({SessionStatus? status, AppUser? user}) { + SessionState copyWith({ + SessionStatus? status, + AppUser? user, + PendingRatingRef? pendingRating, + bool clearPendingRating = false, + }) { return SessionState( status: status ?? this.status, user: user ?? this.user, + pendingRating: + clearPendingRating ? null : (pendingRating ?? this.pendingRating), ); } @override - List get props => [status, user]; + List get props => [status, user, pendingRating?.tripId]; } diff --git a/apps/rider_new/lib/features/auth/data/auth_repository.dart b/apps/rider_new/lib/features/auth/data/auth_repository.dart index 5af7e4f..e8ac180 100644 --- a/apps/rider_new/lib/features/auth/data/auth_repository.dart +++ b/apps/rider_new/lib/features/auth/data/auth_repository.dart @@ -63,6 +63,18 @@ class AuthRepository implements SessionSource { return AppUser.fromJson(res); } + /// من عقد `SessionSource`: الجلسة تحتاج معرفة التقييم المعلّق، ولا يجوز + /// أن يستورد `core` ميزةَ التقييم — فتمرّ عبر الواجهة نفسها. + @override + Future<({String tripId, String role})?> pendingRating() async { + final res = await _api.get>('/trips/rating/pending'); + final pending = res['pending']; + if (pending is! Map) return null; + final tripId = pending['tripId'] as String?; + if (tripId == null) return null; + return (tripId: tripId, role: pending['role'] as String? ?? 'rider'); + } + @override Future logout() => _tokens.clear(); diff --git a/apps/rider_new/lib/features/rating/cubit/rating_cubit.dart b/apps/rider_new/lib/features/rating/cubit/rating_cubit.dart new file mode 100644 index 0000000..05c47e0 --- /dev/null +++ b/apps/rider_new/lib/features/rating/cubit/rating_cubit.dart @@ -0,0 +1,40 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/api/api_exception.dart'; +import '../data/rating_repository.dart'; +import 'rating_state.dart'; + +class RatingCubit extends Cubit { + RatingCubit(this._repo) : super(const RatingState()); + + final RatingRepository _repo; + + void attach(PendingRating pending) => emit(RatingState(pending: pending)); + + void setStars(int stars) => emit(state.copyWith(stars: stars)); + + void setComment(String comment) => emit(state.copyWith(comment: comment)); + + Future submit() async { + final pending = state.pending; + if (pending == null || !state.canSubmit) return; + + emit(state.copyWith(status: RatingStatus.submitting)); + try { + await _repo.rate( + pending.tripId, + state.stars, + comment: state.comment.trim().isEmpty ? null : state.comment.trim(), + ); + emit(state.copyWith(status: RatingStatus.done)); + } on ApiException catch (e) { + // 400 هنا = تقييم مزدوج، وهو نجاحٌ من منظور المستخدم: الرحلة مُقيَّمة + // فعلاً، فلا يُحبس في شاشة لا مخرج منها. + emit(state.copyWith( + status: e.statusCode == 400 + ? RatingStatus.alreadyRated + : RatingStatus.failed, + )); + } + } +} diff --git a/apps/rider_new/lib/features/rating/cubit/rating_state.dart b/apps/rider_new/lib/features/rating/cubit/rating_state.dart new file mode 100644 index 0000000..3053676 --- /dev/null +++ b/apps/rider_new/lib/features/rating/cubit/rating_state.dart @@ -0,0 +1,38 @@ +import 'package:equatable/equatable.dart'; + +import '../data/rating_repository.dart'; + +enum RatingStatus { idle, submitting, done, alreadyRated, failed } + +class RatingState extends Equatable { + const RatingState({ + this.status = RatingStatus.idle, + this.pending, + this.stars = 0, + this.comment = '', + }); + + final RatingStatus status; + final PendingRating? pending; + final int stars; + final String comment; + + bool get canSubmit => stars > 0 && status != RatingStatus.submitting; + + RatingState copyWith({ + RatingStatus? status, + PendingRating? pending, + int? stars, + String? comment, + }) { + return RatingState( + status: status ?? this.status, + pending: pending ?? this.pending, + stars: stars ?? this.stars, + comment: comment ?? this.comment, + ); + } + + @override + List get props => [status, pending?.tripId, stars, comment]; +} diff --git a/apps/rider_new/lib/features/rating/data/rating_repository.dart b/apps/rider_new/lib/features/rating/data/rating_repository.dart new file mode 100644 index 0000000..7ed24c4 --- /dev/null +++ b/apps/rider_new/lib/features/rating/data/rating_repository.dart @@ -0,0 +1,40 @@ +import '../../../core/api/api_client.dart'; + +/// رحلة تنتظر تقييماً — يرجّعها الخادم عند كل فتح للتطبيق (docs/38 §7). +class PendingRating { + const PendingRating({required this.tripId, required this.role}); + + final String tripId; + + /// الدور يحدّد نصّ السؤال: الراكب يقيّم السائق والعكس. + final String role; + + bool get isDriver => role == 'driver'; +} + +class RatingRepository { + RatingRepository(this._api); + + final ApiClient _api; + + /// `{"pending": null, "count": 0}` حين لا شيء — شكل مُتحقَّق حيّاً. + Future pending() async { + final res = await _api.get>('/trips/rating/pending'); + final pending = res['pending']; + if (pending is! Map) return null; + final tripId = pending['tripId'] as String?; + if (tripId == null) return null; + return PendingRating( + tripId: tripId, + role: pending['role'] as String? ?? 'rider', + ); + } + + /// التقييم المزدوج يرجّع **400** — تُعامَل كحالة معروفة لا كعطل. + Future rate(String tripId, int stars, {String? comment}) { + return _api.post>( + '/trips/$tripId/rate', + body: {'stars': stars, 'comment': ?comment}, + ); + } +} diff --git a/apps/rider_new/lib/features/rating/view/rating_page.dart b/apps/rider_new/lib/features/rating/view/rating_page.dart new file mode 100644 index 0000000..24074fc --- /dev/null +++ b/apps/rider_new/lib/features/rating/view/rating_page.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/design/tokens.dart'; +import '../../../core/design/tripz_colors.dart'; +import '../../../core/di.dart'; +import '../../../core/l10n/l10n.dart'; +import '../../../core/ui/tripz_button.dart'; +import '../../../core/ui/tripz_scaffold.dart'; +import '../../../core/ui/tripz_text_field.dart'; +import '../cubit/rating_cubit.dart'; +import '../cubit/rating_state.dart'; +import '../data/rating_repository.dart'; +import 'star_row.dart'; + +/// شاشة التقييم — **تُفرض عند فتح التطبيق** إن كانت هناك رحلة معلّقة +/// (`GET /trips/rating/pending`، docs/38 §7). لذلك بلا زرّ رجوع في الشريط: +/// الطريق الوحيد للخروج هو التقييم أو التخطّي الصريح. +class RatingPage extends StatefulWidget { + const RatingPage({super.key, required this.pending, required this.onDone}); + + final PendingRating pending; + final VoidCallback onDone; + + @override + State createState() => _RatingPageState(); +} + +class _RatingPageState extends State { + late final RatingCubit _cubit = sl()..attach(widget.pending); + final _comment = TextEditingController(); + + @override + void dispose() { + _comment.dispose(); + _cubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + // من يقيّم مَن: دور **هذا** المستخدم في الرحلة يأتي من الخادم، ولا + // يُستنتج من نوع التطبيق — الرقم نفسه قد يكون له حسابان (docs/38 §1). + final title = widget.pending.isDriver ? l10n.rateTitleDriver : l10n.rateTitle; + + return BlocProvider.value( + value: _cubit, + child: BlocConsumer( + listenWhen: (p, n) => p.status != n.status, + listener: (context, state) { + if (state.status == RatingStatus.done || + state.status == RatingStatus.alreadyRated) { + widget.onDone(); + } + }, + builder: (context, state) { + return TripzScaffold( + showAppBar: false, + bottomAction: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TripzButton.primary( + label: l10n.rateSubmit, + loading: state.status == RatingStatus.submitting, + onPressed: state.canSubmit ? _cubit.submit : null, + ), + TripzButton.text( + label: l10n.rateSkip, + onPressed: widget.onDone, + ), + ], + ), + child: ListView( + children: [ + const SizedBox(height: Space.xxl), + Icon( + Icons.star_rounded, + size: 56, + color: context.tripzColors.warning, + ), + const SizedBox(height: Space.md), + Text( + title, + textAlign: TextAlign.center, + style: context.texts.headlineSmall, + ), + const SizedBox(height: Space.xs), + Text( + l10n.rateLead, + textAlign: TextAlign.center, + style: context.texts.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Space.lg), + StarRow(value: state.stars, onChanged: _cubit.setStars), + const SizedBox(height: Space.lg), + TripzTextField( + controller: _comment, + hint: l10n.rateComment, + onChanged: _cubit.setComment, + ), + if (state.status == RatingStatus.failed) ...[ + const SizedBox(height: Space.sm), + Text( + l10n.errorGeneric, + textAlign: TextAlign.center, + style: context.texts.bodySmall?.copyWith( + color: context.tripzColors.danger, + ), + ), + ], + // مساحة للـCTA السفلي كي لا يغطّي الحقل عند فتح الكيبورد. + const SizedBox(height: Sizes.control), + ], + ), + ); + }, + ), + ); + } +} diff --git a/apps/rider_new/lib/features/rating/view/star_row.dart b/apps/rider_new/lib/features/rating/view/star_row.dart new file mode 100644 index 0000000..bd8f664 --- /dev/null +++ b/apps/rider_new/lib/features/rating/view/star_row.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import '../../../core/design/tokens.dart'; +import '../../../core/design/tripz_colors.dart'; + +/// خمس نجوم — أهداف لمس ≥ 48 لأن الشاشة تُستعمل عادةً والمستخدم مستعجل. +class StarRow extends StatelessWidget { + const StarRow({super.key, required this.value, required this.onChanged}); + + final int value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + // النجوم تُقرأ يساراً-ليميناً في اللغتين: ترتيبها ليس نصّاً. + textDirection: TextDirection.ltr, + children: List.generate(5, (i) { + final star = i + 1; + final filled = star <= value; + return IconButton( + onPressed: () => onChanged(star), + iconSize: Sizes.iconLg, + constraints: const BoxConstraints( + minWidth: Sizes.touchTarget, + minHeight: Sizes.touchTarget, + ), + icon: AnimatedSwitcher( + duration: Motion.tap, + child: Icon( + filled ? Icons.star_rounded : Icons.star_outline_rounded, + key: ValueKey(filled), + color: filled + ? context.tripzColors.warning + : context.colors.onSurfaceVariant, + ), + ), + ); + }), + ); + } +} 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 a286c22..0d235f9 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 @@ -3,12 +3,13 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../core/design/tokens.dart'; import '../../../../core/design/tripz_colors.dart'; -import '../../../../core/design/typography.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../core/ui/tripz_button.dart'; import '../../cubit/ride_cubit.dart'; import '../../data/models/trip.dart'; +import 'cancel_sheet.dart'; import 'driver_badge.dart'; +import 'fare_summary.dart'; /// بطاقة الرحلة الجارية: حالة واضحة ثم بيانات السائق ثم فعل واحد. class ActiveTripSheet extends StatelessWidget { @@ -33,28 +34,18 @@ class ActiveTripSheet extends StatelessWidget { DriverBadge(trip: trip), if (trip.priceForPassenger != null) ...[ const SizedBox(height: Space.md), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - l10n.rideConfirmTrip, - style: context.texts.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - Text( - '${trip.priceForPassenger} ${trip.currency}', - style: numericStyle(context.texts.titleLarge), - ), - ], - ), + FareSummary(trip: trip), ], if (canCancel) ...[ const SizedBox(height: Space.md), TripzButton.text( label: l10n.rideCancelTrip, expanded: true, - onPressed: context.read().cancelTrip, + // تأكيد صريح: الإلغاء أثناء رحلة مسندة له كلفة على الطرفين. + onPressed: () async { + final cubit = context.read(); + if (await CancelSheet.confirm(context)) await cubit.cancelTrip(); + }, ), ], ], diff --git a/apps/rider_new/lib/features/trip/view/widgets/cancel_sheet.dart b/apps/rider_new/lib/features/trip/view/widgets/cancel_sheet.dart new file mode 100644 index 0000000..2b70745 --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/widgets/cancel_sheet.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/l10n/l10n.dart'; +import '../../../../core/ui/tripz_button.dart'; +import '../../../../core/ui/tripz_sheet.dart'; + +/// الإلغاء لا يقع بضغطة واحدة: تأكيدٌ صريح مع سبب. +/// +/// السبب لا يُرسَل للخادم (لا يقبله عقد `POST /trips/:id/cancel` — docs/38 +/// §4)، لكن السؤال نفسه يخفض الإلغاء العرضي، ويجهّز الحقل حين يقبله الخادم. +class CancelSheet { + const CancelSheet._(); + + static Future confirm(BuildContext context) async { + final l10n = context.l10n; + var reason = 0; + + final result = await TripzSheet.show( + context, + title: l10n.tripCancelTitle, + child: StatefulBuilder( + builder: (context, setState) => RadioGroup( + groupValue: reason, + onChanged: (v) => setState(() => reason = v ?? 0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (i, label) in [ + l10n.tripCancelReasonWait, + l10n.tripCancelReasonMistake, + l10n.tripCancelReasonOther, + ].indexed) + RadioListTile( + value: i, + contentPadding: EdgeInsets.zero, + title: Text(label), + ), + ], + ), + ), + ), + bottomAction: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TripzButton.primary( + label: l10n.tripCancelKeep, + onPressed: () => Navigator.of(context).pop(false), + ), + const SizedBox(height: Space.xxs), + TripzButton.text( + label: l10n.tripCancelConfirm, + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ), + ); + return result ?? false; + } +} diff --git a/apps/rider_new/lib/features/trip/view/widgets/fare_summary.dart b/apps/rider_new/lib/features/trip/view/widgets/fare_summary.dart new file mode 100644 index 0000000..baf4f83 --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/widgets/fare_summary.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/design/typography.dart'; +import '../../../../core/format/money.dart'; +import '../../../../core/l10n/l10n.dart'; +import '../../data/models/trip.dart'; + +/// ملخّص الأجرة عند الإنهاء. +/// +/// ⚠️ `price_for_passenger` و`price_for_driver` **حقلان منفصلان** والفرق +/// بينهما هو العمولة (docs/38 §4). كلٌّ يرى رقمه: الراكب ما يدفع، والسائق ما +/// يكسب — عرض رقم واحد للطرفين خطأ لا اختصار. +class FareSummary extends StatelessWidget { + const FareSummary({super.key, required this.trip, this.forDriver = false}); + + final Trip trip; + final bool forDriver; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final amount = forDriver ? trip.priceForDriver : trip.priceForPassenger; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.tripFareTitle, style: context.texts.titleMedium), + const SizedBox(height: Space.sm), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + forDriver ? l10n.tripEarnings : l10n.tripFare, + style: context.texts.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + Text( + Money.format(amount, trip.currency), + style: numericStyle(context.texts.headlineSmall).copyWith( + color: forDriver ? context.tripzColors.success : null, + ), + ), + ], + ), + ], + ); + } +} diff --git a/apps/rider_new/lib/features/wallet/cubit/wallet_cubit.dart b/apps/rider_new/lib/features/wallet/cubit/wallet_cubit.dart new file mode 100644 index 0000000..b8f743a --- /dev/null +++ b/apps/rider_new/lib/features/wallet/cubit/wallet_cubit.dart @@ -0,0 +1,45 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/api/api_exception.dart'; +import '../../../core/ui/view_status.dart'; +import '../data/wallet_repository.dart'; +import 'wallet_state.dart'; + +class WalletCubit extends Cubit { + WalletCubit(this._repo) : super(const WalletState()); + + final WalletRepository _repo; + + Future load() async { + emit(state.copyWith(status: ViewStatus.loading)); + try { + final wallet = await _repo.wallet(); + final txns = await _repo.transactions(); + if (isClosed) return; + emit(state.copyWith( + status: ViewStatus.success, + wallet: wallet, + transactions: txns, + )); + } on ApiException catch (e) { + if (!isClosed) { + emit(state.copyWith(status: ViewStatus.error, error: e.message)); + } + } + } + + Future topup(num amount) async { + if (amount <= 0) return; + emit(state.copyWith(action: WalletAction.toppingUp)); + try { + await _repo.topup(amount); + if (isClosed) return; + emit(state.copyWith(action: WalletAction.topupDone)); + // الرصيد يُعاد جلبه من الخادم لا يُحسب محلياً: المال لا يُقدَّر + // في التطبيق (docs/23 §0.2). + await load(); + } on ApiException { + if (!isClosed) emit(state.copyWith(action: WalletAction.topupFailed)); + } + } +} diff --git a/apps/rider_new/lib/features/wallet/cubit/wallet_state.dart b/apps/rider_new/lib/features/wallet/cubit/wallet_state.dart new file mode 100644 index 0000000..5ddedc6 --- /dev/null +++ b/apps/rider_new/lib/features/wallet/cubit/wallet_state.dart @@ -0,0 +1,48 @@ +import 'package:equatable/equatable.dart'; + +import '../../../core/ui/view_status.dart'; +import '../data/models/wallet.dart'; +import '../data/models/wallet_txn.dart'; + +enum WalletAction { none, toppingUp, topupFailed, topupDone } + +class WalletState extends Equatable { + const WalletState({ + this.status = ViewStatus.idle, + this.wallet, + this.transactions = const [], + this.action = WalletAction.none, + this.error, + }); + + final ViewStatus status; + final Wallet? wallet; + final List transactions; + + /// فعلٌ جارٍ داخل الشاشة — منفصل عن [status] كي لا يُخفي الشحنُ الرصيدَ + /// المعروض خلفه. + final WalletAction action; + + final String? error; + + String get currency => wallet?.currency ?? ''; + + WalletState copyWith({ + ViewStatus? status, + Wallet? wallet, + List? transactions, + WalletAction? action, + String? error, + }) { + return WalletState( + status: status ?? this.status, + wallet: wallet ?? this.wallet, + transactions: transactions ?? this.transactions, + action: action ?? this.action, + error: error ?? this.error, + ); + } + + @override + List get props => [status, wallet, transactions, action, error]; +} diff --git a/apps/rider_new/lib/features/wallet/data/models/wallet.dart b/apps/rider_new/lib/features/wallet/data/models/wallet.dart new file mode 100644 index 0000000..b10a0e1 --- /dev/null +++ b/apps/rider_new/lib/features/wallet/data/models/wallet.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; + +/// المحفظة كما يرجّعها `GET /wallet` (شكل مُتحقَّق حيّاً، docs/38 §6). +/// +/// ⚠️ **محفظتان لا واحدة**: محفظة الراكب **التزام** على المنصّة (رصيدٌ دفعه +/// ولم يستهلكه بعد)، ومحفظة السائق **إيراد** له. دمجهما في شاشة أو حساب +/// واحد خطأ محاسبي لا تجميلي — لذلك لا تُوحَّد شاشتاهما (docs/39 §4). +class Wallet extends Equatable { + const Wallet({ + required this.id, + required this.balance, + required this.currency, + }); + + final String id; + + /// نصّ لا رقم (`"0.000"`) — يُحوَّل عبر `Money` عند الحاجة فقط. + final String balance; + + /// من إعداد المستأجر: `JOD`/`EGP`/`SYP` — لا تُثبَّت في التطبيق. + final String currency; + + factory Wallet.fromJson(Map json) => Wallet( + id: json['id'] as String? ?? '', + balance: json['balance']?.toString() ?? '0', + currency: json['currency'] as String? ?? '', + ); + + @override + List get props => [id, balance, currency]; +} diff --git a/apps/rider_new/lib/features/wallet/data/models/wallet_txn.dart b/apps/rider_new/lib/features/wallet/data/models/wallet_txn.dart new file mode 100644 index 0000000..75e5204 --- /dev/null +++ b/apps/rider_new/lib/features/wallet/data/models/wallet_txn.dart @@ -0,0 +1,29 @@ +import 'package:equatable/equatable.dart'; + +class WalletTxn extends Equatable { + const WalletTxn({ + required this.id, + required this.amount, + required this.reason, + this.createdAt, + }); + + final String id; + + /// موجب = دخول، سالب = خروج. النصّ كما وصل، والإشارة تُقرأ منه. + final String amount; + final String reason; + final DateTime? createdAt; + + factory WalletTxn.fromJson(Map json) => WalletTxn( + id: json['id']?.toString() ?? '', + amount: json['amount']?.toString() ?? '0', + reason: json['reason'] as String? ?? json['type'] as String? ?? '', + createdAt: DateTime.tryParse(json['created_at']?.toString() ?? ''), + ); + + bool get isCredit => !amount.trim().startsWith('-'); + + @override + List get props => [id, amount, reason, createdAt]; +} diff --git a/apps/rider_new/lib/features/wallet/data/wallet_repository.dart b/apps/rider_new/lib/features/wallet/data/wallet_repository.dart new file mode 100644 index 0000000..2f7a121 --- /dev/null +++ b/apps/rider_new/lib/features/wallet/data/wallet_repository.dart @@ -0,0 +1,55 @@ +import '../../../core/api/api_client.dart'; +import 'models/wallet.dart'; +import 'models/wallet_txn.dart'; + +class WalletRepository { + WalletRepository(this._api); + + final ApiClient _api; + + Future wallet() async { + final res = await _api.get>('/wallet'); + return Wallet.fromJson(res); + } + + Future> transactions() async { + final res = await _api.get>('/wallet/transactions'); + return res + .whereType>() + .map(WalletTxn.fromJson) + .toList(growable: false); + } + + Future topup(num amount) => + _api.post>('/wallet/topup', body: {'amount': amount}); + + // ── السحب: خطوتان لا خطوة ──────────────────────────────────────────── + // `request` يرسل رمزاً **بلا خصم**، و`confirm` هو ما يخصم ويحجز + // (docs/38 §6). شاشة سيرو كانت خطوة واحدة — لا تُقلَّد. + + Future<({String payoutId, String? devCode})> requestPayout({ + required num amount, + required String channel, + }) async { + final res = await _api.post>( + '/payouts/request', + body: {'amount': amount, 'channel': channel}, + ); + final payout = res['payout'] as Map?; + return ( + payoutId: payout?['id'] as String? ?? '', + devCode: res['dev_code']?.toString(), + ); + } + + Future confirmPayout(String payoutId, String code) => + _api.post>( + '/payouts/$payoutId/confirm', + body: {'code': code}, + ); + + Future>> payouts() async { + final res = await _api.get>('/payouts/mine'); + return res.whereType>().toList(growable: false); + } +} diff --git a/apps/rider_new/lib/features/wallet/view/wallet_page.dart b/apps/rider_new/lib/features/wallet/view/wallet_page.dart new file mode 100644 index 0000000..14208dd --- /dev/null +++ b/apps/rider_new/lib/features/wallet/view/wallet_page.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/design/tokens.dart'; +import '../../../core/design/tripz_colors.dart'; +import '../../../core/di.dart'; +import '../../../core/l10n/l10n.dart'; +import '../../../core/ui/empty_view.dart'; +import '../../../core/ui/tripz_button.dart'; +import '../../../core/ui/tripz_scaffold.dart'; +import '../../../core/ui/tripz_sheet.dart'; +import '../../../core/ui/tripz_text_field.dart'; +import '../cubit/wallet_cubit.dart'; +import '../cubit/wallet_state.dart'; +import 'widgets/balance_card.dart'; +import 'widgets/txn_tile.dart'; + +/// محفظة الراكب — **رصيدٌ دفعه ولم يستهلكه**، أي التزامٌ على المنصّة. +/// شاشة السائق مختلفة عمداً (أرباح + سحب): لا تُوحَّدان (docs/39 §4). +class WalletPage extends StatefulWidget { + const WalletPage({super.key}); + + @override + State createState() => _WalletPageState(); +} + +class _WalletPageState extends State { + late final WalletCubit _cubit = sl()..load(); + + @override + void dispose() { + _cubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return BlocProvider.value( + value: _cubit, + child: BlocConsumer( + listenWhen: (p, n) => p.action != n.action, + listener: (context, state) { + if (state.action == WalletAction.topupFailed) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.walletTopupFailed)), + ); + } + }, + builder: (context, state) { + return TripzScaffold( + title: l10n.walletTitle, + status: state.status, + error: state.error, + onRetry: _cubit.load, + child: ListView( + children: [ + BalanceCard( + label: l10n.walletBalance, + balance: state.wallet?.balance, + currency: state.currency, + action: TripzButton.primary( + label: l10n.walletTopup, + loading: state.action == WalletAction.toppingUp, + onPressed: () => _openTopup(context), + ), + ), + const SizedBox(height: Space.lg), + Text(l10n.walletTransactions, style: context.texts.titleMedium), + const SizedBox(height: Space.xs), + if (state.transactions.isEmpty) + Padding( + padding: const EdgeInsets.only(top: Space.xl), + child: EmptyView( + icon: Icons.receipt_long_outlined, + message: l10n.walletEmpty, + ), + ) + else + for (final txn in state.transactions) + TxnTile(txn: txn, currency: state.currency), + ], + ), + ); + }, + ), + ); + } + + void _openTopup(BuildContext context) { + final l10n = context.l10n; + final controller = TextEditingController(); + TripzSheet.show( + context, + title: l10n.walletTopup, + child: TripzTextField( + controller: controller, + label: l10n.walletTopupAmount, + numeric: true, + autofocus: true, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + bottomAction: TripzButton.primary( + label: l10n.walletTopup, + onPressed: () { + final amount = num.tryParse(controller.text.trim()); + Navigator.of(context).pop(); + if (amount != null) _cubit.topup(amount); + }, + ), + ); + } +} diff --git a/apps/rider_new/lib/features/wallet/view/widgets/balance_card.dart b/apps/rider_new/lib/features/wallet/view/widgets/balance_card.dart new file mode 100644 index 0000000..469815a --- /dev/null +++ b/apps/rider_new/lib/features/wallet/view/widgets/balance_card.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/design/typography.dart'; +import '../../../../core/format/money.dart'; + +/// بطاقة الرصيد — الرقم هو بطل الشاشة، فيأخذ أكبر حجم وأعلى تباين. +class BalanceCard extends StatelessWidget { + const BalanceCard({ + super.key, + required this.label, + required this.balance, + required this.currency, + this.hint, + this.action, + }); + + final String label; + final String? balance; + final String currency; + final String? hint; + final Widget? action; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: Space.page, + decoration: BoxDecoration( + color: context.colors.primaryContainer, + borderRadius: Radii.card_, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: context.texts.labelLarge?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + const SizedBox(height: Space.xs), + Text( + Money.format(balance, currency), + style: numericStyle(context.texts.displaySmall).copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + if (hint != null) ...[ + const SizedBox(height: Space.xxs), + Text( + hint!, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onPrimaryContainer.withValues(alpha: 0.8), + ), + ), + ], + if (action != null) ...[ + const SizedBox(height: Space.md), + action!, + ], + ], + ), + ); + } +} diff --git a/apps/rider_new/lib/features/wallet/view/widgets/txn_tile.dart b/apps/rider_new/lib/features/wallet/view/widgets/txn_tile.dart new file mode 100644 index 0000000..dacd33f --- /dev/null +++ b/apps/rider_new/lib/features/wallet/view/widgets/txn_tile.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/design/typography.dart'; +import '../../../../core/format/money.dart'; +import '../../data/models/wallet_txn.dart'; + +class TxnTile extends StatelessWidget { + const TxnTile({super.key, required this.txn, required this.currency}); + + final WalletTxn txn; + final String currency; + + @override + Widget build(BuildContext context) { + final colors = context.tripzColors; + final isCredit = txn.isCredit; + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + backgroundColor: + (isCredit ? colors.success : colors.danger).withValues(alpha: 0.12), + child: Icon( + isCredit ? Icons.south_west_rounded : Icons.north_east_rounded, + size: Sizes.iconSm, + color: isCredit ? colors.success : colors.danger, + ), + ), + title: Text(txn.reason, style: context.texts.bodyMedium), + subtitle: txn.createdAt == null + ? null + : Text( + // التاريخ بصيغة الـlocale (docs/26 §5). + MaterialLocalizations.of(context).formatFullDate(txn.createdAt!), + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + trailing: Text( + Money.format(txn.amount, currency), + style: numericStyle(context.texts.titleSmall).copyWith( + color: isCredit ? colors.success : colors.danger, + ), + ), + ); + } +} diff --git a/apps/rider_new/pubspec.lock b/apps/rider_new/pubspec.lock index a8ef420..332ce46 100644 --- a/apps/rider_new/pubspec.lock +++ b/apps/rider_new/pubspec.lock @@ -382,7 +382,7 @@ packages: source: hosted version: "2.3.0" intl: - dependency: transitive + dependency: "direct main" description: name: intl sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" diff --git a/apps/rider_new/pubspec.yaml b/apps/rider_new/pubspec.yaml index eecc883..56d1665 100644 --- a/apps/rider_new/pubspec.yaml +++ b/apps/rider_new/pubspec.yaml @@ -29,6 +29,8 @@ dependencies: flutter_secure_storage: ^10.0.0 shared_preferences: ^2.3.3 + intl: ^0.20.2 + # م4: الخريطة والموقع والواقع اللحظي intaleq_maps: ^2.3.0 geolocator: ^14.0.2 diff --git a/docs/37-newapps-cubit-plan.md b/docs/37-newapps-cubit-plan.md index 8cf31b6..e9aaef6 100644 --- a/docs/37-newapps-cubit-plan.md +++ b/docs/37-newapps-cubit-plan.md @@ -247,7 +247,33 @@ 21. **بوابة**: رحلة كاملة من الطلب إلى الإنهاء بين جهازين + الخريطة تتحرّك بسلاسة أثناء التتبّع (لا اهتزاز، لا إعادة رسم للشاشة كاملة). -### المرحلة 5 — مسار الرحلة وما بعدها +### المرحلة 5 — مسار الرحلة وما بعدها — **نُفّذت 2026-08-04** + +- **التقييم مفروض لا اختياري**: `SessionCubit` يفحص + `GET /trips/rating/pending` بعد إثبات الجلسة، ووجودُ رحلة معلّقة **يحجز + التطبيق كله** عند `/rating` حتى تُحسم. دورُ المستخدم في الرحلة يأتي من + الخادم لا من نوع التطبيق (نفس الرقم قد يكون له حسابان — `docs/38 §1`). + التقييم المزدوج يرجّع 400 وهو **نجاحٌ من منظور المستخدم**: الرحلة مُقيَّمة + فعلاً، فلا يُحبس في شاشة بلا مخرج. +- **`core/format/money.dart`**: كل المبالغ تصل نصوصاً، والتحويل والعرض يمرّان + من نقطة واحدة. عدد المنازل من العملة (`JOD` ثلاث)، والعملة من إعداد + المستأجر لا من ثابت في التطبيق. +- **المحفظتان لا تُوحَّدان**: الراكب `WalletPage` (رصيد + شحن = **التزام** + على المنصّة)، والسائق `EarningsPage` (أرباح + سحب = **إيراد** له). شكلهما + متقارب وحسابهما مختلف — دمجهما خطأ محاسبي لا اختصار (`docs/39 §4`). +- **السحب بخطوتين**: `request` يرسل رمزاً **بلا خصم**، و`confirm` هو ما يخصم + ويحجز. شاشة سيرو كانت خطوة واحدة ولم تُقلَّد. الرمز الخاطئ لا يُرجع + المستخدم لخطوة المبلغ — الطلب قائم على الخادم أصلاً. +- **الرصيد التشغيلي معروض منفصلاً** عن الأرباح: منه تُخصم العمولة، ونفاده + يوقف وصول الطلبات — خلطه بالأرباح يخفي سبب توقّف العمل عن السائق. +- **`FareSummary` يعرض رقم كلّ طرف**: `price_for_passenger` للراكب و + `price_for_driver` للسائق — حقلان منفصلان والفرق عمولة (`docs/38 §4`). +- **الإلغاء بتأكيد صريح مع سبب**. السبب لا يقبله عقد الخادم بعد، لكن السؤال + يخفض الإلغاء العرضي ويجهّز الحقل. + +`flutter analyze` نظيف · الراكب 86 ملف/6,280 سطر · السائق 83 ملف/5,818 سطر. + +### (الأصل المعتمد للمرحلة 5) 22. حالات الرحلة · التتبّع الحيّ · الإلغاء · الإنهاء · الأجرة · التقييم. 23. المحفظتان (محفظة الراكب = التزام، محفظة السائق = إيراد — لا تُدمجان).