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,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;
}