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
@@ -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,
),
),
);
}
}