Update codebase
This commit is contained in:
@@ -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()];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user