Files
Siro/siro_rider/lib/controller/home/map/map_socket_controller.dart
T
Hamza-AyedandClaude Opus 5 143146c1b4 إصلاح سوكيت الراكب وتوحيد مسارات الإشعارات
سوكيت الراكب (سبب عدم ظهور معلومات السائق عند القبول):
- تطبيق الراكب كان يرسل 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>
2026-07-26 15:47:23 +03:00

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();
}
}