feat: implement core architecture, design system, localization, and trip features for both driver and rider apps.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user