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
@@ -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,
),
),
],
),
),
),
);
}
}