feat: implement core architecture, design system, localization, and trip features for both driver and rider apps.

This commit is contained in:
Hamza-Ayed
2026-07-19 16:21:58 +03:00
parent 959e2fa758
commit c4dfeb4c6f
207 changed files with 14017 additions and 5567 deletions
@@ -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,
}