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/geo_point.dart'; import '../data/models/place.dart'; import '../data/models/ride_type.dart'; import '../data/models/trip.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, }) : _trips = trips, _maps = maps, _location = location, _realtime = realtime, super(const RideState()); final TripRepository _trips; final MapsRepository _maps; final LocationService _location; final RealtimeService _realtime; 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) return; final point = GeoPoint(pos.lat, pos.lng); emit(state.copyWith(origin: point, cameraTarget: point)); 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, )); } 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 openPlanner() => emit(state.copyWith(phase: RidePhase.planning)); void collapsePlanner() => emit(state.copyWith(phase: RidePhase.idle)); void selectRideType(RideType type) { emit(state.copyWith(selectedRideType: type)); unawaited(_priceIt()); } /// بحث نصّي عن مكان، بمهلة ارتداد: كل ضغطة زر لا تُطلق نداءً. 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)); } } }); } Future pickPlace(Place place, PickTarget target) async { 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 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 _priceIt(); } on ApiException { if (!isClosed) { emit(state.copyWith(busy: false, error: RideError.routeFailed)); } } } /// التعرفة تحتاج مسافة ومدة محسوبتين — لا إحداثيات (docs/38 §7). Future _priceIt() async { final route = state.route; final type = state.selectedRideType; if (route == null || type == null) return; emit(state.copyWith(busy: true)); try { final quote = await _trips.quote( city: '', serviceClass: type.code, distanceKm: route.distanceKm, durationMin: route.durationMin, ); if (isClosed) return; // 200 بقيم `null` ليس نجاحاً — لا يُعرض رقم غير صالح للمستخدم. emit(state.copyWith( quote: quote, busy: false, error: quote.isUsable ? RideError.none : RideError.quoteFailed, )); } on ApiException { if (!isClosed) { emit(state.copyWith(busy: false, error: RideError.quoteFailed)); } } } // ── الطلب والبحث ─────────────────────────────────────────────────────── 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, ); 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, )); } 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) { emit(state.copyWith( driverLocation: GeoPoint(lat.toDouble(), lng.toDouble()), )); } } } }