Compare commits

..
2 Commits
Author SHA1 Message Date
Hamza-AyedandClaude Opus 5 3a8caf7b27 fix(rider): تعطيل أنواع الرحلات بلا تعرفة نشطة
فحص حيّ كشف أن `GET /ride-types` يرجّع ستّة أنواع للمستأجر siro بينما ثلاثة
فقط لها تعرفة نشطة:
  economy 1.55 · comfort 1.85 · electric 1.75  ✅
  family_van · women · scooter                 ❌ 404

أي أن الراكب يرى ستّة خيارات، وثلاثة منها لو اختارها يفشل طلب الرحلة بـ
«No pricing available» بعد أن يكون قد أكمل تحديد وجهته.

المعالجة في التطبيق: النوع بلا تعرفة يظهر **معطّلاً مكتوباً عليه «غير متاح»**
لا مخفياً — إخفاؤه يترك المستخدم يتساءل أين ذهب الخيار، وإظهاره قابلاً
للاختيار يوصله إلى فشل بعد خطوتين. وإن كان المختار بلا تعرفة يُنتقل تلقائياً
لأول نوع مسعَّر، فلا يبقى زرّ التأكيد معطّلاً بلا سبب ظاهر.

هذا علاج للعَرَض؛ السبب بيانات على الخادم: كتالوج ride-types والتعرفات غير
متزامنين.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 00:39:30 +03:00
Hamza-AyedandClaude Opus 5 8976821a93 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>
2026-08-05 00:36:02 +03:00
18 changed files with 763 additions and 86 deletions
@@ -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);
}
}
},
+8
View File
@@ -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;
+9 -1
View File
@@ -193,5 +193,13 @@
"chatHint": "اكتب رسالة",
"chatEmpty": "لا رسائل بعد",
"rideCoupon": "كود خصم",
"rideCouponApply": "تطبيق"
"rideCouponApply": "تطبيق",
"rideNoLocation": "فعّل الموقع لنعرف أين أنت",
"rideBack": "رجوع",
"rideRetryQuote": "أعد حساب الأجرة",
"rideFrom": "من",
"rideTo": "إلى",
"walletChannel": "طريقة الدفع",
"walletTopupNote": "الشحن التجريبي حالياً بلا تحصيل فعلي — ربط البوابة قادم",
"rideTypeUnavailable": "غير متاح"
}
+9 -1
View File
@@ -193,5 +193,13 @@
"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",
"rideTypeUnavailable": "Unavailable"
}
@@ -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,58 @@ 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>>(),
);
// إن كان المختار بلا تعرفة، ننتقل لأول نوع مسعَّر: تركُه مختاراً يعني
// زرَّ تأكيد معطّلاً بلا سبب ظاهر.
var selected = state.selectedRideType;
if (selected == null || !quotes.containsKey(selected.code)) {
selected = types.where((t) => quotes.containsKey(t.code)).firstOrNull;
}
emit(state.copyWith(
quotes: quotes,
selectedRideType: selected,
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,32 @@
import 'package:flutter/material.dart';
import '../../../../core/design/tokens.dart';
import '../../../../core/design/tripz_colors.dart';
import '../../../../core/design/typography.dart';
import '../../../../core/format/money.dart';
import '../../../../core/l10n/l10n.dart';
import '../../data/models/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 +35,123 @@ 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,
final quote = quotes[type.code];
return _Tile(
type: type,
quote: quote,
selected: type.id == selected?.id,
label: type.nameFor(lang),
// نوعٌ بلا تعرفة نشطة **لا يُختار**: اختياره يُفشل طلب الرحلة
// بـ«No pricing available». يبقى ظاهراً معطّلاً كي يعرف المستخدم
// أن الخيار موجود لكنه غير متاح الآن — لا أن يختفي بلا تفسير.
enabled: quote != null,
onTap: () => onSelect(type),
);
},
),
);
}
}
class _Tile extends StatelessWidget {
const _Tile({
required this.type,
required this.quote,
required this.selected,
required this.label,
required this.enabled,
required this.onTap,
});
final RideType type;
final FareQuote? quote;
final bool selected;
final String label;
final bool enabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final scheme = context.colors;
final fare = quote;
final dim = scheme.onSurfaceVariant;
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: enabled ? onTap : null,
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: !enabled
? dim
: selected
? scheme.onPrimaryContainer
: scheme.onSurface,
),
const SizedBox(height: Space.xxs),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.texts.labelMedium?.copyWith(
color: !enabled
? dim
: selected
? scheme.onPrimaryContainer
: null,
),
),
const SizedBox(height: Space.xxs),
// نوعٌ بلا تعرفة نشطة يظهر بشرطة بدل أن يُخفى: إخفاؤه يترك
// المستخدم يتساءل أين ذهب الخيار.
if (fare == null)
Text(
context.l10n.rideTypeUnavailable,
maxLines: 1,
style: context.texts.labelSmall?.copyWith(color: dim),
)
else
Text(
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,