Update codebase

This commit is contained in:
Hamza-Ayed
2026-08-05 14:12:41 +03:00
parent 58928cd258
commit 95e2e4f35d
51 changed files with 3141 additions and 390 deletions
@@ -6,6 +6,7 @@ import '../../../core/api/api_exception.dart';
import '../../../core/location/location_service.dart';
import '../../../core/realtime/realtime_service.dart';
import '../data/models/geo_point.dart';
import '../data/maps_repository.dart';
import '../data/models/trip.dart';
import '../data/trip_repository.dart';
import 'duty_state.dart';
@@ -16,14 +17,17 @@ class DutyCubit extends Cubit<DutyState> {
required TripRepository trips,
required LocationService location,
required RealtimeService realtime,
required MapsRepository maps,
}) : _trips = trips,
_location = location,
_realtime = realtime,
_maps = maps,
super(const DutyState());
final TripRepository _trips;
final LocationService _location;
final RealtimeService _realtime;
final MapsRepository _maps;
StreamSubscription<RealtimeEvent>? _events;
Timer? _offerTimer;
@@ -100,6 +104,7 @@ class DutyCubit extends Cubit<DutyState> {
final heading = position.heading as double?;
emit(state.copyWith(myLocation: GeoPoint(lat, lng)));
_refreshNavRoute(GeoPoint(lat, lng));
// مساران عمداً: REST يغذّي المطابقة في Redis، والسوكت يغذّي خريطة
// الراكب الحيّة. سقوط أحدهما لا يُعمي الآخر.
@@ -202,6 +207,53 @@ class DutyCubit extends Cubit<DutyState> {
busy: false,
phase: DutyPhase.active,
));
// الهدف يتبدّل مع الحالة (الراكب ← الوجهة)، فالمسار يُعاد فوراً عند كل
// انتقال بدل انتظار نبضة الموقع التالية.
_lastNavFrom = null;
final me = state.myLocation;
if (me != null) _refreshNavRoute(me);
}
// ── مسار القيادة ───────────────────────────────────────────────────────
/// أقلّ إزاحة تستدعي إعادة الحساب (~50 متراً) — نفس منطق الراكب: يلتقط
/// الانعطاف الفعلي ويتجاهل رجفة GPS.
static const double _redrawThreshold = 0.0005;
GeoPoint? _lastNavFrom;
bool _routing = false;
void _refreshNavRoute(GeoPoint from) {
final target = state.navTarget;
if (target == null) {
if (state.navRoute != null) emit(state.copyWith(clearNavRoute: true));
_lastNavFrom = null;
return;
}
if (_routing) return;
final last = _lastNavFrom;
final moved = last == null ||
(from.lat - last.lat).abs() > _redrawThreshold ||
(from.lng - last.lng).abs() > _redrawThreshold;
if (!moved) return;
_lastNavFrom = from;
unawaited(_route(from, target));
}
Future<void> _route(GeoPoint from, GeoPoint to) async {
_routing = true;
try {
final route = await _maps.route(from, to);
if (!isClosed) emit(state.copyWith(navRoute: route));
} on ApiException {
// مسارٌ متعذّر لا يوقف الرحلة — السائق يرى النقطة ولو بلا خط،
// وتُعاد المحاولة مع الحركة التالية.
} finally {
_routing = false;
}
}
void _onRealtime(RealtimeEvent event) {
@@ -1,6 +1,7 @@
import 'package:equatable/equatable.dart';
import '../data/models/geo_point.dart';
import '../data/models/route_info.dart';
import '../data/models/trip.dart';
/// حالة السائق. الفرق الجوهري عن الراكب: السائق **يستقبل** عملاً بدل أن
@@ -31,6 +32,7 @@ class DutyState extends Equatable {
this.error = DutyError.none,
this.creditBalance,
this.creditBlocked = false,
this.navRoute,
});
final DutyPhase phase;
@@ -49,6 +51,22 @@ class DutyState extends Equatable {
/// محجوب لنفاد الرصيد — لا يصله عمل، ولا بد أن يعرف السبب صراحةً.
final bool creditBlocked;
/// المسار الذي يقوده السائق الآن: إليه إن كان مقترباً، وإلى وجهة الراكب
/// إن كانت الرحلة جارية. **بلا هذا السائق أعمى** — كان يرى موقعه وحده.
final RouteInfo? navRoute;
/// الهدف الحالي للقيادة — نقطة الراكب أثناء الاقتراب، ثم الوجهة.
///
/// آلة الحالات هي من تقرّره لا الشاشة (docs/38 §4).
GeoPoint? get navTarget => switch (trip?.status) {
TripStatus.assigned ||
TripStatus.driverArriving ||
TripStatus.driverArrived =>
trip?.origin,
TripStatus.inProgress => trip?.destination,
_ => null,
};
bool get isOnline => phase != DutyPhase.offline;
DutyState copyWith({
@@ -60,8 +78,10 @@ class DutyState extends Equatable {
DutyError? error,
String? creditBalance,
bool? creditBlocked,
RouteInfo? navRoute,
bool clearOffer = false,
bool clearTrip = false,
bool clearNavRoute = false,
}) {
return DutyState(
phase: phase ?? this.phase,
@@ -72,6 +92,9 @@ class DutyState extends Equatable {
error: error ?? this.error,
creditBalance: creditBalance ?? this.creditBalance,
creditBlocked: creditBlocked ?? this.creditBlocked,
navRoute: clearNavRoute || clearTrip
? null
: (navRoute ?? this.navRoute),
);
}
@@ -85,5 +108,6 @@ class DutyState extends Equatable {
error,
creditBalance,
creditBlocked,
navRoute,
];
}
@@ -70,7 +70,14 @@ class Trip extends Equatable {
this.driverPhone,
this.vehiclePlate,
this.vehicleModel,
this.vehicleColor,
this.vehicleColorHex,
this.driverRating,
this.requestedAt,
this.distanceKm,
this.durationMin,
this.paymentMethod,
this.serviceClass,
});
final String id;
@@ -90,8 +97,34 @@ class Trip extends Equatable {
final String? driverPhone;
final String? vehiclePlate;
final String? vehicleModel;
/// لون المركبة كاسم (`White`, `أبيض`) — **ما يبحث عنه الراكب في الشارع
/// قبل اللوحة**. الخادم يرسله في `vehicle.color`
/// (`trips.service.ts:427`).
final String? vehicleColor;
/// اللون كـhex إن وُجد (`vehicles` عندها `color_hex`). يُقدَّم على الاسم
/// عند الرسم لأنه دقيق، والاسم يبقى للعرض النصّي.
final String? vehicleColorHex;
final String? driverRating;
// ── حقول السجلّ ────────────────────────────────────────────────────────
// كلها موجودة في `trip.entity.ts` وتصل مع `GET /trips/mine`، وكانت
// مُهمَلة في القراءة — فبدت بطاقة «رحلاتي» فارغة إلا من سعر وحالة.
/// وقت الطلب (`requested_at`, `CreateDateColumn`).
final DateTime? requestedAt;
final double? distanceKm;
final double? durationMin;
/// `cash` افتراضاً في الخادم — يُعرض كي يعرف الراكب كيف دفع.
final String? paymentMethod;
/// نوع الخدمة (`economy` افتراضاً).
final String? serviceClass;
factory Trip.fromJson(Map<String, dynamic> json) {
GeoPoint? point(String key) {
final v = json[key];
@@ -99,6 +132,11 @@ class Trip extends Equatable {
}
final driver = json['driver'] as Map<String, dynamic>?;
// المركبة تصل بشكلين: مسطّحة (`vehicle_plate`) في ردّ الرحلة، ومتداخلة
// (`vehicle: {plate, color}`) في حدث `assigned` (`trips.service.ts:423`).
// القراءة تقبل الاثنين بدل أن تُفرغ البطاقة عند أحدهما.
final vehicle = (json['vehicle'] ?? driver?['vehicle'])
as Map<String, dynamic>?;
return Trip(
id: json['id'] as String,
status: TripStatus.parse(json['status'] as String?),
@@ -112,11 +150,26 @@ class Trip extends Equatable {
driverName: driver?['name'] as String? ?? json['driver_name'] as String?,
driverPhone:
driver?['phone'] as String? ?? json['driver_phone'] as String?,
vehiclePlate:
driver?['vehicle_plate'] as String? ?? json['vehicle_plate'] as String?,
vehicleModel:
driver?['vehicle_model'] as String? ?? json['vehicle_model'] as String?,
vehiclePlate: vehicle?['plate'] as String? ??
driver?['vehicle_plate'] as String? ??
json['vehicle_plate'] as String?,
vehicleModel: vehicle?['model'] as String? ??
driver?['vehicle_model'] as String? ??
json['vehicle_model'] as String?,
vehicleColor: vehicle?['color'] as String? ??
driver?['vehicle_color'] as String? ??
json['vehicle_color'] as String?,
vehicleColorHex: vehicle?['color_hex'] as String? ??
driver?['vehicle_color_hex'] as String? ??
json['vehicle_color_hex'] as String?,
driverRating: driver?['rating']?.toString(),
requestedAt: DateTime.tryParse(json['requested_at']?.toString() ?? ''),
// المسافة الفعلية أدقّ من المقدّرة حين تتوفّر بعد انتهاء الرحلة.
distanceKm: (json['actual_distance_km'] as num?)?.toDouble() ??
(json['distance_km'] as num?)?.toDouble(),
durationMin: (json['duration_min'] as num?)?.toDouble(),
paymentMethod: json['payment_method'] as String?,
serviceClass: json['service_class'] as String?,
);
}
@@ -10,11 +10,38 @@ import '../../../core/ui/app_drawer.dart';
import '../cubit/duty_cubit.dart';
import '../cubit/duty_state.dart';
import '../data/models/geo_point.dart';
import '../data/models/route_info.dart';
import 'widgets/active_duty_sheet.dart';
import 'widgets/duty_toggle.dart';
import 'widgets/offer_sheet.dart';
import 'widgets/ride_map.dart';
/// نصيب الورقة السفلية من ارتفاع الشاشة — مطابق لتطبيق الراكب.
const double _sheetFraction = 0.33;
/// ما تحتاجه الخريطة وحدها — تغيّرُ الرصيد أو الانشغال لا يعيد بناءها.
class _MapData {
const _MapData({this.me, this.pickup, this.dropoff, this.route, this.target});
final GeoPoint? me;
final GeoPoint? pickup;
final GeoPoint? dropoff;
final RouteInfo? route;
final GeoPoint? target;
@override
bool operator ==(Object other) =>
other is _MapData &&
other.me == me &&
other.pickup == pickup &&
other.dropoff == dropoff &&
other.route == route &&
other.target == target;
@override
int get hashCode => Object.hash(me, pickup, dropoff, route, target);
}
/// شاشة السائق: خريطة + مفتاح الاتصال أعلى + ورقة تتبدّل بالمرحلة.
class DutyPage extends StatefulWidget {
const DutyPage({super.key});
@@ -42,9 +69,34 @@ class _DutyPageState extends State<DutyPage> {
children: [
// الخريطة معزولة عن بقية الحالة: نبضة موقع كل 25 متراً يجب ألّا
// تعيد بناء الورقة والمفتاح معها.
BlocSelector<DutyCubit, DutyState, GeoPoint?>(
selector: (s) => s.myLocation,
builder: (context, me) => RideMap(origin: me, cameraTarget: me),
//
// **مرفوعة عن القاع بالثلث** كما في تطبيق الراكب — الورقة لها
// مساحتها ولا تغطّي الخريطة.
Positioned(
top: 0,
left: 0,
right: 0,
bottom: MediaQuery.sizeOf(context).height * _sheetFraction,
child: BlocSelector<DutyCubit, DutyState, _MapData>(
selector: (s) => _MapData(
me: s.myLocation,
pickup: s.trip?.origin,
dropoff: s.trip?.destination,
route: s.navRoute,
target: s.navTarget,
),
// السائق كان يرى **موقعه وحده**: بلا نقطة الراكب ولا الوجهة
// ولا خط يقوده. الخريطة الآن تعرض ما تعرضه خريطة الراكب:
// نقطتَي الرحلة والمسار الحيّ إلى الهدف الحالي.
builder: (context, d) => RideMap(
origin: d.pickup ?? d.me,
destination: d.dropoff,
driver: d.pickup == null ? null : d.me,
route: d.route,
// الكاميرا تتبع الهدف حين يوجد، وإلا فموقع السائق.
cameraTarget: d.target ?? d.me,
),
),
),
SafeArea(
@@ -83,7 +135,7 @@ class _DutyPageState extends State<DutyPage> {
selector: (s) => s.creditBlocked,
builder: (context, blocked) => blocked
? StatusBanner(
message: context.l10n.errorGeneric,
message: context.l10n.creditBlocked,
tone: BannerTone.danger,
icon: Icons.account_balance_wallet_outlined,
)
@@ -132,13 +184,21 @@ class _Sheet extends StatelessWidget {
),
DutyPhase.offered => OfferSheet(offer: state.offer!),
DutyPhase.active =>
ActiveDutySheet(trip: state.trip!, busy: state.busy),
ActiveDutySheet(
trip: state.trip!,
busy: state.busy,
navRoute: state.navRoute,
),
};
if (state.phase == DutyPhase.offline) return const SizedBox.shrink();
return Container(
width: double.infinity,
// الحدّ الأدنى = المساحة المحجوزة تحت الخريطة، وإلا ظهر فراغ بينهما.
constraints: BoxConstraints(
minHeight: MediaQuery.sizeOf(context).height * _sheetFraction,
),
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: Radii.sheetTop,
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../core/design/tokens.dart';
import '../../../core/design/tripz_colors.dart';
@@ -55,6 +56,16 @@ class _HistoryPageState extends State<HistoryPage> {
}
}
/// بطاقة رحلة في السجلّ.
///
/// كانت ثلاثة عناصر فقط (نقطة · حالة · سعر) — بلا تاريخ ولا مسافة ولا طريقة
/// دفع، رغم أن الخادم يرسلها كلها في `GET /trips/mine`
/// (`trip.entity.ts`: `requested_at`, `distance_km`, `duration_min`,
/// `payment_method`, `service_class`). البطاقة الآن تقرأ ما كان يصل ويُهمَل.
///
/// **العناوين النصّية غائبة عمداً**: `trip.entity.ts` يخزّن إحداثيات
/// (`origin_lat/lng`) بلا `origin_address`، فسيرو يعرض عناوين ونحن لا نقدر
/// حتى يُضاف الحقل على الخادم. عرضُ إحداثيات خام للراكب أسوأ من لا شيء.
class _TripCard extends StatelessWidget {
const _TripCard({required this.trip});
@@ -77,25 +88,106 @@ class _TripCard extends StatelessWidget {
_ => (l10n.historyStatusLive, colors.info),
};
// سطر التفاصيل: ما توفّر منه فقط، بفواصل — رحلة ملغاة بلا مسافة لا
// تعرض «0 كم».
final details = [
if (trip.distanceKm != null && trip.distanceKm! > 0)
l10n.rideDistanceKm(trip.distanceKm!.toStringAsFixed(1)),
if (trip.durationMin != null && trip.durationMin! > 0)
l10n.rideEta(trip.durationMin!.round().toString()),
].join(' · ');
return TripzCard(
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: Space.sm),
Expanded(
child: Text(label, style: context.texts.bodyMedium),
),
Text(
Money.format(trip.priceForPassenger ?? trip.quotedFare,
trip.currency),
style: numericStyle(context.texts.titleSmall),
Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: Space.sm),
Expanded(
child: Text(
label,
style: context.texts.bodyMedium?.copyWith(color: color),
),
),
Text(
Money.format(
trip.priceForPassenger ?? trip.quotedFare,
trip.currency,
),
style: numericStyle(context.texts.titleSmall),
),
],
),
if (trip.requestedAt != null) ...[
const SizedBox(height: Space.xs),
Text(
DateFormat.yMMMd().add_jm().format(trip.requestedAt!.toLocal()),
style: context.texts.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
if (details.isNotEmpty || trip.paymentMethod != null) ...[
const SizedBox(height: Space.xs),
Row(
children: [
if (details.isNotEmpty)
Expanded(
child: Text(
details,
style: numericStyle(context.texts.bodySmall).copyWith(
color: context.colors.onSurfaceVariant,
),
),
)
else
const Spacer(),
if (trip.paymentMethod != null)
_Tag(text: _paymentLabel(context, trip.paymentMethod!)),
],
),
],
],
),
);
}
/// طرق الدفع المعروفة تُترجم؛ وأي قيمة جديدة من الخادم تُعرض كما هي بدل
/// أن تختفي.
String _paymentLabel(BuildContext context, String method) {
final l10n = context.l10n;
return switch (method) {
'cash' => l10n.paymentCash,
'wallet' => l10n.paymentWallet,
_ => method,
};
}
}
/// شارة صغيرة — طريقة الدفع في السجلّ.
class _Tag extends StatelessWidget {
const _Tag({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: Space.xs,
vertical: Space.xxs / 2,
),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: Radii.field_,
),
child: Text(text, style: context.texts.labelSmall),
);
}
}
@@ -11,16 +11,26 @@ import '../../../../core/tenant/feature_gate.dart';
import '../../../../core/ui/tripz_button.dart';
import '../../cubit/duty_cubit.dart';
import '../../data/models/trip.dart';
import '../../data/models/route_info.dart';
import 'fare_summary.dart';
import 'nav_eta_strip.dart';
/// الرحلة من طرف السائق: **زر واحد** يدفع آلة الحالات خطوة واحدة.
/// السائق لا يختار الحالة التالية — الكيوبت يشتقّها (docs/38 §4).
class ActiveDutySheet extends StatelessWidget {
const ActiveDutySheet({super.key, required this.trip, required this.busy});
const ActiveDutySheet({
super.key,
required this.trip,
required this.busy,
this.navRoute,
});
final Trip trip;
final bool busy;
/// مسار القيادة إلى الهدف الحالي — منه شريط الوقت والمسافة.
final RouteInfo? navRoute;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
@@ -31,6 +41,13 @@ class ActiveDutySheet extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(_statusText(context), style: context.texts.titleMedium),
if (navRoute != null) ...[
const SizedBox(height: Space.sm),
NavEtaStrip(
minutes: navRoute!.durationMin,
km: navRoute!.distanceKm,
),
],
if (trip.priceForDriver != null) ...[
const SizedBox(height: Space.sm),
// السائق يرى **أرباحه** لا أجرة الراكب: حقلان منفصلان والفرق عمولة.
@@ -42,7 +59,7 @@ class ActiveDutySheet extends StatelessWidget {
label: l10n.chatTitle,
icon: Icons.forum_outlined,
expanded: true,
onPressed: () => context.push('${Routes.chat}?trip=\${trip.id}'),
onPressed: () => context.push('${Routes.chat}?trip=${trip.id}'),
),
],
const SizedBox(height: Space.md),
@@ -4,6 +4,7 @@ import '../../../../core/design/tokens.dart';
import '../../../../core/design/tripz_colors.dart';
import '../../../../core/design/typography.dart';
import '../../data/models/trip.dart';
import 'vehicle_color.dart';
/// بطاقة السائق ولوحة المركبة — أهم ما يبحث عنه الراكب في الشارع، فيأخذ
/// أوضح موضع وأكبر تباين.
@@ -15,25 +16,35 @@ class DriverBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (trip.driverName == null) return const SizedBox.shrink();
// اللون **يُرسم لا يُكتب فقط**: الراكب يمسح الشارع بعينه بحثاً عن سيارة
// بيضاء، لا يقرأ كلمة «أبيض». نمط سيرو (`apply_order_widget.dart:333`)
// يلوّن رمز السيارة بلون المركبة الفعلي — وهذا مقابله.
final carColor = VehicleColor.resolve(
hex: trip.vehicleColorHex,
name: trip.vehicleColor,
);
// سطر المركبة: الموديل واللون معاً بفاصل — أحدهما قد يغيب.
final vehicleLine = [
if (trip.vehicleModel != null && trip.vehicleModel!.isNotEmpty)
trip.vehicleModel!,
if (trip.vehicleColor != null && trip.vehicleColor!.isNotEmpty)
trip.vehicleColor!,
].join(' • ');
return Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: context.colors.primaryContainer,
child: Icon(
Icons.person_rounded,
color: context.colors.onPrimaryContainer,
),
),
_CarAvatar(color: carColor),
const SizedBox(width: Space.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(trip.driverName!, style: context.texts.titleSmall),
if (trip.vehicleModel != null)
if (vehicleLine.isNotEmpty)
Text(
trip.vehicleModel!,
vehicleLine,
style: context.texts.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
@@ -62,6 +73,43 @@ class DriverBadge extends StatelessWidget {
}
}
/// رمز سيارة ملوّن بلون المركبة الحقيقي.
///
/// اللون الفاتح (أبيض/بيج) يختفي على سطح فاتح، فتُرسم حلقة حدّ حوله دائماً —
/// وبها تبقى السيارة البيضاء مقروءة في الوضع النهاري.
class _CarAvatar extends StatelessWidget {
const _CarAvatar({this.color});
final Color? color;
@override
Widget build(BuildContext context) {
final resolved = color ?? context.colors.onSurfaceVariant;
return Container(
width: 48,
height: 48,
decoration: BoxDecoration(
// ‏10% من لون السيارة خلفيةً — يربط الرمز بلونه بلا صراخ.
color: color?.withValues(alpha: 0.12) ??
context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(color: context.colors.outlineVariant),
),
child: Center(
child: Icon(
Icons.directions_car_filled_rounded,
size: Sizes.icon,
color: resolved,
// ظلّ خفيف كي لا تذوب السيارة البيضاء في الخلفية الفاتحة.
shadows: const [
Shadow(color: Color(0x33000000), blurRadius: 2),
],
),
),
);
}
}
/// لوحة مرسومة لا نصّ عادي — تُقرأ من بعيد ومن زاوية.
class _Plate extends StatelessWidget {
const _Plate({required this.plate});
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import '../../../../core/design/tokens.dart';
import '../../../../core/design/tripz_colors.dart';
import '../../../../core/l10n/l10n.dart';
/// «كم بقي للهدف» — الوقت والمسافة من مسار القيادة الحيّ.
///
/// الهدف يتبدّل مع آلة الحالات: الراكب أثناء الاقتراب، ثم وجهته بعد بدء
/// الرحلة (`DutyState.navTarget`). فالشريط نفسه يخدم المرحلتين.
class NavEtaStrip extends StatelessWidget {
const NavEtaStrip({super.key, required this.minutes, required this.km});
final double minutes;
final double km;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: Space.md,
vertical: Space.sm,
),
decoration: BoxDecoration(
color: context.tripzColors.info.withValues(alpha: 0.10),
borderRadius: Radii.card_,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.navigation_outlined,
size: Sizes.iconSm,
color: context.tripzColors.info,
),
const SizedBox(width: Space.xs),
Text(
// أقلّ من دقيقة تُعرض «1»: «0 دقيقة» تعني «وصلتُ» وهي كذبة.
l10n.rideEta(minutes < 1 ? '1' : minutes.round().toString()),
style: context.texts.titleSmall,
),
const SizedBox(width: Space.sm),
Text('·', style: context.texts.titleSmall),
const SizedBox(width: Space.sm),
Text(
l10n.rideDistanceKm(km.toStringAsFixed(1)),
style: context.texts.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
);
}
}
@@ -1,3 +1,5 @@
import 'dart:developer' as dev;
import 'package:flutter/material.dart';
import 'package:intaleq_maps/intaleq_maps.dart' as im;
@@ -83,6 +85,29 @@ class _RideMapState extends State<RideMap> {
static const _fallback = im.LatLng(31.9539, 35.9106); // عمّان
/// حارس الإحداثيات — **بلا هذا يسقط التطبيق أصلاً لا استثناءً في دارت**.
///
/// `std::domain_error` الذي رأيناه (2026-08-05) يأتي من `mbgl::LatLng`
/// في MapLibre الأصلي، وهو يرمي حرفياً عند: `latitude must not be NaN` ·
/// `longitude must not be NaN` · `latitude must be between -90 and 90` ·
/// `longitude must not be infinite`. (السلاسل الأربع موجودة في ثنائي
/// `MapLibre.framework`، وهي مصدر `std::domain_error` الوحيد فيه — أمّا
/// الستايل فبريء: الملفّات الخمسة كلها اجتازت `validateStyleMin` بصفر
/// أخطاء.)
///
/// وهو استثناء **C++ يعبر حدّ المنصّة**، فلا `try/catch` في دارت يمسكه:
/// المنع قبل التمرير هو العلاج الوحيد.
static im.LatLng? _safe(GeoPoint? p, String tag) {
if (p == null) return null;
final ok = p.lat.isFinite && p.lng.isFinite && p.lat.abs() <= 90;
if (!ok) {
dev.log('إحداثية غير صالحة رُفضت [$tag]: ${p.lat}, ${p.lng}',
name: 'RideMap');
return null;
}
return im.LatLng(p.lat, p.lng);
}
/// الستايلان مرفقان في `assets/` — منقولان من سيرو الميداني.
static const _lightStyleAsset = 'assets/style.json';
static const _darkStyleAsset = 'assets/style_dark.json';
@@ -123,9 +148,43 @@ class _RideMapState extends State<RideMap> {
}
void _moveTo(GeoPoint p) {
_controller?.animateCamera(
im.CameraUpdate.newLatLngZoom(im.LatLng(p.lat, p.lng), 15.5),
);
final target = _safe(p, 'moveTo');
if (target == null) return;
_controller?.animateCamera(im.CameraUpdate.newLatLngZoom(target, 15.5));
}
/// يفكّ ترميز المسار **مصفّىً**: مسار مشوّه من الخادم (أو سلسلة مقطوعة)
/// يعطي خطوط عرض خارج ±90، وتلك تصل `mbgl::LatLng` فتُسقط التطبيق أصلاً.
static List<im.LatLng> _decodeRoute(String encoded) {
final all = im.PolylineUtils.decode(encoded);
final good = all
.where((p) =>
p.latitude.isFinite &&
p.longitude.isFinite &&
p.latitude.abs() <= 90)
.toList();
if (good.length != all.length) {
dev.log('نقاط مسار غير صالحة رُفضت: ${all.length - good.length}',
name: 'RideMap');
}
return good;
}
/// المسار كطبقة `Polyline` — مجموعة فارغة ما لم يكن الستايل جاهزاً
/// والنقاط صالحة.
Set<im.Polyline> _routePolylines(Color color) {
final encoded = widget.route?.encodedPoints;
if (!_styleReady || encoded == null || encoded.isEmpty) return const {};
final points = _decodeRoute(encoded);
if (points.length < 2) return const {};
return {
im.Polyline(
polylineId: const im.PolylineId('route'),
points: points,
color: color,
width: 5,
),
};
}
/// يضبط الكاميرا على المسار كاملاً بدل نقطة واحدة.
@@ -135,7 +194,7 @@ class _RideMapState extends State<RideMap> {
void _fitRoute() {
final encoded = widget.route?.encodedPoints;
if (encoded == null || encoded.isEmpty) return;
final points = im.PolylineUtils.decode(encoded);
final points = _decodeRoute(encoded);
if (points.length < 2) return;
var minLat = points.first.latitude, maxLat = points.first.latitude;
@@ -147,6 +206,10 @@ class _RideMapState extends State<RideMap> {
if (p.longitude > maxLng) maxLng = p.longitude;
}
// `mbgl::EdgeInsets` يرمي هو الآخر على NaN (`bottom must not be NaN`)،
// و`bottomInset` مشتقّ من `MediaQuery` فقد يصل صفراً أو NaN في أوّل إطار.
final bottom = widget.bottomInset.isFinite ? widget.bottomInset : 280.0;
_controller?.animateCamera(
im.CameraUpdate.newLatLngBounds(
im.LatLngBounds(
@@ -156,7 +219,7 @@ class _RideMapState extends State<RideMap> {
left: 48,
right: 48,
top: 120,
bottom: widget.bottomInset,
bottom: bottom,
),
);
}
@@ -167,19 +230,19 @@ class _RideMapState extends State<RideMap> {
if (controller == null || _syncing) return;
_syncing = true;
Future<Offset?> px(GeoPoint? p) async {
if (p == null) return null;
final point = await controller.getScreenCoordinate(
im.LatLng(p.lat, p.lng),
);
// `getScreenCoordinate` يعبر إلى `mbgl::LatLng` هو الآخر — نفس الحارس.
Future<Offset?> px(GeoPoint? p, String tag) async {
final target = _safe(p, tag);
if (target == null) return null;
final point = await controller.getScreenCoordinate(target);
return Offset(point.x.toDouble(), point.y.toDouble());
}
try {
final results = await Future.wait([
px(widget.origin),
px(widget.destination),
px(widget.driver),
px(widget.origin, 'origin'),
px(widget.destination, 'destination'),
px(widget.driver, 'driver'),
]);
if (!mounted) return;
setState(() {
@@ -205,22 +268,19 @@ class _RideMapState extends State<RideMap> {
// الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر
// الغرض (docs/26 §2). وتبديله يهدم مديري التعليقات، فيُصفَّر العَلَم.
//
// ⚠️ **ستايل محلّي لا بعيد.** ستايل map-saas البعيد
// (`/api/maps/style.json?theme=light`) فيه **12 طبقة `symbol` تستعمل
// `icon-image` بلا `sprite` معرَّف** — وMapLibre الأصلي يرمي عندها
// `std::domain_error` فيُسقط التطبيق قبل أن يظهر إطار واحد
// (مثبت 2026-08-05). الستايلان المرفقان في `assets/` سليمان: بهما
// `sprite` وبلاطاتهما من `tiles.intaleqapp.com` نفسها.
//
// وهذا حرفياً حلّ سيرو الميداني: انظر
// **ستايل محلّي لا بعيد** — كما في سيرو الميداني حرفياً:
// `apps/rider/lib/views/home/map_widget.dart/google_map_passenger_widget.dart`
// — يمرّر `'assets/style.json'` / `'assets/style_dark.json'` نفسهما، وملفّاه
// يمرّر `'assets/style.json'` / `'assets/style_dark.json'` نفسهما، وملفّاه
// مطابقان لهذين طبقةً بطبقة (لا يختلفان إلا في اسم العلامة التجارية).
// فلا تُعِد هذا السطر إلى `IntaleqStyles.light/obsidian` — جُرّب وأسقط
// التطبيق مرتين.
//
// ومعها نكسب الإقلاع بلا انتظار شبكة — وهو ما يعنيه «الشكل حتمي من أول
// إقلاع» (docs/26 §1).
//
// ملاحظة تصحيحية (2026-08-05): كان مكتوباً هنا أن ستايل map-saas البعيد
// هو سبب `std::domain_error` لأنه بلا `sprite`. **هذا خطأ.** الستايلات
// الخمسة (المحلّيان + البعيد + ستايلا الحزمة) اجتازت
// `validateStyleMin` من `@maplibre/maplibre-gl-style-spec` بصفر أخطاء،
// والسقوط تكرّر بالستايل المحلّي أيضاً. المصدر الحقيقي هو حارس
// الإحداثيات في `mbgl::LatLng`/`mbgl::EdgeInsets` — انظر `_safe` أعلاه.
final styleUrl = isDark ? _darkStyleAsset : _lightStyleAsset;
if (_styleUrl != null && _styleUrl != styleUrl) _styleReady = false;
_styleUrl = styleUrl;
@@ -234,13 +294,17 @@ class _RideMapState extends State<RideMap> {
// فنقفله من هنا بدل الاتّكال على ذلك.
autoCache: false,
initialCameraPosition: im.CameraPosition(
target: widget.origin == null
? _fallback
: im.LatLng(widget.origin!.lat, widget.origin!.lng),
target: _safe(widget.origin, 'initialCamera') ?? _fallback,
zoom: 15,
),
styleUrl: styleUrl,
myLocationEnabled: true,
// نقطة «موقعي» تُفعَّل **بعد** أن يصلنا موقع حقيقي لا عند الإنشاء.
// قبل ذلك يكون `MLNMapView.userLocation.coordinate` هو
// `kCLLocationCoordinate2DInvalid` أي (-180, -180) — وخط عرض -180
// يخترق حارس `mbgl::LatLng` نفسه (`latitude must be between -90
// and 90`). و`origin` لا يصير غير فارغ إلا بعد منح الإذن ووصول
// قراءة فعلية، فهو الشرط الصحيح تماماً.
myLocationEnabled: widget.origin != null,
compassEnabled: false,
zoomControlsEnabled: false,
onMapCreated: _onCreated,
@@ -257,20 +321,9 @@ class _RideMapState extends State<RideMap> {
}
_syncPixels();
},
// **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل.
polylines: _styleReady && widget.route != null &&
widget.route!.encodedPoints.isNotEmpty
? {
im.Polyline(
polylineId: const im.PolylineId('route'),
points: im.PolylineUtils.decode(
widget.route!.encodedPoints,
),
color: colors.mapRoute,
width: 5,
),
}
: const {},
// **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل
// وبتصفية الإحداثيات (نقاط المسار تعبر إلى `mbgl::LatLng` أيضاً).
polylines: _routePolylines(colors.mapRoute),
),
// ── الطبقة الفلاترية: نقاط وأسماء وسائق ──────────────────────────
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
/// يحوّل لون المركبة — hex أو اسماً — إلى لون يُرسم به.
///
/// الخادم غير متّسق عمداً: `vehicles` عندها `color_hex` بينما حدث `assigned`
/// يرسل `color` اسماً (`trips.service.ts:427`). فالمُحوِّل يقبل الاثنين
/// ويُقدّم الـhex لأنه دقيق.
///
/// الأسماء تُطابَق **بالعربية والإنجليزية معاً**: السائقون يُدخلونها بلغتهم،
/// وقائمة إنجليزية وحدها تعني رمادياً افتراضياً لنصف الأسطول.
class VehicleColor {
const VehicleColor._();
/// اللون الذي يُرسم به رمز السيارة، أو `null` إن تعذّرت المطابقة —
/// و`null` تعني «لا تلوّن» لا «لوّن رمادياً»، فالرمادي لونُ سيارةٍ حقيقي
/// ولا يصحّ أن يعني «مجهول».
static Color? resolve({String? hex, String? name}) =>
_fromHex(hex) ?? _fromName(name);
static Color? _fromHex(String? hex) {
if (hex == null) return null;
var raw = hex.replaceFirst('#', '').trim();
if (raw.length == 3) {
// ‏`#f00` → `#ff0000`: صيغة مختصرة شائعة في لوحات الإدارة.
raw = raw.split('').map((c) => '$c$c').join();
}
if (raw.length == 6) raw = 'FF$raw';
if (raw.length != 8) return null;
final value = int.tryParse(raw, radix: 16);
return value == null ? null : Color(value);
}
static const _names = <String, Color>{
'white': Colors.white,
'أبيض': Colors.white,
'black': Colors.black,
'أسود': Colors.black,
'silver': Color(0xFFC0C0C0),
'فضي': Color(0xFFC0C0C0),
'grey': Color(0xFF808080),
'gray': Color(0xFF808080),
'رمادي': Color(0xFF808080),
'red': Color(0xFFD32F2F),
'أحمر': Color(0xFFD32F2F),
'blue': Color(0xFF1976D2),
'أزرق': Color(0xFF1976D2),
'green': Color(0xFF388E3C),
'أخضر': Color(0xFF388E3C),
'yellow': Color(0xFFFBC02D),
'أصفر': Color(0xFFFBC02D),
'orange': Color(0xFFF57C00),
'برتقالي': Color(0xFFF57C00),
'brown': Color(0xFF6D4C41),
'بني': Color(0xFF6D4C41),
'beige': Color(0xFFE8DCC4),
'بيج': Color(0xFFE8DCC4),
'gold': Color(0xFFC9A227),
'ذهبي': Color(0xFFC9A227),
'navy': Color(0xFF1A2340),
'كحلي': Color(0xFF1A2340),
};
static Color? _fromName(String? name) {
if (name == null) return null;
return _names[name.trim().toLowerCase()];
}
}