fix(apps): إصلاحات من أول تجربة حقيقية على الجهاز
## تعذّر حساب الأجرة — الخطأ في التطبيق لا في جدول الكازان الخادم يبذر التعرفة بـ 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
29f375b122
commit
8976821a93
@@ -8,7 +8,20 @@ import '../config.dart';
|
||||
class LocationService {
|
||||
StreamSubscription<Position>? _sub;
|
||||
|
||||
/// بلا هذا الفحص يرمي `getCurrentPosition` ونبتلع الخطأ فتبقى الكاميرا
|
||||
/// على الاحتياطي بلا سبب ظاهر — وهو ما كان يبدو «الخريطة لا تتبع موقعي».
|
||||
Future<bool> 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(
|
||||
|
||||
@@ -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<PaymentChannel> 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<PaymentChannel> payoutFor(String countryPack) =>
|
||||
forCountry(countryPack)
|
||||
.where((c) => c != PaymentChannel.cash)
|
||||
.toList(growable: false);
|
||||
}
|
||||
@@ -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<PaymentChannel> channels;
|
||||
final PaymentChannel? selected;
|
||||
final ValueChanged<PaymentChannel> 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,
|
||||
};
|
||||
}
|
||||
@@ -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<GeoPoint>? onCameraIdle;
|
||||
|
||||
/// ارتفاع ما تغطّيه الورقة السفلية — يُحسب منه إطار المسار.
|
||||
final double bottomInset;
|
||||
|
||||
@override
|
||||
State<RideMap> createState() => _RideMapState();
|
||||
}
|
||||
@@ -48,12 +52,62 @@ class _RideMapState extends State<RideMap> {
|
||||
@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<RideMap> {
|
||||
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<RideMap> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<PayoutSheet> {
|
||||
final _amount = TextEditingController();
|
||||
final _code = TextEditingController();
|
||||
PaymentChannel? _channel;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -75,7 +79,7 @@ class _PayoutSheetState extends State<PayoutSheet> {
|
||||
hasError: state.failure == PayoutFailure.rejected,
|
||||
onCompleted: cubit.confirm,
|
||||
)
|
||||
else
|
||||
else ...[
|
||||
TripzTextField(
|
||||
controller: _amount,
|
||||
label: l10n.payoutAmount,
|
||||
@@ -85,6 +89,22 @@ class _PayoutSheetState extends State<PayoutSheet> {
|
||||
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<TenantCubit>().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<PayoutSheet> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -193,5 +193,12 @@
|
||||
"chatHint": "اكتب رسالة",
|
||||
"chatEmpty": "لا رسائل بعد",
|
||||
"rideCoupon": "كود خصم",
|
||||
"rideCouponApply": "تطبيق"
|
||||
"rideCouponApply": "تطبيق",
|
||||
"rideNoLocation": "فعّل الموقع لنعرف أين أنت",
|
||||
"rideBack": "رجوع",
|
||||
"rideRetryQuote": "أعد حساب الأجرة",
|
||||
"rideFrom": "من",
|
||||
"rideTo": "إلى",
|
||||
"walletChannel": "طريقة الدفع",
|
||||
"walletTopupNote": "الشحن التجريبي حالياً بلا تحصيل فعلي — ربط البوابة قادم"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -8,7 +8,20 @@ import '../config.dart';
|
||||
class LocationService {
|
||||
StreamSubscription<Position>? _sub;
|
||||
|
||||
/// بلا هذا الفحص يرمي `getCurrentPosition` ونبتلع الخطأ فتبقى الكاميرا
|
||||
/// على الاحتياطي بلا سبب ظاهر — وهو ما كان يبدو «الخريطة لا تتبع موقعي».
|
||||
Future<bool> 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(
|
||||
|
||||
@@ -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<PaymentChannel> 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<PaymentChannel> payoutFor(String countryPack) =>
|
||||
forCountry(countryPack)
|
||||
.where((c) => c != PaymentChannel.cash)
|
||||
.toList(growable: false);
|
||||
}
|
||||
@@ -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<PaymentChannel> channels;
|
||||
final PaymentChannel? selected;
|
||||
final ValueChanged<PaymentChannel> 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,
|
||||
};
|
||||
}
|
||||
@@ -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<RideState> {
|
||||
|
||||
// الكاميرا تتوسّط الموقع **فوراً**، ولا تنتظر العنوان — انتظارها كان
|
||||
// باگ سيرو المُصلَح 2026-07-20.
|
||||
unawaited(_locateMe());
|
||||
unawaited(locateMe());
|
||||
unawaited(_loadRideTypes());
|
||||
unawaited(_resumeLiveTrip());
|
||||
}
|
||||
|
||||
Future<void> _locateMe() async {
|
||||
/// تُنادى عند الإقلاع وعند العودة للتطبيق — الإذن قد يُمنح من إعدادات
|
||||
/// النظام بينما التطبيق في الخلفية.
|
||||
Future<void> 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<RideState> {
|
||||
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<RideState> {
|
||||
|
||||
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<RideState> {
|
||||
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<RideState> {
|
||||
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<RideState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// التعرفة تحتاج مسافة ومدة محسوبتين — لا إحداثيات (docs/38 §7).
|
||||
Future<void> _priceIt() async {
|
||||
/// يسعّر **كل الأنواع دفعةً واحدة** بعد اكتمال المسار.
|
||||
///
|
||||
/// المسافة والمدة ثابتتان لكل الأنواع، فالفرق في التعرفة وحدها. النداءات
|
||||
/// متوازية ومرة واحدة — لا نداء عند كل ضغطة نوع.
|
||||
///
|
||||
/// التعرفة تحتاج مسافة ومدة محسوبتين لا إحداثيات، و`city` يجب أن تكون
|
||||
/// `default` كما يبذرها الخادم — أي قيمة أخرى ترجّع 404 (docs/38 §7).
|
||||
/// إعادة تسعير يدوية — مخرج المستخدم حين يفشل الحساب.
|
||||
Future<void> repriceAll() => _priceAll();
|
||||
|
||||
Future<void> _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<String, FareQuote>.fromEntries(
|
||||
results.whereType<MapEntry<String, FareQuote>>(),
|
||||
);
|
||||
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,
|
||||
));
|
||||
}
|
||||
|
||||
// ── الطلب والبحث ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<String, FareQuote> 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<String, FareQuote>? quotes,
|
||||
Trip? trip,
|
||||
GeoPoint? driverLocation,
|
||||
List<Place>? 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,
|
||||
|
||||
@@ -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<RidePage> createState() => _RidePageState();
|
||||
}
|
||||
|
||||
class _RidePageState extends State<RidePage> {
|
||||
class _RidePageState extends State<RidePage> with WidgetsBindingObserver {
|
||||
late final RideCubit _cubit = sl<RideCubit>()..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<RidePage> {
|
||||
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<RideCubit>().openPlanner,
|
||||
final cubit = context.read<RideCubit>();
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) ...[
|
||||
|
||||
@@ -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<GeoPoint>? onCameraIdle;
|
||||
|
||||
/// ارتفاع ما تغطّيه الورقة السفلية — يُحسب منه إطار المسار.
|
||||
final double bottomInset;
|
||||
|
||||
@override
|
||||
State<RideMap> createState() => _RideMapState();
|
||||
}
|
||||
@@ -48,12 +52,62 @@ class _RideMapState extends State<RideMap> {
|
||||
@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<RideMap> {
|
||||
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<RideMap> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<RideType> types;
|
||||
final RideType? selected;
|
||||
final Map<String, FareQuote> quotes;
|
||||
final ValueChanged<RideType> 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WalletPage> {
|
||||
void _openTopup(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final controller = TextEditingController();
|
||||
// القنوات من حزمة دولة المستأجر لا من لغة الجهاز (docs/38 §10).
|
||||
final channels = PaymentChannel.forCountry(
|
||||
context.read<TenantCubit>().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,
|
||||
|
||||
Reference in New Issue
Block a user