سوكيت الراكب (سبب عدم ظهور معلومات السائق عند القبول): - تطبيق الراكب كان يرسل id فقط بلا jwt، و passenger_socket.php يرفض أي اتصال بلا jwt ⇒ الراكب لا ينضم لغرفته أبداً ولا يستلم ride_status_change ولا driver_location_update. تظهر حالة القبول عبر الـ polling فقط بينما driver_info يصل بالسوكيت وحده. (سوكيت السائق يعتبر الـ jwt اختيارياً، ومن هنا جاء التباين بين التطبيقين.) - cancelled_by_driver كان يسقط من switch حالات الراكب فيبقى معلّقاً بعد إلغاء السائق. - حماية socket (late) من القراءة قبل التهيئة عند الانسحاب بلا jwt. الإشعارات والرسائل (سبب "مرات توصل ومرات لا"): - جدول tokens يخزّن توكن الراكب مشفّراً، و getRideWaiting.php كان يرجعه بلا فك تشفير ⇒ من يقبل من قائمة السوق يحمل blob مشفّراً يستخدمه كـ FCM target فيرفضه FCM بـ 400: لا إشعار قبول ولا رسائل. ومن يقبل من الـ dispatch/FCM يحمل نصاً صريحاً فتعمل. الفرق كان في طريقة القبول. - acceptRide.php يحلّ التوكن من القاعدة دائماً ولا يثق بالعميل (أصحّ أمنياً). - market_new_ride كان لا يحمل passengerId ولا الإحداثيات فتصل "null"؛ أُضيفت بلا أي PII لأن الحمولة تُبَثّ لكل سائق قريب لا للفائز فقط. - send_fcm.php: مهلة على OAuth (كان يعلّق حتى مهلة PHP فتُسقط الرسالة بصمت)، توحيد ding→default لأندرويد، وحقن title/body/tone في data مطابقةً لـ FcmService. - تطبيق السائق يقرأ title/body من data أولاً مثل الراكب، ولا يعرض فقاعة فارغة للرسائل الصامتة. السوكيت والإعدادات: - forwardLocationToPassengerSocket كان يقرأ lat/lng والحمولة فيها latitude/longitude ⇒ المسافة تخرج ضخمة والـ throttle معطّل تماماً فيُعاد التوجيه مع كل نبضة GPS. - notifyPassengerOnRideServer كان يرجع null بصمت مطلق عند حجب العنوان. - العنوان الافتراضي لسيرفر الموقع كان nginx/loction_server/driver_socket.php وهو ديمون Workerman لا يُخدَم عبر nginx ⇒ صار socket_driver:2021. (LOCATION_API_URL بقي على nginx لأن api_get_nearby.php سكربت عادي.) - ride_server/passenger_socket.php (النسخة التي يشغّلها Docker) كانت ناقصة كل كود مواصلاتي الموجود في passenger_server/ ⇒ نُقل مع REDIS_HOST. - .env.example: ALLOWED_SOCKET_URLS يغطّي أسماء حاويات Docker، وإضافة PASSENGER_SOCKET_INTERNAL_URL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
422 lines
14 KiB
Dart
422 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:get/get.dart';
|
|
import 'package:socket_io_client/socket_io_client.dart' as io_client;
|
|
import 'package:intaleq_maps/intaleq_maps.dart';
|
|
|
|
import '../../../constant/box_name.dart';
|
|
import '../../../constant/links.dart';
|
|
import '../../../env/env.dart';
|
|
import '../../../main.dart'; // contains global 'box' + 'storage'
|
|
import '../../../print.dart';
|
|
import '../../functions/encrypt_decrypt.dart'; // r()
|
|
import 'ride_lifecycle_controller.dart';
|
|
import 'nearby_drivers_controller.dart';
|
|
import 'map_engine_controller.dart';
|
|
|
|
class MapSocketController extends GetxController {
|
|
late io_client.Socket socket;
|
|
bool isSocketConnected = false;
|
|
bool _isSocketInitialized = false;
|
|
Timer? _heartbeatTimer;
|
|
DateTime? _lastSocketLocationTime;
|
|
int _socketLocationUpdatesCount = 0;
|
|
Timer? _watchdogTimer;
|
|
|
|
// 🚌 مواصلاتي — الخط الحالي المشترَك فيه + مستمع تحديث موقع الباص
|
|
int? _subscribedTransitRouteId;
|
|
void Function(Map<String, dynamic> data)? onBusLocationUpdate;
|
|
void Function(String errorCode)? onTransitError;
|
|
|
|
DateTime? get lastDriverLocationTime => _lastSocketLocationTime;
|
|
int get socketLocationUpdatesCount => _socketLocationUpdatesCount;
|
|
|
|
/// يقرأ الـ JWT بنفس آلية CRUD._getJwt (SecureStorage أولاً ثم GetStorage).
|
|
/// passenger_socket.php يرفض أي اتصال بلا jwt، فبدونه لا تصل أي أحداث للراكب.
|
|
Future<String> _getJwtForSocket() async {
|
|
try {
|
|
final String? encryptedJwt = await storage.read(key: BoxName.jwt);
|
|
if (encryptedJwt != null && encryptedJwt.isNotEmpty) {
|
|
return r(encryptedJwt).toString().split(Env.addd)[0];
|
|
}
|
|
} catch (e) {
|
|
Log.print('Error reading JWT from SecureStorage for socket: $e');
|
|
}
|
|
final String? fallback = box.read(BoxName.jwt);
|
|
if (fallback != null && fallback.toString().isNotEmpty) {
|
|
try {
|
|
return r(fallback).toString().split(Env.addd)[0];
|
|
} catch (e) {
|
|
Log.print('Error decrypting fallback JWT for socket: $e');
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
Future<void> initConnectionWithSocket() async {
|
|
if (isSocketConnected) return;
|
|
|
|
String passengerId = box.read(BoxName.passengerID).toString();
|
|
final String jwt = await _getJwtForSocket();
|
|
if (jwt.isEmpty) {
|
|
Log.print(
|
|
"⚠️ No JWT available — passenger socket would be rejected by the server. Aborting connect.");
|
|
return;
|
|
}
|
|
Log.print("🔌 Initializing Socket for Passenger: $passengerId");
|
|
|
|
socket = io_client.io(
|
|
AppLink.serverSocket,
|
|
io_client.OptionBuilder()
|
|
.setTransports(['websocket'])
|
|
.disableAutoConnect()
|
|
.setQuery({'id': passengerId, 'jwt': jwt})
|
|
.setReconnectionAttempts(20)
|
|
.setReconnectionDelay(2000)
|
|
.setReconnectionDelayMax(10000)
|
|
.enableReconnection()
|
|
.setTimeout(20000)
|
|
.setExtraHeaders({'Connection': 'Upgrade'})
|
|
.build(),
|
|
);
|
|
_isSocketInitialized = true;
|
|
|
|
socket.connect();
|
|
|
|
socket.onConnect((_) {
|
|
Log.print("✅ Socket Connected Successfully");
|
|
isSocketConnected = true;
|
|
_startHeartbeat();
|
|
|
|
final rideLifecycle = Get.find<RideLifecycleController>();
|
|
if (rideLifecycle.rideId != 'yet' && rideLifecycle.driverId.isNotEmpty) {
|
|
socket.emit('subscribe_driver_location', {
|
|
'ride_id': rideLifecycle.rideId,
|
|
'driver_id': rideLifecycle.driverId,
|
|
});
|
|
Log.print("📡 Re-subscribed to driver location after connect");
|
|
}
|
|
if (_subscribedTransitRouteId != null) {
|
|
socket.emit('subscribe_transit_route', {'route_id': _subscribedTransitRouteId});
|
|
Log.print("🚌 Re-subscribed to transit route after connect");
|
|
}
|
|
update();
|
|
});
|
|
|
|
socket.onDisconnect((_) {
|
|
Log.print("⚠️ Socket Disconnected — Auto-Reconnect will handle it");
|
|
isSocketConnected = false;
|
|
|
|
final rideLifecycle = Get.find<RideLifecycleController>();
|
|
if (rideLifecycle.isActiveRideState()) {
|
|
Log.print("🔄 Enabling Fast Polling Fallback (4s) until reconnect...");
|
|
rideLifecycle.startMasterTimerWithInterval(4);
|
|
}
|
|
update();
|
|
});
|
|
|
|
socket.onReconnect((_) {
|
|
Log.print("🔁 Socket Reconnected Successfully!");
|
|
isSocketConnected = true;
|
|
_startHeartbeat();
|
|
|
|
final rideLifecycle = Get.find<RideLifecycleController>();
|
|
if (rideLifecycle.rideId != 'yet' && rideLifecycle.driverId.isNotEmpty) {
|
|
socket.emit('subscribe_driver_location', {
|
|
'ride_id': rideLifecycle.rideId,
|
|
'driver_id': rideLifecycle.driverId,
|
|
});
|
|
Log.print("📡 Re-subscribed to driver location after reconnect");
|
|
}
|
|
if (_subscribedTransitRouteId != null) {
|
|
socket.emit('subscribe_transit_route', {'route_id': _subscribedTransitRouteId});
|
|
Log.print("🚌 Re-subscribed to transit route after reconnect");
|
|
}
|
|
|
|
if (rideLifecycle.isActiveRideState()) {
|
|
Log.print("✅ Socket back online — stopping Fast Polling Fallback");
|
|
rideLifecycle.cancelMasterTimer();
|
|
}
|
|
update();
|
|
});
|
|
|
|
socket.onReconnectAttempt((attemptNumber) {
|
|
Log.print("🔄 Socket Reconnect Attempt #$attemptNumber...");
|
|
});
|
|
|
|
socket.onError((error) {
|
|
Log.print("❌ Socket Error: $error");
|
|
isSocketConnected = false;
|
|
});
|
|
|
|
socket.on('connect_error', (error) {
|
|
Log.print("❌ Socket Connect Error: $error");
|
|
isSocketConnected = false;
|
|
// في الإصدار 1.0.2 أحياناً auto-reconnect لا يعمل بعد connect_error
|
|
// نتأكد يدوياً من إعادة الاتصال
|
|
Future.delayed(const Duration(seconds: 3), () {
|
|
if (!isSocketConnected && _isSocketInitialized) {
|
|
Log.print("🔄 Manual reconnect after connect_error...");
|
|
try {
|
|
socket.connect();
|
|
} catch (e) {
|
|
Log.print("Manual reconnect error: $e");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
socket.on('ride_status_change', (data) {
|
|
Log.print("📩 Socket Event: ride_status_change -> $data");
|
|
_handleRideStatusChangeWithSocket(data);
|
|
});
|
|
|
|
socket.on('driver_location_update', (data) {
|
|
handleDriverLocationUpdate(data);
|
|
});
|
|
|
|
// 🚌 مواصلاتي — بثّ موقع الباص الحي لخط مشترَك فيه
|
|
socket.on('bus_location_update', (data) {
|
|
if (data == null) return;
|
|
try {
|
|
final map = Map<String, dynamic>.from(data as Map);
|
|
onBusLocationUpdate?.call(map);
|
|
} catch (e) {
|
|
Log.print('Error parsing bus_location_update: $e');
|
|
}
|
|
});
|
|
|
|
socket.on('transit_error', (data) {
|
|
if (data == null) return;
|
|
try {
|
|
final map = Map<String, dynamic>.from(data as Map);
|
|
final code = map['code']?.toString() ?? '';
|
|
Log.print('⚠️ transit_error: $code');
|
|
onTransitError?.call(code);
|
|
} catch (e) {
|
|
Log.print('Error parsing transit_error: $e');
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── مواصلاتي: اشتراك/إلغاء اشتراك ببثّ موقع خط ─────────────
|
|
// يُستدعى عند فتح/إغلاق شاشة تتبع الباص الحي. يضمن السوكيت متصلاً أولاً.
|
|
Future<void> subscribeToTransitRoute(int routeId) async {
|
|
_subscribedTransitRouteId = routeId;
|
|
if (!isSocketConnected) {
|
|
await initConnectionWithSocket();
|
|
// سيُعاد الاشتراك تلقائياً من onConnect إن أضفنا ذلك، لكن نحاول فوراً أيضاً
|
|
}
|
|
// socket هو late — لو انسحب initConnectionWithSocket (بلا JWT مثلاً) يبقى
|
|
// غير مُهيّأ، فقراءته مباشرة ترمي LateInitializationError.
|
|
if (_isSocketInitialized && socket.connected) {
|
|
socket.emit('subscribe_transit_route', {'route_id': routeId});
|
|
Log.print('🚌 Subscribed to transit route #$routeId');
|
|
}
|
|
}
|
|
|
|
void unsubscribeFromTransitRoute(int routeId) {
|
|
if (_subscribedTransitRouteId == routeId) _subscribedTransitRouteId = null;
|
|
if (_isSocketInitialized && isSocketConnected && socket.connected) {
|
|
socket.emit('unsubscribe_transit_route', {'route_id': routeId});
|
|
Log.print('🚌 Unsubscribed from transit route #$routeId');
|
|
}
|
|
}
|
|
|
|
void _startHeartbeat() {
|
|
_heartbeatTimer?.cancel();
|
|
_heartbeatTimer = Timer.periodic(const Duration(seconds: 15), (timer) {
|
|
if (isSocketConnected && socket.connected) {
|
|
socket.emit('heartbeat',
|
|
{'passenger_id': box.read(BoxName.passengerID).toString()});
|
|
}
|
|
});
|
|
}
|
|
|
|
bool isSocketHealthy() {
|
|
if (!isSocketConnected) return false;
|
|
if (_lastSocketLocationTime == null) return false;
|
|
final diff = DateTime.now().difference(_lastSocketLocationTime!).inSeconds;
|
|
return diff < 20;
|
|
}
|
|
|
|
void _handleRideStatusChangeWithSocket(dynamic data) {
|
|
if (data == null || data['status'] == null) return;
|
|
|
|
String newStatus = data['status'].toString().toLowerCase();
|
|
Log.print("🔔 Socket Status Update: $newStatus");
|
|
|
|
final rideLifecycle = Get.find<RideLifecycleController>();
|
|
|
|
Map<String, dynamic>? driverInfo;
|
|
if (data['driver_info'] != null && data['driver_info'] is Map) {
|
|
driverInfo = Map<String, dynamic>.from(data['driver_info']);
|
|
}
|
|
|
|
switch (newStatus) {
|
|
case 'accepted':
|
|
case 'apply':
|
|
case 'applied':
|
|
rideLifecycle.processRideAcceptance(
|
|
driverData: driverInfo, source: "Socket");
|
|
break;
|
|
|
|
case 'arrived':
|
|
rideLifecycle.processDriverArrival("Socket");
|
|
break;
|
|
|
|
case 'started':
|
|
case 'begin':
|
|
rideLifecycle.processRideBegin(source: "Socket");
|
|
break;
|
|
|
|
case 'finished':
|
|
case 'ended':
|
|
_onRideFinishedWithSocket(data);
|
|
break;
|
|
|
|
// cancel_ride_by_driver.php يبعث 'cancelled_by_driver' حرفياً،
|
|
// و cancel_ride_by_passenger.php يبعث 'cancelled_by_passenger'.
|
|
// كانت الحالتان تسقطان من الـ switch فيبقى الراكب معلّقاً على الشاشة.
|
|
case 'cancelled':
|
|
case 'cancelled_by_driver':
|
|
case 'canceled_by_driver':
|
|
rideLifecycle.processRideCancelledByDriver(data, source: "Socket");
|
|
break;
|
|
|
|
case 'no_drivers_found':
|
|
rideLifecycle.showNoDriverDialog();
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _onRideFinishedWithSocket(dynamic data) {
|
|
Log.print("🏁 Ride Finished (Socket)");
|
|
final rideLifecycle = Get.find<RideLifecycleController>();
|
|
|
|
var rawList = data['DriverList'];
|
|
List<dynamic> listToSend = [];
|
|
|
|
if (rawList != null) {
|
|
if (rawList is List) {
|
|
listToSend = rawList;
|
|
} else if (rawList is String) {
|
|
try {
|
|
listToSend = jsonDecode(rawList);
|
|
} catch (e) {
|
|
Log.print("Error decoding DriverList: $e");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (listToSend.isEmpty && data['price'] != null) {
|
|
listToSend = [
|
|
rideLifecycle.driverId,
|
|
rideLifecycle.rideId,
|
|
rideLifecycle.driverToken,
|
|
data['price'].toString()
|
|
];
|
|
}
|
|
|
|
rideLifecycle.processRideFinished(listToSend, source: "Socket");
|
|
}
|
|
|
|
void handleDriverLocationUpdate(dynamic data) {
|
|
if (!isSocketConnected || data == null) return;
|
|
_lastSocketLocationTime = DateTime.now();
|
|
_socketLocationUpdatesCount++;
|
|
|
|
final rideLifecycle = Get.find<RideLifecycleController>();
|
|
if (rideLifecycle.driverId.isEmpty &&
|
|
(data['driver_id'] ?? data['driverId']) != null) {
|
|
rideLifecycle.driverId =
|
|
(data['driver_id'] ?? data['driverId']).toString();
|
|
}
|
|
|
|
if (_socketLocationUpdatesCount >= 3 &&
|
|
rideLifecycle.locationPollingTimer != null) {
|
|
Log.print("✅ Socket delivering locations reliably. Stopping polling.");
|
|
rideLifecycle.stopDriverLocationPolling();
|
|
}
|
|
|
|
try {
|
|
double lat = double.tryParse(
|
|
(data['latitude'] ?? data['lat'])?.toString() ?? '0') ??
|
|
0;
|
|
double lng = double.tryParse(
|
|
(data['longitude'] ?? data['lng'])?.toString() ?? '0') ??
|
|
0;
|
|
double heading = double.tryParse(data['heading']?.toString() ?? '0') ?? 0;
|
|
|
|
if (lat == 0 || lng == 0) return;
|
|
|
|
LatLng newPos = LatLng(lat, lng);
|
|
|
|
final nearbyDrivers = Get.find<NearbyDriversController>();
|
|
if (nearbyDrivers.driverCarsLocationToPassengerAfterApplied.isEmpty) {
|
|
nearbyDrivers.driverCarsLocationToPassengerAfterApplied.add(newPos);
|
|
} else {
|
|
nearbyDrivers.driverCarsLocationToPassengerAfterApplied[0] = newPos;
|
|
}
|
|
|
|
double speed = double.tryParse(data['speed']?.toString() ?? '0') ?? 0;
|
|
rideLifecycle.checkAndRecalculateIfDeviated(
|
|
newPos,
|
|
heading: heading,
|
|
speed: speed,
|
|
);
|
|
|
|
final mapEngine = Get.find<MapEngineController>();
|
|
if (mapEngine.mapController != null) {
|
|
double zoom = 16.5;
|
|
if (speed > 0) {
|
|
zoom = 17.0 - ((speed - 10) / 70) * 2.5;
|
|
zoom = zoom.clamp(14.5, 17.0);
|
|
}
|
|
mapEngine.mapController!
|
|
.animateCamera(CameraUpdate.newLatLngZoom(newPos, zoom));
|
|
}
|
|
|
|
final dynamic distanceValue =
|
|
data['distance_m'] ?? data['distance_meters'];
|
|
final double? distanceMeters =
|
|
double.tryParse(distanceValue?.toString() ?? '');
|
|
final int? etaSeconds = data['eta_seconds'] == null
|
|
? null
|
|
: int.tryParse(data['eta_seconds'].toString());
|
|
final bool hasServerMetrics = (etaSeconds != null && etaSeconds > 0) ||
|
|
(distanceMeters != null && distanceMeters > 0);
|
|
if (hasServerMetrics) {
|
|
rideLifecycle.updateDriverRouteMetrics(
|
|
etaSeconds: etaSeconds != null && etaSeconds > 0 ? etaSeconds : null,
|
|
distanceMeters: distanceMeters,
|
|
);
|
|
}
|
|
|
|
rideLifecycle.updateDriverMarker(newPos, heading);
|
|
rideLifecycle.updateRemainingRoute(newPos, updateEta: !hasServerMetrics);
|
|
rideLifecycle.update();
|
|
} catch (e) {
|
|
Log.print('Error in handleDriverLocationUpdate: $e');
|
|
}
|
|
}
|
|
|
|
void disposeRideSocket() {
|
|
_heartbeatTimer?.cancel();
|
|
_watchdogTimer?.cancel();
|
|
if (_isSocketInitialized) {
|
|
socket.disconnect();
|
|
socket.dispose();
|
|
isSocketConnected = false;
|
|
_isSocketInitialized = false;
|
|
Log.print("🔌 Socket Disposed");
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
disposeRideSocket();
|
|
super.onClose();
|
|
}
|
|
}
|