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
@@ -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,
));
}
// ── الطلب والبحث ───────────────────────────────────────────────────────