feat(apps): التقييم والمحفظتان ومسار الرحلة — المرحلة 5

## التقييم مفروض لا اختياري
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 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-08-04 23:42:07 +03:00
co-authored by Claude Opus 5
parent 1156299d09
commit 66e28acc58
56 changed files with 2301 additions and 52 deletions
+16
View File
@@ -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<void> setupInjector() async {
sl.registerSingleton<LocationService>(LocationService());
sl.registerSingleton<RealtimeService>(RealtimeService(tokens));
sl.registerSingleton<RatingRepository>(RatingRepository(sl()));
sl.registerFactory<RatingCubit>(() => RatingCubit(sl()));
// المحفظة خلف علم الاستحقاق: الميزة المطفأة لا تُسجَّل أصلاً (docs/23 §5).
if (Features.wallet) {
sl.registerSingleton<WalletRepository>(WalletRepository(sl()));
sl.registerFactory<WalletCubit>(() => WalletCubit(sl()));
sl.registerFactory<PayoutCubit>(() => PayoutCubit(sl()));
}
sl.registerFactory<DutyCubit>(() => DutyCubit(
trips: sl(),
location: sl(),
@@ -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';
}
}
+39 -1
View File
@@ -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": "طلبات السحب"
}
+39 -1
View File
@@ -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"
}
+32 -4
View File
@@ -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<SessionCubit>().state.pendingRating!;
return RatingPage(
pending: PendingRating(
tripId: pending.tripId,
role: pending.role,
),
onDone: sl<SessionCubit>().onRatingResolved,
);
},
),
GoRoute(
path: Routes.wallet,
builder: (context, state) => const EarningsPage(),
),
GoRoute(
path: Routes.home,
builder: (context, state) => const DutyPage(),
@@ -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<SessionState> {
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<void> _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 {
// فحصٌ مساعد لا حاجز دخول.
}
}
}
@@ -8,6 +8,9 @@ import 'app_user.dart';
/// ويُنفّذها `AuthRepository` في ميزة المصادقة.
abstract interface class SessionSource {
Future<AppUser> me();
/// رحلة تنتظر تقييماً، أو `null`. يُنفّذها `RatingRepository`.
Future<({String tripId, String role})?> pendingRating();
Future<void> logout();
bool get hasSession;
}
@@ -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<Object?> get props => [status, user];
List<Object?> get props => [status, user, pendingRating?.tripId];
}
@@ -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<Map<String, dynamic>>('/trips/rating/pending');
final pending = res['pending'];
if (pending is! Map<String, dynamic>) return null;
final tripId = pending['tripId'] as String?;
if (tripId == null) return null;
return (tripId: tripId, role: pending['role'] as String? ?? 'rider');
}
@override
Future<void> logout() => _tokens.clear();
@@ -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<RatingState> {
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<void> 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,
));
}
}
}
@@ -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<Object?> get props => [status, pending?.tripId, stars, comment];
}
@@ -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<PendingRating?> pending() async {
final res = await _api.get<Map<String, dynamic>>('/trips/rating/pending');
final pending = res['pending'];
if (pending is! Map<String, dynamic>) 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<void> rate(String tripId, int stars, {String? comment}) {
return _api.post<Map<String, dynamic>>(
'/trips/$tripId/rate',
body: {'stars': stars, 'comment': ?comment},
);
}
}
@@ -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<RatingPage> createState() => _RatingPageState();
}
class _RatingPageState extends State<RatingPage> {
late final RatingCubit _cubit = sl<RatingCubit>()..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<RatingCubit, RatingState>(
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),
],
),
);
},
),
);
}
}
@@ -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<int> 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,
),
),
);
}),
);
}
}
@@ -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),
@@ -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,
),
),
],
),
],
);
}
}
@@ -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<PayoutState> {
PayoutCubit(this._repo) : super(const PayoutState());
final WalletRepository _repo;
Future<void> 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<void> 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());
}
@@ -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<WalletState> {
WalletCubit(this._repo) : super(const WalletState());
final WalletRepository _repo;
Future<void> 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<void> 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));
}
}
}
@@ -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<WalletTxn> transactions;
/// فعلٌ جارٍ داخل الشاشة — منفصل عن [status] كي لا يُخفي الشحنُ الرصيدَ
/// المعروض خلفه.
final WalletAction action;
final String? error;
String get currency => wallet?.currency ?? '';
WalletState copyWith({
ViewStatus? status,
Wallet? wallet,
List<WalletTxn>? 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<Object?> get props => [status, wallet, transactions, action, error];
}
@@ -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<String, dynamic> json) => Wallet(
id: json['id'] as String? ?? '',
balance: json['balance']?.toString() ?? '0',
currency: json['currency'] as String? ?? '',
);
@override
List<Object?> get props => [id, balance, currency];
}
@@ -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<String, dynamic> 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<Object?> get props => [id, amount, reason, createdAt];
}
@@ -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> wallet() async {
final res = await _api.get<Map<String, dynamic>>('/wallet');
return Wallet.fromJson(res);
}
Future<List<WalletTxn>> transactions() async {
final res = await _api.get<List<dynamic>>('/wallet/transactions');
return res
.whereType<Map<String, dynamic>>()
.map(WalletTxn.fromJson)
.toList(growable: false);
}
Future<void> topup(num amount) =>
_api.post<Map<String, dynamic>>('/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<Map<String, dynamic>>(
'/payouts/request',
body: {'amount': amount, 'channel': channel},
);
final payout = res['payout'] as Map<String, dynamic>?;
return (
payoutId: payout?['id'] as String? ?? '',
devCode: res['dev_code']?.toString(),
);
}
Future<void> confirmPayout(String payoutId, String code) =>
_api.post<Map<String, dynamic>>(
'/payouts/$payoutId/confirm',
body: {'code': code},
);
Future<List<Map<String, dynamic>>> payouts() async {
final res = await _api.get<List<dynamic>>('/payouts/mine');
return res.whereType<Map<String, dynamic>>().toList(growable: false);
}
}
@@ -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<EarningsPage> createState() => _EarningsPageState();
}
class _EarningsPageState extends State<EarningsPage> {
late final WalletCubit _wallet = sl<WalletCubit>()..load();
@override
void dispose() {
_wallet.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return BlocProvider.value(
value: _wallet,
child: BlocBuilder<WalletCubit, WalletState>(
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<PayoutCubit>(),
child: PayoutSheet(onDone: _wallet.load),
),
);
}
}
@@ -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!,
],
],
),
);
}
}
@@ -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<PayoutSheet> createState() => _PayoutSheetState();
}
class _PayoutSheetState extends State<PayoutSheet> {
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<PayoutCubit, PayoutState>(
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<PayoutCubit>();
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');
}
}
},
),
],
);
},
);
}
}
@@ -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,
),
),
);
}
}
+1 -1
View File
@@ -382,7 +382,7 @@ packages:
source: hosted
version: "2.3.0"
intl:
dependency: transitive
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
+2
View File
@@ -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
+14
View File
@@ -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<void> setupInjector() async {
sl.registerSingleton<LocationService>(LocationService());
sl.registerSingleton<RealtimeService>(RealtimeService(tokens));
sl.registerSingleton<RatingRepository>(RatingRepository(sl()));
sl.registerFactory<RatingCubit>(() => RatingCubit(sl()));
// المحفظة خلف علم الاستحقاق: الميزة المطفأة لا تُسجَّل أصلاً (docs/23 §5).
if (Features.wallet) {
sl.registerSingleton<WalletRepository>(WalletRepository(sl()));
sl.registerFactory<WalletCubit>(() => WalletCubit(sl()));
}
sl.registerFactory<RideCubit>(() => RideCubit(
trips: sl(),
maps: sl(),
+34
View File
@@ -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';
}
}
+39 -1
View File
@@ -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": "طلبات السحب"
}
+39 -1
View File
@@ -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"
}
+32 -4
View File
@@ -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<SessionCubit>().state.pendingRating!;
return RatingPage(
pending: PendingRating(
tripId: pending.tripId,
role: pending.role,
),
onDone: sl<SessionCubit>().onRatingResolved,
);
},
),
GoRoute(
path: Routes.wallet,
builder: (context, state) => const WalletPage(),
),
GoRoute(
path: Routes.home,
builder: (context, state) => const RidePage(),
@@ -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<SessionState> {
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<void> _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 {
// فحصٌ مساعد لا حاجز دخول.
}
}
}
@@ -8,6 +8,9 @@ import 'app_user.dart';
/// ويُنفّذها `AuthRepository` في ميزة المصادقة.
abstract interface class SessionSource {
Future<AppUser> me();
/// رحلة تنتظر تقييماً، أو `null`. يُنفّذها `RatingRepository`.
Future<({String tripId, String role})?> pendingRating();
Future<void> logout();
bool get hasSession;
}
@@ -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<Object?> get props => [status, user];
List<Object?> get props => [status, user, pendingRating?.tripId];
}
@@ -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<Map<String, dynamic>>('/trips/rating/pending');
final pending = res['pending'];
if (pending is! Map<String, dynamic>) return null;
final tripId = pending['tripId'] as String?;
if (tripId == null) return null;
return (tripId: tripId, role: pending['role'] as String? ?? 'rider');
}
@override
Future<void> logout() => _tokens.clear();
@@ -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<RatingState> {
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<void> 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,
));
}
}
}
@@ -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<Object?> get props => [status, pending?.tripId, stars, comment];
}
@@ -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<PendingRating?> pending() async {
final res = await _api.get<Map<String, dynamic>>('/trips/rating/pending');
final pending = res['pending'];
if (pending is! Map<String, dynamic>) 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<void> rate(String tripId, int stars, {String? comment}) {
return _api.post<Map<String, dynamic>>(
'/trips/$tripId/rate',
body: {'stars': stars, 'comment': ?comment},
);
}
}
@@ -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<RatingPage> createState() => _RatingPageState();
}
class _RatingPageState extends State<RatingPage> {
late final RatingCubit _cubit = sl<RatingCubit>()..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<RatingCubit, RatingState>(
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),
],
),
);
},
),
);
}
}
@@ -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<int> 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,
),
),
);
}),
);
}
}
@@ -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<RideCubit>().cancelTrip,
// تأكيد صريح: الإلغاء أثناء رحلة مسندة له كلفة على الطرفين.
onPressed: () async {
final cubit = context.read<RideCubit>();
if (await CancelSheet.confirm(context)) await cubit.cancelTrip();
},
),
],
],
@@ -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<bool> confirm(BuildContext context) async {
final l10n = context.l10n;
var reason = 0;
final result = await TripzSheet.show<bool>(
context,
title: l10n.tripCancelTitle,
child: StatefulBuilder(
builder: (context, setState) => RadioGroup<int>(
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<int>(
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;
}
}
@@ -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,
),
),
],
),
],
);
}
}
@@ -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<WalletState> {
WalletCubit(this._repo) : super(const WalletState());
final WalletRepository _repo;
Future<void> 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<void> 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));
}
}
}
@@ -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<WalletTxn> transactions;
/// فعلٌ جارٍ داخل الشاشة — منفصل عن [status] كي لا يُخفي الشحنُ الرصيدَ
/// المعروض خلفه.
final WalletAction action;
final String? error;
String get currency => wallet?.currency ?? '';
WalletState copyWith({
ViewStatus? status,
Wallet? wallet,
List<WalletTxn>? 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<Object?> get props => [status, wallet, transactions, action, error];
}
@@ -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<String, dynamic> json) => Wallet(
id: json['id'] as String? ?? '',
balance: json['balance']?.toString() ?? '0',
currency: json['currency'] as String? ?? '',
);
@override
List<Object?> get props => [id, balance, currency];
}
@@ -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<String, dynamic> 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<Object?> get props => [id, amount, reason, createdAt];
}
@@ -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> wallet() async {
final res = await _api.get<Map<String, dynamic>>('/wallet');
return Wallet.fromJson(res);
}
Future<List<WalletTxn>> transactions() async {
final res = await _api.get<List<dynamic>>('/wallet/transactions');
return res
.whereType<Map<String, dynamic>>()
.map(WalletTxn.fromJson)
.toList(growable: false);
}
Future<void> topup(num amount) =>
_api.post<Map<String, dynamic>>('/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<Map<String, dynamic>>(
'/payouts/request',
body: {'amount': amount, 'channel': channel},
);
final payout = res['payout'] as Map<String, dynamic>?;
return (
payoutId: payout?['id'] as String? ?? '',
devCode: res['dev_code']?.toString(),
);
}
Future<void> confirmPayout(String payoutId, String code) =>
_api.post<Map<String, dynamic>>(
'/payouts/$payoutId/confirm',
body: {'code': code},
);
Future<List<Map<String, dynamic>>> payouts() async {
final res = await _api.get<List<dynamic>>('/payouts/mine');
return res.whereType<Map<String, dynamic>>().toList(growable: false);
}
}
@@ -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<WalletPage> createState() => _WalletPageState();
}
class _WalletPageState extends State<WalletPage> {
late final WalletCubit _cubit = sl<WalletCubit>()..load();
@override
void dispose() {
_cubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return BlocProvider.value(
value: _cubit,
child: BlocConsumer<WalletCubit, WalletState>(
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);
},
),
);
}
}
@@ -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!,
],
],
),
);
}
}
@@ -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,
),
),
);
}
}
+1 -1
View File
@@ -382,7 +382,7 @@ packages:
source: hosted
version: "2.3.0"
intl:
dependency: transitive
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
+2
View File
@@ -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