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