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,
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../../../core/di.dart';
|
||||
import 'trip_event_merger.dart';
|
||||
|
||||
/// القناة الثانية للزمن الحقيقي — FCM data-message (docs/23 §15).
|
||||
///
|
||||
/// تستقبل رسائل data-only من السيرفر حول تحديثات الرحلة وتُمرّرها
|
||||
/// إلى TripEventMerger بنفس شكل WebSocket — أيّهما يسبق يفوز،
|
||||
/// والـ merger يتكفّل بالتصفية الصامتة.
|
||||
class FcmService {
|
||||
final TripEventMerger _merger;
|
||||
StreamSubscription<RemoteMessage>? _onMessageSub;
|
||||
StreamSubscription<RemoteMessage>? _onOpenSub;
|
||||
|
||||
FcmService(this._merger);
|
||||
|
||||
/// تهيئة FCM بعد تسجيل الدخول — تُستدعى من نقطة واحدة.
|
||||
Future<void> init() async {
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
|
||||
await messaging.requestPermission(
|
||||
alert: false,
|
||||
badge: false,
|
||||
sound: false,
|
||||
provisional: false,
|
||||
);
|
||||
|
||||
final token = await messaging.getToken();
|
||||
if (token != null) {
|
||||
_registerToken(token);
|
||||
}
|
||||
messaging.onTokenRefresh.listen(_registerToken);
|
||||
|
||||
_onMessageSub = FirebaseMessaging.onMessage.listen(_handleMessage);
|
||||
_onOpenSub = FirebaseMessaging.onMessageOpenedApp.listen(_handleMessage);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_onMessageSub?.cancel();
|
||||
_onOpenSub?.cancel();
|
||||
}
|
||||
|
||||
void _handleMessage(RemoteMessage message) {
|
||||
final data = message.data;
|
||||
final type = data['type'] as String?;
|
||||
final tripId = data['tripId'] as String?;
|
||||
|
||||
if (type == null || tripId == null || tripId.isEmpty) return;
|
||||
|
||||
final status = type.startsWith('trip_') ? type.substring(5) : null;
|
||||
if (status == null) return;
|
||||
|
||||
final extra = Map<String, dynamic>.from(data)
|
||||
..remove('type')
|
||||
..remove('tripId')
|
||||
..remove('title')
|
||||
..remove('body');
|
||||
|
||||
_merger.addUpdate(TripUpdate(
|
||||
tripId: tripId,
|
||||
status: status,
|
||||
driverId: data['driverId'] as String?,
|
||||
extra: extra,
|
||||
));
|
||||
}
|
||||
|
||||
static Future<void> _registerToken(String token) async {
|
||||
try {
|
||||
final api = getIt<ApiClient>();
|
||||
await api.dio.post('/notifications/token', data: {
|
||||
'token': token,
|
||||
'platform': Platform.isIOS ? 'ios' : 'android',
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// نقطة توقف أثناء الرحلة.
|
||||
class StopModel extends Equatable {
|
||||
final double lat;
|
||||
final double lng;
|
||||
final String? label;
|
||||
final String? address;
|
||||
|
||||
const StopModel({
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
this.label,
|
||||
this.address,
|
||||
});
|
||||
|
||||
factory StopModel.fromJson(Map<String, dynamic> j) => StopModel(
|
||||
lat: (j['lat'] as num).toDouble(),
|
||||
lng: (j['lng'] as num).toDouble(),
|
||||
label: j['label'] as String?,
|
||||
address: j['address'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
if (label != null) 'label': label,
|
||||
if (address != null) 'address': address,
|
||||
};
|
||||
|
||||
@override
|
||||
List<Object?> get props => [lat, lng, label, address];
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// حالة الرحلة — تُرتيب حسب docs/03 §7.
|
||||
enum TripStatus {
|
||||
requested,
|
||||
accepted,
|
||||
arriving,
|
||||
arrived,
|
||||
inProgress,
|
||||
completed,
|
||||
cancelled,
|
||||
}
|
||||
|
||||
/// نموذج الرحلة المُنشأة من trips endpoint.
|
||||
class TripModel extends Equatable {
|
||||
final String id;
|
||||
final String status;
|
||||
final String clientId;
|
||||
final double pickupLat;
|
||||
final double pickupLng;
|
||||
final String? pickupAddress;
|
||||
final double dropoffLat;
|
||||
final double dropoffLng;
|
||||
final String? dropoffAddress;
|
||||
final String vehicleClass;
|
||||
final num? fare;
|
||||
final String? currency;
|
||||
final String? paymentMethod;
|
||||
final String? couponCode;
|
||||
final String? driverId;
|
||||
final String? driverName;
|
||||
final String? driverPhone;
|
||||
final double? driverLat;
|
||||
final double? driverLng;
|
||||
final String? vehicleMake;
|
||||
final String? vehicleModel;
|
||||
final String? vehicleColor;
|
||||
final String? plateNumber;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
const TripModel({
|
||||
required this.id,
|
||||
required this.status,
|
||||
required this.clientId,
|
||||
required this.pickupLat,
|
||||
required this.pickupLng,
|
||||
this.pickupAddress,
|
||||
required this.dropoffLat,
|
||||
required this.dropoffLng,
|
||||
this.dropoffAddress,
|
||||
required this.vehicleClass,
|
||||
this.fare,
|
||||
this.currency,
|
||||
this.paymentMethod,
|
||||
this.couponCode,
|
||||
this.driverId,
|
||||
this.driverName,
|
||||
this.driverPhone,
|
||||
this.driverLat,
|
||||
this.driverLng,
|
||||
this.vehicleMake,
|
||||
this.vehicleModel,
|
||||
this.vehicleColor,
|
||||
this.plateNumber,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory TripModel.fromJson(Map<String, dynamic> j) => TripModel(
|
||||
id: j['id'] as String,
|
||||
status: j['status'] as String,
|
||||
clientId: (j['clientId'] ?? '') as String,
|
||||
pickupLat: (j['pickup']?['lat'] ?? j['pickupLat'] ?? 0).toDouble(),
|
||||
pickupLng: (j['pickup']?['lng'] ?? j['pickupLng'] ?? 0).toDouble(),
|
||||
pickupAddress: (j['pickup']?['address'] ?? j['pickupAddress']) as String?,
|
||||
dropoffLat: (j['dropoff']?['lat'] ?? j['dropoffLat'] ?? 0).toDouble(),
|
||||
dropoffLng: (j['dropoff']?['lng'] ?? j['dropoffLng'] ?? 0).toDouble(),
|
||||
dropoffAddress: (j['dropoff']?['address'] ?? j['dropoffAddress']) as String?,
|
||||
vehicleClass: (j['vehicleClass'] ?? '') as String,
|
||||
fare: (j['fare'] as num?),
|
||||
currency: j['currency'] as String?,
|
||||
paymentMethod: j['paymentMethod'] as String?,
|
||||
couponCode: j['couponCode'] as String?,
|
||||
driverId: j['driverId'] as String?,
|
||||
driverName: j['driverName'] as String?,
|
||||
driverPhone: j['driverPhone'] as String?,
|
||||
driverLat: (j['driverLat'] as num?)?.toDouble(),
|
||||
driverLng: (j['driverLng'] as num?)?.toDouble(),
|
||||
vehicleMake: j['vehicleMake'] as String?,
|
||||
vehicleModel: j['vehicleModel'] as String?,
|
||||
vehicleColor: j['vehicleColor'] as String?,
|
||||
plateNumber: j['plateNumber'] as String?,
|
||||
createdAt: j['createdAt'] != null ? DateTime.tryParse(j['createdAt'] as String) : null,
|
||||
updatedAt: j['updatedAt'] != null ? DateTime.tryParse(j['updatedAt'] as String) : null,
|
||||
);
|
||||
|
||||
TripStatus get parsedStatus {
|
||||
switch (status) {
|
||||
case 'requested':
|
||||
return TripStatus.requested;
|
||||
case 'accepted':
|
||||
return TripStatus.accepted;
|
||||
case 'arriving':
|
||||
return TripStatus.arriving;
|
||||
case 'arrived':
|
||||
return TripStatus.arrived;
|
||||
case 'in_progress':
|
||||
return TripStatus.inProgress;
|
||||
case 'completed':
|
||||
return TripStatus.completed;
|
||||
case 'cancelled':
|
||||
return TripStatus.cancelled;
|
||||
default:
|
||||
return TripStatus.requested;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, status, clientId, vehicleClass];
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// تحديث حالة من أي مصدر (WebSocket أو FCM).
|
||||
class TripUpdate {
|
||||
final String tripId;
|
||||
final String status;
|
||||
final String? driverId;
|
||||
final Map<String, dynamic> extra;
|
||||
|
||||
const TripUpdate({
|
||||
required this.tripId,
|
||||
required this.status,
|
||||
this.driverId,
|
||||
this.extra = const {},
|
||||
});
|
||||
}
|
||||
|
||||
/// موقع سائق من WebSocket.
|
||||
class DriverLocationUpdate {
|
||||
final String tripId;
|
||||
final double lat;
|
||||
final double lng;
|
||||
final double heading;
|
||||
final double speed;
|
||||
|
||||
const DriverLocationUpdate({
|
||||
required this.tripId,
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
this.heading = 0,
|
||||
this.speed = 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// ترتيب الحالات — يُستخدم للتصفية الصامتة (docs/23 §15).
|
||||
int statusOrder(String s) {
|
||||
switch (s) {
|
||||
case 'searching':
|
||||
return 0;
|
||||
case 'assigned':
|
||||
return 1;
|
||||
case 'driver_arriving':
|
||||
return 2;
|
||||
case 'driver_arrived':
|
||||
return 3;
|
||||
case 'in_progress':
|
||||
return 4;
|
||||
case 'completed':
|
||||
return 5;
|
||||
case 'paid':
|
||||
return 6;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// محول أحداث موحّد — يستقبل من WebSocket وFCM ويُخرج أولاً-فوزان.
|
||||
///
|
||||
/// التصفية الصامتة: أي حدث بحالة ≤ الحالة المعروضة يُهمل.
|
||||
/// لا حاجة لمعرّفات رسالة — ترتيب الحالات يكفي (docs/23 §15).
|
||||
class TripEventMerger {
|
||||
final _updateController = StreamController<TripUpdate>.broadcast();
|
||||
final _locationController = StreamController<DriverLocationUpdate>.broadcast();
|
||||
|
||||
/// الحالة المعروضة حالياً لكل رحلة — للتصفية الصامتة.
|
||||
final Map<String, int> _currentOrder = {};
|
||||
|
||||
Stream<TripUpdate> get updateStream => _updateController.stream;
|
||||
Stream<DriverLocationUpdate> get locationStream => _locationController.stream;
|
||||
|
||||
/// استقبال تحديث حالة من أي مصدر.
|
||||
void addUpdate(TripUpdate update) {
|
||||
final newOrder = statusOrder(update.status);
|
||||
final currentOrder = _currentOrder[update.tripId] ?? -1;
|
||||
|
||||
// التصفية الصامتة: تجاهل الأحداث القديمة أو المتطابقة.
|
||||
if (newOrder >= 0 && newOrder <= currentOrder) return;
|
||||
|
||||
// تحديث الحالة المعروضة.
|
||||
if (newOrder >= 0) {
|
||||
_currentOrder[update.tripId] = newOrder;
|
||||
}
|
||||
|
||||
_updateController.add(update);
|
||||
}
|
||||
|
||||
/// استقبال موقع سائق (لا يحتاج تصفية — كل ping مفيد).
|
||||
void addLocation(DriverLocationUpdate location) {
|
||||
_locationController.add(location);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_updateController.close();
|
||||
_locationController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../../../core/api/api_client.dart';
|
||||
import 'models/trip_model.dart';
|
||||
import 'models/stop_model.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
|
||||
/// مستودع الرحلات — يُستخدم من TripBloc.
|
||||
class TripRepository {
|
||||
final ApiClient _api;
|
||||
TripRepository(this._api);
|
||||
|
||||
/// GET /trips/mine — استرجاع رحلة نشطة.
|
||||
Future<TripModel?> getActiveTrip() async {
|
||||
try {
|
||||
final r = await _api.dio.get('/trips/mine');
|
||||
final data = r.data;
|
||||
if (data == null) return null;
|
||||
final items = (data['items'] ?? data) as List;
|
||||
if (items.isEmpty) return null;
|
||||
final trip = TripModel.fromJson(items.first as Map<String, dynamic>);
|
||||
final active = ['searching', 'assigned', 'driver_arriving', 'driver_arrived', 'in_progress'];
|
||||
return active.contains(trip.status) ? trip : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /trips/:id — جلب رحلة محددة.
|
||||
Future<TripModel?> getTrip(String tripId) async {
|
||||
try {
|
||||
final r = await _api.dio.get('/trips/$tripId');
|
||||
return TripModel.fromJson(r.data as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /trips — إنشاء رحلة جديدة.
|
||||
Future<TripModel> createTrip({
|
||||
required double pickupLat,
|
||||
required double pickupLng,
|
||||
String? pickupAddress,
|
||||
required double dropoffLat,
|
||||
required double dropoffLng,
|
||||
String? dropoffAddress,
|
||||
List<StopModel>? stops,
|
||||
required String vehicleClass,
|
||||
String paymentMethod = 'cash',
|
||||
String? couponCode,
|
||||
String? scheduledAt,
|
||||
}) async {
|
||||
final data = <String, dynamic>{
|
||||
'clientId': _uuid.v4(),
|
||||
'pickup': {
|
||||
'lat': pickupLat,
|
||||
'lng': pickupLng,
|
||||
if (pickupAddress != null) 'address': pickupAddress,
|
||||
},
|
||||
'dropoff': {
|
||||
'lat': dropoffLat,
|
||||
'lng': dropoffLng,
|
||||
if (dropoffAddress != null) 'address': dropoffAddress,
|
||||
},
|
||||
'vehicleClass': vehicleClass,
|
||||
'paymentMethod': paymentMethod,
|
||||
};
|
||||
if (stops != null && stops.isNotEmpty) {
|
||||
data['stops'] = stops.map((s) => s.toJson()).toList();
|
||||
}
|
||||
if (couponCode != null && couponCode.isNotEmpty) {
|
||||
data['couponCode'] = couponCode;
|
||||
}
|
||||
if (scheduledAt != null) data['scheduledAt'] = scheduledAt;
|
||||
|
||||
final r = await _api.dio.post('/trips', data: data);
|
||||
return TripModel.fromJson(r.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// POST /trips/:id/cancel — إلغاء الرحلة.
|
||||
Future<TripModel> cancelTrip(String tripId) async {
|
||||
final r = await _api.dio.post('/trips/$tripId/cancel');
|
||||
return TripModel.fromJson(r.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// POST /trips/:id/rate — تقييم الرحلة.
|
||||
Future<void> rateTrip(String tripId, {required int stars, String? comment}) async {
|
||||
await _api.dio.post('/trips/$tripId/rate', data: {
|
||||
'stars': stars,
|
||||
if (comment != null && comment.isNotEmpty) 'comment': comment,
|
||||
});
|
||||
}
|
||||
|
||||
/// GET /trips/rating/pending — هل هناك تقييم معلّق?
|
||||
Future<Map<String, dynamic>?> getPendingRating() async {
|
||||
try {
|
||||
final r = await _api.dio.get('/trips/rating/pending');
|
||||
final data = r.data as Map<String, dynamic>;
|
||||
if (data['pending'] != null) return data;
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'dart:async';
|
||||
import 'package:socket_io_client/socket_io_client.dart' as io;
|
||||
import '../../../core/config.dart';
|
||||
import '../../../core/storage/token_store.dart';
|
||||
import 'trip_event_merger.dart';
|
||||
|
||||
/// خدمة WebSocket — تتبع رحلة عبر Socket.IO.
|
||||
///
|
||||
/// الاتصال مُصادق عبر JWT (نفس توكن API).
|
||||
/// الأحداث الواردة تُمرّ عبر TripEventMerger.
|
||||
class TripSocketService {
|
||||
final TokenStore _tokenStore;
|
||||
final TripEventMerger _merger;
|
||||
|
||||
io.Socket? _socket;
|
||||
bool _connected = false;
|
||||
String? _currentTripId;
|
||||
|
||||
TripSocketService(this._tokenStore, this._merger);
|
||||
|
||||
/// الاتصال بالخادم.
|
||||
Future<void> connect() async {
|
||||
if (_connected) return;
|
||||
|
||||
final token = await _tokenStore.access();
|
||||
if (token == null || token.isEmpty) return;
|
||||
|
||||
_socket = io.io(
|
||||
AppConfig.baseUrl.replaceAll('/api', ''),
|
||||
io.OptionBuilder()
|
||||
.setTransports(['websocket'])
|
||||
.enableAutoConnect()
|
||||
.enableReconnection()
|
||||
.setReconnectionDelay(2000)
|
||||
.setReconnectionAttempts(10)
|
||||
.setAuth({'token': token})
|
||||
.build(),
|
||||
);
|
||||
|
||||
_socket!.onConnect((_) {
|
||||
_connected = true;
|
||||
});
|
||||
|
||||
_socket!.onDisconnect((_) {
|
||||
_connected = false;
|
||||
});
|
||||
|
||||
_socket!.onReconnect((_) {
|
||||
// عند إعادة الاتصال: أعد الانضمام لغرفة الرحلة.
|
||||
if (_currentTripId != null) {
|
||||
joinTrip(_currentTripId!);
|
||||
}
|
||||
});
|
||||
|
||||
// استقبال تحديثات الحالة.
|
||||
_socket!.on('trip:update', (data) {
|
||||
if (data is! Map) return;
|
||||
final map = Map<String, dynamic>.from(data);
|
||||
_merger.addUpdate(TripUpdate(
|
||||
tripId: map['tripId'] as String? ?? '',
|
||||
status: map['status'] as String? ?? '',
|
||||
driverId: map['driverId'] as String?,
|
||||
extra: map..remove('tripId')..remove('status')..remove('driverId'),
|
||||
));
|
||||
});
|
||||
|
||||
// استقبال موقع السائق الحي.
|
||||
_socket!.on('driver:location', (data) {
|
||||
if (data is! Map) return;
|
||||
final map = Map<String, dynamic>.from(data);
|
||||
_merger.addLocation(DriverLocationUpdate(
|
||||
tripId: map['tripId'] as String? ?? '',
|
||||
lat: (map['lat'] as num?)?.toDouble() ?? 0,
|
||||
lng: (map['lng'] as num?)?.toDouble() ?? 0,
|
||||
heading: (map['heading'] as num?)?.toDouble() ?? 0,
|
||||
speed: (map['speed'] as num?)?.toDouble() ?? 0,
|
||||
));
|
||||
});
|
||||
|
||||
_socket!.connect();
|
||||
}
|
||||
|
||||
/// الانضمام لغرفة الرحلة.
|
||||
void joinTrip(String tripId) {
|
||||
_currentTripId = tripId;
|
||||
_socket?.emit('trip:join', {'tripId': tripId});
|
||||
}
|
||||
|
||||
/// مغادرة غرفة الرحلة.
|
||||
void leaveTrip(String tripId) {
|
||||
_currentTripId = null;
|
||||
// Socket.IO لا يدعم مغادرة الغرفة بشكل صريح،
|
||||
// لكن عند رحلة جديدة سننضم للغرفة الجديدة.
|
||||
}
|
||||
|
||||
/// قطع الاتصال.
|
||||
void disconnect() {
|
||||
_socket?.disconnect();
|
||||
_socket?.dispose();
|
||||
_socket = null;
|
||||
_connected = false;
|
||||
}
|
||||
|
||||
bool get isConnected => _connected;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/l10n/app_localizations.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../../core/ui/tripz_button.dart';
|
||||
import '../bloc/trip_bloc.dart';
|
||||
import '../bloc/trip_state.dart';
|
||||
import '../bloc/trip_event.dart';
|
||||
|
||||
/// شاشة تقييم الرحلة.
|
||||
class RatingScreen extends StatefulWidget {
|
||||
const RatingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RatingScreen> createState() => _RatingScreenState();
|
||||
}
|
||||
|
||||
class _RatingScreenState extends State<RatingScreen> {
|
||||
int _stars = 0;
|
||||
final _commentController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_commentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return BlocListener<TripBloc, TripState>(
|
||||
listenWhen: (prev, cur) => prev.rated != cur.rated,
|
||||
listener: (context, state) {
|
||||
if (state.rated) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.thankYou)),
|
||||
);
|
||||
context.read<TripBloc>().add(const TripClear());
|
||||
context.go('/home');
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.rateYourTrip)),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(T.pagePadding),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
|
||||
// نجوم التقييم
|
||||
Text(
|
||||
l10n.rateDriver,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(5, (i) {
|
||||
final index = i + 1;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _stars = index),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Icon(
|
||||
index <= _stars ? Icons.star_rounded : Icons.star_outline_rounded,
|
||||
size: 48,
|
||||
color: index <= _stars ? Colors.amber : cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
const SizedBox(height: T.s24),
|
||||
|
||||
// حقل التعليق
|
||||
TextField(
|
||||
controller: _commentController,
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.ratingComment,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(flex: 3),
|
||||
|
||||
// زر الإرسال
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: TripzButton(
|
||||
label: l10n.submitRating,
|
||||
onPressed: _stars > 0
|
||||
? () {
|
||||
context.read<TripBloc>().add(TripRate(
|
||||
stars: _stars,
|
||||
comment: _commentController.text,
|
||||
));
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/l10n/app_localizations.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../../core/ui/tripz_button.dart';
|
||||
import '../bloc/trip_bloc.dart';
|
||||
import '../bloc/trip_state.dart';
|
||||
import '../bloc/trip_event.dart';
|
||||
|
||||
/// شاشة فاتورة الرحلة بعد الانتهاء.
|
||||
class TripInvoiceScreen extends StatelessWidget {
|
||||
const TripInvoiceScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return BlocBuilder<TripBloc, TripState>(
|
||||
builder: (context, state) {
|
||||
final trip = state.trip;
|
||||
if (trip == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.rideCompleted)),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final fare = trip.fare ?? 0;
|
||||
final currency = trip.currency ?? 'JOD';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.rideCompleted),
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(T.pagePadding),
|
||||
child: Column(
|
||||
children: [
|
||||
// أيقونة النجاح
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.check_circle_rounded, size: 48, color: Colors.green),
|
||||
),
|
||||
const SizedBox(height: T.s16),
|
||||
Text(
|
||||
l10n.rideCompleted,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s24),
|
||||
|
||||
// تفاصيل الأجرة
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(T.pagePadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l10n.fareBreakdown,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
)),
|
||||
const SizedBox(height: T.s12),
|
||||
_FareRow(label: l10n.tripFare, value: '$fare $currency'),
|
||||
if (trip.paymentMethod == 'wallet')
|
||||
_FareRow(label: l10n.walletPayment, value: l10n.youPaid),
|
||||
if (trip.paymentMethod == 'cash')
|
||||
_FareRow(label: l10n.cash, value: l10n.payCash),
|
||||
const Divider(height: T.s16),
|
||||
_FareRow(
|
||||
label: l10n.total,
|
||||
value: '$fare $currency',
|
||||
bold: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// أزرار الإجراءات
|
||||
TripzButton(
|
||||
label: l10n.rateYourTrip,
|
||||
onPressed: () => context.push('/trip-rating'),
|
||||
),
|
||||
const SizedBox(height: T.s8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<TripBloc>().add(const TripClear());
|
||||
context.go('/home');
|
||||
},
|
||||
child: Text(l10n.skipRating),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FareRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool bold;
|
||||
|
||||
const _FareRow({required this.label, required this.value, this.bold = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = bold
|
||||
? Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)
|
||||
: Theme.of(context).textTheme.bodyMedium;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: style),
|
||||
Text(value, style: style),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/l10n/app_localizations.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../bloc/trip_bloc.dart';
|
||||
import '../bloc/trip_state.dart';
|
||||
import '../bloc/trip_event.dart';
|
||||
import 'widgets/driver_card.dart';
|
||||
import 'widgets/trip_status_banner.dart';
|
||||
|
||||
/// شاشة تتبع الرحلة — من searching إلى completed.
|
||||
class TripTrackingScreen extends StatefulWidget {
|
||||
const TripTrackingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<TripTrackingScreen> createState() => _TripTrackingScreenState();
|
||||
}
|
||||
|
||||
class _TripTrackingScreenState extends State<TripTrackingScreen> {
|
||||
IntaleqMapController? _mapController;
|
||||
Timer? _cameraFollowTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// مراقبة حالة التطبيق للتحقق من الرحلة النشطة عند العودة.
|
||||
WidgetsBinding.instance.addObserver(_AppLifecycleObserver(context));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cameraFollowTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return BlocConsumer<TripBloc, TripState>(
|
||||
listenWhen: (prev, cur) => prev.trip?.status != cur.trip?.status,
|
||||
listener: (context, state) {
|
||||
if (state.isCompleted) {
|
||||
// الرحلة اكتملت — انتقل لشاشة الفاتورة.
|
||||
context.pushReplacement('/trip-invoice');
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final trip = state.trip;
|
||||
if (trip == null) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
final pickup = LatLng(trip.pickupLat, trip.pickupLng);
|
||||
final dropoff = LatLng(trip.dropoffLat, trip.dropoffLng);
|
||||
final driverPos = state.driverLat != null && state.driverLng != null
|
||||
? LatLng(state.driverLat!, state.driverLng!)
|
||||
: null;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// الخريطة
|
||||
IntaleqMap(
|
||||
apiKey: '',
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: driverPos ?? pickup,
|
||||
zoom: 15,
|
||||
),
|
||||
myLocationEnabled: false,
|
||||
zoomControlsEnabled: false,
|
||||
compassEnabled: false,
|
||||
onMapCreated: (c) => _mapController = c,
|
||||
markers: _buildMarkers(pickup, dropoff, driverPos),
|
||||
),
|
||||
|
||||
// شريط الحالة العلوي
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + T.s8,
|
||||
left: T.pagePadding,
|
||||
right: T.pagePadding,
|
||||
child: TripStatusBanner(status: trip.status),
|
||||
),
|
||||
|
||||
// بطاقة السائق (إذا تم تعيينه)
|
||||
if (state.requestStatus == TripRequestStatus.active)
|
||||
Positioned(
|
||||
left: T.pagePadding,
|
||||
right: T.pagePadding,
|
||||
bottom: T.pagePadding,
|
||||
child: DriverCard(
|
||||
driverName: state.driverName ?? '',
|
||||
vehicleMake: state.vehicleMake ?? '',
|
||||
vehicleModel: state.vehicleModel ?? '',
|
||||
vehiclePlate: state.vehiclePlate ?? '',
|
||||
vehicleColor: state.vehicleColor ?? '',
|
||||
status: trip.status,
|
||||
onCall: () {
|
||||
// TODO: WebRTC calling — Phase 3
|
||||
},
|
||||
onChat: () {
|
||||
// TODO: Chat — Phase 3
|
||||
},
|
||||
onCancel: state.canCancel
|
||||
? () => context.read<TripBloc>().add(const TripCancelRequested())
|
||||
: null,
|
||||
),
|
||||
),
|
||||
|
||||
// شاشة البحث (قبل تعيين السائق)
|
||||
if (state.requestStatus == TripRequestStatus.searching)
|
||||
_SearchingBody(elapsed: 0),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Set<Marker> _buildMarkers(LatLng pickup, LatLng dropoff, LatLng? driverPos) {
|
||||
final markers = <Marker>{
|
||||
Marker(
|
||||
markerId: const MarkerId('pickup'),
|
||||
position: pickup,
|
||||
icon: InlqBitmap.defaultMarkerWithHue(210),
|
||||
anchor: const Offset(0.5, 1.0),
|
||||
),
|
||||
Marker(
|
||||
markerId: const MarkerId('dropoff'),
|
||||
position: dropoff,
|
||||
icon: InlqBitmap.defaultMarkerWithHue(0),
|
||||
anchor: const Offset(0.5, 1.0),
|
||||
),
|
||||
};
|
||||
if (driverPos != null) {
|
||||
markers.add(Marker(
|
||||
markerId: const MarkerId('driver'),
|
||||
position: driverPos,
|
||||
icon: InlqBitmap.defaultMarkerWithHue(140),
|
||||
anchor: const Offset(0.5, 0.5),
|
||||
zIndex: 3,
|
||||
));
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
}
|
||||
|
||||
/// شاشة البحث عن سائق مع مؤقت.
|
||||
class _SearchingBody extends StatefulWidget {
|
||||
final int elapsed;
|
||||
const _SearchingBody({required this.elapsed});
|
||||
|
||||
@override
|
||||
State<_SearchingBody> createState() => _SearchingBodyState();
|
||||
}
|
||||
|
||||
class _SearchingBodyState extends State<_SearchingBody>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _pulse;
|
||||
late Animation<double> _scale;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pulse = AnimationController(vsync: this, duration: const Duration(seconds: 2))
|
||||
..repeat(reverse: true);
|
||||
_scale = Tween<double>(begin: 0.8, end: 1.2).animate(
|
||||
CurvedAnimation(parent: _pulse, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulse.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Material(
|
||||
color: Colors.black54,
|
||||
child: Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(T.pagePadding * 2),
|
||||
padding: const EdgeInsets.all(T.pagePadding * 1.5),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: BorderRadius.circular(T.r16),
|
||||
boxShadow: [T.shadowLg],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ScaleTransition(
|
||||
scale: _scale,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.directions_car_filled_rounded,
|
||||
size: 40, color: cs.onPrimaryContainer),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s16),
|
||||
Text(l10n.searchingDriver,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: T.s20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => context.read<TripBloc>().add(const TripCancelRequested()),
|
||||
style: OutlinedButton.styleFrom(foregroundColor: cs.error, side: BorderSide(color: cs.error)),
|
||||
child: Text(l10n.cancelSearch),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// مراقب دورة حياة التطبيق — يتحقق من الرحلة عند العودة.
|
||||
class _AppLifecycleObserver extends WidgetsBindingObserver {
|
||||
final BuildContext context;
|
||||
_AppLifecycleObserver(this.context);
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
context.read<TripBloc>().add(const TripCheckActive());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/l10n/app_localizations.dart';
|
||||
import '../../../../core/design/tokens.dart';
|
||||
|
||||
/// بطاقة السائق في شاشة التتبع.
|
||||
class DriverCard extends StatelessWidget {
|
||||
final String driverName;
|
||||
final String vehicleMake;
|
||||
final String vehicleModel;
|
||||
final String vehiclePlate;
|
||||
final String vehicleColor;
|
||||
final String status;
|
||||
final VoidCallback? onCall;
|
||||
final VoidCallback? onChat;
|
||||
final VoidCallback? onCancel;
|
||||
|
||||
const DriverCard({
|
||||
super.key,
|
||||
required this.driverName,
|
||||
required this.vehicleMake,
|
||||
required this.vehicleModel,
|
||||
required this.vehiclePlate,
|
||||
required this.vehicleColor,
|
||||
required this.status,
|
||||
this.onCall,
|
||||
this.onChat,
|
||||
this.onCancel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(T.pagePadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// صف الصورة + المعلومات
|
||||
Row(
|
||||
children: [
|
||||
// صورة السائق ( Placeholder )
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: cs.primaryContainer,
|
||||
child: Icon(Icons.person, color: cs.onPrimaryContainer),
|
||||
),
|
||||
const SizedBox(width: T.s12),
|
||||
// اسم السائق + التقييم
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
driverName.isNotEmpty ? driverName : l10n.loading,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.star_rounded, size: 16, color: Colors.amber),
|
||||
const SizedBox(width: 4),
|
||||
Text('4.8', style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: T.s12),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: T.s12),
|
||||
|
||||
// معلومات المركبة
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.directions_car_filled_rounded, size: 20, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: T.s8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$vehicleColor $vehicleMake $vehicleModel',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// رقم اللوحة — بارز بوضوح
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.s8, vertical: T.s4),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(T.r8),
|
||||
),
|
||||
child: Text(
|
||||
vehiclePlate,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: T.s12),
|
||||
|
||||
// أزرار الإجراءات
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onCall,
|
||||
icon: const Icon(Icons.call_rounded, size: 18),
|
||||
label: Text(l10n.callDriver),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: T.s8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onChat,
|
||||
icon: const Icon(Icons.chat_rounded, size: 18),
|
||||
label: Text(l10n.chatWithDriver),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (onCancel != null) ...[
|
||||
const SizedBox(height: T.s8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextButton(
|
||||
onPressed: onCancel,
|
||||
child: Text(l10n.cancel, style: TextStyle(color: cs.error)),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/l10n/app_localizations.dart';
|
||||
import '../../../../core/design/tokens.dart';
|
||||
|
||||
/// شريط الحالة العلوي في شاشة التتبع.
|
||||
class TripStatusBanner extends StatelessWidget {
|
||||
final String status;
|
||||
|
||||
const TripStatusBanner({super.key, required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
final (text, icon, color) = switch (status) {
|
||||
'searching' => (l10n.searchingDriver, Icons.search_rounded, cs.primary),
|
||||
'assigned' || 'driver_arriving' => (l10n.driverArriving, Icons.directions_car_rounded, cs.tertiary),
|
||||
'driver_arrived' => (l10n.driverArrived, Icons.location_on_rounded, Colors.green),
|
||||
'in_progress' => (l10n.rideInProgress, Icons.play_arrow_rounded, cs.primary),
|
||||
'completed' => (l10n.rideCompleted, Icons.check_circle_rounded, Colors.green),
|
||||
'cancelled' => (l10n.tripCancelled, Icons.cancel_rounded, cs.error),
|
||||
'no_drivers' => (l10n.tripNoDrivers, Icons.error_outline_rounded, cs.error),
|
||||
_ => (l10n.loading, Icons.hourglass_empty_rounded, cs.onSurfaceVariant),
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.pagePadding, vertical: T.s12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(T.r12),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 20),
|
||||
const SizedBox(width: T.s8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user