import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../core/api/api_exception.dart'; import '../../../core/config.dart'; import '../../../core/location/location_service.dart'; import '../../../core/realtime/realtime_service.dart'; import '../data/maps_repository.dart'; import '../data/models/fare_quote.dart'; 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'; /// شاشة الراكب كاملة: التخطيط ← التسعيرة ← الطلب ← البحث ← الرحلة الجارية. /// /// لا حساب أجرة ولا قرار مطابقة هنا — الخادم مصدر الحقيقة (docs/23 §0.2). /// دور الكيوبت جمع المدخلات وعرض ما يُعطى. class RideCubit extends Cubit { RideCubit({ required TripRepository trips, 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? _events; Timer? _searchTimeout; Timer? _searchPoll; Timer? _debounce; @override Future close() { _events?.cancel(); _searchTimeout?.cancel(); _searchPoll?.cancel(); _debounce?.cancel(); return super.close(); } // ── الإقلاع ──────────────────────────────────────────────────────────── Future init() async { _events = _realtime.events.listen(_onRealtime); await _realtime.connect(); // الكاميرا تتوسّط الموقع **فوراً**، ولا تنتظر العنوان — انتظارها كان // باگ سيرو المُصلَح 2026-07-20. unawaited(locateMe()); unawaited(_loadRideTypes()); unawaited(_resumeLiveTrip()); } /// تُنادى عند الإقلاع وعند العودة للتطبيق — الإذن قد يُمنح من إعدادات /// النظام بينما التطبيق في الخلفية. Future locateMe() async { final pos = await _location.current(); if (pos == null || isClosed) { // بلا موقع لا خريطة مفيدة — تُعرض الحالة كي يفهم المستخدم السبب. if (!isClosed && state.origin == null) { emit(state.copyWith(error: RideError.noLocation)); } return; } final point = GeoPoint(pos.lat, pos.lng); emit(state.copyWith( origin: point, cameraTarget: point, error: state.error == RideError.noLocation ? RideError.none : null, )); final place = await _maps.reverse(point); if (isClosed || place == null) return; emit(state.copyWith(originLabel: place.label)); } Future _loadRideTypes() async { try { final types = await _trips.rideTypes(); if (isClosed || types.isEmpty) return; emit(state.copyWith( rideTypes: types, selectedRideType: state.selectedRideType ?? types.first, )); // قد تصل الأنواع بعد رسم المسار — عندها تُسعَّر فوراً بلا تدخّل. if (state.route != null && state.quotes.isEmpty) await _priceAll(); } on ApiException { // قائمة الأنواع ليست حاجزاً للخريطة — تُعاد المحاولة عند التأكيد. } } /// رحلة جارية من جلسة سابقة (التطبيق أُغلق أثناءها) تُستأنف بلا تدخّل. Future _resumeLiveTrip() async { try { final live = (await _trips.mine()).where((t) => t.status.isLive); if (isClosed || live.isEmpty) return; _attachTrip(live.first); } on ApiException { // لا شيء يُستأنف — تجربة عادية لا خطأ يُعرض. } } // ── التخطيط ──────────────────────────────────────────────────────────── void couponChanged(String code) => emit(state.copyWith(couponCode: code)); void collapsePlanner() => emit(state.copyWith(phase: RidePhase.idle)); /// تبديل النوع **لا يُطلق نداءً**: كل الأنواع مسعَّرة مسبقاً في [_priceAll]. void selectRideType(RideType type) { emit(state.copyWith( selectedRideType: type, quote: state.quoteFor(type.code), )); } /// بحث نصّي عن مكان، بمهلة ارتداد: كل ضغطة زر لا تُطلق نداءً. void searchPlaces(String query) { _debounce?.cancel(); if (query.trim().length < 2) { emit(state.copyWith(searchResults: const [], searching: false)); return; } emit(state.copyWith(searching: true)); _debounce = Timer(const Duration(milliseconds: 350), () async { try { final results = await _maps.search(query, near: state.origin); if (!isClosed) { emit(state.copyWith(searchResults: results, searching: false)); } } on ApiException { if (!isClosed) { emit(state.copyWith(searchResults: const [], searching: false)); } } }); } /// الأماكن الأخيرة — تُقرأ في الشاشة، وتُحدَّث عند كل اختيار وجهة. List get recentPlaces => _recents.items; Future 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, destination: target == PickTarget.destination ? place.point : null, destinationLabel: target == PickTarget.destination ? place.label : null, searchResults: const [], cameraTarget: place.point, )); await _routeAndPrice(); } /// اختيار من الخريطة: المستخدم يحرّك **الخريطة** لا الدبّوس (docs/39 §3). void startMapPick(PickTarget target) => emit(state.copyWith( phase: RidePhase.pickingOnMap, pickTarget: target, )); Future confirmMapPick(GeoPoint point) async { final isOrigin = state.pickTarget == PickTarget.origin; emit(state.copyWith( phase: RidePhase.planning, origin: isOrigin ? point : null, destination: isOrigin ? null : point, )); final place = await _maps.reverse(point); if (isClosed) return; emit(state.copyWith( originLabel: isOrigin ? (place?.label ?? '') : null, destinationLabel: isOrigin ? null : (place?.label ?? ''), )); await _routeAndPrice(); } void addStop(GeoPoint stop) => emit(state.copyWith(stops: [...state.stops, stop])); void removeStop(int index) { final next = [...state.stops]..removeAt(index); emit(state.copyWith(stops: next)); } /// تراجع خطوة واحدة — المخرج الوحيد من شاشة التأكيد حين تفشل التسعيرة. void back() { switch (state.phase) { case RidePhase.confirming: emit(state.copyWith(phase: RidePhase.planning, error: RideError.none)); case RidePhase.pickingOnMap: case RidePhase.planning: emit(state.copyWith(phase: RidePhase.idle, error: RideError.none)); case RidePhase.idle: case RidePhase.searching: case RidePhase.active: break; } } void clearDestination() => emit(state.copyWith( clearDestination: true, clearRoute: true, phase: RidePhase.planning, )); // ── المسار والتسعيرة ─────────────────────────────────────────────────── Future _routeAndPrice() async { final from = state.origin; final to = state.destination; if (from == null || to == null) return; emit(state.copyWith(busy: true, error: RideError.none)); try { final route = await _maps.route(from, to); if (isClosed) return; emit(state.copyWith(route: route, phase: RidePhase.confirming)); await _priceAll(); } on ApiException { if (!isClosed) { emit(state.copyWith(busy: false, error: RideError.routeFailed)); } } } /// يسعّر **كل الأنواع دفعةً واحدة** بعد اكتمال المسار. /// /// المسافة والمدة ثابتتان لكل الأنواع، فالفرق في التعرفة وحدها. النداءات /// متوازية ومرة واحدة — لا نداء عند كل ضغطة نوع. /// /// التعرفة تحتاج مسافة ومدة محسوبتين لا إحداثيات، و`city` يجب أن تكون /// `default` كما يبذرها الخادم — أي قيمة أخرى ترجّع 404 (docs/38 §7). /// إعادة تسعير يدوية — مخرج المستخدم حين يفشل الحساب. Future repriceAll() => _priceAll(); Future _priceAll() async { final route = state.route; final types = state.rideTypes; if (route == null || types.isEmpty) return; emit(state.copyWith(busy: true)); final results = await Future.wait( types.map((type) async { try { final quote = await _trips.quote( city: AppConfig.defaultCity, serviceClass: type.code, distanceKm: route.distanceKm, durationMin: route.durationMin, ); // 200 بقيم `null` ليس نجاحاً — لا يُعرض رقم غير صالح للمستخدم. return quote.isUsable ? MapEntry(type.code, quote) : null; } on ApiException { // نوعٌ بلا تعرفة نشطة لا يُسقط الباقي — يُخفى سعره وحده. return null; } }), ); if (isClosed) return; final quotes = Map.fromEntries( results.whereType>(), ); // إن كان المختار بلا تعرفة، ننتقل لأول نوع مسعَّر: تركُه مختاراً يعني // زرَّ تأكيد معطّلاً بلا سبب ظاهر. var selected = state.selectedRideType; if (selected == null || !quotes.containsKey(selected.code)) { selected = types.where((t) => quotes.containsKey(t.code)).firstOrNull; } emit(state.copyWith( quotes: quotes, selectedRideType: selected, quote: selected == null ? null : quotes[selected.code], busy: false, error: quotes.isEmpty ? RideError.quoteFailed : RideError.none, )); } // ── الطلب والبحث ─────────────────────────────────────────────────────── Future requestTrip() async { final from = state.origin; final to = state.destination; final type = state.selectedRideType; if (from == null || to == null || type == null) return; emit(state.copyWith(busy: true, error: RideError.none)); try { final res = await _trips.request( origin: from, destination: to, serviceClass: type.code, stops: state.stops, couponCode: state.couponCode.trim().isEmpty ? null : state.couponCode.trim(), ); if (isClosed) return; // صفر سائقين = جواب نهائي فوري، لا داعي لانتظار مهلة كاملة. if (res.offeredDrivers == 0) { emit(state.copyWith( busy: false, error: RideError.noDrivers, phase: RidePhase.confirming, )); unawaited(_trips.cancel(res.trip.id)); return; } _attachTrip(res.trip); _startSearchGuards(res.trip.id); } on ApiException { if (!isClosed) { emit(state.copyWith(busy: false, error: RideError.requestFailed)); } } } /// حارسان أثناء البحث: /// 1. **مهلة محليّة** — الخادم قد لا يُطلق `expired`/`no_drivers` أبداً /// (ثغرة R1، docs/38 §4)، فبلا هذه المهلة تدور الشاشة إلى الأبد. /// 2. **استطلاع دوري** — يلتقط القبول لو ضاع حدث `trip:update`. void _startSearchGuards(String tripId) { _searchTimeout?.cancel(); _searchPoll?.cancel(); _searchTimeout = Timer(AppConfig.searchingTimeout, () async { if (isClosed || state.phase != RidePhase.searching) return; await cancelTrip(); if (!isClosed) emit(state.copyWith(error: RideError.noDrivers)); }); _searchPoll = Timer.periodic(const Duration(seconds: 5), (_) async { if (isClosed || state.phase != RidePhase.searching) return; try { _attachTrip(await _trips.get(tripId)); } on ApiException { // الاستطلاع شبكة أمان لا مصدر حقيقة — فشلُه لا يُعرض. } }); } Future cancelTrip() async { final trip = state.trip; if (trip == null) return; _stopSearchGuards(); try { await _trips.cancel(trip.id); } on ApiException { // حتى لو فشل الإلغاء على الخادم، المستخدم خرج من الشاشة — وحالة // الرحلة تُصحَّح عند الاستئناف التالي. } if (!isClosed) _resetToIdle(); } void _stopSearchGuards() { _searchTimeout?.cancel(); _searchPoll?.cancel(); _searchTimeout = null; _searchPoll = null; } void _resetToIdle() { emit(state.copyWith( phase: RidePhase.idle, clearTrip: true, clearRoute: true, clearDestination: true, busy: false, stops: const [], )); } // ── الواقع اللحظي ────────────────────────────────────────────────────── void _attachTrip(Trip trip) { // الانضمام قبل أي انتقال حالة، وإلا فاتت الأحداث (docs/38 §9). _realtime.joinTrip(trip.id); if (trip.status.isOver) { _stopSearchGuards(); emit(state.copyWith( trip: trip, busy: false, phase: RidePhase.idle, error: trip.status == TripStatus.noDrivers || trip.status == TripStatus.expired ? RideError.noDrivers : RideError.none, )); return; } if (trip.status != TripStatus.searching) _stopSearchGuards(); emit(state.copyWith( trip: trip, busy: false, phase: trip.status == TripStatus.searching ? RidePhase.searching : RidePhase.active, )); } // ── المحاكاة ─────────────────────────────────────────────────────────── /// عَلَم بناء: `--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: final id = event.data['id'] ?? event.data['tripId']; if (id != null && id != state.trip?.id) return; _attachTrip(Trip.fromJson(event.data)); case RealtimeEvent.driverLocation: final lat = event.data['lat']; final lng = event.data['lng']; if (lat is num && lng is num) { _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 _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; } } }