269 lines
8.3 KiB
Dart
269 lines
8.3 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import '../data/models/trip_model.dart';
|
|
import '../data/trip_repository.dart';
|
|
import '../data/trip_socket_service.dart';
|
|
import '../data/trip_event_merger.dart';
|
|
import 'trip_event.dart';
|
|
import 'trip_state.dart';
|
|
|
|
/// TripBloc — Bloc الوحيد للراكب (docs/03 §14).
|
|
///
|
|
/// يتعامل مع Lifecycle كامل: إنشاء → تتبّع → إتمام → تقييم.
|
|
/// المصادر الثلاث: أحداث المستخدم، WebSocket، FCM — تمر عبر TripEventMerger.
|
|
class TripBloc extends Bloc<TripEvent, TripState> {
|
|
final TripRepository _tripRepo;
|
|
final TripSocketService _socket;
|
|
final TripEventMerger _merger;
|
|
Timer? _reconciliationTimer;
|
|
|
|
TripBloc({
|
|
required TripRepository tripRepo,
|
|
required TripSocketService socket,
|
|
TripEventMerger? merger,
|
|
}) : _tripRepo = tripRepo,
|
|
_socket = socket,
|
|
_merger = merger ?? TripEventMerger(),
|
|
super(const TripState()) {
|
|
on<TripCheckActive>(_onCheckActive);
|
|
on<TripRequested>(_onRequested);
|
|
on<TripUpdateReceived>(_onUpdateReceived);
|
|
on<TripDriverLocation>(_onDriverLocation);
|
|
on<TripCancelRequested>(_onCancel);
|
|
on<TripRetry>(_onRetry);
|
|
on<TripRate>(_onRate);
|
|
on<TripClear>(_onClear);
|
|
|
|
// استمع لتحديثات TripEventMerger.
|
|
_merger.updateStream.listen((update) {
|
|
add(TripUpdateReceived(
|
|
tripId: update.tripId,
|
|
status: update.status,
|
|
driverId: update.driverId,
|
|
extra: update.extra,
|
|
));
|
|
});
|
|
|
|
_merger.locationStream.listen((loc) {
|
|
add(TripDriverLocation(
|
|
tripId: loc.tripId,
|
|
lat: loc.lat,
|
|
lng: loc.lng,
|
|
heading: loc.heading,
|
|
speed: loc.speed,
|
|
));
|
|
});
|
|
|
|
// اتصل بالـWebSocket.
|
|
_socket.connect();
|
|
}
|
|
|
|
/// فحص رحلة نشطة عند بدء التشغيل أو العودة من الخلفية.
|
|
Future<void> _onCheckActive(TripCheckActive event, Emitter<TripState> emit) async {
|
|
try {
|
|
final trip = await _tripRepo.getActiveTrip();
|
|
if (trip != null) {
|
|
emit(state.copyWith(
|
|
requestStatus: _statusFromTrip(trip.status),
|
|
trip: trip,
|
|
));
|
|
_joinTripRoom(trip.id);
|
|
_startReconciliation(trip.id);
|
|
}
|
|
} catch (_) {
|
|
// خطأ صامت — سنحاول مرة أخرى عند إعادة الاتصال.
|
|
}
|
|
}
|
|
|
|
/// طلب رحلة جديدة.
|
|
Future<void> _onRequested(TripRequested event, Emitter<TripState> emit) async {
|
|
emit(state.copyWith(requestStatus: TripRequestStatus.loading, clearError: true));
|
|
try {
|
|
final trip = await _tripRepo.createTrip(
|
|
pickupLat: event.pickupLat,
|
|
pickupLng: event.pickupLng,
|
|
pickupAddress: event.pickupAddress,
|
|
dropoffLat: event.dropoffLat,
|
|
dropoffLng: event.dropoffLng,
|
|
dropoffAddress: event.dropoffAddress,
|
|
vehicleClass: event.vehicleClass,
|
|
paymentMethod: event.paymentMethod,
|
|
couponCode: event.couponCode,
|
|
);
|
|
emit(state.copyWith(
|
|
requestStatus: TripRequestStatus.searching,
|
|
trip: trip,
|
|
));
|
|
_joinTripRoom(trip.id);
|
|
_startReconciliation(trip.id);
|
|
} catch (e) {
|
|
emit(state.copyWith(
|
|
requestStatus: TripRequestStatus.error,
|
|
error: e.toString(),
|
|
));
|
|
}
|
|
}
|
|
|
|
/// تحديث حالة الرحلة — من WebSocket أو FCM عبر TripEventMerger.
|
|
void _onUpdateReceived(TripUpdateReceived event, Emitter<TripState> emit) {
|
|
final currentTrip = state.trip;
|
|
if (currentTrip == null || currentTrip.id != event.tripId) return;
|
|
|
|
// التصفية الصامتة: تجاهل الأحداث القديمة.
|
|
final currentOrder = statusOrder(currentTrip.status);
|
|
final newOrder = statusOrder(event.status);
|
|
if (newOrder >= 0 && newOrder <= currentOrder) return;
|
|
|
|
// تحديث النموذج محلياً.
|
|
final updatedTrip = TripModel(
|
|
id: currentTrip.id,
|
|
status: event.status,
|
|
clientId: currentTrip.clientId,
|
|
pickupLat: currentTrip.pickupLat,
|
|
pickupLng: currentTrip.pickupLng,
|
|
pickupAddress: currentTrip.pickupAddress,
|
|
dropoffLat: currentTrip.dropoffLat,
|
|
dropoffLng: currentTrip.dropoffLng,
|
|
dropoffAddress: currentTrip.dropoffAddress,
|
|
vehicleClass: currentTrip.vehicleClass,
|
|
fare: (event.extra['fare'] as num?) ?? currentTrip.fare,
|
|
currency: event.extra['currency'] as String? ?? currentTrip.currency,
|
|
paymentMethod: currentTrip.paymentMethod,
|
|
driverId: event.driverId ?? currentTrip.driverId,
|
|
createdAt: currentTrip.createdAt,
|
|
updatedAt: DateTime.now(),
|
|
);
|
|
|
|
final newStatus = _statusFromTrip(event.status);
|
|
|
|
// استخراج معلومات السائق من extra.
|
|
final vehicle = event.extra['vehicle'] as Map<String, dynamic>?;
|
|
|
|
emit(state.copyWith(
|
|
requestStatus: newStatus,
|
|
trip: updatedTrip,
|
|
driverName: event.extra['driverName'] as String?,
|
|
vehicleMake: vehicle?['make'] as String?,
|
|
vehicleModel: vehicle?['model'] as String?,
|
|
vehiclePlate: vehicle?['plate'] as String?,
|
|
vehicleColor: vehicle?['color'] as String?,
|
|
));
|
|
|
|
// حالة نهائية: أوقف مؤقت الم reconciliation.
|
|
if (isTerminalStatus(event.status)) {
|
|
_stopReconciliation();
|
|
_socket.leaveTrip(event.tripId);
|
|
}
|
|
}
|
|
|
|
/// موقع السائق الحي.
|
|
void _onDriverLocation(TripDriverLocation event, Emitter<TripState> emit) {
|
|
if (state.trip?.id != event.tripId) return;
|
|
emit(state.copyWith(
|
|
driverLat: event.lat,
|
|
driverLng: event.lng,
|
|
driverHeading: event.heading,
|
|
));
|
|
}
|
|
|
|
/// إلغاء الرحلة.
|
|
Future<void> _onCancel(TripCancelRequested event, Emitter<TripState> emit) async {
|
|
if (state.trip == null) return;
|
|
try {
|
|
final trip = await _tripRepo.cancelTrip(state.trip!.id);
|
|
_stopReconciliation();
|
|
_socket.leaveTrip(state.trip!.id);
|
|
emit(state.copyWith(
|
|
requestStatus: TripRequestStatus.completed,
|
|
trip: trip,
|
|
));
|
|
} catch (e) {
|
|
emit(state.copyWith(error: e.toString()));
|
|
}
|
|
}
|
|
|
|
/// إعادة المحاولة بعد خطأ.
|
|
void _onRetry(TripRetry event, Emitter<TripState> emit) {
|
|
emit(state.copyWith(requestStatus: TripRequestStatus.idle, clearError: true));
|
|
}
|
|
|
|
/// تقييم الرحلة.
|
|
Future<void> _onRate(TripRate event, Emitter<TripState> emit) async {
|
|
if (state.trip == null) return;
|
|
try {
|
|
await _tripRepo.rateTrip(state.trip!.id, stars: event.stars, comment: event.comment);
|
|
emit(state.copyWith(rated: true));
|
|
} catch (e) {
|
|
emit(state.copyWith(error: e.toString()));
|
|
}
|
|
}
|
|
|
|
/// مسح الرحلة من الذاكرة.
|
|
void _onClear(TripClear event, Emitter<TripState> emit) {
|
|
_stopReconciliation();
|
|
emit(const TripState());
|
|
}
|
|
|
|
// --- مساعدات ---
|
|
|
|
void _joinTripRoom(String tripId) {
|
|
_socket.joinTrip(tripId);
|
|
}
|
|
|
|
void _startReconciliation(String tripId) {
|
|
_stopReconciliation();
|
|
_reconciliationTimer = Timer.periodic(const Duration(seconds: 25), (_) async {
|
|
try {
|
|
final trip = await _tripRepo.getTrip(tripId);
|
|
if (trip != null) {
|
|
add(TripUpdateReceived(
|
|
tripId: trip.id,
|
|
status: trip.status,
|
|
driverId: trip.driverId,
|
|
extra: {
|
|
if (trip.fare != null) 'fare': trip.fare,
|
|
if (trip.currency != null) 'currency': trip.currency,
|
|
},
|
|
));
|
|
}
|
|
} catch (_) {
|
|
// خطأ صامت — سيحاول مرة أخرى.
|
|
}
|
|
});
|
|
}
|
|
|
|
void _stopReconciliation() {
|
|
_reconciliationTimer?.cancel();
|
|
_reconciliationTimer = null;
|
|
}
|
|
|
|
TripRequestStatus _statusFromTrip(String status) {
|
|
switch (status) {
|
|
case 'searching':
|
|
return TripRequestStatus.searching;
|
|
case 'assigned':
|
|
case 'driver_arriving':
|
|
case 'driver_arrived':
|
|
case 'in_progress':
|
|
return TripRequestStatus.active;
|
|
case 'completed':
|
|
case 'paid':
|
|
return TripRequestStatus.completed;
|
|
case 'cancelled':
|
|
case 'no_drivers':
|
|
case 'expired':
|
|
return TripRequestStatus.completed;
|
|
default:
|
|
return TripRequestStatus.searching;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> close() {
|
|
_stopReconciliation();
|
|
_merger.dispose();
|
|
_socket.disconnect();
|
|
return super.close();
|
|
}
|
|
}
|