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;
}
}