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,
}
@@ -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,
),
),
),
],
),
);
}
}