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
@@ -12,6 +12,7 @@ import '../data/models/geo_point.dart';
import '../data/models/place.dart';
import '../data/models/ride_type.dart';
import '../data/models/trip.dart';
import '../data/recent_places_store.dart';
import '../data/trip_repository.dart';
import 'ride_state.dart';
@@ -25,16 +26,19 @@ class RideCubit extends Cubit<RideState> {
required MapsRepository maps,
required LocationService location,
required RealtimeService realtime,
required RecentPlacesStore recents,
}) : _trips = trips,
_maps = maps,
_location = location,
_realtime = realtime,
_recents = recents,
super(const RideState());
final TripRepository _trips;
final MapsRepository _maps;
final LocationService _location;
final RealtimeService _realtime;
final RecentPlacesStore _recents;
StreamSubscription<RealtimeEvent>? _events;
Timer? _searchTimeout;
@@ -116,8 +120,6 @@ class RideCubit extends Cubit<RideState> {
void couponChanged(String code) => emit(state.copyWith(couponCode: code));
void openPlanner() => emit(state.copyWith(phase: RidePhase.planning));
void collapsePlanner() => emit(state.copyWith(phase: RidePhase.idle));
/// تبديل النوع **لا يُطلق نداءً**: كل الأنواع مسعَّرة مسبقاً في [_priceAll].
@@ -150,7 +152,12 @@ class RideCubit extends Cubit<RideState> {
});
}
/// الأماكن الأخيرة — تُقرأ في الشاشة، وتُحدَّث عند كل اختيار وجهة.
List<Place> get recentPlaces => _recents.items;
Future<void> pickPlace(Place place, PickTarget target) async {
// الوجهة وحدها تُحفظ: «الأخيرة» تعني أين ذهبتُ لا من أين انطلقت.
if (target == PickTarget.destination) unawaited(_recents.add(place));
emit(state.copyWith(
origin: target == PickTarget.origin ? place.point : null,
originLabel: target == PickTarget.origin ? place.label : null,
@@ -416,6 +423,29 @@ class RideCubit extends Cubit<RideState> {
));
}
// ── المحاكاة ───────────────────────────────────────────────────────────
/// عَلَم بناء: `--dart-define=SIMULATE_TRIP=true`.
///
/// **`const` عمداً** — في بناء الإنتاج يصير الشرط ثابتاً زمن الترجمة
/// فيُحذف كل نداء محاكاة من الـbinary (نفس منطق `Features` في docs/23 §5).
static const bool simulationEnabled =
bool.fromEnvironment('SIMULATE_TRIP');
/// مدخل المحاكي — يمرّ بـ[_attachTrip] نفسه الذي يمرّ به `trip:update`
/// الحقيقي، فالشاشات لا تعرف الفرق ولا يُختبر مسارٌ ثانٍ.
void injectSimulatedTrip(Trip trip) {
if (!simulationEnabled || isClosed) return;
_stopSearchGuards();
_attachTrip(trip);
}
void injectSimulatedDriverLocation(GeoPoint point) {
if (!simulationEnabled || isClosed) return;
// نفس مسار الحدث الحقيقي — فمسار الاقتراب يُحسب في المحاكاة أيضاً.
_onDriverMoved(point);
}
void _onRealtime(RealtimeEvent event) {
switch (event.name) {
case RealtimeEvent.tripUpdate:
@@ -427,10 +457,62 @@ class RideCubit extends Cubit<RideState> {
final lat = event.data['lat'];
final lng = event.data['lng'];
if (lat is num && lng is num) {
emit(state.copyWith(
driverLocation: GeoPoint(lat.toDouble(), lng.toDouble()),
));
_onDriverMoved(GeoPoint(lat.toDouble(), lng.toDouble()));
}
}
}
// ── مسار الاقتراب ──────────────────────────────────────────────────────
/// أقلّ إزاحة تستدعي إعادة رسم المسار، بالدرجات (~50 متراً).
///
/// السائق يبثّ موقعه كل بضع ثوانٍ؛ نداءُ مسارٍ عند كل بثّة يعني عشرات
/// النداءات في الدقيقة بلا فرق مرئي. وهذا الحدّ يلتقط **تغيّر الاتجاه**
/// (انعطاف فعلي) ويتجاهل رجفة GPS في المكان.
static const double _redrawThreshold = 0.0005;
GeoPoint? _lastPickupFrom;
bool _pickupRouting = false;
void _onDriverMoved(GeoPoint point) {
emit(state.copyWith(driverLocation: point));
// المسار يُرسم أثناء الاقتراب فقط — بعد بدء الرحلة صار مسار الرحلة هو
// المعروض، ورسم خطٍّ للسائق إلى راكبٍ في سيارته بلا معنى.
final approaching = const {
TripStatus.assigned,
TripStatus.driverArriving,
}.contains(state.trip?.status);
if (!approaching) {
if (state.pickupRoute != null) {
emit(state.copyWith(clearPickupRoute: true));
}
return;
}
final rider = state.origin;
if (rider == null || _pickupRouting) return;
final last = _lastPickupFrom;
final moved = last == null ||
(point.lat - last.lat).abs() > _redrawThreshold ||
(point.lng - last.lng).abs() > _redrawThreshold;
if (!moved) return;
_lastPickupFrom = point;
unawaited(_routePickup(point, rider));
}
Future<void> _routePickup(GeoPoint from, GeoPoint to) async {
_pickupRouting = true;
try {
final route = await _maps.route(from, to);
if (!isClosed) emit(state.copyWith(pickupRoute: route));
} on ApiException {
// تعذّر المسار لا يُخفي السائق ولا يُعرض خطأً — البطاقة تبقى بلا
// وقتٍ ومسافة، والمحاولة تتكرّر مع الحركة التالية.
} finally {
_pickupRouting = false;
}
}
}
@@ -58,6 +58,7 @@ class RideState extends Equatable {
this.quotes = const {},
this.trip,
this.driverLocation,
this.pickupRoute,
this.searchResults = const [],
this.searching = false,
this.busy = false,
@@ -95,6 +96,12 @@ class RideState extends Equatable {
/// موقع السائق الحيّ من `driver:location`.
final GeoPoint? driverLocation;
/// مسار **السائق ← الراكب** أثناء الاقتراب، ومنه الوقت والمسافة المعروضان.
///
/// منفصل عن [route] (مسار الرحلة نفسها) لأنهما يُرسمان معاً بلونين
/// مختلفين، ولأن هذا يُعاد حسابه مع حركة السائق بينما ذاك ثابت.
final RouteInfo? pickupRoute;
final List<Place> searchResults;
/// بحث نصّي جارٍ — منفصل عن [busy] كي لا يقفل مؤشّرُ البحثِ الشاشةَ كلها.
@@ -133,6 +140,7 @@ class RideState extends Equatable {
Map<String, FareQuote>? quotes,
Trip? trip,
GeoPoint? driverLocation,
RouteInfo? pickupRoute,
List<Place>? searchResults,
bool? searching,
bool? busy,
@@ -144,6 +152,7 @@ class RideState extends Equatable {
bool clearRoute = false,
bool clearTrip = false,
bool clearCamera = false,
bool clearPickupRoute = false,
}) {
return RideState(
phase: phase ?? this.phase,
@@ -161,6 +170,9 @@ class RideState extends Equatable {
quotes: clearRoute ? const {} : (quotes ?? this.quotes),
trip: clearTrip ? null : (trip ?? this.trip),
driverLocation: driverLocation ?? this.driverLocation,
pickupRoute: clearPickupRoute || clearRoute
? null
: (pickupRoute ?? this.pickupRoute),
searchResults: searchResults ?? this.searchResults,
searching: searching ?? this.searching,
busy: busy ?? this.busy,
@@ -186,6 +198,7 @@ class RideState extends Equatable {
quotes,
trip,
driverLocation,
pickupRoute,
searchResults,
searching,
busy,
@@ -0,0 +1,124 @@
import 'dart:async';
import '../data/models/geo_point.dart';
import '../data/models/trip.dart';
import 'ride_cubit.dart';
/// محاكي رحلة — يمشّي الرحلة عبر حالاتها **بلا تطبيق سائق**.
///
/// ## لماذا يوجد
///
/// شاشات ما بعد الطلب (`assigned` ← `driver_arriving` ← `driver_arrived` ←
/// `in_progress` ← `completed`) لا تظهر إلا حين يقبل سائقٌ حقيقي ويتقدّم في
/// الرحلة. ومراجعتها بصرياً كانت تعني تشغيل تطبيقين وحسابين وسائقاً متاحاً —
/// فبقيت غير مُراجَعة.
///
/// ## ما يفعله وما لا يفعله
///
/// **يحقن `Trip` في `RideCubit` عبر مسار الأحداث نفسه** الذي يسلكه
/// `trip:update` القادم من الخادم — أي أن الشاشات لا تعرف أنها محاكاة، ونفس
/// منطق `_attachTrip` ونفس الانتقالات تُختبر. لا شاشة وهمية ولا فرع `if
/// (simulated)` في أي ودجت.
///
/// **لا يلمس الخادم**: لا رحلة تُنشأ ولا سائق يُحجز ولا مال يُحرَّك. ولذلك
/// هو أداة *مراجعة شكل* لا اختبار تكامل — ما يثبت هنا لا يثبت أن الخادم
/// يرسل نفس التتابع.
///
/// ## الأمان
///
/// محروس بـ[RideCubit.simulationEnabled] المشتقّ من `--dart-define`، وهو
/// `false` افتراضياً. في بناء الإنتاج الشرط ثابتٌ زمن الترجمة فيُحذف الكود.
class TripSimulator {
TripSimulator(this._cubit);
final RideCubit _cubit;
Timer? _timer;
/// الخطوات بترتيبها الواقعي ومدّة كل واحدة — مضغوطة كي تُراجَع الشاشات
/// في نصف دقيقة لا في رحلة كاملة.
static const _steps = <(TripStatus, Duration)>[
(TripStatus.assigned, Duration(seconds: 4)),
(TripStatus.driverArriving, Duration(seconds: 5)),
(TripStatus.driverArrived, Duration(seconds: 4)),
(TripStatus.inProgress, Duration(seconds: 6)),
(TripStatus.completed, Duration(seconds: 3)),
];
static const _driverName = 'سائق تجريبي';
static const _driverPhone = '0790000000';
bool get isRunning => _timer != null;
/// يبدأ من الحالة الحالية للكيوبت: المصدر والوجهة والأجرة الحقيقية إن
/// وُجدت، وإلا قيم معقولة كي تُملأ الشاشة.
void start() {
if (!RideCubit.simulationEnabled || isRunning) return;
final state = _cubit.state;
final origin = state.origin ?? const GeoPoint(31.9539, 35.9106);
final destination = state.destination ??
GeoPoint(origin.lat + 0.012, origin.lng + 0.012);
// المبالغ نصوص في `Trip` (docs/38 §12.2) بينما `FareQuote.total` رقم —
// التحويل هنا لا في الودجت.
final fare = state.quote?.total?.toStringAsFixed(2) ?? '2.50';
final currency = state.quote?.currency.isNotEmpty == true
? state.quote!.currency
: 'JOD';
var index = 0;
// موقع السائق يتحرّك نحو الراكب ثم نحو الوجهة — بلا حركة تبدو الخريطة
// معطّلة وتضيع نصف الفائدة من المراجعة.
var driver = GeoPoint(origin.lat - 0.006, origin.lng - 0.006);
void emitStep() {
final (status, _) = _steps[index];
_cubit.injectSimulatedTrip(
Trip(
id: 'sim-trip',
status: status,
origin: origin,
destination: destination,
quotedFare: fare,
priceForPassenger: fare,
currency: currency,
driverName: _driverName,
driverPhone: _driverPhone,
vehiclePlate: '12-3456',
vehicleModel: 'Toyota Corolla',
vehicleColor: 'أبيض',
vehicleColorHex: '#FFFFFF',
driverRating: '4.8',
),
);
// بعد بدء الرحلة يتّجه السائق للوجهة لا للراكب.
final target = status == TripStatus.inProgress ? destination : origin;
driver = GeoPoint(
driver.lat + (target.lat - driver.lat) * 0.5,
driver.lng + (target.lng - driver.lng) * 0.5,
);
_cubit.injectSimulatedDriverLocation(driver);
}
void schedule() {
final (_, delay) = _steps[index];
_timer = Timer(delay, () {
index++;
if (index >= _steps.length) {
_timer = null;
return;
}
emitStep();
schedule();
});
}
emitStep();
schedule();
}
void stop() {
_timer?.cancel();
_timer = null;
}
}
@@ -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?,
);
}
@@ -0,0 +1,74 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'models/geo_point.dart';
import 'models/place.dart';
/// الوجهات الأخيرة — **محلّية**، كما في سيرو
/// (`rp_favorites.dart: RpRecentsRow` + جدول `recentLocations`).
///
/// أكثر رحلات الراكب تتكرّر: البيت، العمل، بيت الأهل. وبلا هذا يعيد كتابة
/// الاسم نفسه كل مرة — وهي أكبر خطوة ضائعة في الشاشة الرئيسية.
///
/// محلّي لا خادمي عن قصد: `GET /trips/mine` يرجّع رحلات لا **أماكن**
/// (بلا اسم الوجهة)، فبناء «الأخيرة» عليه يعطي أسطراً بلا عناوين.
class RecentPlacesStore {
static const _key = 'recent_places';
/// خمسة تكفي صفّاً أفقياً واحداً؛ أكثر منها يصير قائمةً تُتصفَّح لا تُلمح.
static const _max = 5;
List<Place> _items = const [];
List<Place> get items => _items;
Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_key);
if (raw == null || raw.isEmpty) return;
try {
final list = jsonDecode(raw) as List<dynamic>;
_items = list
.whereType<Map<String, dynamic>>()
.map(_decode)
.toList(growable: false);
} catch (_) {
_items = const [];
}
}
/// يضيف المكان في الصدارة، ويزيح تكراره إن سبق — لا يتكدّس نفس المكان.
Future<void> add(Place place) async {
if (place.label.isEmpty) return;
_items = [
place,
..._items.where((p) => p.id != place.id || p.label != place.label),
].take(_max).toList(growable: false);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_key,
jsonEncode(_items.map(_encode).toList(growable: false)),
);
}
static Map<String, dynamic> _encode(Place p) => {
'id': p.id,
'name': p.name,
'address': p.address,
'category': p.category,
'lat': p.point.lat,
'lng': p.point.lng,
};
static Place _decode(Map<String, dynamic> json) => Place(
id: json['id']?.toString() ?? '',
name: json['name'] as String? ?? '',
address: json['address'] as String? ?? '',
category: json['category'] as String? ?? '',
point: GeoPoint(
(json['lat'] as num?)?.toDouble() ?? 0,
(json['lng'] as num?)?.toDouble() ?? 0,
),
);
}
@@ -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),
);
}
}
@@ -10,10 +10,13 @@ import '../../../core/ui/status_banner.dart';
import '../../../core/ui/tripz_button.dart';
import '../cubit/ride_cubit.dart';
import '../cubit/ride_state.dart';
import '../cubit/trip_simulator.dart';
import '../data/models/geo_point.dart';
import '../data/models/route_info.dart';
import 'widgets/active_trip_sheet.dart';
import 'widgets/center_pin.dart';
import 'widgets/confirm_sheet.dart';
import 'widgets/place_search_dialog.dart';
import 'widgets/planner_sheet.dart';
import 'widgets/ride_map.dart';
import 'widgets/searching_sheet.dart';
@@ -30,8 +33,16 @@ class RidePage extends StatefulWidget {
State<RidePage> createState() => _RidePageState();
}
/// نصيب الورقة السفلية من ارتفاع الشاشة — الخريطة تأخذ ما تبقّى (~67%).
///
/// قرار المالك 2026-08-05: الخريطة تُرفع عن القاع بالثلث كي تبقى مساحة
/// الورقة خالصة لها. سيرو يرفعها بـ20%، وتريبز بـ33% لأن ورقته تعرض
/// خطوطاً أكثر (الخط الزمني + الأنواع + السعر).
const double _sheetFraction = 0.33;
class _RidePageState extends State<RidePage> with WidgetsBindingObserver {
late final RideCubit _cubit = sl<RideCubit>()..init();
late final TripSimulator _simulator = TripSimulator(_cubit);
GeoPoint? _mapCenter;
@override
@@ -43,6 +54,7 @@ class _RidePageState extends State<RidePage> with WidgetsBindingObserver {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_simulator.stop();
_cubit.close();
super.dispose();
}
@@ -65,47 +77,82 @@ class _RidePageState extends State<RidePage> with WidgetsBindingObserver {
body: Stack(
children: [
// ── الخريطة ────────────────────────────────────────────────
BlocSelector<RideCubit, RideState, _MapData>(
selector: (s) => _MapData(
origin: s.origin,
destination: s.destination,
driver: s.driverLocation,
route: s.route,
camera: s.cameraTarget,
originLabel: s.originLabel,
destinationLabel: s.destinationLabel,
),
builder: (context, data) => RideMap(
origin: data.origin,
destination: data.destination,
driver: data.driver,
route: data.route,
cameraTarget: data.camera,
onCameraIdle: (p) => _mapCenter = p,
// الورقة تغطّي نحو ثلث الشاشة — يُحسب منه إطار المسار كي لا
// يختفي نصفه تحتها.
bottomInset: MediaQuery.sizeOf(context).height * 0.34,
originLabel: data.originLabel,
destinationLabel: data.destinationLabel,
),
),
//
// **مرفوعة عن القاع بمقدار [_sheetFraction]** لا مالئةً الشاشة:
// الثلث السفلي مساحة الورقة وحدها، فلا تغطّي الورقةُ الخريطةَ
// (نمط سيرو — `Positioned(bottom: Get.height * .2, top: 0)` في
// `google_map_passenger_widget.dart`).
//
// والدبّوس **داخل نفس المنطقة** لا في الـ`Stack` الخارجي: مركز
// الكاميرا (`onCameraIdle`) هو مركز ودجت الخريطة، فلو بقي
// الدبّوس متمركزاً على الشاشة لأشار إلى نقطة غير التي تُؤكَّد.
Positioned(
top: 0,
left: 0,
right: 0,
bottom: MediaQuery.sizeOf(context).height * _sheetFraction,
child: Stack(
children: [
BlocSelector<RideCubit, RideState, _MapData>(
selector: (s) => _MapData(
origin: s.origin,
destination: s.destination,
driver: s.driverLocation,
route: s.route,
pickupRoute: s.pickupRoute,
camera: s.cameraTarget,
originLabel: s.originLabel,
destinationLabel: s.destinationLabel,
),
builder: (context, data) => RideMap(
origin: data.origin,
destination: data.destination,
driver: data.driver,
route: data.route,
pickupRoute: data.pickupRoute,
cameraTarget: data.camera,
onCameraIdle: (p) => _mapCenter = p,
// الورقة لم تعد تغطّي الخريطة — تبقى حشوة تنفّس فقط
// كي لا يلتصق المسار بحافّة الخريطة السفلية.
bottomInset: Space.xxl,
originLabel: data.originLabel,
destinationLabel: data.destinationLabel,
),
),
// ── دبّوس الاختيار ─────────────────────────────────────────
BlocSelector<RideCubit, RideState, bool>(
selector: (s) => s.phase == RidePhase.pickingOnMap,
builder: (context, picking) =>
picking ? const CenterPin() : const SizedBox.shrink(),
// ── دبّوس الاختيار ─────────────────────────────────────
BlocSelector<RideCubit, RideState, bool>(
selector: (s) => s.phase == RidePhase.pickingOnMap,
builder: (context, picking) =>
picking ? const CenterPin() : const SizedBox.shrink(),
),
],
),
),
// زر القائمة — يطفو فوق الخريطة، ويحترم المنطقة الآمنة.
SafeArea(
child: Padding(
padding: Space.page,
child: Builder(
builder: (context) => _MapFab(
icon: Icons.menu_rounded,
onTap: Scaffold.of(context).openDrawer,
),
child: Row(
children: [
Builder(
builder: (context) => _MapFab(
icon: Icons.menu_rounded,
onTap: Scaffold.of(context).openDrawer,
),
),
// زرّ المحاكاة — يظهر فقط مع
// `--dart-define=SIMULATE_TRIP=true`. الشرط `const` فيُحذف
// هو وما تحته من بناء الإنتاج.
if (RideCubit.simulationEnabled) ...[
const SizedBox(width: Space.xs),
_MapFab(
icon: Icons.play_arrow_rounded,
onTap: () => _simulator.start(),
),
],
],
),
),
),
@@ -137,6 +184,7 @@ class _MapData {
this.destination,
this.driver,
this.route,
this.pickupRoute,
this.camera,
this.originLabel = '',
this.destinationLabel = '',
@@ -145,7 +193,8 @@ class _MapData {
final GeoPoint? origin;
final GeoPoint? destination;
final GeoPoint? driver;
final dynamic route;
final RouteInfo? route;
final RouteInfo? pickupRoute;
final GeoPoint? camera;
final String originLabel;
final String destinationLabel;
@@ -157,6 +206,7 @@ class _MapData {
other.destination == destination &&
other.driver == driver &&
other.route == route &&
other.pickupRoute == pickupRoute &&
other.camera == camera &&
other.originLabel == originLabel &&
other.destinationLabel == destinationLabel;
@@ -167,6 +217,7 @@ class _MapData {
destination,
driver,
route,
pickupRoute,
camera,
originLabel,
destinationLabel,
@@ -190,11 +241,20 @@ class _Sheet extends StatelessWidget {
RidePhase.pickingOnMap => _PickConfirm(onConfirm: onConfirmPick),
RidePhase.confirming => ConfirmSheet(state: state),
RidePhase.searching => const SearchingSheet(),
RidePhase.active => ActiveTripSheet(trip: state.trip!),
RidePhase.active => ActiveTripSheet(
trip: state.trip!,
pickupRoute: state.pickupRoute,
),
};
return Container(
width: double.infinity,
// الحدّ الأدنى = المساحة المحجوزة للورقة تحت الخريطة، وإلا ظهر فراغُ
// الـ`Scaffold` بينهما في المراحل قصيرة المحتوى (`idle` مثلاً).
// وهو حدّ أدنى لا ثابت: `active` و`confirming` يكبران فوقه بحرّية.
constraints: BoxConstraints(
minHeight: MediaQuery.sizeOf(context).height * _sheetFraction,
),
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: Radii.sheetTop,
@@ -299,21 +359,32 @@ class _Collapsed extends StatelessWidget {
),
const SizedBox(height: Space.sm),
],
// حقل بحث لا زرّ: الضغط عليه يفتح المخطّط والكيبورد معاً.
// حقل بحث لا زرّ: الضغط عليه يفتح بحث الأماكن بملء الشاشة مباشرةً
// (docs/40 §2) — لا ورقةً وسيطة ثم بحثاً داخلها.
//
// الحشوة سخيّة عمداً: الثلث السفلي صار مساحة الورقة وحدها بعد رفع
// الخريطة، وحقلٌ نحيف وسط فراغ يبدو مقصوصاً لا أنيقاً.
Material(
color: context.colors.surfaceContainerHighest,
borderRadius: Radii.card_,
child: InkWell(
onTap: cubit.openPlanner,
onTap: () => PlaceSearchDialog.show(
context,
target: PickTarget.destination,
),
borderRadius: Radii.card_,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Space.md,
vertical: Space.sm,
vertical: Space.md,
),
child: Row(
children: [
Icon(Icons.search_rounded, color: context.colors.primary),
Icon(
Icons.search_rounded,
size: Sizes.icon,
color: context.colors.primary,
),
const SizedBox(width: Space.sm),
Expanded(
child: Text(
@@ -323,16 +394,63 @@ class _Collapsed extends StatelessWidget {
),
),
),
Icon(
Icons.chevron_left_rounded,
color: context.colors.onSurfaceVariant,
),
],
),
),
),
),
// الوجهات الأخيرة — نمط سيرو (`rp_favorites.dart: RpRecentsRow`).
// أكثر الرحلات تتكرّر، وبلا هذا يعيد الراكب كتابة الاسم نفسه كل مرة.
const _RecentsRow(),
],
);
}
}
/// صفّ أفقي للوجهات الأخيرة — يختفي كلياً حين لا توجد، فلا يترك فراغاً.
class _RecentsRow extends StatelessWidget {
const _RecentsRow();
@override
Widget build(BuildContext context) {
final cubit = context.read<RideCubit>();
final places = cubit.recentPlaces;
if (places.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: Space.sm),
child: SizedBox(
height: Sizes.touchTarget,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: places.length,
separatorBuilder: (context, i) => const SizedBox(width: Space.xs),
itemBuilder: (context, i) {
final place = places[i];
return ActionChip(
avatar: Icon(
Icons.history_rounded,
size: Sizes.iconSm,
color: context.colors.onSurfaceVariant,
),
label: Text(
place.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onPressed: () => cubit.pickPlace(place, PickTarget.destination),
);
},
),
),
);
}
}
class _PickConfirm extends StatelessWidget {
const _PickConfirm({required this.onConfirm});
@@ -1,26 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/build_config.dart';
import '../../../../core/design/tokens.dart';
import '../../../../core/design/tripz_colors.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/router.dart';
import '../../../../core/tenant/feature_gate.dart';
import '../../../../core/ui/tripz_button.dart';
import '../../cubit/ride_cubit.dart';
import '../../data/models/route_info.dart';
import '../../data/models/trip.dart';
import 'cancel_sheet.dart';
import 'driver_badge.dart';
import 'fare_summary.dart';
import 'trip_contact_row.dart';
/// بطاقة الرحلة الجارية: حالة واضحة ثم بيانات السائق ثم فعل واحد.
class ActiveTripSheet extends StatelessWidget {
const ActiveTripSheet({super.key, required this.trip});
const ActiveTripSheet({super.key, required this.trip, this.pickupRoute});
final Trip trip;
/// مسار اقتراب السائق — منه شريط الوقت والمسافة.
final RouteInfo? pickupRoute;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
@@ -29,22 +30,27 @@ class ActiveTripSheet extends StatelessWidget {
TripStatus.driverArriving,
}.contains(trip.status);
// الشريط أثناء الاقتراب وحده: بعد ركوب الراكب لم يعد «كم بقي ليصل» سؤالاً.
final eta = const {
TripStatus.assigned,
TripStatus.driverArriving,
}.contains(trip.status)
? pickupRoute
: null;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(_statusText(context), style: context.texts.titleMedium),
if (eta != null) ...[
const SizedBox(height: Space.sm),
TripEtaStrip(minutes: eta.durationMin, km: eta.distanceKm),
],
const SizedBox(height: Space.md),
DriverBadge(trip: trip),
if (Features.chat && context.tenantHas('chat')) ...[
const SizedBox(height: Space.sm),
TripzButton.secondary(
label: l10n.chatTitle,
icon: Icons.forum_outlined,
expanded: true,
onPressed: () => context.push('${Routes.chat}?trip=\${trip.id}'),
),
],
const SizedBox(height: Space.sm),
TripContactRow(trip: trip),
if (trip.priceForPassenger != null) ...[
const SizedBox(height: Space.md),
FareSummary(trip: trip),
@@ -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,219 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/design/tokens.dart';
import '../../../../core/design/tripz_colors.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/ui/tripz_text_field.dart';
import '../../cubit/ride_cubit.dart';
import '../../cubit/ride_state.dart';
import '../../data/models/place.dart';
import 'route_timeline.dart';
/// بحث الأماكن بملء الشاشة — نمط سيرو
/// (`apps/rider/lib/views/home/route_planner/rp_search_dialog.dart`):
/// ديالوج منفصل عن الورقة السفلية بدل حقل مضغوط داخلها (docs/40 §2).
///
/// [target] يحدّد الحقل الذي يُملأ عند الاختيار — البحث نفسه (`searchPlaces`)
/// مشترك، فلا حاجة لنسخة ثانية من الكيوبت لكل اتجاه.
class PlaceSearchDialog extends StatefulWidget {
const PlaceSearchDialog({super.key, required this.target});
final PickTarget target;
static Future<void> show(BuildContext context, {required PickTarget target}) {
final cubit = context.read<RideCubit>();
return showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: Colors.black54,
transitionDuration: Motion.page,
pageBuilder: (context, animation, secondaryAnimation) =>
BlocProvider.value(
value: cubit,
child: PlaceSearchDialog(target: target),
),
transitionBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)),
child: child,
);
},
);
}
@override
State<PlaceSearchDialog> createState() => _PlaceSearchDialogState();
}
class _PlaceSearchDialogState extends State<PlaceSearchDialog> {
final _query = TextEditingController();
@override
void dispose() {
// البحث ينتهي مع الديالوج — لا يبقى مصدر نتائج قديمة معلَّقاً في الحالة
// حين يُفتح البحث ثانيةً بحقل فارغ.
context.read<RideCubit>().searchPlaces('');
_query.dispose();
super.dispose();
}
IconData _iconFor(String category) => switch (category) {
'street' || 'road' => Icons.route_outlined,
'restaurant' || 'cafe' || 'food' => Icons.restaurant_outlined,
'hospital' || 'clinic' || 'pharmacy' => Icons.local_hospital_outlined,
'school' || 'university' => Icons.school_outlined,
'mall' || 'shop' || 'store' => Icons.storefront_outlined,
'mosque' => Icons.mosque_outlined,
'hotel' => Icons.hotel_outlined,
_ => Icons.place_outlined,
};
void _select(BuildContext context, Place place) {
Navigator.of(context).pop();
context.read<RideCubit>().pickPlace(place, widget.target);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final hint = widget.target == PickTarget.origin
? l10n.rideOrigin
: l10n.rideSearchHint;
return Scaffold(
backgroundColor: context.colors.surface,
body: SafeArea(
child: Padding(
padding: Space.page,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.of(context).pop(),
),
Expanded(
child: Text(l10n.rideWhereTo, style: context.texts.titleMedium),
),
],
),
const SizedBox(height: Space.sm),
BlocSelector<RideCubit, RideState, ({String o, String d, int stops})>(
selector: (s) => (o: s.originLabel, d: s.destinationLabel, stops: s.stops.length),
builder: (context, r) => RouteTimeline(
originLabel: r.o,
destinationLabel: r.d,
stopCount: r.stops,
onTapOrigin: () {
if (widget.target != PickTarget.origin) {
Navigator.of(context).pop();
PlaceSearchDialog.show(context, target: PickTarget.origin);
}
},
onTapDestination: () {
if (widget.target != PickTarget.destination) {
Navigator.of(context).pop();
PlaceSearchDialog.show(context, target: PickTarget.destination);
}
},
),
),
const SizedBox(height: Space.md),
TripzTextField(
controller: _query,
hint: hint,
prefixIcon: Icons.search_rounded,
autofocus: true,
onChanged: context.read<RideCubit>().searchPlaces,
suffix: _query.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () {
_query.clear();
context.read<RideCubit>().searchPlaces('');
setState(() {});
},
),
),
const SizedBox(height: Space.sm),
Expanded(
child: BlocBuilder<RideCubit, RideState>(
builder: (context, state) {
if (state.searching) {
return const Padding(
padding: EdgeInsets.only(top: Space.md),
child: LinearProgressIndicator(minHeight: 2),
);
}
if (_query.text.trim().length >= 2 &&
state.searchResults.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.only(top: Space.lg),
child: Text(
l10n.rideSearchNoResults,
textAlign: TextAlign.center,
style: context.texts.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
);
}
// حقل فارغ ⇒ الأخيرة بدل شاشة بيضاء — الراكب غالباً
// ذاهبٌ لمكان سبق أن ذهب إليه.
final results = state.searchResults.isEmpty &&
_query.text.trim().isEmpty
? context.read<RideCubit>().recentPlaces
: state.searchResults;
return ListView.separated(
padding: const EdgeInsets.only(top: Space.xs),
itemCount: results.length,
separatorBuilder: (context, i) => const Divider(height: 1),
itemBuilder: (context, i) {
final place = results[i];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
radius: 18,
backgroundColor: context.colors.surfaceContainerHighest,
child: Icon(
_iconFor(place.category),
size: Sizes.iconSm,
color: context.colors.onSurfaceVariant,
),
),
title: Text(place.label, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: place.address.isEmpty
? null
: Text(place.address, maxLines: 1, overflow: TextOverflow.ellipsis),
onTap: () => _select(context, place),
);
},
);
},
),
),
TextButton.icon(
icon: const Icon(Icons.map_outlined),
label: Text(l10n.ridePickOnMap),
onPressed: () {
Navigator.of(context).pop();
context.read<RideCubit>().startMapPick(widget.target);
},
),
],
),
),
),
);
}
}
@@ -2,52 +2,25 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/design/tokens.dart';
import '../../../../core/design/tripz_colors.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/ui/tripz_button.dart';
import '../../../../core/ui/tripz_text_field.dart';
import '../../cubit/ride_cubit.dart';
import '../../cubit/ride_state.dart';
import 'place_search_dialog.dart';
import 'route_timeline.dart';
/// المخطّط الموسّع بترتيبه المثبت ميدانياً (docs/39 §3):
/// الخط الزمني للمسار ← البحث ← تحديد من الخريطة.
class PlannerSheet extends StatefulWidget {
/// المخطّط الموسّع: الخط الزمني للمسار فقط — البحث بحدّ ذاته انتقل إلى
/// [PlaceSearchDialog] بملء الشاشة (docs/40 §2). الضغط على أي حقل في الخط
/// الزمني يفتح ذلك الديالوج بدل بحث مضغوط داخل هذه الورقة.
class PlannerSheet extends StatelessWidget {
const PlannerSheet({super.key, required this.state});
final RideState state;
@override
State<PlannerSheet> createState() => _PlannerSheetState();
}
class _PlannerSheetState extends State<PlannerSheet> {
final _query = TextEditingController();
@override
void dispose() {
_query.dispose();
super.dispose();
}
/// أيقونة تعبّر عن نوع المكان — مطعم غير شارع غير مستشفى. يقرأها المستخدم
/// أسرع من النصّ.
IconData _iconFor(String category) => switch (category) {
'street' || 'road' => Icons.route_outlined,
'restaurant' || 'cafe' || 'food' => Icons.restaurant_outlined,
'hospital' || 'clinic' || 'pharmacy' => Icons.local_hospital_outlined,
'school' || 'university' => Icons.school_outlined,
'mall' || 'shop' || 'store' => Icons.storefront_outlined,
'mosque' => Icons.mosque_outlined,
'hotel' => Icons.hotel_outlined,
_ => Icons.place_outlined,
};
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final cubit = context.read<RideCubit>();
final state = widget.state;
return Column(
mainAxisSize: MainAxisSize.min,
@@ -57,81 +30,12 @@ class _PlannerSheetState extends State<PlannerSheet> {
originLabel: state.originLabel,
destinationLabel: state.destinationLabel,
stopCount: state.stops.length,
onTapOrigin: () => cubit.startMapPick(PickTarget.origin),
onTapDestination: () => cubit.startMapPick(PickTarget.destination),
onTapOrigin: () =>
PlaceSearchDialog.show(context, target: PickTarget.origin),
onTapDestination: () =>
PlaceSearchDialog.show(context, target: PickTarget.destination),
),
const SizedBox(height: Space.md),
TripzTextField(
controller: _query,
hint: l10n.rideSearchHint,
prefixIcon: Icons.search_rounded,
// الكيبورد يفتح فوراً: المستخدم فتح المخطّط ليكتب، لا لينظر.
autofocus: true,
onChanged: cubit.searchPlaces,
suffix: _query.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () {
_query.clear();
cubit.searchPlaces('');
setState(() {});
},
),
),
if (state.searching)
const Padding(
padding: EdgeInsets.only(top: Space.md),
child: LinearProgressIndicator(minHeight: 2),
),
if (!state.searching &&
_query.text.trim().length >= 2 &&
state.searchResults.isEmpty)
Padding(
padding: const EdgeInsets.only(top: Space.md),
child: Text(
l10n.rideSearchNoResults,
textAlign: TextAlign.center,
style: context.texts.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
if (state.searchResults.isNotEmpty)
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.only(top: Space.xs),
itemCount: state.searchResults.length,
separatorBuilder: (context, i) => const Divider(height: 1),
itemBuilder: (context, i) {
final place = state.searchResults[i];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
radius: 18,
backgroundColor: context.colors.surfaceContainerHighest,
child: Icon(
_iconFor(place.category),
size: Sizes.iconSm,
color: context.colors.onSurfaceVariant,
),
),
title: Text(place.label, maxLines: 1,
overflow: TextOverflow.ellipsis),
subtitle: place.address.isEmpty
? null
: Text(place.address,
maxLines: 1, overflow: TextOverflow.ellipsis),
onTap: () {
_query.clear();
cubit.pickPlace(place, PickTarget.destination);
},
);
},
),
),
const SizedBox(height: Space.md),
TripzButton.secondary(
label: l10n.ridePickOnMap,
icon: Icons.map_outlined,
@@ -1,3 +1,5 @@
import 'dart:developer' as dev;
import 'package:flutter/material.dart';
import 'package:intaleq_maps/intaleq_maps.dart' as im;
@@ -35,6 +37,7 @@ class RideMap extends StatefulWidget {
this.destination,
this.driver,
this.route,
this.pickupRoute,
this.cameraTarget,
this.onCameraIdle,
this.bottomInset = 280,
@@ -46,6 +49,10 @@ class RideMap extends StatefulWidget {
final GeoPoint? destination;
final GeoPoint? driver;
final RouteInfo? route;
/// مسار اقتراب السائق — يُرسم بلون وعرض مختلفين عن [route].
final RouteInfo? pickupRoute;
final GeoPoint? cameraTarget;
/// مركز الخريطة عند استقرارها — يغذّي اختيار النقطة بالدبّوس الثابت.
@@ -83,6 +90,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 +153,66 @@ 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` — مجموعة فارغة ما لم يكن الستايل جاهزاً
/// والنقاط صالحة.
///
/// **خطّان لا خطّ**: مسار الرحلة (`route`) بلون المسار وعرض 5، ومسار
/// اقتراب السائق (`pickupRoute`) أرفع وبلون المعلومات — كي يفرّق الراكب
/// بنظرة بين «طريقي» و«طريق السائق إليّ».
Set<im.Polyline> _routePolylines(Color routeColor, Color pickupColor) {
if (!_styleReady) return const {};
final lines = <im.Polyline>{};
final encoded = widget.route?.encodedPoints;
if (encoded != null && encoded.isNotEmpty) {
final points = _decodeRoute(encoded);
if (points.length >= 2) {
lines.add(im.Polyline(
polylineId: const im.PolylineId('route'),
points: points,
color: routeColor,
width: 5,
));
}
}
final pickup = widget.pickupRoute?.encodedPoints;
if (pickup != null && pickup.isNotEmpty) {
final points = _decodeRoute(pickup);
if (points.length >= 2) {
lines.add(im.Polyline(
polylineId: const im.PolylineId('pickup'),
points: points,
color: pickupColor,
width: 4,
));
}
}
return lines;
}
/// يضبط الكاميرا على المسار كاملاً بدل نقطة واحدة.
@@ -135,7 +222,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 +234,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 +247,7 @@ class _RideMapState extends State<RideMap> {
left: 48,
right: 48,
top: 120,
bottom: widget.bottomInset,
bottom: bottom,
),
);
}
@@ -167,19 +258,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 +296,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 +322,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 +349,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, colors.info),
),
// ── الطبقة الفلاترية: نقاط وأسماء وسائق ──────────────────────────
@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../../core/build_config.dart';
import '../../../../core/design/tokens.dart';
import '../../../../core/design/tripz_colors.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/router.dart';
import '../../../../core/tenant/feature_gate.dart';
import '../../data/models/trip.dart';
/// طرق التواصل مع السائق في صفّ واحد — نمط سيرو
/// (`controller/functions/launch.dart`: هاتف · رسالة · واتساب).
///
/// **ثلاثة أفعال لا زرّ واحد**: الشبكة تسقط فتفشل المحادثة داخل التطبيق،
/// وحينها يبقى الاتصال الهاتفي العادي — وهو الوحيد الذي يعمل بلا إنترنت.
/// لذلك لا يُختزل شيء منها في قائمة مخفيّة.
///
/// **المكالمة المجانية (VoIP) غير موجودة هنا** عن قصد: `Features.calls`
/// مطفأ لأن WebRTC لم يُبنَ في التطبيق بعد (الخادم يدعمه — `call:*` في
/// docs/38 §9). حين يُبنى يُضاف زرّ رابع خلف نفس العَلَم.
class TripContactRow extends StatelessWidget {
const TripContactRow({super.key, required this.trip});
final Trip trip;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final phone = trip.driverPhone;
final hasPhone = phone != null && phone.trim().isNotEmpty;
return Row(
children: [
if (Features.chat && context.tenantHas('chat'))
Expanded(
child: _ContactAction(
icon: Icons.forum_outlined,
label: l10n.chatTitle,
onTap: () => context.push('${Routes.chat}?trip=${trip.id}'),
),
),
if (hasPhone) ...[
const SizedBox(width: Space.xs),
Expanded(
child: _ContactAction(
icon: Icons.call_outlined,
label: l10n.tripCall,
onTap: () => _launch('tel:${_clean(phone)}'),
),
),
const SizedBox(width: Space.xs),
Expanded(
child: _ContactAction(
icon: Icons.sms_outlined,
label: l10n.tripMessage,
onTap: () => _launch('sms:${_clean(phone)}'),
),
),
],
],
);
}
/// الأرقام تصل بصيغ مختلفة (`0790…`, `+962790…`, بمسافات) — يُنظَّف الرقم
/// من كل ما ليس رقماً أو `+`، وإلا رفضه `tel:` صامتاً.
static String _clean(String raw) {
final kept = raw.replaceAll(RegExp(r'[^0-9+]'), '');
return kept.isEmpty ? raw : kept;
}
static Future<void> _launch(String url) async {
final uri = Uri.tryParse(url);
if (uri == null) return;
// جهازٌ بلا تطبيق هاتف (جهاز لوحي) يفشل هنا — لا يُسقط الشاشة.
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
class _ContactAction extends StatelessWidget {
const _ContactAction({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: context.colors.surfaceContainerHighest,
borderRadius: Radii.card_,
child: InkWell(
onTap: onTap,
borderRadius: Radii.card_,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Space.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: Sizes.iconSm, color: context.colors.primary),
const SizedBox(height: Space.xxs),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.texts.labelSmall,
),
],
),
),
),
);
}
}
/// شريط «كم بقي» — الوقت والمسافة من مسار الاقتراب الحيّ.
///
/// هذا **أكثر ما يسأل عنه الراكب** بعد وصول السائق للشاشة، وكان غائباً
/// تماماً: البطاقة كانت تقول «السائق في الطريق» بلا رقم واحد.
class TripEtaStrip extends StatelessWidget {
const TripEtaStrip({
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.access_time_rounded,
size: Sizes.iconSm,
color: context.tripzColors.info,
),
const SizedBox(width: Space.xs),
Text(
// أقلّ من دقيقة يُعرض «1» لا «0» — «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,
),
),
],
),
);
}
}
@@ -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()];
}
}