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,117 @@
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 '../../home/data/models/trip_offer_model.dart';
import 'trip_event_merger.dart';
/// خدمة WebSocket للسائق — تتصل بالسيرفر وتستقبل:
/// - `trip:offer` — عرض رحلة وارد
/// - `trip:offer_taken` — تم قبول العرض (من سائق آخر)
/// - `trip:update` — تحديث حالة الرحلة النشطة
/// - `trip:new` — رحلة جديدة متاحة
class DriverSocketService {
final TokenStore _tokenStore;
final DriverTripEventMerger _merger;
io.Socket? _socket;
bool _connected = false;
String? _currentTripId;
DriverSocketService(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:offer', (data) {
if (data is! Map) return;
final map = Map<String, dynamic>.from(data);
_merger.addOffer(TripOfferModel.fromJson(map));
});
// العرض تم قبوله من سائق آخر.
_socket!.on('trip:offer_taken', (data) {
if (data is! Map) return;
final map = Map<String, dynamic>.from(data);
final tripId = map['tripId'] as String?;
if (tripId != null) {
_merger.addOfferTaken(tripId);
}
});
// تحديث حالة الرحلة النشطة.
_socket!.on('trip:update', (data) {
if (data is! Map) return;
final map = Map<String, dynamic>.from(data);
_merger.addUpdate(DriverTripUpdate(
tripId: map['tripId'] as String? ?? '',
status: map['status'] as String? ?? '',
extra: map..remove('tripId')..remove('status'),
));
});
// رحلة جديدة متاحة (إشعار عام).
_socket!.on('trip:new', (data) {
if (data is! Map) return;
final map = Map<String, dynamic>.from(data);
final tripId = map['tripId'] as String?;
if (tripId != null) {
_merger.addNewTrip(tripId);
}
});
_socket!.connect();
}
/// الانضمام لغرفة الرحلة.
void joinTrip(String tripId) {
_currentTripId = tripId;
_socket?.emit('trip:join', {'tripId': tripId});
}
/// مغادرة غرفة الرحلة.
void leaveTrip(String tripId) {
_currentTripId = null;
}
/// قطع الاتصال.
void disconnect() {
_socket?.disconnect();
_socket?.dispose();
_socket = null;
_connected = false;
}
bool get isConnected => _connected;
}
@@ -0,0 +1,75 @@
import '../../../core/api/api_client.dart';
import 'models/driver_trip_model.dart';
import 'models/chat_message_model.dart';
/// مستودع رحلة السائق — يُستخدم من DriverTripBloc.
///
/// يشمل تغييرات الحالة ودردشة الرحلة.
class DriverTripRepository {
final ApiClient _api;
DriverTripRepository(this._api);
/// `GET /trips/:id` — جلب رحلة محددة.
Future<DriverTripModel?> getTrip(String tripId) async {
try {
final r = await _api.dio.get('/trips/$tripId');
return DriverTripModel.fromJson(r.data as Map<String, dynamic>);
} catch (_) {
return null;
}
}
/// `PATCH /trips/:id/status` — تغيير حالة الرحلة (status machine).
///
/// الحالات المسموحة للسائق: `driver_arriving`, `driver_arrived`, `in_progress`, `completed`.
Future<DriverTripModel?> updateStatus(String tripId, String status) async {
try {
final r = await _api.dio.patch('/trips/$tripId/status', data: {
'status': status,
});
return DriverTripModel.fromJson(r.data as Map<String, dynamic>);
} catch (_) {
return null;
}
}
/// `POST /trips/:id/cancel` — إلغاء الرحلة من السائق.
Future<DriverTripModel?> cancelTrip(String tripId) async {
try {
final r = await _api.dio.post('/trips/$tripId/cancel');
return DriverTripModel.fromJson(r.data as Map<String, dynamic>);
} catch (_) {
return null;
}
}
/// `GET /chat/:tripId/messages` — جلب سجل الرسائل.
Future<List<ChatMessageModel>> getMessages(String tripId, {String? currentUserId}) async {
try {
final r = await _api.dio.get('/chat/$tripId/messages');
final items = (r.data['items'] ?? r.data) as List;
return items
.map((j) => ChatMessageModel.fromJson(
j as Map<String, dynamic>,
currentUserId: currentUserId,
))
.toList();
} catch (_) {
return [];
}
}
/// `POST /chat/:tripId/send` — إرسال رسالة.
Future<ChatMessageModel?> sendMessage(String tripId, String text,
{String? currentUserId}) async {
try {
final r = await _api.dio.post('/chat/$tripId/send', data: {'text': text});
return ChatMessageModel.fromJson(
r.data as Map<String, dynamic>,
currentUserId: currentUserId,
);
} catch (_) {
return null;
}
}
}
@@ -0,0 +1,89 @@
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).
///
/// للسائق: يستقبل عروض الرحلات (trip:offer) وتحديثات الحالة
/// عبر FCM كطبقة احتياطية للسوكت. أيّهما يسبق يفوز —
/// الـ merger يتكفّل بالتصفية الصامتة.
class DriverFcmService {
final DriverTripEventMerger _merger;
StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onOpenSub;
DriverFcmService(this._merger);
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;
switch (type) {
case 'trip_offer':
// عرض رحلة وارد — سيُعالج عبر OffersBloc.
_merger.addNewTrip(tripId);
break;
case 'trip_offer_taken':
// العرض تم قبوله من سائق آخر.
_merger.addOfferTaken(tripId);
break;
default:
// تحديث حالة رحلة نشطة.
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(DriverTripUpdate(
tripId: tripId,
status: status,
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';
/// نموذج رسالة الدردشة — من `GET /chat/:tripId/messages` و`POST /chat/:tripId/send`.
class ChatMessageModel extends Equatable {
final String id;
final String tripId;
final String senderId;
final String text;
final DateTime createdAt;
final bool isMe;
const ChatMessageModel({
required this.id,
required this.tripId,
required this.senderId,
required this.text,
required this.createdAt,
required this.isMe,
});
factory ChatMessageModel.fromJson(Map<String, dynamic> j, {String? currentUserId}) =>
ChatMessageModel(
id: j['id'] as String,
tripId: (j['tripId'] ?? '') as String,
senderId: j['senderId'] as String,
text: j['text'] as String,
createdAt: DateTime.parse(j['createdAt'] as String),
isMe: j['senderId'] == currentUserId,
);
@override
List<Object?> get props => [id, tripId, senderId, text, createdAt];
}
@@ -0,0 +1,93 @@
import 'package:equatable/equatable.dart';
/// نموذج الرحلة من وجهة نظر السائق — من `GET /trips/available` وتحديثات WebSocket.
class DriverTripModel extends Equatable {
final String id;
final String status;
final String? clientId;
final String? clientName;
final String? clientPhone;
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 DateTime? createdAt;
final DateTime? updatedAt;
const DriverTripModel({
required this.id,
required this.status,
this.clientId,
this.clientName,
this.clientPhone,
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.createdAt,
this.updatedAt,
});
factory DriverTripModel.fromJson(Map<String, dynamic> j) => DriverTripModel(
id: j['id'] as String,
status: j['status'] as String,
clientId: j['clientId'] as String?,
clientName: j['clientName'] as String?,
clientPhone: j['clientPhone'] 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?,
createdAt: j['createdAt'] != null ? DateTime.tryParse(j['createdAt'] as String) : null,
updatedAt: j['updatedAt'] != null ? DateTime.tryParse(j['updatedAt'] as String) : null,
);
DriverTripModel copyWith({
String? status,
String? clientName,
String? clientPhone,
num? fare,
String? currency,
}) {
return DriverTripModel(
id: id,
status: status ?? this.status,
clientId: clientId,
clientName: clientName ?? this.clientName,
clientPhone: clientPhone ?? this.clientPhone,
pickupLat: pickupLat,
pickupLng: pickupLng,
pickupAddress: pickupAddress,
dropoffLat: dropoffLat,
dropoffLng: dropoffLng,
dropoffAddress: dropoffAddress,
vehicleClass: vehicleClass,
fare: fare ?? this.fare,
currency: currency ?? this.currency,
paymentMethod: paymentMethod,
createdAt: createdAt,
updatedAt: DateTime.now(),
);
}
@override
List<Object?> get props => [id, status, clientId, vehicleClass, fare];
}
@@ -0,0 +1,92 @@
import 'dart:async';
import '../../home/data/models/trip_offer_model.dart';
/// تحديث حالة رحلة من WebSocket (للسائق).
class DriverTripUpdate {
final String tripId;
final String status;
final Map<String, dynamic> extra;
const DriverTripUpdate({
required this.tripId,
required this.status,
this.extra = const {},
});
}
/// محول أحداث WebSocket للسائق — يدعم:
/// - عروض الرحلات (`trip:offer`)
/// - إلغاء العروض (`trip:offer_taken`)
/// - تحديثات الحالة (`trip:update`)
/// - رحلات جديدة (`trip:new`)
///
/// التصفية الصامتة تتم بترتيب الحالات (docs/23 §15).
int _statusOrder(String s) {
switch (s) {
case 'assigned':
return 0;
case 'driver_arriving':
return 1;
case 'driver_arrived':
return 2;
case 'in_progress':
return 3;
case 'completed':
return 4;
case 'paid':
return 5;
default:
return -1; // cancelled, expired
}
}
class DriverTripEventMerger {
final _offerController = StreamController<TripOfferModel>.broadcast();
final _offerTakenController = StreamController<String>.broadcast();
final _updateController = StreamController<DriverTripUpdate>.broadcast();
final _newTripController = StreamController<String>.broadcast();
/// الحالة المعروضة حالياً لكل رحلة.
final Map<String, int> _currentOrder = {};
Stream<TripOfferModel> get offerStream => _offerController.stream;
Stream<String> get offerTakenStream => _offerTakenController.stream;
Stream<DriverTripUpdate> get updateStream => _updateController.stream;
Stream<String> get newTripStream => _newTripController.stream;
/// استقبال عرض رحلة وارد.
void addOffer(TripOfferModel offer) {
_offerController.add(offer);
}
/// العرض تم قبوله من سائق آخر.
void addOfferTaken(String tripId) {
_offerTakenController.add(tripId);
}
/// تحديث حالة رحلة نشطة.
void addUpdate(DriverTripUpdate 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);
}
/// إشعار برحلة جديدة متاحة.
void addNewTrip(String tripId) {
_newTripController.add(tripId);
}
void dispose() {
_offerController.close();
_offerTakenController.close();
_updateController.close();
_newTripController.close();
}
}