From 8976821a9374f22114f6a7e1a1ad5e7feb832883 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Wed, 5 Aug 2026 00:36:02 +0300 Subject: [PATCH] =?UTF-8?q?fix(apps):=20=D8=A5=D8=B5=D9=84=D8=A7=D8=AD?= =?UTF-8?q?=D8=A7=D8=AA=20=D9=85=D9=86=20=D8=A3=D9=88=D9=84=20=D8=AA=D8=AC?= =?UTF-8?q?=D8=B1=D8=A8=D8=A9=20=D8=AD=D9=82=D9=8A=D9=82=D9=8A=D8=A9=20?= =?UTF-8?q?=D8=B9=D9=84=D9=89=20=D8=A7=D9=84=D8=AC=D9=87=D8=A7=D8=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## تعذّر حساب الأجرة — الخطأ في التطبيق لا في جدول الكازان الخادم يبذر التعرفة بـ city: 'default' (tenants.service.ts:156) ويستعمل dto.city ?? 'default' عند إنشاء الرحلة، وكنت أرسل city: '' فيرجّع 404 «No active tariff for /economy». تحقّق حيّ: city='default' → total 1.55 JOD ✅ city='' → 404 ❌ city='amman' → 404 ❌ لا تغيير مطلوب على السيرفر. أُضيف AppConfig.defaultCity موثّقاً بالسبب. ## التسعير مرة واحدة لكل الأنواع كان نداء شبكة عند كل ضغطة نوع. صار _priceAll يسعّر كل الأنواع بالتوازي بعد اكتمال المسار — المسافة والمدة ثابتتان والفرق في التعرفة وحدها. الراكب يرى سعر الاقتصادي والمريح معاً تحت كل خيار ويقارن بنظرة، وتبديل النوع بلا نداء. ## الكاميرا لا تتبع الموقع سببان: الموقع يصل قبل إنشاء الخريطة فيضيع التحريك، ولا فحص للإذن/الخدمة قبل getCurrentPosition فيُبتلع الخطأ صامتاً. - _onCreated يلحق بما فات عند جهوز الخريطة - LocationService.ensureReady يفحص الخدمة والإذن أولاً - didChangeAppLifecycleState يعيد المحاولة عند العودة من إعدادات النظام - حالة noLocation تُعرض للمستخدم مع زر إعادة بدل خريطة صامتة ## الخريطة والتنقّل - إطار المسار من حدوده الفعلية مع حشوة سفلية بمقدار ما تغطّيه الورقة - علامتا البداية والنهاية دائرتان ملوّنتان تطابقان ألوان الخط الزمني - زر رجوع في ورقة التأكيد + زر إعادة حساب عند فشل التسعيرة: كان المستخدم يعلق في الشاشة بلا مخرج ## المحفظة — على أي أساس يُشحن POST /wallet/topup شحنٌ تجريبي بلا مزوّد دفع خلفه (الخادم نفسه يصفه كذلك). أُضيف PaymentChannel مشتقّاً من countryPack المستأجر لا من لغة الجهاز: jo → CliQ · sy → سيرياتيل/MTN/شام كاش · eg → PayMob. ويُعرض تنبيه صريح بأن الشحن بلا تحصيل فعلي — إخفاؤه يجعل المستخدم يظنّ أنه دفع. وسحب السائق صار يستعمل القناة المختارة بدل 'cliq' المكتوبة في الكود. flutter analyze نظيف في التطبيقين. Co-Authored-By: Claude Opus 5 --- .../lib/core/location/location_service.dart | 13 ++ .../lib/core/payments/payment_channels.dart | 50 ++++++++ .../lib/core/ui/channel_picker.dart | 48 ++++++++ .../features/trip/view/widgets/ride_map.dart | 85 +++++++++++-- .../wallet/view/widgets/payout_sheet.dart | 27 +++- apps/rider_new/lib/core/config.dart | 8 ++ apps/rider_new/lib/core/l10n/app_ar.arb | 9 +- apps/rider_new/lib/core/l10n/app_en.arb | 9 +- .../lib/core/location/location_service.dart | 13 ++ .../lib/core/payments/payment_channels.dart | 50 ++++++++ .../rider_new/lib/core/ui/channel_picker.dart | 48 ++++++++ .../lib/features/trip/cubit/ride_cubit.dart | 113 ++++++++++++----- .../lib/features/trip/cubit/ride_state.dart | 27 +++- .../lib/features/trip/view/ride_page.dart | 59 +++++++-- .../trip/view/widgets/confirm_sheet.dart | 14 +++ .../features/trip/view/widgets/ride_map.dart | 85 +++++++++++-- .../trip/view/widgets/ride_type_picker.dart | 115 +++++++++++++++--- .../lib/features/wallet/view/wallet_page.dart | 45 ++++++- 18 files changed, 731 insertions(+), 87 deletions(-) create mode 100644 apps/driver_new/lib/core/payments/payment_channels.dart create mode 100644 apps/driver_new/lib/core/ui/channel_picker.dart create mode 100644 apps/rider_new/lib/core/payments/payment_channels.dart create mode 100644 apps/rider_new/lib/core/ui/channel_picker.dart diff --git a/apps/driver_new/lib/core/location/location_service.dart b/apps/driver_new/lib/core/location/location_service.dart index 83e0be3..0aa0fab 100644 --- a/apps/driver_new/lib/core/location/location_service.dart +++ b/apps/driver_new/lib/core/location/location_service.dart @@ -8,7 +8,20 @@ import '../config.dart'; class LocationService { StreamSubscription? _sub; + /// بلا هذا الفحص يرمي `getCurrentPosition` ونبتلع الخطأ فتبقى الكاميرا + /// على الاحتياطي بلا سبب ظاهر — وهو ما كان يبدو «الخريطة لا تتبع موقعي». + Future ensureReady() async { + if (!await Geolocator.isLocationServiceEnabled()) return false; + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + return permission == LocationPermission.always || + permission == LocationPermission.whileInUse; + } + Future<({double lat, double lng})?> current() async { + if (!await ensureReady()) return null; try { final p = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( diff --git a/apps/driver_new/lib/core/payments/payment_channels.dart b/apps/driver_new/lib/core/payments/payment_channels.dart new file mode 100644 index 0000000..03cab81 --- /dev/null +++ b/apps/driver_new/lib/core/payments/payment_channels.dart @@ -0,0 +1,50 @@ +/// قنوات الدفع والسحب حسب **حزمة دولة المستأجر** (`countryPack` من +/// `GET /tenant/config/:slug` — docs/38 §10)، لا حسب لغة الجهاز ولا موقعه. +/// +/// المستأجر هو من يحدّد السوق: مشغّل أردني يخدم مستخدماً هاتفه إنجليزي +/// وموقعه خارج الأردن يبقى أردنيّ القنوات. +/// +/// ⚠️ **`POST /wallet/topup` شحنٌ تجريبي بلا مزوّد دفع خلفه** — الخادم نفسه +/// يصفه بـ«كاش-إن، للإنتاج يُربط بمزوّد دفع أو يُقيَّد للأدمن». اختيار +/// القناة هنا يجهّز الواجهة والعقد؛ التحصيل الفعلي عبر `/payments/charge` +/// وبوابة كل قناة عملٌ منفصل لم يُنفَّذ بعد. +enum PaymentChannel { + cliq('cliq', 'CliQ', 'كليك'), + syriatel('syriatel_cash', 'Syriatel Cash', 'سيرياتيل كاش'), + mtn('mtn_cash', 'MTN Cash', 'إم تي إن كاش'), + shamCash('sham_cash', 'Sham Cash', 'شام كاش'), + paymob('paymob', 'PayMob', 'باي موب'), + cash('cash', 'Cash', 'نقداً'); + + const PaymentChannel(this.wire, this.nameEn, this.nameAr); + + /// القيمة المرسلة للخادم في حقل `channel`. + final String wire; + final String nameEn; + final String nameAr; + + String nameFor(String languageCode) => + languageCode == 'ar' ? nameAr : nameEn; + + /// القنوات المتاحة لكل حزمة دولة — مطابقة لما هو مدعوم فعلياً في السوق + /// (نفس قنوات سيرو الميدانية). + static List forCountry(String countryPack) => + switch (countryPack) { + 'jo' => const [PaymentChannel.cliq, PaymentChannel.cash], + 'sy' => const [ + PaymentChannel.syriatel, + PaymentChannel.mtn, + PaymentChannel.shamCash, + PaymentChannel.cash, + ], + 'eg' => const [PaymentChannel.paymob, PaymentChannel.cash], + // حزمة غير معروفة: النقد وحده — لا نعرض قناة قد لا تعمل في سوقها. + _ => const [PaymentChannel.cash], + }; + + /// قنوات السحب — النقد ليس قناة سحب. + static List payoutFor(String countryPack) => + forCountry(countryPack) + .where((c) => c != PaymentChannel.cash) + .toList(growable: false); +} diff --git a/apps/driver_new/lib/core/ui/channel_picker.dart b/apps/driver_new/lib/core/ui/channel_picker.dart new file mode 100644 index 0000000..a17a570 --- /dev/null +++ b/apps/driver_new/lib/core/ui/channel_picker.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import '../design/tokens.dart'; +import '../design/tripz_colors.dart'; +import '../payments/payment_channels.dart'; + +/// اختيار قناة الدفع/السحب. القائمة تأتي من حزمة دولة المستأجر، فلا يرى +/// الأردنيُّ «شام كاش» ولا السوريُّ «كليك». +class ChannelPicker extends StatelessWidget { + const ChannelPicker({ + super.key, + required this.channels, + required this.selected, + required this.onSelect, + }); + + final List channels; + final PaymentChannel? selected; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + if (channels.isEmpty) return const SizedBox.shrink(); + final lang = Localizations.localeOf(context).languageCode; + + return Wrap( + spacing: Space.xs, + runSpacing: Space.xs, + children: [ + for (final channel in channels) + ChoiceChip( + selected: channel == selected, + onSelected: (_) => onSelect(channel), + avatar: Icon(_iconOf(channel), size: Sizes.iconSm), + label: Text(channel.nameFor(lang)), + labelStyle: context.texts.labelLarge, + ), + ], + ); + } + + static IconData _iconOf(PaymentChannel channel) => switch (channel) { + PaymentChannel.cash => Icons.payments_outlined, + PaymentChannel.cliq => Icons.account_balance_outlined, + PaymentChannel.paymob => Icons.credit_card_outlined, + _ => Icons.smartphone_outlined, + }; +} diff --git a/apps/driver_new/lib/features/trip/view/widgets/ride_map.dart b/apps/driver_new/lib/features/trip/view/widgets/ride_map.dart index de790f5..158be7f 100644 --- a/apps/driver_new/lib/features/trip/view/widgets/ride_map.dart +++ b/apps/driver_new/lib/features/trip/view/widgets/ride_map.dart @@ -24,6 +24,7 @@ class RideMap extends StatefulWidget { this.route, this.cameraTarget, this.onCameraIdle, + this.bottomInset = 280, }); final GeoPoint? origin; @@ -35,6 +36,9 @@ class RideMap extends StatefulWidget { /// مركز الخريطة عند استقرارها — يغذّي اختيار النقطة بالدبّوس الثابت. final ValueChanged? onCameraIdle; + /// ارتفاع ما تغطّيه الورقة السفلية — يُحسب منه إطار المسار. + final double bottomInset; + @override State createState() => _RideMapState(); } @@ -48,12 +52,62 @@ class _RideMapState extends State { @override void didUpdateWidget(RideMap old) { super.didUpdateWidget(old); - final target = widget.cameraTarget; - if (target != null && target != old.cameraTarget) { - _controller?.animateCamera( - im.CameraUpdate.newLatLng(im.LatLng(target.lat, target.lng)), - ); + if (widget.route != null && widget.route != old.route) { + _fitRoute(); + return; } + final target = widget.cameraTarget; + if (target != null && target != old.cameraTarget) _moveTo(target); + } + + /// **يُنادى عند جهوز الخريطة**: الموقع الحالي غالباً يصل *قبل* إنشاء + /// الخريطة، فالتحريك في `didUpdateWidget` وحده يضيع و تبقى الكاميرا على + /// الاحتياطي. هنا نلحق بما فات. + void _onCreated(im.IntaleqMapController controller) { + _controller = controller; + final target = widget.cameraTarget ?? widget.origin; + if (widget.route != null) { + _fitRoute(); + } else if (target != null) { + _moveTo(target); + } + } + + void _moveTo(GeoPoint p) => _controller?.animateCamera( + im.CameraUpdate.newLatLngZoom(im.LatLng(p.lat, p.lng), 15.5), + ); + + /// يضبط الكاميرا على المسار كاملاً بدل نقطة واحدة. + /// + /// الحشوة السفلية أكبر لأن الورقة السفلية تغطّي نحو ثلث الشاشة — بلا ذلك + /// يختفي نصف المسار تحتها. + void _fitRoute() { + final encoded = widget.route?.encodedPoints; + if (encoded == null || encoded.isEmpty) return; + final points = im.PolylineUtils.decode(encoded); + if (points.length < 2) return; + + var minLat = points.first.latitude, maxLat = points.first.latitude; + var minLng = points.first.longitude, maxLng = points.first.longitude; + for (final p in points) { + if (p.latitude < minLat) minLat = p.latitude; + if (p.latitude > maxLat) maxLat = p.latitude; + if (p.longitude < minLng) minLng = p.longitude; + if (p.longitude > maxLng) maxLng = p.longitude; + } + + _controller?.animateCamera( + im.CameraUpdate.newLatLngBounds( + im.LatLngBounds( + southwest: im.LatLng(minLat, minLng), + northeast: im.LatLng(maxLat, maxLng), + ), + left: 48, + right: 48, + top: 120, + bottom: widget.bottomInset, + ), + ); } @override @@ -77,17 +131,21 @@ class _RideMapState extends State { myLocationEnabled: true, compassEnabled: false, zoomControlsEnabled: false, - onMapCreated: (c) => _controller = c, + onMapCreated: _onCreated, onCameraMove: (position) => _center = position.target, onCameraIdle: () { final c = _center; if (c != null) widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude)); }, - markers: { + // العلامات دوائر ملوّنة تطابق ألوان الخط الزمني في الورقة السفلية، + // فيربط المستخدم بين «من» و«إلى» وما يراه على الخريطة بلا شرح. + circles: { if (widget.origin != null) - _marker('origin', widget.origin!), + _dot('origin', widget.origin!, colors.mapOrigin), if (widget.destination != null) - _marker('destination', widget.destination!), + _dot('destination', widget.destination!, colors.mapDestination), + }, + markers: { if (widget.driver != null) _marker('driver', widget.driver!), }, polylines: { @@ -106,4 +164,13 @@ class _RideMapState extends State { markerId: im.MarkerId(id), position: im.LatLng(p.lat, p.lng), ); + + im.Circle _dot(String id, GeoPoint p, Color color) => im.Circle( + circleId: im.CircleId(id), + center: im.LatLng(p.lat, p.lng), + radius: 18, + fillColor: color, + strokeColor: Colors.white, + strokeWidth: 3, + ); } diff --git a/apps/driver_new/lib/features/wallet/view/widgets/payout_sheet.dart b/apps/driver_new/lib/features/wallet/view/widgets/payout_sheet.dart index b440576..c8a8883 100644 --- a/apps/driver_new/lib/features/wallet/view/widgets/payout_sheet.dart +++ b/apps/driver_new/lib/features/wallet/view/widgets/payout_sheet.dart @@ -4,6 +4,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../core/config.dart'; import '../../../../core/design/tokens.dart'; import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/payments/payment_channels.dart'; +import '../../../../core/tenant/tenant_cubit.dart'; +import '../../../../core/ui/channel_picker.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../core/ui/tripz_button.dart'; import '../../../../core/ui/tripz_otp_field.dart'; @@ -24,6 +27,7 @@ class PayoutSheet extends StatefulWidget { class _PayoutSheetState extends State { final _amount = TextEditingController(); final _code = TextEditingController(); + PaymentChannel? _channel; @override void dispose() { @@ -75,7 +79,7 @@ class _PayoutSheetState extends State { hasError: state.failure == PayoutFailure.rejected, onCompleted: cubit.confirm, ) - else + else ...[ TripzTextField( controller: _amount, label: l10n.payoutAmount, @@ -85,6 +89,22 @@ class _PayoutSheetState extends State { decimal: true, ), ), + const SizedBox(height: Space.md), + Text(l10n.payoutChannel, style: context.texts.titleSmall), + const SizedBox(height: Space.xs), + Builder(builder: (context) { + // قنوات السحب من حزمة دولة المستأجر — والنقد ليس قناة سحب. + final channels = PaymentChannel.payoutFor( + context.watch().state.countryPack, + ); + _channel ??= channels.isEmpty ? null : channels.first; + return ChannelPicker( + channels: channels, + selected: _channel, + onSelect: (c) => setState(() => _channel = c), + ); + }), + ], if (state.failure != PayoutFailure.none) ...[ const SizedBox(height: Space.sm), Text( @@ -107,8 +127,9 @@ class _PayoutSheetState extends State { cubit.confirm(_code.text.trim()); } else { final amount = num.tryParse(_amount.text.trim()); - if (amount != null) { - cubit.request(amount: amount, channel: 'cliq'); + final channel = _channel; + if (amount != null && channel != null) { + cubit.request(amount: amount, channel: channel.wire); } } }, diff --git a/apps/rider_new/lib/core/config.dart b/apps/rider_new/lib/core/config.dart index 49020f1..1960ca9 100644 --- a/apps/rider_new/lib/core/config.dart +++ b/apps/rider_new/lib/core/config.dart @@ -41,6 +41,14 @@ class AppConfig { defaultValue: 'in_9478b32836d19cff73db3063', ); + /// المدينة الافتراضية للتعرفة. + /// + /// ⚠️ الخادم يبذر التعرفة بـ`city: 'default'` (`tenants.service.ts:156`) + /// ويستعمل `dto.city ?? 'default'` عند إنشاء الرحلة. إرسال نصّ فارغ أو اسم + /// مدينة حقيقي يرجّع **404 «No active tariff»** — وهو ما كان يظهر + /// للمستخدم كـ«تعذّر حساب الأجرة». + static const String defaultCity = 'default'; + /// كل كم متر يُرفع موقع السائق. أقل من ذلك يستنزف البطارية بلا فائدة. static const int locationFilterMeters = 25; diff --git a/apps/rider_new/lib/core/l10n/app_ar.arb b/apps/rider_new/lib/core/l10n/app_ar.arb index 5554570..a7afd8b 100644 --- a/apps/rider_new/lib/core/l10n/app_ar.arb +++ b/apps/rider_new/lib/core/l10n/app_ar.arb @@ -193,5 +193,12 @@ "chatHint": "اكتب رسالة", "chatEmpty": "لا رسائل بعد", "rideCoupon": "كود خصم", - "rideCouponApply": "تطبيق" + "rideCouponApply": "تطبيق", + "rideNoLocation": "فعّل الموقع لنعرف أين أنت", + "rideBack": "رجوع", + "rideRetryQuote": "أعد حساب الأجرة", + "rideFrom": "من", + "rideTo": "إلى", + "walletChannel": "طريقة الدفع", + "walletTopupNote": "الشحن التجريبي حالياً بلا تحصيل فعلي — ربط البوابة قادم" } \ No newline at end of file diff --git a/apps/rider_new/lib/core/l10n/app_en.arb b/apps/rider_new/lib/core/l10n/app_en.arb index 0c70943..7678cd7 100644 --- a/apps/rider_new/lib/core/l10n/app_en.arb +++ b/apps/rider_new/lib/core/l10n/app_en.arb @@ -193,5 +193,12 @@ "chatHint": "Type a message", "chatEmpty": "No messages yet", "rideCoupon": "Promo code", - "rideCouponApply": "Apply" + "rideCouponApply": "Apply", + "rideNoLocation": "Turn on location so we know where you are", + "rideBack": "Back", + "rideRetryQuote": "Recalculate fare", + "rideFrom": "From", + "rideTo": "To", + "walletChannel": "Payment method", + "walletTopupNote": "Top-up is currently a test cash-in with no real charge — gateway wiring is next" } \ No newline at end of file diff --git a/apps/rider_new/lib/core/location/location_service.dart b/apps/rider_new/lib/core/location/location_service.dart index 83e0be3..0aa0fab 100644 --- a/apps/rider_new/lib/core/location/location_service.dart +++ b/apps/rider_new/lib/core/location/location_service.dart @@ -8,7 +8,20 @@ import '../config.dart'; class LocationService { StreamSubscription? _sub; + /// بلا هذا الفحص يرمي `getCurrentPosition` ونبتلع الخطأ فتبقى الكاميرا + /// على الاحتياطي بلا سبب ظاهر — وهو ما كان يبدو «الخريطة لا تتبع موقعي». + Future ensureReady() async { + if (!await Geolocator.isLocationServiceEnabled()) return false; + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + return permission == LocationPermission.always || + permission == LocationPermission.whileInUse; + } + Future<({double lat, double lng})?> current() async { + if (!await ensureReady()) return null; try { final p = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( diff --git a/apps/rider_new/lib/core/payments/payment_channels.dart b/apps/rider_new/lib/core/payments/payment_channels.dart new file mode 100644 index 0000000..03cab81 --- /dev/null +++ b/apps/rider_new/lib/core/payments/payment_channels.dart @@ -0,0 +1,50 @@ +/// قنوات الدفع والسحب حسب **حزمة دولة المستأجر** (`countryPack` من +/// `GET /tenant/config/:slug` — docs/38 §10)، لا حسب لغة الجهاز ولا موقعه. +/// +/// المستأجر هو من يحدّد السوق: مشغّل أردني يخدم مستخدماً هاتفه إنجليزي +/// وموقعه خارج الأردن يبقى أردنيّ القنوات. +/// +/// ⚠️ **`POST /wallet/topup` شحنٌ تجريبي بلا مزوّد دفع خلفه** — الخادم نفسه +/// يصفه بـ«كاش-إن، للإنتاج يُربط بمزوّد دفع أو يُقيَّد للأدمن». اختيار +/// القناة هنا يجهّز الواجهة والعقد؛ التحصيل الفعلي عبر `/payments/charge` +/// وبوابة كل قناة عملٌ منفصل لم يُنفَّذ بعد. +enum PaymentChannel { + cliq('cliq', 'CliQ', 'كليك'), + syriatel('syriatel_cash', 'Syriatel Cash', 'سيرياتيل كاش'), + mtn('mtn_cash', 'MTN Cash', 'إم تي إن كاش'), + shamCash('sham_cash', 'Sham Cash', 'شام كاش'), + paymob('paymob', 'PayMob', 'باي موب'), + cash('cash', 'Cash', 'نقداً'); + + const PaymentChannel(this.wire, this.nameEn, this.nameAr); + + /// القيمة المرسلة للخادم في حقل `channel`. + final String wire; + final String nameEn; + final String nameAr; + + String nameFor(String languageCode) => + languageCode == 'ar' ? nameAr : nameEn; + + /// القنوات المتاحة لكل حزمة دولة — مطابقة لما هو مدعوم فعلياً في السوق + /// (نفس قنوات سيرو الميدانية). + static List forCountry(String countryPack) => + switch (countryPack) { + 'jo' => const [PaymentChannel.cliq, PaymentChannel.cash], + 'sy' => const [ + PaymentChannel.syriatel, + PaymentChannel.mtn, + PaymentChannel.shamCash, + PaymentChannel.cash, + ], + 'eg' => const [PaymentChannel.paymob, PaymentChannel.cash], + // حزمة غير معروفة: النقد وحده — لا نعرض قناة قد لا تعمل في سوقها. + _ => const [PaymentChannel.cash], + }; + + /// قنوات السحب — النقد ليس قناة سحب. + static List payoutFor(String countryPack) => + forCountry(countryPack) + .where((c) => c != PaymentChannel.cash) + .toList(growable: false); +} diff --git a/apps/rider_new/lib/core/ui/channel_picker.dart b/apps/rider_new/lib/core/ui/channel_picker.dart new file mode 100644 index 0000000..a17a570 --- /dev/null +++ b/apps/rider_new/lib/core/ui/channel_picker.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import '../design/tokens.dart'; +import '../design/tripz_colors.dart'; +import '../payments/payment_channels.dart'; + +/// اختيار قناة الدفع/السحب. القائمة تأتي من حزمة دولة المستأجر، فلا يرى +/// الأردنيُّ «شام كاش» ولا السوريُّ «كليك». +class ChannelPicker extends StatelessWidget { + const ChannelPicker({ + super.key, + required this.channels, + required this.selected, + required this.onSelect, + }); + + final List channels; + final PaymentChannel? selected; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + if (channels.isEmpty) return const SizedBox.shrink(); + final lang = Localizations.localeOf(context).languageCode; + + return Wrap( + spacing: Space.xs, + runSpacing: Space.xs, + children: [ + for (final channel in channels) + ChoiceChip( + selected: channel == selected, + onSelected: (_) => onSelect(channel), + avatar: Icon(_iconOf(channel), size: Sizes.iconSm), + label: Text(channel.nameFor(lang)), + labelStyle: context.texts.labelLarge, + ), + ], + ); + } + + static IconData _iconOf(PaymentChannel channel) => switch (channel) { + PaymentChannel.cash => Icons.payments_outlined, + PaymentChannel.cliq => Icons.account_balance_outlined, + PaymentChannel.paymob => Icons.credit_card_outlined, + _ => Icons.smartphone_outlined, + }; +} diff --git a/apps/rider_new/lib/features/trip/cubit/ride_cubit.dart b/apps/rider_new/lib/features/trip/cubit/ride_cubit.dart index 78949ea..bb79014 100644 --- a/apps/rider_new/lib/features/trip/cubit/ride_cubit.dart +++ b/apps/rider_new/lib/features/trip/cubit/ride_cubit.dart @@ -14,6 +14,7 @@ import '../data/maps_repository.dart'; import '../data/models/geo_point.dart'; +import '../data/models/fare_quote.dart'; import '../data/models/place.dart'; import '../data/models/ride_type.dart'; @@ -67,16 +68,28 @@ class RideCubit extends Cubit { // الكاميرا تتوسّط الموقع **فوراً**، ولا تنتظر العنوان — انتظارها كان // باگ سيرو المُصلَح 2026-07-20. - unawaited(_locateMe()); + unawaited(locateMe()); unawaited(_loadRideTypes()); unawaited(_resumeLiveTrip()); } - Future _locateMe() async { + /// تُنادى عند الإقلاع وعند العودة للتطبيق — الإذن قد يُمنح من إعدادات + /// النظام بينما التطبيق في الخلفية. + Future locateMe() async { final pos = await _location.current(); - if (pos == null || isClosed) return; + if (pos == null || isClosed) { + // بلا موقع لا خريطة مفيدة — تُعرض الحالة كي يفهم المستخدم السبب. + if (!isClosed && state.origin == null) { + emit(state.copyWith(error: RideError.noLocation)); + } + return; + } final point = GeoPoint(pos.lat, pos.lng); - emit(state.copyWith(origin: point, cameraTarget: point)); + emit(state.copyWith( + origin: point, + cameraTarget: point, + error: state.error == RideError.noLocation ? RideError.none : null, + )); final place = await _maps.reverse(point); if (isClosed || place == null) return; @@ -91,6 +104,8 @@ class RideCubit extends Cubit { rideTypes: types, selectedRideType: state.selectedRideType ?? types.first, )); + // قد تصل الأنواع بعد رسم المسار — عندها تُسعَّر فوراً بلا تدخّل. + if (state.route != null && state.quotes.isEmpty) await _priceAll(); } on ApiException { // قائمة الأنواع ليست حاجزاً للخريطة — تُعاد المحاولة عند التأكيد. } @@ -115,9 +130,12 @@ class RideCubit extends Cubit { void collapsePlanner() => emit(state.copyWith(phase: RidePhase.idle)); + /// تبديل النوع **لا يُطلق نداءً**: كل الأنواع مسعَّرة مسبقاً في [_priceAll]. void selectRideType(RideType type) { - emit(state.copyWith(selectedRideType: type)); - unawaited(_priceIt()); + emit(state.copyWith( + selectedRideType: type, + quote: state.quoteFor(type.code), + )); } /// بحث نصّي عن مكان، بمهلة ارتداد: كل ضغطة زر لا تُطلق نداءً. @@ -186,6 +204,21 @@ class RideCubit extends Cubit { emit(state.copyWith(stops: next)); } + /// تراجع خطوة واحدة — المخرج الوحيد من شاشة التأكيد حين تفشل التسعيرة. + void back() { + switch (state.phase) { + case RidePhase.confirming: + emit(state.copyWith(phase: RidePhase.planning, error: RideError.none)); + case RidePhase.pickingOnMap: + case RidePhase.planning: + emit(state.copyWith(phase: RidePhase.idle, error: RideError.none)); + case RidePhase.idle: + case RidePhase.searching: + case RidePhase.active: + break; + } + } + void clearDestination() => emit(state.copyWith( clearDestination: true, clearRoute: true, @@ -204,7 +237,7 @@ class RideCubit extends Cubit { final route = await _maps.route(from, to); if (isClosed) return; emit(state.copyWith(route: route, phase: RidePhase.confirming)); - await _priceIt(); + await _priceAll(); } on ApiException { if (!isClosed) { emit(state.copyWith(busy: false, error: RideError.routeFailed)); @@ -212,32 +245,52 @@ class RideCubit extends Cubit { } } - /// التعرفة تحتاج مسافة ومدة محسوبتين — لا إحداثيات (docs/38 §7). - Future _priceIt() async { + /// يسعّر **كل الأنواع دفعةً واحدة** بعد اكتمال المسار. + /// + /// المسافة والمدة ثابتتان لكل الأنواع، فالفرق في التعرفة وحدها. النداءات + /// متوازية ومرة واحدة — لا نداء عند كل ضغطة نوع. + /// + /// التعرفة تحتاج مسافة ومدة محسوبتين لا إحداثيات، و`city` يجب أن تكون + /// `default` كما يبذرها الخادم — أي قيمة أخرى ترجّع 404 (docs/38 §7). + /// إعادة تسعير يدوية — مخرج المستخدم حين يفشل الحساب. + Future repriceAll() => _priceAll(); + + Future _priceAll() async { final route = state.route; - final type = state.selectedRideType; - if (route == null || type == null) return; + final types = state.rideTypes; + if (route == null || types.isEmpty) return; emit(state.copyWith(busy: true)); - try { - final quote = await _trips.quote( - city: '', - serviceClass: type.code, - distanceKm: route.distanceKm, - durationMin: route.durationMin, - ); - if (isClosed) return; - // 200 بقيم `null` ليس نجاحاً — لا يُعرض رقم غير صالح للمستخدم. - emit(state.copyWith( - quote: quote, - busy: false, - error: quote.isUsable ? RideError.none : RideError.quoteFailed, - )); - } on ApiException { - if (!isClosed) { - emit(state.copyWith(busy: false, error: RideError.quoteFailed)); - } - } + final results = await Future.wait( + types.map((type) async { + try { + final quote = await _trips.quote( + city: AppConfig.defaultCity, + serviceClass: type.code, + distanceKm: route.distanceKm, + durationMin: route.durationMin, + ); + // 200 بقيم `null` ليس نجاحاً — لا يُعرض رقم غير صالح للمستخدم. + return quote.isUsable ? MapEntry(type.code, quote) : null; + } on ApiException { + // نوعٌ بلا تعرفة نشطة لا يُسقط الباقي — يُخفى سعره وحده. + return null; + } + }), + ); + if (isClosed) return; + + final quotes = Map.fromEntries( + results.whereType>(), + ); + final selected = state.selectedRideType; + + emit(state.copyWith( + quotes: quotes, + quote: selected == null ? null : quotes[selected.code], + busy: false, + error: quotes.isEmpty ? RideError.quoteFailed : RideError.none, + )); } // ── الطلب والبحث ─────────────────────────────────────────────────────── diff --git a/apps/rider_new/lib/features/trip/cubit/ride_state.dart b/apps/rider_new/lib/features/trip/cubit/ride_state.dart index 3a42106..9077b81 100644 --- a/apps/rider_new/lib/features/trip/cubit/ride_state.dart +++ b/apps/rider_new/lib/features/trip/cubit/ride_state.dart @@ -37,7 +37,16 @@ enum RidePhase { /// أيّ حقلٍ يلتقط اختيار الخريطة. enum PickTarget { origin, destination } -enum RideError { none, noDrivers, quoteFailed, routeFailed, requestFailed } +enum RideError { + none, + noDrivers, + quoteFailed, + routeFailed, + requestFailed, + + /// الموقع مرفوض أو الخدمة مطفأة — بلا موقع لا خريطة مفيدة. + noLocation, +} class RideState extends Equatable { const RideState({ @@ -51,6 +60,7 @@ class RideState extends Equatable { this.selectedRideType, this.route, this.quote, + this.quotes = const {}, this.trip, this.driverLocation, this.searchResults = const [], @@ -74,8 +84,17 @@ class RideState extends Equatable { final RideType? selectedRideType; final RouteInfo? route; + + /// تسعيرة النوع المختار — مشتقّة من [quotes] للراحة. final FareQuote? quote; + /// **كل الأنواع مسعَّرة دفعةً واحدة** بمفتاح `RideType.code`. + /// + /// الراكب يرى أسعار الاقتصادي والمريح والعائلي معاً فيقارن ويقرّر — بدل + /// نداء شبكة عند كل ضغطة نوع. المسافة والمدة ثابتتان للمسار، فالنداءات + /// متوازية ومرة واحدة. + final Map quotes; + final Trip? trip; /// موقع السائق الحيّ من `driver:location`. @@ -96,6 +115,9 @@ class RideState extends Equatable { /// كود الخصم — يُقيَّم على الخادم عند الطلب لا في التطبيق. final String couponCode; + /// تسعيرة نوعٍ بعينه — لعرض السعر تحت كل خيار. + FareQuote? quoteFor(String code) => quotes[code]; + bool get canConfirm => origin != null && destination != null && @@ -113,6 +135,7 @@ class RideState extends Equatable { RideType? selectedRideType, RouteInfo? route, FareQuote? quote, + Map? quotes, Trip? trip, GeoPoint? driverLocation, List? searchResults, @@ -140,6 +163,7 @@ class RideState extends Equatable { selectedRideType: selectedRideType ?? this.selectedRideType, route: clearRoute ? null : (route ?? this.route), quote: clearRoute ? null : (quote ?? this.quote), + quotes: clearRoute ? const {} : (quotes ?? this.quotes), trip: clearTrip ? null : (trip ?? this.trip), driverLocation: driverLocation ?? this.driverLocation, searchResults: searchResults ?? this.searchResults, @@ -164,6 +188,7 @@ class RideState extends Equatable { selectedRideType, route, quote, + quotes, trip, driverLocation, searchResults, diff --git a/apps/rider_new/lib/features/trip/view/ride_page.dart b/apps/rider_new/lib/features/trip/view/ride_page.dart index 44a2e37..5d6cba1 100644 --- a/apps/rider_new/lib/features/trip/view/ride_page.dart +++ b/apps/rider_new/lib/features/trip/view/ride_page.dart @@ -12,6 +12,7 @@ import '../../../core/l10n/l10n.dart'; import '../../../core/ui/app_drawer.dart'; +import '../../../core/ui/status_banner.dart'; import '../../../core/ui/tripz_button.dart'; import '../cubit/ride_cubit.dart'; @@ -44,16 +45,32 @@ class RidePage extends StatefulWidget { State createState() => _RidePageState(); } -class _RidePageState extends State { +class _RidePageState extends State with WidgetsBindingObserver { late final RideCubit _cubit = sl()..init(); GeoPoint? _mapCenter; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _cubit.close(); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState lifecycle) { + // الإذن قد يُمنح من إعدادات النظام والتطبيق في الخلفية — نلتقطه عند + // العودة بدل أن تبقى الخريطة على الموقع الاحتياطي. + if (lifecycle == AppLifecycleState.resumed && _cubit.state.origin == null) { + _cubit.locateMe(); + } + } + @override Widget build(BuildContext context) { return BlocProvider.value( @@ -78,6 +95,9 @@ class _RidePageState extends State { route: data.route, cameraTarget: data.camera, onCameraIdle: (p) => _mapCenter = p, + // الورقة تغطّي نحو ثلث الشاشة — يُحسب منه إطار المسار كي لا + // يختفي نصفه تحتها. + bottomInset: MediaQuery.sizeOf(context).height * 0.34, ), ), @@ -159,7 +179,8 @@ class _Sheet extends StatelessWidget { @override Widget build(BuildContext context) { final child = switch (state.phase) { - RidePhase.idle => const _Collapsed(), + RidePhase.idle => + _Collapsed(noLocation: state.error == RideError.noLocation), RidePhase.planning => PlannerSheet(state: state), RidePhase.pickingOnMap => _PickConfirm(onConfirm: onConfirmPick), RidePhase.confirming => ConfirmSheet(state: state), @@ -217,15 +238,37 @@ class _MapFab extends StatelessWidget { } class _Collapsed extends StatelessWidget { - const _Collapsed(); + const _Collapsed({required this.noLocation}); + + final bool noLocation; @override Widget build(BuildContext context) { - return TripzButton.secondary( - label: context.l10n.rideWhereTo, - icon: Icons.search_rounded, - expanded: true, - onPressed: context.read().openPlanner, + final cubit = context.read(); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // بلا موقع لا خريطة مفيدة — السبب يُعرض مع مخرجه بدل خريطة صامتة + // على موقع خاطئ. + if (noLocation) ...[ + StatusBanner( + message: context.l10n.rideNoLocation, + tone: BannerTone.warning, + icon: Icons.location_off_outlined, + trailing: TripzButton.text( + label: context.l10n.actionRetry, + onPressed: cubit.locateMe, + ), + ), + const SizedBox(height: Space.sm), + ], + TripzButton.secondary( + label: context.l10n.rideWhereTo, + icon: Icons.search_rounded, + expanded: true, + onPressed: cubit.openPlanner, + ), + ], ); } } diff --git a/apps/rider_new/lib/features/trip/view/widgets/confirm_sheet.dart b/apps/rider_new/lib/features/trip/view/widgets/confirm_sheet.dart index 4a9316a..cabfd55 100644 --- a/apps/rider_new/lib/features/trip/view/widgets/confirm_sheet.dart +++ b/apps/rider_new/lib/features/trip/view/widgets/confirm_sheet.dart @@ -42,6 +42,15 @@ class ConfirmSheet extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // زر التراجع: بلا مخرج كان المستخدم يعلق في الشاشة إذا فشلت التسعيرة. + Align( + alignment: AlignmentDirectional.centerStart, + child: TripzButton.text( + label: l10n.rideBack, + icon: Icons.arrow_back_rounded, + onPressed: cubit.back, + ), + ), RouteTimeline( originLabel: state.originLabel, destinationLabel: state.destinationLabel, @@ -52,6 +61,7 @@ class ConfirmSheet extends StatelessWidget { RideTypePicker( types: state.rideTypes, selected: state.selectedRideType, + quotes: state.quotes, onSelect: cubit.selectRideType, ), const SizedBox(height: Space.md), @@ -89,6 +99,10 @@ class ConfirmSheet extends StatelessWidget { StatusBanner( message: l10n.rideQuoteFailed, tone: BannerTone.danger, + trailing: TripzButton.text( + label: l10n.actionRetry, + onPressed: cubit.repriceAll, + ), ), ], if (state.error == RideError.noDrivers) ...[ diff --git a/apps/rider_new/lib/features/trip/view/widgets/ride_map.dart b/apps/rider_new/lib/features/trip/view/widgets/ride_map.dart index de790f5..158be7f 100644 --- a/apps/rider_new/lib/features/trip/view/widgets/ride_map.dart +++ b/apps/rider_new/lib/features/trip/view/widgets/ride_map.dart @@ -24,6 +24,7 @@ class RideMap extends StatefulWidget { this.route, this.cameraTarget, this.onCameraIdle, + this.bottomInset = 280, }); final GeoPoint? origin; @@ -35,6 +36,9 @@ class RideMap extends StatefulWidget { /// مركز الخريطة عند استقرارها — يغذّي اختيار النقطة بالدبّوس الثابت. final ValueChanged? onCameraIdle; + /// ارتفاع ما تغطّيه الورقة السفلية — يُحسب منه إطار المسار. + final double bottomInset; + @override State createState() => _RideMapState(); } @@ -48,12 +52,62 @@ class _RideMapState extends State { @override void didUpdateWidget(RideMap old) { super.didUpdateWidget(old); - final target = widget.cameraTarget; - if (target != null && target != old.cameraTarget) { - _controller?.animateCamera( - im.CameraUpdate.newLatLng(im.LatLng(target.lat, target.lng)), - ); + if (widget.route != null && widget.route != old.route) { + _fitRoute(); + return; } + final target = widget.cameraTarget; + if (target != null && target != old.cameraTarget) _moveTo(target); + } + + /// **يُنادى عند جهوز الخريطة**: الموقع الحالي غالباً يصل *قبل* إنشاء + /// الخريطة، فالتحريك في `didUpdateWidget` وحده يضيع و تبقى الكاميرا على + /// الاحتياطي. هنا نلحق بما فات. + void _onCreated(im.IntaleqMapController controller) { + _controller = controller; + final target = widget.cameraTarget ?? widget.origin; + if (widget.route != null) { + _fitRoute(); + } else if (target != null) { + _moveTo(target); + } + } + + void _moveTo(GeoPoint p) => _controller?.animateCamera( + im.CameraUpdate.newLatLngZoom(im.LatLng(p.lat, p.lng), 15.5), + ); + + /// يضبط الكاميرا على المسار كاملاً بدل نقطة واحدة. + /// + /// الحشوة السفلية أكبر لأن الورقة السفلية تغطّي نحو ثلث الشاشة — بلا ذلك + /// يختفي نصف المسار تحتها. + void _fitRoute() { + final encoded = widget.route?.encodedPoints; + if (encoded == null || encoded.isEmpty) return; + final points = im.PolylineUtils.decode(encoded); + if (points.length < 2) return; + + var minLat = points.first.latitude, maxLat = points.first.latitude; + var minLng = points.first.longitude, maxLng = points.first.longitude; + for (final p in points) { + if (p.latitude < minLat) minLat = p.latitude; + if (p.latitude > maxLat) maxLat = p.latitude; + if (p.longitude < minLng) minLng = p.longitude; + if (p.longitude > maxLng) maxLng = p.longitude; + } + + _controller?.animateCamera( + im.CameraUpdate.newLatLngBounds( + im.LatLngBounds( + southwest: im.LatLng(minLat, minLng), + northeast: im.LatLng(maxLat, maxLng), + ), + left: 48, + right: 48, + top: 120, + bottom: widget.bottomInset, + ), + ); } @override @@ -77,17 +131,21 @@ class _RideMapState extends State { myLocationEnabled: true, compassEnabled: false, zoomControlsEnabled: false, - onMapCreated: (c) => _controller = c, + onMapCreated: _onCreated, onCameraMove: (position) => _center = position.target, onCameraIdle: () { final c = _center; if (c != null) widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude)); }, - markers: { + // العلامات دوائر ملوّنة تطابق ألوان الخط الزمني في الورقة السفلية، + // فيربط المستخدم بين «من» و«إلى» وما يراه على الخريطة بلا شرح. + circles: { if (widget.origin != null) - _marker('origin', widget.origin!), + _dot('origin', widget.origin!, colors.mapOrigin), if (widget.destination != null) - _marker('destination', widget.destination!), + _dot('destination', widget.destination!, colors.mapDestination), + }, + markers: { if (widget.driver != null) _marker('driver', widget.driver!), }, polylines: { @@ -106,4 +164,13 @@ class _RideMapState extends State { markerId: im.MarkerId(id), position: im.LatLng(p.lat, p.lng), ); + + im.Circle _dot(String id, GeoPoint p, Color color) => im.Circle( + circleId: im.CircleId(id), + center: im.LatLng(p.lat, p.lng), + radius: 18, + fillColor: color, + strokeColor: Colors.white, + strokeWidth: 3, + ); } diff --git a/apps/rider_new/lib/features/trip/view/widgets/ride_type_picker.dart b/apps/rider_new/lib/features/trip/view/widgets/ride_type_picker.dart index b0122b5..ae12f20 100644 --- a/apps/rider_new/lib/features/trip/view/widgets/ride_type_picker.dart +++ b/apps/rider_new/lib/features/trip/view/widgets/ride_type_picker.dart @@ -1,23 +1,31 @@ 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/fare_quote.dart'; import '../../data/models/ride_type.dart'; -/// قائمة الأنواع تُبنى من `GET /ride-types` لا من قائمة مكتوبة (docs/38 §7). -/// `women_only` علامة مرئية لا فلتر صامت. +/// اختيار نوع الرحلة — **بالسعر تحت كل خيار**. +/// +/// كل الأنواع مسعَّرة دفعةً واحدة بعد اكتمال المسار، فالراكب يقارن الاقتصادي +/// بالمريح ويقرّر بنظرة. تبديل النوع لا يُطلق نداءً. +/// +/// القائمة نفسها من `GET /ride-types` لا من قائمة مكتوبة (docs/38 §7)، +/// و`women_only` علامة مرئية لا فلتر صامت. class RideTypePicker extends StatelessWidget { const RideTypePicker({ super.key, required this.types, required this.selected, + required this.quotes, required this.onSelect, }); final List types; final RideType? selected; + final Map quotes; final ValueChanged onSelect; @override @@ -26,30 +34,99 @@ class RideTypePicker extends StatelessWidget { final lang = Localizations.localeOf(context).languageCode; return SizedBox( - height: 44, + height: 96, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: types.length, separatorBuilder: (context, i) => const SizedBox(width: Space.xs), itemBuilder: (context, i) { final type = types[i]; - final isSelected = type.id == selected?.id; - return ChoiceChip( - selected: isSelected, - onSelected: (_) => onSelect(type), - avatar: type.womenOnly - ? const Icon(Icons.female_rounded, size: Sizes.iconSm) - : Icon( - type.vehicleKind == 'moto' - ? Icons.two_wheeler_rounded - : Icons.directions_car_rounded, - size: Sizes.iconSm, - ), - label: Text(type.nameFor(lang)), - labelStyle: context.texts.labelLarge, + return _Tile( + type: type, + quote: quotes[type.code], + selected: type.id == selected?.id, + label: type.nameFor(lang), + onTap: () => onSelect(type), ); }, ), ); } } + +class _Tile extends StatelessWidget { + const _Tile({ + required this.type, + required this.quote, + required this.selected, + required this.label, + required this.onTap, + }); + + final RideType type; + final FareQuote? quote; + final bool selected; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final scheme = context.colors; + final fare = quote; + + return AnimatedContainer( + duration: Motion.tap, + curve: Motion.curve, + width: 112, + decoration: BoxDecoration( + color: + selected ? scheme.primaryContainer : context.tripzColors.surfaceRaised, + borderRadius: Radii.card_, + border: Border.all( + color: selected ? scheme.primary : scheme.outlineVariant, + width: selected ? 1.5 : 1, + ), + ), + child: InkWell( + onTap: onTap, + borderRadius: Radii.card_, + child: Padding( + padding: const EdgeInsets.all(Space.xs), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + type.womenOnly + ? Icons.female_rounded + : type.vehicleKind == 'moto' + ? Icons.two_wheeler_rounded + : Icons.directions_car_rounded, + size: Sizes.icon, + color: selected ? scheme.onPrimaryContainer : scheme.onSurface, + ), + const SizedBox(height: Space.xxs), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.texts.labelMedium?.copyWith( + color: selected ? scheme.onPrimaryContainer : null, + ), + ), + const SizedBox(height: Space.xxs), + // نوعٌ بلا تعرفة نشطة يظهر بشرطة بدل أن يُخفى: إخفاؤه يترك + // المستخدم يتساءل أين ذهب الخيار. + Text( + fare == null ? '—' : Money.formatNum(fare.total!, fare.currency), + maxLines: 1, + style: numericStyle(context.texts.titleSmall).copyWith( + color: selected ? scheme.onPrimaryContainer : scheme.primary, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/rider_new/lib/features/wallet/view/wallet_page.dart b/apps/rider_new/lib/features/wallet/view/wallet_page.dart index 24a2e7d..4202bfa 100644 --- a/apps/rider_new/lib/features/wallet/view/wallet_page.dart +++ b/apps/rider_new/lib/features/wallet/view/wallet_page.dart @@ -5,6 +5,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../core/design/tokens.dart'; import '../../../core/design/tripz_colors.dart'; +import '../../../core/payments/payment_channels.dart'; +import '../../../core/tenant/tenant_cubit.dart'; +import '../../../core/ui/channel_picker.dart'; +import '../../../core/ui/status_banner.dart'; import '../../../core/di.dart'; @@ -103,15 +107,44 @@ class _WalletPageState extends State { void _openTopup(BuildContext context) { final l10n = context.l10n; final controller = TextEditingController(); + // القنوات من حزمة دولة المستأجر لا من لغة الجهاز (docs/38 §10). + final channels = PaymentChannel.forCountry( + context.read().state.countryPack, + ); + var channel = channels.first; + TripzSheet.show( context, title: l10n.walletTopup, - child: TripzTextField( - controller: controller, - label: l10n.walletTopupAmount, - numeric: true, - autofocus: true, - keyboardType: const TextInputType.numberWithOptions(decimal: true), + child: StatefulBuilder( + builder: (context, setState) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TripzTextField( + controller: controller, + label: l10n.walletTopupAmount, + numeric: true, + autofocus: true, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + const SizedBox(height: Space.md), + Text(l10n.walletChannel, style: context.texts.titleSmall), + const SizedBox(height: Space.xs), + ChannelPicker( + channels: channels, + selected: channel, + onSelect: (c) => setState(() => channel = c), + ), + const SizedBox(height: Space.md), + // صراحةٌ مقصودة: الخادم يصف هذه النقطة بـ«كاش-إن تجريبي». إخفاء + // ذلك يجعل المستخدم يظنّ أنه دفع فعلاً. + StatusBanner( + message: l10n.walletTopupNote, + tone: BannerTone.warning, + ), + ], + ), ), bottomAction: TripzButton.primary( label: l10n.walletTopup,