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:
Hamza-Ayed
2026-08-05 00:36:02 +03:00
co-authored by Claude Opus 5
parent 29f375b122
commit 8976821a93
18 changed files with 731 additions and 87 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,
};
}