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