From 6a33cc724112f27461758223b7a9694141efb81f Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Wed, 5 Aug 2026 01:26:05 +0300 Subject: [PATCH] =?UTF-8?q?fix(rider):=20=D8=A7=D9=84=D8=A8=D8=AD=D8=AB=20?= =?UTF-8?q?=D8=B9=D9=86=20=D9=85=D9=83=D8=A7=D9=86=20=D9=88=D8=A7=D9=84?= =?UTF-8?q?=D9=85=D8=AD=D9=81=D8=B8=D8=A9=20=D8=A7=D9=84=D8=AD=D9=82=D9=8A?= =?UTF-8?q?=D9=82=D9=8A=D8=A9=20=D9=88=D8=A3=D8=B3=D9=85=D8=A7=D8=A1=20?= =?UTF-8?q?=D8=A7=D9=84=D8=AE=D8=B1=D9=8A=D8=B7=D8=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## البحث لم يكن يستجيب — غلافان مختلفان مثبت بـcurl: /geocoding/search يرجّع {"results":[…]} بينما /geocoding/reverse يرجّع مصفوفة عارية. افترضتُ كليهما مصفوفة، فكان البحث يفشل صامتاً — ولهذا «تحديد من الخريطة» يعمل (يستعمل reverse) والبحث لا. أُضيفت رسالة «لا نتائج» كي لا يبقى الفراغ غامضاً، وأيقونة لكل صنف مكان. ## المحفظة صارت دفعاً حقيقياً لا كاش-إن كنت أستعمل POST /wallet/topup — وهو بوصف الخادم كاش-إن تجريبي يزيد الرصيد بلا دفع. المسار الصحيح موجود أصلاً: POST /payments/charge مع provider وpurpose، وسجلّ بوابات فيه cash · paymob · syriatel · mtn ومحوّل فاتورة لـ cliq · shamcash · zaincash. كليك وشام كاش وزين كاش تعمل بنمط **الفاتورة** — نفس ما يفعله سيرو حرفياً (create_cliq_invoice.php): الخادم يولّد مرجعاً ويعرض حساب الاستلام، ويحوّل المستخدم، والتسوية تصل لاحقاً بمطابقة رسالة المزوّد. لا بوابة تحويل حيّة. - InvoiceSheet يعرض المرجع وحساب الاستلام بزرّي نسخ — الرقم يُحوَّل في تطبيق بنك آخر فالنسخ ليس رفاهية - يُقال صراحةً أن الرصيد لا يزيد الآن، قبل الضغط لا بعده - حساب استلام غير مضبوط للمستأجر يُعرض كخطأ لا كتعليمات ناقصة - أسماء المزوّدين صُحّحت لتطابق PaymentGatewayRegistry حرفياً (syriatel لا syriatel_cash…) — أي اسم آخر يرجّع 400 - qi_card حُذف: لا محوّل له على الخادم - السائق: chargeCredit بنيّة credit_topup للرصيد التشغيلي ## الخريطة وواجهة «إلى أين» - أسماء البداية والنهاية تُرسم فوق الخريطة (نمط سيرو) كطبقة فلاتر لا marker - البطاقة المطوية صارت حقل بحث بارزاً مع الموقع الحالي فوقه - حقل البحث يفتح مركّزاً مع زرّ مسح ## أداة tool/sync_from_rider.sh كان يستثني ملفَّي ARB فيُسقط كل مفتاح مشترك جديد ويكسر بناء السائق — تكرّر ثلاث مرات. صارا يُنسخان ثم تُفرض نصوص السائق فوقهما. Co-Authored-By: Claude Opus 5 --- apps/driver_new/lib/core/l10n/app_ar.arb | 15 +++ apps/driver_new/lib/core/l10n/app_en.arb | 15 +++ .../lib/core/payments/payment_channels.dart | 29 ++--- .../features/trip/data/maps_repository.dart | 10 +- .../lib/features/trip/data/models/place.dart | 1 + .../features/trip/view/widgets/map_label.dart | 66 +++++++++++ .../features/trip/view/widgets/ride_map.dart | 111 ++++++++++++++---- .../features/wallet/cubit/wallet_cubit.dart | 26 +++- .../features/wallet/cubit/wallet_state.dart | 12 +- .../wallet/data/wallet_repository.dart | 74 +++++++++++- .../wallet/view/widgets/invoice_sheet.dart | 105 +++++++++++++++++ apps/driver_new/tool/sync_from_rider.sh | 6 +- apps/rider_new/lib/core/l10n/app_ar.arb | 11 +- apps/rider_new/lib/core/l10n/app_en.arb | 11 +- .../lib/core/payments/payment_channels.dart | 29 ++--- .../features/trip/data/maps_repository.dart | 10 +- .../lib/features/trip/data/models/place.dart | 1 + .../lib/features/trip/view/ride_page.dart | 95 +++++++++++++-- .../features/trip/view/widgets/map_label.dart | 66 +++++++++++ .../trip/view/widgets/planner_sheet.dart | 57 ++++++++- .../features/trip/view/widgets/ride_map.dart | 111 ++++++++++++++---- .../features/wallet/cubit/wallet_cubit.dart | 26 +++- .../features/wallet/cubit/wallet_state.dart | 12 +- .../wallet/data/wallet_repository.dart | 58 ++++++++- .../lib/features/wallet/view/wallet_page.dart | 24 ++-- .../wallet/view/widgets/invoice_sheet.dart | 105 +++++++++++++++++ 26 files changed, 965 insertions(+), 121 deletions(-) create mode 100644 apps/driver_new/lib/features/trip/view/widgets/map_label.dart create mode 100644 apps/driver_new/lib/features/wallet/view/widgets/invoice_sheet.dart create mode 100644 apps/rider_new/lib/features/trip/view/widgets/map_label.dart create mode 100644 apps/rider_new/lib/features/wallet/view/widgets/invoice_sheet.dart diff --git a/apps/driver_new/lib/core/l10n/app_ar.arb b/apps/driver_new/lib/core/l10n/app_ar.arb index 8159cea..e515bee 100644 --- a/apps/driver_new/lib/core/l10n/app_ar.arb +++ b/apps/driver_new/lib/core/l10n/app_ar.arb @@ -194,6 +194,21 @@ "chatEmpty": "لا رسائل بعد", "rideCoupon": "كود خصم", "rideCouponApply": "تطبيق", + "rideNoLocation": "فعّل الموقع لنعرف أين أنت", + "rideBack": "رجوع", + "rideRetryQuote": "أعد حساب الأجرة", + "rideFrom": "من", + "rideTo": "إلى", + "walletChannel": "طريقة الدفع", + "rideTypeUnavailable": "غير متاح", + "rideSearchNoResults": "لا نتائج — جرّب اسماً آخر أو حدّد من الخريطة", + "walletInvoiceTitle": "أكمل التحويل", + "walletInvoiceRef": "رقم المرجع", + "walletInvoiceTarget": "حوّل إلى", + "walletInvoicePending": "سيُضاف الرصيد فور تأكيد التحويل", + "walletCopy": "نسخ", + "walletCopied": "نُسخ", + "walletNoTarget": "لم يُضبط حساب استلام لهذه القناة بعد — راجع المشغّل", "obApplyTitle": "سجّل كسائق", "obApplyLead": "خطوات قصيرة ثم تبدأ العمل", "obVehicleMake": "شركة السيارة", diff --git a/apps/driver_new/lib/core/l10n/app_en.arb b/apps/driver_new/lib/core/l10n/app_en.arb index d223628..f6e5538 100644 --- a/apps/driver_new/lib/core/l10n/app_en.arb +++ b/apps/driver_new/lib/core/l10n/app_en.arb @@ -194,6 +194,21 @@ "chatEmpty": "No messages yet", "rideCoupon": "Promo code", "rideCouponApply": "Apply", + "rideNoLocation": "Turn on location so we know where you are", + "rideBack": "Back", + "rideRetryQuote": "Recalculate fare", + "rideFrom": "From", + "rideTo": "To", + "walletChannel": "Payment method", + "rideTypeUnavailable": "Unavailable", + "rideSearchNoResults": "No results — try another name or pick on the map", + "walletInvoiceTitle": "Complete the transfer", + "walletInvoiceRef": "Reference", + "walletInvoiceTarget": "Transfer to", + "walletInvoicePending": "Your balance updates once the transfer is confirmed", + "walletCopy": "Copy", + "walletCopied": "Copied", + "walletNoTarget": "No receiving account is configured for this channel yet — contact the operator", "obApplyTitle": "Register as a driver", "obApplyLead": "A few short steps, then you're working", "obVehicleMake": "Car make", diff --git a/apps/driver_new/lib/core/payments/payment_channels.dart b/apps/driver_new/lib/core/payments/payment_channels.dart index 4e7b7a4..2bb7195 100644 --- a/apps/driver_new/lib/core/payments/payment_channels.dart +++ b/apps/driver_new/lib/core/payments/payment_channels.dart @@ -4,18 +4,23 @@ /// المستأجر هو من يحدّد السوق: مشغّل أردني يخدم مستخدماً هاتفه إنجليزي /// وموقعه خارج الأردن يبقى أردنيّ القنوات. /// -/// ⚠️ **`POST /wallet/topup` شحنٌ تجريبي بلا مزوّد دفع خلفه** — الخادم نفسه -/// يصفه بـ«كاش-إن، للإنتاج يُربط بمزوّد دفع أو يُقيَّد للأدمن». اختيار -/// القناة هنا يجهّز الواجهة والعقد؛ التحصيل الفعلي عبر `/payments/charge` -/// وبوابة كل قناة عملٌ منفصل لم يُنفَّذ بعد. +/// الشحن يمرّ بـ**`POST /payments/charge`** لا `POST /wallet/topup` — الأخير +/// كاش-إن تجريبي يزيد الرصيد بلا دفع. +/// +/// كليك وشام كاش وزين كاش تعمل بنمط **الفاتورة**: الخادم يولّد مرجعاً ويعرض +/// حساب الاستلام، والمستخدم يحوّل، والتسوية تصل لاحقاً عبر مطابقة رسالة +/// المزوّد (SMS) — وهو **نفس ما يفعله سيرو** حرفياً +/// (`create_cliq_invoice.php`). لا بوابة تحويل حيّة لأيٍّ منها. +/// PayMob وحده يعمل بإعادة توجيه فعلية. enum PaymentChannel { + // ⚠️ `wire` يجب أن يطابق اسم المحوّل في `PaymentGatewayRegistry` حرفياً — + // أي اسم آخر يرجّع 400 `unsupported provider`. cliq('cliq', 'CliQ', 'كليك'), - syriatel('syriatel_cash', 'Syriatel Cash', 'سيرياتيل كاش'), - mtn('mtn_cash', 'MTN Cash', 'إم تي إن كاش'), - shamCash('sham_cash', 'Sham Cash', 'شام كاش'), + syriatel('syriatel', 'Syriatel Cash', 'سيرياتيل كاش'), + mtn('mtn', 'MTN Cash', 'إم تي إن كاش'), + shamCash('shamcash', 'Sham Cash', 'شام كاش'), + zainCash('zaincash', 'ZainCash', 'زين كاش'), paymob('paymob', 'PayMob', 'باي موب'), - zainCash('zain_cash', 'ZainCash', 'زين كاش'), - qiCard('qi_card', 'Qi Card', 'كي كارد'), cash('cash', 'Cash', 'نقداً'); const PaymentChannel(this.wire, this.nameEn, this.nameAr); @@ -43,11 +48,7 @@ enum PaymentChannel { // العراق: المحافظ الأشيع محلياً. ⚠️ الباك إند لا يعرف حزمة `iq` بعد // (`default-tariffs.ts` فيه jo/sy/eg فقط) — مستأجر عراقي يُزوَّد بلا // تعرفة، وهذا شغل خادم يسبق أي إطلاق عراقي. - 'iq' => const [ - PaymentChannel.zainCash, - PaymentChannel.qiCard, - PaymentChannel.cash, - ], + 'iq' => const [PaymentChannel.zainCash, PaymentChannel.cash], // حزمة غير معروفة: النقد وحده — لا نعرض قناة قد لا تعمل في سوقها. _ => const [PaymentChannel.cash], }; diff --git a/apps/driver_new/lib/features/trip/data/maps_repository.dart b/apps/driver_new/lib/features/trip/data/maps_repository.dart index f3dff78..b89c500 100644 --- a/apps/driver_new/lib/features/trip/data/maps_repository.dart +++ b/apps/driver_new/lib/features/trip/data/maps_repository.dart @@ -10,9 +10,13 @@ class MapsRepository { final AntlaqApi _api; + /// ⚠️ **الغلافان مختلفان بين النقطتين** (مثبت بـcurl 2026-08-05): + /// `search` يرجّع `{"results":[…]}` بينما `reverse` يرجّع **مصفوفة عارية**. + /// افتراض أن كليهما مصفوفة كان يُفشل البحث صامتاً — فيبدو للمستخدم أن + /// «البحث لا يستجيب» بينما التحديد من الخريطة يعمل. Future> search(String query, {GeoPoint? near}) async { if (query.trim().isEmpty) return const []; - final res = await _api.get>( + final res = await _api.get>( '/geocoding/search', query: { 'q': query.trim(), @@ -20,7 +24,9 @@ class MapsRepository { if (near != null) 'lng': near.lng, }, ); - return res + final results = res['results']; + if (results is! List) return const []; + return results .whereType>() .map(Place.fromJson) .toList(growable: false); diff --git a/apps/driver_new/lib/features/trip/data/models/place.dart b/apps/driver_new/lib/features/trip/data/models/place.dart index a1f7e23..547c39f 100644 --- a/apps/driver_new/lib/features/trip/data/models/place.dart +++ b/apps/driver_new/lib/features/trip/data/models/place.dart @@ -26,6 +26,7 @@ class Place extends Equatable { name: (json['name_ar'] as String?)?.trim().isNotEmpty == true ? json['name_ar'] as String : (json['name'] as String? ?? ''), + // `address` قد يصل `null` صراحةً من map-saas — لا يُسقِط النتيجة. address: json['address'] as String? ?? '', category: json['category'] as String? ?? '', point: GeoPoint( diff --git a/apps/driver_new/lib/features/trip/view/widgets/map_label.dart b/apps/driver_new/lib/features/trip/view/widgets/map_label.dart new file mode 100644 index 0000000..a30d64f --- /dev/null +++ b/apps/driver_new/lib/features/trip/view/widgets/map_label.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; + +/// بطاقة اسم فوق نقطة على الخريطة — نمط سيرو: الراكب يقرأ «دوّار عبدون» على +/// الخريطة نفسها لا في الورقة السفلية وحدها، فيتأكّد أن النقطة صحيحة قبل +/// أن يطلب. +/// +/// تُرسم في طبقة فلاتر فوق الخريطة لا كـmarker: نصّ عربي داخل صورة علامة +/// يصعب ضبطه، وطبقة فلاتر تعطينا الخط والاتجاه والثيم مجاناً. +class MapLabel extends StatelessWidget { + const MapLabel({ + super.key, + required this.text, + required this.color, + this.icon, + }); + + final String text; + final Color color; + final IconData? icon; + + @override + Widget build(BuildContext context) { + if (text.isEmpty) return const SizedBox.shrink(); + return Container( + constraints: const BoxConstraints(maxWidth: 200), + padding: const EdgeInsets.symmetric( + horizontal: Space.xs, + vertical: Space.xxs, + ), + decoration: BoxDecoration( + color: context.tripzColors.surfaceRaised, + borderRadius: Radii.field_, + border: Border.all(color: color, width: 1.5), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: Space.xxs), + Flexible( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.texts.labelMedium, + ), + ), + ], + ), + ); + } +} 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 158be7f..78b1b24 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 @@ -9,6 +9,7 @@ import '../../../../core/design/tripz_colors.dart'; import '../../data/models/geo_point.dart'; import '../../data/models/route_info.dart'; +import 'map_label.dart'; /// الخريطة — **الطبقة الوحيدة التي تعرف `intaleq_maps`**. ما فوقها يتعامل /// مع `GeoPoint` وحده، فتبديل محرّك الخريطة لاحقاً لا يلمس منطقاً. @@ -25,6 +26,8 @@ class RideMap extends StatefulWidget { this.cameraTarget, this.onCameraIdle, this.bottomInset = 280, + this.originLabel = '', + this.destinationLabel = '', }); final GeoPoint? origin; @@ -39,6 +42,10 @@ class RideMap extends StatefulWidget { /// ارتفاع ما تغطّيه الورقة السفلية — يُحسب منه إطار المسار. final double bottomInset; + /// أسماء النقطتين — تُرسم بطاقاتها فوق الخريطة (نمط سيرو). + final String originLabel; + final String destinationLabel; + @override State createState() => _RideMapState(); } @@ -46,12 +53,14 @@ class RideMap extends StatefulWidget { class _RideMapState extends State { im.IntaleqMapController? _controller; im.LatLng? _center; + bool _isStyleLoaded = false; static const _fallback = im.LatLng(31.9539, 35.9106); // عمّان @override void didUpdateWidget(RideMap old) { super.didUpdateWidget(old); + if (!_isStyleLoaded) return; if (widget.route != null && widget.route != old.route) { _fitRoute(); return; @@ -110,12 +119,53 @@ class _RideMapState extends State { ); } + /// موضع النقطتين على الشاشة — يُحدَّث عند استقرار الكاميرا كي تتبع + /// البطاقاتُ الخريطةَ بلا حساب في كل إطار. + Offset? _originPx; + Offset? _destPx; + + Future _syncLabelPositions() async { + final controller = _controller; + if (controller == null) return; + final origin = widget.origin; + final dest = widget.destination; + try { + final o = origin == null + ? null + : await controller + .getScreenCoordinate(im.LatLng(origin.lat, origin.lng)); + final d = dest == null + ? null + : await controller.getScreenCoordinate(im.LatLng(dest.lat, dest.lng)); + if (!mounted) return; + setState(() { + _originPx = o == null ? null : Offset(o.x.toDouble(), o.y.toDouble()); + _destPx = d == null ? null : Offset(d.x.toDouble(), d.y.toDouble()); + }); + } catch (_) { + // الخريطة قد تكون بين حالتين — البطاقات تُخفى بدل أن تُرسم في مكان خطأ. + if (mounted) setState(() => _originPx = _destPx = null); + } + } + + /// بطاقة اسم فوق نقطة، مرفوعة عن الدائرة كي لا تغطّيها. + Widget _label(Offset? px, String text, Color color) { + if (px == null || text.isEmpty) return const SizedBox.shrink(); + return Positioned( + left: px.dx - 100, + top: px.dy - 46, + width: 200, + child: Center(child: MapLabel(text: text, color: color)), + ); + } + @override Widget build(BuildContext context) { final colors = context.tripzColors; final isDark = Theme.of(context).brightness == Brightness.dark; - return im.IntaleqMap( + return Stack(children: [ + im.IntaleqMap( apiKey: AppConfig.mapApiKey, initialCameraPosition: im.CameraPosition( target: widget.origin == null @@ -132,32 +182,51 @@ class _RideMapState extends State { compassEnabled: false, zoomControlsEnabled: false, onMapCreated: _onCreated, + onStyleLoaded: () { + if (mounted && !_isStyleLoaded) { + setState(() => _isStyleLoaded = true); + } + }, onCameraMove: (position) => _center = position.target, onCameraIdle: () { final c = _center; - if (c != null) widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude)); + if (c != null) { + widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude)); + } + _syncLabelPositions(); }, // العلامات دوائر ملوّنة تطابق ألوان الخط الزمني في الورقة السفلية، // فيربط المستخدم بين «من» و«إلى» وما يراه على الخريطة بلا شرح. - circles: { - if (widget.origin != null) - _dot('origin', widget.origin!, colors.mapOrigin), - if (widget.destination != null) - _dot('destination', widget.destination!, colors.mapDestination), - }, - markers: { - if (widget.driver != null) _marker('driver', widget.driver!), - }, - polylines: { - if (widget.route != null && widget.route!.encodedPoints.isNotEmpty) - im.Polyline( - polylineId: const im.PolylineId('route'), - points: im.PolylineUtils.decode(widget.route!.encodedPoints), - color: colors.mapRoute, - width: 5, - ), - }, - ); + circles: _isStyleLoaded + ? { + if (widget.origin != null) + _dot('origin', widget.origin!, colors.mapOrigin), + if (widget.destination != null) + _dot('destination', widget.destination!, colors.mapDestination), + } + : const {}, + markers: _isStyleLoaded + ? { + if (widget.driver != null) _marker('driver', widget.driver!), + } + : const {}, + polylines: _isStyleLoaded + ? { + if (widget.route != null && widget.route!.encodedPoints.isNotEmpty) + im.Polyline( + polylineId: const im.PolylineId('route'), + points: im.PolylineUtils.decode(widget.route!.encodedPoints), + color: colors.mapRoute, + width: 5, + ), + } + : const {}, + ), + // أسماء النقطتين فوق الخريطة (نمط سيرو) — طبقة فلاتر لا marker، كي + // نرث الخط والاتجاه والثيم. + _label(_originPx, widget.originLabel, colors.mapOrigin), + _label(_destPx, widget.destinationLabel, colors.mapDestination), + ]); } im.Marker _marker(String id, GeoPoint p) => im.Marker( diff --git a/apps/driver_new/lib/features/wallet/cubit/wallet_cubit.dart b/apps/driver_new/lib/features/wallet/cubit/wallet_cubit.dart index b8f743a..5dca2d2 100644 --- a/apps/driver_new/lib/features/wallet/cubit/wallet_cubit.dart +++ b/apps/driver_new/lib/features/wallet/cubit/wallet_cubit.dart @@ -28,16 +28,30 @@ class WalletCubit extends Cubit { } } - Future topup(num amount) async { + /// شحن حقيقي. **لا يزيد الرصيد فوراً** إلا في نمط الدفع الفوري — الفاتورة + /// تُعرض للمستخدم ويحوّل، والتسوية تصل لاحقاً عبر مطابقة رسالة المزوّد. + Future topup({required num amount, required String provider}) async { if (amount <= 0) return; emit(state.copyWith(action: WalletAction.toppingUp)); try { - await _repo.topup(amount); + final result = await _repo.charge( + amount: amount, + provider: provider, + currency: state.currency, + ); if (isClosed) return; - emit(state.copyWith(action: WalletAction.topupDone)); - // الرصيد يُعاد جلبه من الخادم لا يُحسب محلياً: المال لا يُقدَّر - // في التطبيق (docs/23 §0.2). - await load(); + + if (result.isInstant) { + emit(state.copyWith(action: WalletAction.topupDone)); + // الرصيد يُعاد جلبه من الخادم لا يُحسب محلياً: المال لا يُقدَّر + // في التطبيق (docs/23 §0.2). + await load(); + return; + } + emit(state.copyWith( + action: WalletAction.invoiceReady, + invoice: result, + )); } on ApiException { if (!isClosed) emit(state.copyWith(action: WalletAction.topupFailed)); } diff --git a/apps/driver_new/lib/features/wallet/cubit/wallet_state.dart b/apps/driver_new/lib/features/wallet/cubit/wallet_state.dart index 5ddedc6..121258f 100644 --- a/apps/driver_new/lib/features/wallet/cubit/wallet_state.dart +++ b/apps/driver_new/lib/features/wallet/cubit/wallet_state.dart @@ -3,8 +3,9 @@ import 'package:equatable/equatable.dart'; import '../../../core/ui/view_status.dart'; import '../data/models/wallet.dart'; import '../data/models/wallet_txn.dart'; +import '../data/wallet_repository.dart'; -enum WalletAction { none, toppingUp, topupFailed, topupDone } +enum WalletAction { none, toppingUp, topupFailed, topupDone, invoiceReady } class WalletState extends Equatable { const WalletState({ @@ -12,6 +13,7 @@ class WalletState extends Equatable { this.wallet, this.transactions = const [], this.action = WalletAction.none, + this.invoice, this.error, }); @@ -23,6 +25,9 @@ class WalletState extends Equatable { /// المعروض خلفه. final WalletAction action; + /// فاتورة تحويل بانتظار الدفع — تُعرض للمستخدم بمرجعها وحساب الاستلام. + final TopupResult? invoice; + final String? error; String get currency => wallet?.currency ?? ''; @@ -32,6 +37,7 @@ class WalletState extends Equatable { Wallet? wallet, List? transactions, WalletAction? action, + TopupResult? invoice, String? error, }) { return WalletState( @@ -39,10 +45,12 @@ class WalletState extends Equatable { wallet: wallet ?? this.wallet, transactions: transactions ?? this.transactions, action: action ?? this.action, + invoice: invoice ?? this.invoice, error: error ?? this.error, ); } @override - List get props => [status, wallet, transactions, action, error]; + List get props => + [status, wallet, transactions, action, invoice?.reference, error]; } diff --git a/apps/driver_new/lib/features/wallet/data/wallet_repository.dart b/apps/driver_new/lib/features/wallet/data/wallet_repository.dart index 2f7a121..857329c 100644 --- a/apps/driver_new/lib/features/wallet/data/wallet_repository.dart +++ b/apps/driver_new/lib/features/wallet/data/wallet_repository.dart @@ -2,6 +2,33 @@ import '../../../core/api/api_client.dart'; import 'models/wallet.dart'; import 'models/wallet_txn.dart'; +/// نتيجة الشحن — ثلاثة أنماط: فوري · فاتورة · إعادة توجيه. +class TopupResult { + const TopupResult({ + required this.status, + this.reference, + this.transferTarget, + this.instructions, + this.redirectUrl, + }); + + final String status; + + /// مرجع الفاتورة الذي يذكره المستخدم عند التحويل — به تُطابَق رسالة + /// المزوّد لاحقاً. + final String? reference; + + /// حساب/رقم استلام المستأجر عند هذا المزوّد. + final String? transferTarget; + + final String? instructions; + final String? redirectUrl; + + bool get isInvoice => reference != null; + bool get isInstant => status == 'success'; + bool get isRedirect => redirectUrl != null; +} + class WalletRepository { WalletRepository(this._api); @@ -20,8 +47,51 @@ class WalletRepository { .toList(growable: false); } - Future topup(num amount) => - _api.post>('/wallet/topup', body: {'amount': amount}); + /// شحن حقيقي عبر `POST /payments/charge`. + /// + /// **لا يزيد الرصيد فوراً.** كليك وشام كاش وزين كاش تعمل بنمط الفاتورة: + /// الخادم يولّد مرجعاً ويعرض حساب الاستلام، ثم يحوّل المستخدم، والتسوية + /// تصل لاحقاً عبر مطابقة رسالة المزوّد. هذا **نفس مسار سيرو** حرفياً. + /// PayMob وحده يرجّع رابط إعادة توجيه، و`cash` وحده فوري. + /// شحن **الرصيد التشغيلي** للسائق — نيّة مختلفة عن شحن المحفظة: + /// `credit_topup` إيرادٌ يُقيَّد على المستأجر، والخادم يرفضها من غير + /// السائق (`payments.service.ts:67`). + Future chargeCredit({ + required num amount, + required String provider, + required String currency, + }) => + charge( + amount: amount, + provider: provider, + currency: currency, + purpose: 'credit_topup', + ); + + Future charge({ + required num amount, + required String provider, + required String currency, + String purpose = 'topup', + }) async { + final res = await _api.post>( + '/payments/charge', + body: { + 'amount': amount, + 'provider': provider, + 'purpose': purpose, + 'currency': currency, + }, + ); + final payment = res['payment'] as Map?; + return TopupResult( + status: payment?['status'] as String? ?? 'pending', + reference: res['reference'] as String?, + transferTarget: res['transferTarget'] as String?, + instructions: res['instructions'] as String?, + redirectUrl: res['redirectUrl'] as String?, + ); + } // ── السحب: خطوتان لا خطوة ──────────────────────────────────────────── // `request` يرسل رمزاً **بلا خصم**، و`confirm` هو ما يخصم ويحجز diff --git a/apps/driver_new/lib/features/wallet/view/widgets/invoice_sheet.dart b/apps/driver_new/lib/features/wallet/view/widgets/invoice_sheet.dart new file mode 100644 index 0000000..3e3834c --- /dev/null +++ b/apps/driver_new/lib/features/wallet/view/widgets/invoice_sheet.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/status_banner.dart'; +import '../../data/wallet_repository.dart'; + +/// فاتورة التحويل — نمط سيرو حرفياً (`create_cliq_invoice.php`). +/// +/// المستخدم يحوّل بنفسه إلى حساب المشغّل ويذكر المرجع، والتسوية تصل لاحقاً +/// بمطابقة رسالة المزوّد. **الرصيد لا يزيد الآن** — وهذا يُقال صراحةً بدل +/// أن ينتظر المستخدم رقماً لن يتغيّر. +class InvoiceSheet extends StatelessWidget { + const InvoiceSheet({super.key, required this.invoice}); + + final TopupResult invoice; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final target = invoice.transferTarget; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.walletInvoiceTitle, style: context.texts.titleLarge), + const SizedBox(height: Space.md), + + // حساب الاستلام قد لا يكون مضبوطاً للمستأجر — يُقال بوضوح بدل تعليمات + // ناقصة تُرسل المال إلى لا مكان. + if (target == null || target.isEmpty) + StatusBanner(message: l10n.walletNoTarget, tone: BannerTone.danger) + else + _CopyRow(label: l10n.walletInvoiceTarget, value: target), + + const SizedBox(height: Space.sm), + if (invoice.reference != null) + _CopyRow(label: l10n.walletInvoiceRef, value: invoice.reference!), + + const SizedBox(height: Space.md), + StatusBanner( + message: l10n.walletInvoicePending, + tone: BannerTone.info, + icon: Icons.schedule_rounded, + ), + ], + ); + } +} + +/// قيمة مع زرّ نسخ — الرقم يُحوَّل يدوياً في تطبيق بنك آخر، فالنسخ ليس رفاهية. +class _CopyRow extends StatelessWidget { + const _CopyRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: Space.page, + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: Radii.card_, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: context.texts.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Space.xxs), + SelectableText( + value, + textDirection: TextDirection.ltr, + style: numericStyle(context.texts.titleMedium), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.copy_rounded), + tooltip: context.l10n.walletCopy, + onPressed: () { + Clipboard.setData(ClipboardData(text: value)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.walletCopied)), + ); + }, + ), + ], + ), + ); + } +} diff --git a/apps/driver_new/tool/sync_from_rider.sh b/apps/driver_new/tool/sync_from_rider.sh index 936301d..1787960 100755 --- a/apps/driver_new/tool/sync_from_rider.sh +++ b/apps/driver_new/tool/sync_from_rider.sh @@ -15,8 +15,6 @@ OWN=( "core/build_config.dart" # اسم التطبيق (الألوان تُحدَّث يدوياً معاً) "core/di.dart" # DutyCubit + OnboardingCubit + PayoutCubit "core/router.dart" # DutyPage + EarningsPage + بوابة التسجيل - "core/l10n/app_ar.arb" - "core/l10n/app_en.arb" "core/l10n/gen" "features/onboarding" # تسجيل السائق — لا مقابل له عند الراكب "features/trip/cubit/duty_cubit.dart" @@ -51,6 +49,8 @@ for p in "${OWN[@]}" "${RIDER_ONLY[@]}"; do args+=(--exclude "$p"); done rsync -a "${args[@]}" ../rider_new/lib/ lib/ -# نصوص السائق تُعاد بعد كل مزامنة (ملفا ARB مستثنيان لكن قد يتغيّر المشترك) +# ملفّا ARB **يُنسخان** من الراكب كي تصل المفاتيح المشتركة الجديدة، ثم +# يُعاد فرض نصوص السائق فوقهما. استثناؤهما من النسخ كان يُسقط كل مفتاح +# مشترك جديد ويكسر البناء — تكرّر ثلاث مرات قبل هذا التصحيح. python3 tool/driver_l10n_extra.py echo "✅ تمّت المزامنة — شغّل: flutter gen-l10n && flutter analyze" diff --git a/apps/rider_new/lib/core/l10n/app_ar.arb b/apps/rider_new/lib/core/l10n/app_ar.arb index 83099d2..ca5cf13 100644 --- a/apps/rider_new/lib/core/l10n/app_ar.arb +++ b/apps/rider_new/lib/core/l10n/app_ar.arb @@ -200,6 +200,13 @@ "rideFrom": "من", "rideTo": "إلى", "walletChannel": "طريقة الدفع", - "walletTopupNote": "الشحن التجريبي حالياً بلا تحصيل فعلي — ربط البوابة قادم", - "rideTypeUnavailable": "غير متاح" + "rideTypeUnavailable": "غير متاح", + "rideSearchNoResults": "لا نتائج — جرّب اسماً آخر أو حدّد من الخريطة", + "walletInvoiceTitle": "أكمل التحويل", + "walletInvoiceRef": "رقم المرجع", + "walletInvoiceTarget": "حوّل إلى", + "walletInvoicePending": "سيُضاف الرصيد فور تأكيد التحويل", + "walletCopy": "نسخ", + "walletCopied": "نُسخ", + "walletNoTarget": "لم يُضبط حساب استلام لهذه القناة بعد — راجع المشغّل" } \ 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 e64ad05..9b9ef73 100644 --- a/apps/rider_new/lib/core/l10n/app_en.arb +++ b/apps/rider_new/lib/core/l10n/app_en.arb @@ -200,6 +200,13 @@ "rideFrom": "From", "rideTo": "To", "walletChannel": "Payment method", - "walletTopupNote": "Top-up is currently a test cash-in with no real charge — gateway wiring is next", - "rideTypeUnavailable": "Unavailable" + "rideTypeUnavailable": "Unavailable", + "rideSearchNoResults": "No results — try another name or pick on the map", + "walletInvoiceTitle": "Complete the transfer", + "walletInvoiceRef": "Reference", + "walletInvoiceTarget": "Transfer to", + "walletInvoicePending": "Your balance updates once the transfer is confirmed", + "walletCopy": "Copy", + "walletCopied": "Copied", + "walletNoTarget": "No receiving account is configured for this channel yet — contact the operator" } \ No newline at end of file diff --git a/apps/rider_new/lib/core/payments/payment_channels.dart b/apps/rider_new/lib/core/payments/payment_channels.dart index 4e7b7a4..2bb7195 100644 --- a/apps/rider_new/lib/core/payments/payment_channels.dart +++ b/apps/rider_new/lib/core/payments/payment_channels.dart @@ -4,18 +4,23 @@ /// المستأجر هو من يحدّد السوق: مشغّل أردني يخدم مستخدماً هاتفه إنجليزي /// وموقعه خارج الأردن يبقى أردنيّ القنوات. /// -/// ⚠️ **`POST /wallet/topup` شحنٌ تجريبي بلا مزوّد دفع خلفه** — الخادم نفسه -/// يصفه بـ«كاش-إن، للإنتاج يُربط بمزوّد دفع أو يُقيَّد للأدمن». اختيار -/// القناة هنا يجهّز الواجهة والعقد؛ التحصيل الفعلي عبر `/payments/charge` -/// وبوابة كل قناة عملٌ منفصل لم يُنفَّذ بعد. +/// الشحن يمرّ بـ**`POST /payments/charge`** لا `POST /wallet/topup` — الأخير +/// كاش-إن تجريبي يزيد الرصيد بلا دفع. +/// +/// كليك وشام كاش وزين كاش تعمل بنمط **الفاتورة**: الخادم يولّد مرجعاً ويعرض +/// حساب الاستلام، والمستخدم يحوّل، والتسوية تصل لاحقاً عبر مطابقة رسالة +/// المزوّد (SMS) — وهو **نفس ما يفعله سيرو** حرفياً +/// (`create_cliq_invoice.php`). لا بوابة تحويل حيّة لأيٍّ منها. +/// PayMob وحده يعمل بإعادة توجيه فعلية. enum PaymentChannel { + // ⚠️ `wire` يجب أن يطابق اسم المحوّل في `PaymentGatewayRegistry` حرفياً — + // أي اسم آخر يرجّع 400 `unsupported provider`. cliq('cliq', 'CliQ', 'كليك'), - syriatel('syriatel_cash', 'Syriatel Cash', 'سيرياتيل كاش'), - mtn('mtn_cash', 'MTN Cash', 'إم تي إن كاش'), - shamCash('sham_cash', 'Sham Cash', 'شام كاش'), + syriatel('syriatel', 'Syriatel Cash', 'سيرياتيل كاش'), + mtn('mtn', 'MTN Cash', 'إم تي إن كاش'), + shamCash('shamcash', 'Sham Cash', 'شام كاش'), + zainCash('zaincash', 'ZainCash', 'زين كاش'), paymob('paymob', 'PayMob', 'باي موب'), - zainCash('zain_cash', 'ZainCash', 'زين كاش'), - qiCard('qi_card', 'Qi Card', 'كي كارد'), cash('cash', 'Cash', 'نقداً'); const PaymentChannel(this.wire, this.nameEn, this.nameAr); @@ -43,11 +48,7 @@ enum PaymentChannel { // العراق: المحافظ الأشيع محلياً. ⚠️ الباك إند لا يعرف حزمة `iq` بعد // (`default-tariffs.ts` فيه jo/sy/eg فقط) — مستأجر عراقي يُزوَّد بلا // تعرفة، وهذا شغل خادم يسبق أي إطلاق عراقي. - 'iq' => const [ - PaymentChannel.zainCash, - PaymentChannel.qiCard, - PaymentChannel.cash, - ], + 'iq' => const [PaymentChannel.zainCash, PaymentChannel.cash], // حزمة غير معروفة: النقد وحده — لا نعرض قناة قد لا تعمل في سوقها. _ => const [PaymentChannel.cash], }; diff --git a/apps/rider_new/lib/features/trip/data/maps_repository.dart b/apps/rider_new/lib/features/trip/data/maps_repository.dart index f3dff78..b89c500 100644 --- a/apps/rider_new/lib/features/trip/data/maps_repository.dart +++ b/apps/rider_new/lib/features/trip/data/maps_repository.dart @@ -10,9 +10,13 @@ class MapsRepository { final AntlaqApi _api; + /// ⚠️ **الغلافان مختلفان بين النقطتين** (مثبت بـcurl 2026-08-05): + /// `search` يرجّع `{"results":[…]}` بينما `reverse` يرجّع **مصفوفة عارية**. + /// افتراض أن كليهما مصفوفة كان يُفشل البحث صامتاً — فيبدو للمستخدم أن + /// «البحث لا يستجيب» بينما التحديد من الخريطة يعمل. Future> search(String query, {GeoPoint? near}) async { if (query.trim().isEmpty) return const []; - final res = await _api.get>( + final res = await _api.get>( '/geocoding/search', query: { 'q': query.trim(), @@ -20,7 +24,9 @@ class MapsRepository { if (near != null) 'lng': near.lng, }, ); - return res + final results = res['results']; + if (results is! List) return const []; + return results .whereType>() .map(Place.fromJson) .toList(growable: false); diff --git a/apps/rider_new/lib/features/trip/data/models/place.dart b/apps/rider_new/lib/features/trip/data/models/place.dart index a1f7e23..547c39f 100644 --- a/apps/rider_new/lib/features/trip/data/models/place.dart +++ b/apps/rider_new/lib/features/trip/data/models/place.dart @@ -26,6 +26,7 @@ class Place extends Equatable { name: (json['name_ar'] as String?)?.trim().isNotEmpty == true ? json['name_ar'] as String : (json['name'] as String? ?? ''), + // `address` قد يصل `null` صراحةً من map-saas — لا يُسقِط النتيجة. address: json['address'] as String? ?? '', category: json['category'] as String? ?? '', point: GeoPoint( 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 3d642df..1b6436e 100644 --- a/apps/rider_new/lib/features/trip/view/ride_page.dart +++ b/apps/rider_new/lib/features/trip/view/ride_page.dart @@ -72,6 +72,8 @@ class _RidePageState extends State with WidgetsBindingObserver { driver: s.driverLocation, route: s.route, camera: s.cameraTarget, + originLabel: s.originLabel, + destinationLabel: s.destinationLabel, ), builder: (context, data) => RideMap( origin: data.origin, @@ -83,6 +85,8 @@ class _RidePageState extends State with WidgetsBindingObserver { // الورقة تغطّي نحو ثلث الشاشة — يُحسب منه إطار المسار كي لا // يختفي نصفه تحتها. bottomInset: MediaQuery.sizeOf(context).height * 0.34, + originLabel: data.originLabel, + destinationLabel: data.destinationLabel, ), ), @@ -134,6 +138,8 @@ class _MapData { this.driver, this.route, this.camera, + this.originLabel = '', + this.destinationLabel = '', }); final GeoPoint? origin; @@ -141,6 +147,8 @@ class _MapData { final GeoPoint? driver; final dynamic route; final GeoPoint? camera; + final String originLabel; + final String destinationLabel; @override bool operator ==(Object other) => @@ -149,10 +157,20 @@ class _MapData { other.destination == destination && other.driver == driver && other.route == route && - other.camera == camera; + other.camera == camera && + other.originLabel == originLabel && + other.destinationLabel == destinationLabel; @override - int get hashCode => Object.hash(origin, destination, driver, route, camera); + int get hashCode => Object.hash( + origin, + destination, + driver, + route, + camera, + originLabel, + destinationLabel, + ); } class _Sheet extends StatelessWidget { @@ -164,8 +182,10 @@ class _Sheet extends StatelessWidget { @override Widget build(BuildContext context) { final child = switch (state.phase) { - RidePhase.idle => - _Collapsed(noLocation: state.error == RideError.noLocation), + RidePhase.idle => _Collapsed( + noLocation: state.error == RideError.noLocation, + originLabel: state.originLabel, + ), RidePhase.planning => PlannerSheet(state: state), RidePhase.pickingOnMap => _PickConfirm(onConfirm: onConfirmPick), RidePhase.confirming => ConfirmSheet(state: state), @@ -222,16 +242,22 @@ class _MapFab extends StatelessWidget { } } +/// البطاقة المطوية — واجهة «إلى أين؟». +/// +/// حقل بحث حقيقي لا زرّ يفتح شاشة: الراكب يبدأ الكتابة فوراً. وموقعه الحالي +/// معروض فوقه كي يتأكّد أن نقطة البداية صحيحة قبل أن يفكّر في الوجهة. class _Collapsed extends StatelessWidget { - const _Collapsed({required this.noLocation}); + const _Collapsed({required this.noLocation, required this.originLabel}); final bool noLocation; + final String originLabel; @override Widget build(BuildContext context) { final cubit = context.read(); return Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // بلا موقع لا خريطة مفيدة — السبب يُعرض مع مخرجه بدل خريطة صامتة // على موقع خاطئ. @@ -247,11 +273,60 @@ class _Collapsed extends StatelessWidget { ), const SizedBox(height: Space.sm), ], - TripzButton.secondary( - label: context.l10n.rideWhereTo, - icon: Icons.search_rounded, - expanded: true, - onPressed: cubit.openPlanner, + if (originLabel.isNotEmpty) ...[ + Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: context.tripzColors.mapOrigin, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: Space.xs), + Expanded( + child: Text( + originLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: Space.sm), + ], + // حقل بحث لا زرّ: الضغط عليه يفتح المخطّط والكيبورد معاً. + Material( + color: context.colors.surfaceContainerHighest, + borderRadius: Radii.card_, + child: InkWell( + onTap: cubit.openPlanner, + borderRadius: Radii.card_, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Space.md, + vertical: Space.sm, + ), + child: Row( + children: [ + Icon(Icons.search_rounded, color: context.colors.primary), + const SizedBox(width: Space.sm), + Expanded( + child: Text( + context.l10n.rideWhereTo, + style: context.texts.titleMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), ), ], ); diff --git a/apps/rider_new/lib/features/trip/view/widgets/map_label.dart b/apps/rider_new/lib/features/trip/view/widgets/map_label.dart new file mode 100644 index 0000000..a30d64f --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/widgets/map_label.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; + +/// بطاقة اسم فوق نقطة على الخريطة — نمط سيرو: الراكب يقرأ «دوّار عبدون» على +/// الخريطة نفسها لا في الورقة السفلية وحدها، فيتأكّد أن النقطة صحيحة قبل +/// أن يطلب. +/// +/// تُرسم في طبقة فلاتر فوق الخريطة لا كـmarker: نصّ عربي داخل صورة علامة +/// يصعب ضبطه، وطبقة فلاتر تعطينا الخط والاتجاه والثيم مجاناً. +class MapLabel extends StatelessWidget { + const MapLabel({ + super.key, + required this.text, + required this.color, + this.icon, + }); + + final String text; + final Color color; + final IconData? icon; + + @override + Widget build(BuildContext context) { + if (text.isEmpty) return const SizedBox.shrink(); + return Container( + constraints: const BoxConstraints(maxWidth: 200), + padding: const EdgeInsets.symmetric( + horizontal: Space.xs, + vertical: Space.xxs, + ), + decoration: BoxDecoration( + color: context.tripzColors.surfaceRaised, + borderRadius: Radii.field_, + border: Border.all(color: color, width: 1.5), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: Space.xxs), + Flexible( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.texts.labelMedium, + ), + ), + ], + ), + ); + } +} diff --git a/apps/rider_new/lib/features/trip/view/widgets/planner_sheet.dart b/apps/rider_new/lib/features/trip/view/widgets/planner_sheet.dart index a72fe32..eef1b22 100644 --- a/apps/rider_new/lib/features/trip/view/widgets/planner_sheet.dart +++ b/apps/rider_new/lib/features/trip/view/widgets/planner_sheet.dart @@ -2,6 +2,7 @@ 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/l10n/l10n.dart'; import '../../../../core/ui/tripz_button.dart'; import '../../../../core/ui/tripz_text_field.dart'; @@ -29,6 +30,19 @@ class _PlannerSheetState extends State { super.dispose(); } + /// أيقونة تعبّر عن نوع المكان — مطعم غير شارع غير مستشفى. يقرأها المستخدم + /// أسرع من النصّ. + IconData _iconFor(String category) => switch (category) { + 'street' || 'road' => Icons.route_outlined, + 'restaurant' || 'cafe' || 'food' => Icons.restaurant_outlined, + 'hospital' || 'clinic' || 'pharmacy' => Icons.local_hospital_outlined, + 'school' || 'university' => Icons.school_outlined, + 'mall' || 'shop' || 'store' => Icons.storefront_outlined, + 'mosque' => Icons.mosque_outlined, + 'hotel' => Icons.hotel_outlined, + _ => Icons.place_outlined, + }; + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -51,13 +65,38 @@ class _PlannerSheetState extends State { controller: _query, hint: l10n.rideSearchHint, prefixIcon: Icons.search_rounded, + // الكيبورد يفتح فوراً: المستخدم فتح المخطّط ليكتب، لا لينظر. + autofocus: true, onChanged: cubit.searchPlaces, + suffix: _query.text.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () { + _query.clear(); + cubit.searchPlaces(''); + setState(() {}); + }, + ), ), if (state.searching) const Padding( padding: EdgeInsets.only(top: Space.md), child: LinearProgressIndicator(minHeight: 2), ), + if (!state.searching && + _query.text.trim().length >= 2 && + state.searchResults.isEmpty) + Padding( + padding: const EdgeInsets.only(top: Space.md), + child: Text( + l10n.rideSearchNoResults, + textAlign: TextAlign.center, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), if (state.searchResults.isNotEmpty) Flexible( child: ListView.separated( @@ -69,9 +108,21 @@ class _PlannerSheetState extends State { final place = state.searchResults[i]; return ListTile( contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.place_outlined), - title: Text(place.label), - subtitle: place.address.isEmpty ? null : Text(place.address), + leading: CircleAvatar( + radius: 18, + backgroundColor: context.colors.surfaceContainerHighest, + child: Icon( + _iconFor(place.category), + size: Sizes.iconSm, + color: context.colors.onSurfaceVariant, + ), + ), + title: Text(place.label, maxLines: 1, + overflow: TextOverflow.ellipsis), + subtitle: place.address.isEmpty + ? null + : Text(place.address, + maxLines: 1, overflow: TextOverflow.ellipsis), onTap: () { _query.clear(); cubit.pickPlace(place, PickTarget.destination); 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 158be7f..78b1b24 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 @@ -9,6 +9,7 @@ import '../../../../core/design/tripz_colors.dart'; import '../../data/models/geo_point.dart'; import '../../data/models/route_info.dart'; +import 'map_label.dart'; /// الخريطة — **الطبقة الوحيدة التي تعرف `intaleq_maps`**. ما فوقها يتعامل /// مع `GeoPoint` وحده، فتبديل محرّك الخريطة لاحقاً لا يلمس منطقاً. @@ -25,6 +26,8 @@ class RideMap extends StatefulWidget { this.cameraTarget, this.onCameraIdle, this.bottomInset = 280, + this.originLabel = '', + this.destinationLabel = '', }); final GeoPoint? origin; @@ -39,6 +42,10 @@ class RideMap extends StatefulWidget { /// ارتفاع ما تغطّيه الورقة السفلية — يُحسب منه إطار المسار. final double bottomInset; + /// أسماء النقطتين — تُرسم بطاقاتها فوق الخريطة (نمط سيرو). + final String originLabel; + final String destinationLabel; + @override State createState() => _RideMapState(); } @@ -46,12 +53,14 @@ class RideMap extends StatefulWidget { class _RideMapState extends State { im.IntaleqMapController? _controller; im.LatLng? _center; + bool _isStyleLoaded = false; static const _fallback = im.LatLng(31.9539, 35.9106); // عمّان @override void didUpdateWidget(RideMap old) { super.didUpdateWidget(old); + if (!_isStyleLoaded) return; if (widget.route != null && widget.route != old.route) { _fitRoute(); return; @@ -110,12 +119,53 @@ class _RideMapState extends State { ); } + /// موضع النقطتين على الشاشة — يُحدَّث عند استقرار الكاميرا كي تتبع + /// البطاقاتُ الخريطةَ بلا حساب في كل إطار. + Offset? _originPx; + Offset? _destPx; + + Future _syncLabelPositions() async { + final controller = _controller; + if (controller == null) return; + final origin = widget.origin; + final dest = widget.destination; + try { + final o = origin == null + ? null + : await controller + .getScreenCoordinate(im.LatLng(origin.lat, origin.lng)); + final d = dest == null + ? null + : await controller.getScreenCoordinate(im.LatLng(dest.lat, dest.lng)); + if (!mounted) return; + setState(() { + _originPx = o == null ? null : Offset(o.x.toDouble(), o.y.toDouble()); + _destPx = d == null ? null : Offset(d.x.toDouble(), d.y.toDouble()); + }); + } catch (_) { + // الخريطة قد تكون بين حالتين — البطاقات تُخفى بدل أن تُرسم في مكان خطأ. + if (mounted) setState(() => _originPx = _destPx = null); + } + } + + /// بطاقة اسم فوق نقطة، مرفوعة عن الدائرة كي لا تغطّيها. + Widget _label(Offset? px, String text, Color color) { + if (px == null || text.isEmpty) return const SizedBox.shrink(); + return Positioned( + left: px.dx - 100, + top: px.dy - 46, + width: 200, + child: Center(child: MapLabel(text: text, color: color)), + ); + } + @override Widget build(BuildContext context) { final colors = context.tripzColors; final isDark = Theme.of(context).brightness == Brightness.dark; - return im.IntaleqMap( + return Stack(children: [ + im.IntaleqMap( apiKey: AppConfig.mapApiKey, initialCameraPosition: im.CameraPosition( target: widget.origin == null @@ -132,32 +182,51 @@ class _RideMapState extends State { compassEnabled: false, zoomControlsEnabled: false, onMapCreated: _onCreated, + onStyleLoaded: () { + if (mounted && !_isStyleLoaded) { + setState(() => _isStyleLoaded = true); + } + }, onCameraMove: (position) => _center = position.target, onCameraIdle: () { final c = _center; - if (c != null) widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude)); + if (c != null) { + widget.onCameraIdle?.call(GeoPoint(c.latitude, c.longitude)); + } + _syncLabelPositions(); }, // العلامات دوائر ملوّنة تطابق ألوان الخط الزمني في الورقة السفلية، // فيربط المستخدم بين «من» و«إلى» وما يراه على الخريطة بلا شرح. - circles: { - if (widget.origin != null) - _dot('origin', widget.origin!, colors.mapOrigin), - if (widget.destination != null) - _dot('destination', widget.destination!, colors.mapDestination), - }, - markers: { - if (widget.driver != null) _marker('driver', widget.driver!), - }, - polylines: { - if (widget.route != null && widget.route!.encodedPoints.isNotEmpty) - im.Polyline( - polylineId: const im.PolylineId('route'), - points: im.PolylineUtils.decode(widget.route!.encodedPoints), - color: colors.mapRoute, - width: 5, - ), - }, - ); + circles: _isStyleLoaded + ? { + if (widget.origin != null) + _dot('origin', widget.origin!, colors.mapOrigin), + if (widget.destination != null) + _dot('destination', widget.destination!, colors.mapDestination), + } + : const {}, + markers: _isStyleLoaded + ? { + if (widget.driver != null) _marker('driver', widget.driver!), + } + : const {}, + polylines: _isStyleLoaded + ? { + if (widget.route != null && widget.route!.encodedPoints.isNotEmpty) + im.Polyline( + polylineId: const im.PolylineId('route'), + points: im.PolylineUtils.decode(widget.route!.encodedPoints), + color: colors.mapRoute, + width: 5, + ), + } + : const {}, + ), + // أسماء النقطتين فوق الخريطة (نمط سيرو) — طبقة فلاتر لا marker، كي + // نرث الخط والاتجاه والثيم. + _label(_originPx, widget.originLabel, colors.mapOrigin), + _label(_destPx, widget.destinationLabel, colors.mapDestination), + ]); } im.Marker _marker(String id, GeoPoint p) => im.Marker( diff --git a/apps/rider_new/lib/features/wallet/cubit/wallet_cubit.dart b/apps/rider_new/lib/features/wallet/cubit/wallet_cubit.dart index b8f743a..5dca2d2 100644 --- a/apps/rider_new/lib/features/wallet/cubit/wallet_cubit.dart +++ b/apps/rider_new/lib/features/wallet/cubit/wallet_cubit.dart @@ -28,16 +28,30 @@ class WalletCubit extends Cubit { } } - Future topup(num amount) async { + /// شحن حقيقي. **لا يزيد الرصيد فوراً** إلا في نمط الدفع الفوري — الفاتورة + /// تُعرض للمستخدم ويحوّل، والتسوية تصل لاحقاً عبر مطابقة رسالة المزوّد. + Future topup({required num amount, required String provider}) async { if (amount <= 0) return; emit(state.copyWith(action: WalletAction.toppingUp)); try { - await _repo.topup(amount); + final result = await _repo.charge( + amount: amount, + provider: provider, + currency: state.currency, + ); if (isClosed) return; - emit(state.copyWith(action: WalletAction.topupDone)); - // الرصيد يُعاد جلبه من الخادم لا يُحسب محلياً: المال لا يُقدَّر - // في التطبيق (docs/23 §0.2). - await load(); + + if (result.isInstant) { + emit(state.copyWith(action: WalletAction.topupDone)); + // الرصيد يُعاد جلبه من الخادم لا يُحسب محلياً: المال لا يُقدَّر + // في التطبيق (docs/23 §0.2). + await load(); + return; + } + emit(state.copyWith( + action: WalletAction.invoiceReady, + invoice: result, + )); } on ApiException { if (!isClosed) emit(state.copyWith(action: WalletAction.topupFailed)); } diff --git a/apps/rider_new/lib/features/wallet/cubit/wallet_state.dart b/apps/rider_new/lib/features/wallet/cubit/wallet_state.dart index 5ddedc6..121258f 100644 --- a/apps/rider_new/lib/features/wallet/cubit/wallet_state.dart +++ b/apps/rider_new/lib/features/wallet/cubit/wallet_state.dart @@ -3,8 +3,9 @@ import 'package:equatable/equatable.dart'; import '../../../core/ui/view_status.dart'; import '../data/models/wallet.dart'; import '../data/models/wallet_txn.dart'; +import '../data/wallet_repository.dart'; -enum WalletAction { none, toppingUp, topupFailed, topupDone } +enum WalletAction { none, toppingUp, topupFailed, topupDone, invoiceReady } class WalletState extends Equatable { const WalletState({ @@ -12,6 +13,7 @@ class WalletState extends Equatable { this.wallet, this.transactions = const [], this.action = WalletAction.none, + this.invoice, this.error, }); @@ -23,6 +25,9 @@ class WalletState extends Equatable { /// المعروض خلفه. final WalletAction action; + /// فاتورة تحويل بانتظار الدفع — تُعرض للمستخدم بمرجعها وحساب الاستلام. + final TopupResult? invoice; + final String? error; String get currency => wallet?.currency ?? ''; @@ -32,6 +37,7 @@ class WalletState extends Equatable { Wallet? wallet, List? transactions, WalletAction? action, + TopupResult? invoice, String? error, }) { return WalletState( @@ -39,10 +45,12 @@ class WalletState extends Equatable { wallet: wallet ?? this.wallet, transactions: transactions ?? this.transactions, action: action ?? this.action, + invoice: invoice ?? this.invoice, error: error ?? this.error, ); } @override - List get props => [status, wallet, transactions, action, error]; + List get props => + [status, wallet, transactions, action, invoice?.reference, error]; } diff --git a/apps/rider_new/lib/features/wallet/data/wallet_repository.dart b/apps/rider_new/lib/features/wallet/data/wallet_repository.dart index 2f7a121..70ea750 100644 --- a/apps/rider_new/lib/features/wallet/data/wallet_repository.dart +++ b/apps/rider_new/lib/features/wallet/data/wallet_repository.dart @@ -2,6 +2,33 @@ import '../../../core/api/api_client.dart'; import 'models/wallet.dart'; import 'models/wallet_txn.dart'; +/// نتيجة الشحن — ثلاثة أنماط: فوري · فاتورة · إعادة توجيه. +class TopupResult { + const TopupResult({ + required this.status, + this.reference, + this.transferTarget, + this.instructions, + this.redirectUrl, + }); + + final String status; + + /// مرجع الفاتورة الذي يذكره المستخدم عند التحويل — به تُطابَق رسالة + /// المزوّد لاحقاً. + final String? reference; + + /// حساب/رقم استلام المستأجر عند هذا المزوّد. + final String? transferTarget; + + final String? instructions; + final String? redirectUrl; + + bool get isInvoice => reference != null; + bool get isInstant => status == 'success'; + bool get isRedirect => redirectUrl != null; +} + class WalletRepository { WalletRepository(this._api); @@ -20,8 +47,35 @@ class WalletRepository { .toList(growable: false); } - Future topup(num amount) => - _api.post>('/wallet/topup', body: {'amount': amount}); + /// شحن حقيقي عبر `POST /payments/charge`. + /// + /// **لا يزيد الرصيد فوراً.** كليك وشام كاش وزين كاش تعمل بنمط الفاتورة: + /// الخادم يولّد مرجعاً ويعرض حساب الاستلام، ثم يحوّل المستخدم، والتسوية + /// تصل لاحقاً عبر مطابقة رسالة المزوّد. هذا **نفس مسار سيرو** حرفياً. + /// PayMob وحده يرجّع رابط إعادة توجيه، و`cash` وحده فوري. + Future charge({ + required num amount, + required String provider, + required String currency, + }) async { + final res = await _api.post>( + '/payments/charge', + body: { + 'amount': amount, + 'provider': provider, + 'purpose': 'topup', + 'currency': currency, + }, + ); + final payment = res['payment'] as Map?; + return TopupResult( + status: payment?['status'] as String? ?? 'pending', + reference: res['reference'] as String?, + transferTarget: res['transferTarget'] as String?, + instructions: res['instructions'] as String?, + redirectUrl: res['redirectUrl'] as String?, + ); + } // ── السحب: خطوتان لا خطوة ──────────────────────────────────────────── // `request` يرسل رمزاً **بلا خصم**، و`confirm` هو ما يخصم ويحجز 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 9e30801..e2a03a9 100644 --- a/apps/rider_new/lib/features/wallet/view/wallet_page.dart +++ b/apps/rider_new/lib/features/wallet/view/wallet_page.dart @@ -17,6 +17,7 @@ import '../../../core/ui/tripz_text_field.dart'; import '../cubit/wallet_cubit.dart'; import '../cubit/wallet_state.dart'; import 'widgets/balance_card.dart'; +import 'widgets/invoice_sheet.dart'; import 'widgets/txn_tile.dart'; /// محفظة الراكب — **رصيدٌ دفعه ولم يستهلكه**، أي التزامٌ على المنصّة. @@ -50,6 +51,12 @@ class _WalletPageState extends State { SnackBar(content: Text(l10n.walletTopupFailed)), ); } + // الفاتورة تُعرض فور جهوزها: المستخدم يحتاج المرجع وحساب الاستلام + // قبل أن يغادر التطبيق ليحوّل. + final invoice = state.invoice; + if (state.action == WalletAction.invoiceReady && invoice != null) { + TripzSheet.show(context, child: InvoiceSheet(invoice: invoice)); + } }, builder: (context, state) { return TripzScaffold( @@ -124,12 +131,13 @@ class _WalletPageState extends State { onSelect: (c) => setState(() => channel = c), ), const SizedBox(height: Space.md), - // صراحةٌ مقصودة: الخادم يصف هذه النقطة بـ«كاش-إن تجريبي». إخفاء - // ذلك يجعل المستخدم يظنّ أنه دفع فعلاً. - StatusBanner( - message: l10n.walletTopupNote, - tone: BannerTone.warning, - ), + // الشحن ليس فورياً في قنوات الفاتورة — يُقال قبل الضغط لا بعده. + if (channel != PaymentChannel.cash) + StatusBanner( + message: l10n.walletInvoicePending, + tone: BannerTone.info, + icon: Icons.schedule_rounded, + ), ], ), ), @@ -138,7 +146,9 @@ class _WalletPageState extends State { onPressed: () { final amount = num.tryParse(controller.text.trim()); Navigator.of(context).pop(); - if (amount != null) _cubit.topup(amount); + if (amount != null) { + _cubit.topup(amount: amount, provider: channel.wire); + } }, ), ); diff --git a/apps/rider_new/lib/features/wallet/view/widgets/invoice_sheet.dart b/apps/rider_new/lib/features/wallet/view/widgets/invoice_sheet.dart new file mode 100644 index 0000000..3e3834c --- /dev/null +++ b/apps/rider_new/lib/features/wallet/view/widgets/invoice_sheet.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/status_banner.dart'; +import '../../data/wallet_repository.dart'; + +/// فاتورة التحويل — نمط سيرو حرفياً (`create_cliq_invoice.php`). +/// +/// المستخدم يحوّل بنفسه إلى حساب المشغّل ويذكر المرجع، والتسوية تصل لاحقاً +/// بمطابقة رسالة المزوّد. **الرصيد لا يزيد الآن** — وهذا يُقال صراحةً بدل +/// أن ينتظر المستخدم رقماً لن يتغيّر. +class InvoiceSheet extends StatelessWidget { + const InvoiceSheet({super.key, required this.invoice}); + + final TopupResult invoice; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final target = invoice.transferTarget; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.walletInvoiceTitle, style: context.texts.titleLarge), + const SizedBox(height: Space.md), + + // حساب الاستلام قد لا يكون مضبوطاً للمستأجر — يُقال بوضوح بدل تعليمات + // ناقصة تُرسل المال إلى لا مكان. + if (target == null || target.isEmpty) + StatusBanner(message: l10n.walletNoTarget, tone: BannerTone.danger) + else + _CopyRow(label: l10n.walletInvoiceTarget, value: target), + + const SizedBox(height: Space.sm), + if (invoice.reference != null) + _CopyRow(label: l10n.walletInvoiceRef, value: invoice.reference!), + + const SizedBox(height: Space.md), + StatusBanner( + message: l10n.walletInvoicePending, + tone: BannerTone.info, + icon: Icons.schedule_rounded, + ), + ], + ); + } +} + +/// قيمة مع زرّ نسخ — الرقم يُحوَّل يدوياً في تطبيق بنك آخر، فالنسخ ليس رفاهية. +class _CopyRow extends StatelessWidget { + const _CopyRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: Space.page, + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: Radii.card_, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: context.texts.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Space.xxs), + SelectableText( + value, + textDirection: TextDirection.ltr, + style: numericStyle(context.texts.titleMedium), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.copy_rounded), + tooltip: context.l10n.walletCopy, + onPressed: () { + Clipboard.setData(ClipboardData(text: value)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.walletCopied)), + ); + }, + ), + ], + ), + ); + } +}