feat: implement core architecture, design system, localization, and trip features for both driver and rider apps.
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// أحداث TripBloc — المصادر الثلاث: المستخدم، WebSocket، FCM.
|
||||
///
|
||||
/// كل حدث يحمل `timestamp` لتمييز التسلسل عند الحاجة.
|
||||
sealed class TripEvent extends Equatable {
|
||||
const TripEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// فحص رحلة نشطة عند بدء التشغيل أو العودة للخلفية.
|
||||
class TripCheckActive extends TripEvent {
|
||||
const TripCheckActive();
|
||||
}
|
||||
|
||||
/// طلب رحلة جديدة (من شاشة تأكيد الاختيار).
|
||||
class TripRequested extends TripEvent {
|
||||
final double pickupLat;
|
||||
final double pickupLng;
|
||||
final String? pickupAddress;
|
||||
final double dropoffLat;
|
||||
final double dropoffLng;
|
||||
final String? dropoffAddress;
|
||||
final String vehicleClass;
|
||||
final String paymentMethod;
|
||||
final String? couponCode;
|
||||
|
||||
const TripRequested({
|
||||
required this.pickupLat,
|
||||
required this.pickupLng,
|
||||
this.pickupAddress,
|
||||
required this.dropoffLat,
|
||||
required this.dropoffLng,
|
||||
this.dropoffAddress,
|
||||
required this.vehicleClass,
|
||||
this.paymentMethod = 'cash',
|
||||
this.couponCode,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [pickupLat, pickupLng, dropoffLat, dropoffLng, vehicleClass];
|
||||
}
|
||||
|
||||
/// تحديث حالة الرحلة — ي הגיע عبر WebSocket أو FCM.
|
||||
/// الترتيب: searching < assigned < driver_arriving < driver_arrived < in_progress < completed < paid
|
||||
class TripUpdateReceived extends TripEvent {
|
||||
final String tripId;
|
||||
final String status;
|
||||
final String? driverId;
|
||||
final Map<String, dynamic> extra;
|
||||
|
||||
const TripUpdateReceived({
|
||||
required this.tripId,
|
||||
required this.status,
|
||||
this.driverId,
|
||||
this.extra = const {},
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tripId, status, driverId];
|
||||
}
|
||||
|
||||
/// موقع السائق الحي — من WebSocket.
|
||||
class TripDriverLocation extends TripEvent {
|
||||
final String tripId;
|
||||
final double lat;
|
||||
final double lng;
|
||||
final double heading;
|
||||
final double speed;
|
||||
|
||||
const TripDriverLocation({
|
||||
required this.tripId,
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
this.heading = 0,
|
||||
this.speed = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tripId, lat, lng];
|
||||
}
|
||||
|
||||
/// إلغاء الرحلة من الراكب.
|
||||
class TripCancelRequested extends TripEvent {
|
||||
const TripCancelRequested();
|
||||
}
|
||||
|
||||
/// إعادة المحاولة بعد خطأ.
|
||||
class TripRetry extends TripEvent {
|
||||
const TripRetry();
|
||||
}
|
||||
|
||||
/// تقييم الرحلة المكتملة.
|
||||
class TripRate extends TripEvent {
|
||||
final int stars;
|
||||
final String? comment;
|
||||
|
||||
const TripRate({required this.stars, this.comment});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [stars, comment];
|
||||
}
|
||||
|
||||
/// مسح الرحلة من الذاكرة (بعد التقييم أو العودة للرئيسية).
|
||||
class TripClear extends TripEvent {
|
||||
const TripClear();
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../data/models/trip_model.dart';
|
||||
|
||||
/// هل هذه الحالة نهائية (لا انتقالات منها)?
|
||||
bool isTerminalStatus(String status) {
|
||||
return status == 'cancelled' || status == 'no_drivers' || status == 'expired' || status == 'paid';
|
||||
}
|
||||
|
||||
/// حالة TripBloc الموحدة.
|
||||
class TripState extends Equatable {
|
||||
final TripRequestStatus requestStatus;
|
||||
final TripModel? trip;
|
||||
final String? driverName;
|
||||
final String? vehicleMake;
|
||||
final String? vehicleModel;
|
||||
final String? vehiclePlate;
|
||||
final String? vehicleColor;
|
||||
final double? driverLat;
|
||||
final double? driverLng;
|
||||
final double? driverHeading;
|
||||
final String? error;
|
||||
final bool rated;
|
||||
|
||||
const TripState({
|
||||
this.requestStatus = TripRequestStatus.idle,
|
||||
this.trip,
|
||||
this.driverName,
|
||||
this.vehicleMake,
|
||||
this.vehicleModel,
|
||||
this.vehiclePlate,
|
||||
this.vehicleColor,
|
||||
this.driverLat,
|
||||
this.driverLng,
|
||||
this.driverHeading,
|
||||
this.error,
|
||||
this.rated = false,
|
||||
});
|
||||
|
||||
TripState copyWith({
|
||||
TripRequestStatus? requestStatus,
|
||||
TripModel? trip,
|
||||
String? driverName,
|
||||
String? vehicleMake,
|
||||
String? vehicleModel,
|
||||
String? vehiclePlate,
|
||||
String? vehicleColor,
|
||||
double? driverLat,
|
||||
double? driverLng,
|
||||
double? driverHeading,
|
||||
String? error,
|
||||
bool? rated,
|
||||
bool clearError = false,
|
||||
bool clearDriver = false,
|
||||
}) {
|
||||
return TripState(
|
||||
requestStatus: requestStatus ?? this.requestStatus,
|
||||
trip: trip ?? this.trip,
|
||||
driverName: clearDriver ? null : (driverName ?? this.driverName),
|
||||
vehicleMake: clearDriver ? null : (vehicleMake ?? this.vehicleMake),
|
||||
vehicleModel: clearDriver ? null : (vehicleModel ?? this.vehicleModel),
|
||||
vehiclePlate: clearDriver ? null : (vehiclePlate ?? this.vehiclePlate),
|
||||
vehicleColor: clearDriver ? null : (vehicleColor ?? this.vehicleColor),
|
||||
driverLat: driverLat ?? this.driverLat,
|
||||
driverLng: driverLng ?? this.driverLng,
|
||||
driverHeading: driverHeading ?? this.driverHeading,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
rated: rated ?? this.rated,
|
||||
);
|
||||
}
|
||||
|
||||
/// هل هناك رحلة نشطة (ليست idle ولا terminal)?
|
||||
bool get hasActiveTrip =>
|
||||
trip != null &&
|
||||
requestStatus != TripRequestStatus.idle &&
|
||||
!isTerminalStatus(trip!.status);
|
||||
|
||||
/// هل يمكن إلغاء الرحلة حالياً?
|
||||
bool get canCancel {
|
||||
if (trip == null) return false;
|
||||
return ['searching', 'assigned', 'driver_arriving', 'driver_arrived']
|
||||
.contains(trip!.status);
|
||||
}
|
||||
|
||||
/// هل يمكن للمستخدم ركوب الرحلة (اكتملت)?
|
||||
bool get isCompleted => trip?.status == 'completed' || trip?.status == 'paid';
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
requestStatus,
|
||||
trip,
|
||||
driverName,
|
||||
vehicleMake,
|
||||
vehicleModel,
|
||||
vehiclePlate,
|
||||
vehicleColor,
|
||||
driverLat,
|
||||
driverLng,
|
||||
driverHeading,
|
||||
error,
|
||||
rated,
|
||||
];
|
||||
}
|
||||
|
||||
enum TripRequestStatus {
|
||||
idle,
|
||||
loading,
|
||||
searching,
|
||||
active, // assigned → in_progress
|
||||
completed,
|
||||
error,
|
||||
}
|
||||
Reference in New Issue
Block a user