1109 lines
39 KiB
Dart
Executable File
1109 lines
39 KiB
Dart
Executable File
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:get/get.dart';
|
||
import 'package:geolocator/geolocator.dart' as geo;
|
||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||
import 'package:location/location.dart';
|
||
import 'package:battery_plus/battery_plus.dart';
|
||
import 'package:permission_handler/permission_handler.dart' as ph;
|
||
import 'package:socket_io_client/socket_io_client.dart' as IO;
|
||
import 'package:siro_driver/constant/table_names.dart';
|
||
import 'package:trip_overlay_plugin/trip_overlay_plugin.dart';
|
||
import '../../constant/country_polygons.dart';
|
||
import '../../constant/box_name.dart';
|
||
import '../../constant/links.dart';
|
||
import '../../main.dart';
|
||
import '../../print.dart';
|
||
import '../firebase/local_notification.dart';
|
||
import '../home/captin/home_captain_controller.dart';
|
||
import '../home/captin/map_driver_controller.dart';
|
||
import '../home/payment/captain_wallet_controller.dart';
|
||
import '../home/navigation/navigation_controller.dart';
|
||
import 'background_service.dart';
|
||
import 'crud.dart';
|
||
import '../transit/transit_driver_controller.dart';
|
||
import '../food_delivery/food_location_broadcaster.dart';
|
||
|
||
/// إعدادات تتبّع الموقع لحالة معيّنة من حالات السائق.
|
||
/// الهدف: عدم تشغيل الـ GPS بأقصى طاقته إلا أثناء الرحلة الفعلية.
|
||
class _LocationProfile {
|
||
final String name;
|
||
final LocationAccuracy accuracy;
|
||
final int interval; // ميلي ثانية
|
||
final double distanceFilter; // متر
|
||
|
||
const _LocationProfile(
|
||
this.name, this.accuracy, this.interval, this.distanceFilter);
|
||
|
||
_LocationProfile copyWith(
|
||
{String? name,
|
||
LocationAccuracy? accuracy,
|
||
int? interval,
|
||
double? distanceFilter}) =>
|
||
_LocationProfile(
|
||
name ?? this.name,
|
||
accuracy ?? this.accuracy,
|
||
interval ?? this.interval,
|
||
distanceFilter ?? this.distanceFilter,
|
||
);
|
||
|
||
/// بصمة تُستخدم لتفادي استدعاء changeSettings بلا داعٍ.
|
||
String get signature => '$accuracy|$interval|$distanceFilter';
|
||
|
||
@override
|
||
String toString() =>
|
||
'$name(acc: ${accuracy.name}, ${interval}ms, ${distanceFilter}m)';
|
||
}
|
||
|
||
class LocationController extends GetxController with WidgetsBindingObserver {
|
||
// ===================================================================
|
||
// ====== Tunables ======
|
||
// ===================================================================
|
||
static const Duration recordIntervalNormal = Duration(seconds: 3);
|
||
static const Duration uploadBatchIntervalNormal = Duration(minutes: 2);
|
||
static const Duration recordIntervalPowerSave = Duration(seconds: 10);
|
||
static const Duration uploadBatchIntervalPowerSave = Duration(minutes: 5);
|
||
|
||
static const double lowWalletThreshold = -200;
|
||
static const int powerSaveTriggerLevel = 20;
|
||
static const int powerSaveExitLevel = 25;
|
||
|
||
// ===================================================================
|
||
// ====== ملفات التتبّع التكيّفية (أ) ======
|
||
// ===================================================================
|
||
/// السائق غير متاح (off/blocked): أرخص إعداد ممكن مع إبقاء آخر موقع معروف.
|
||
static const _profileIdle =
|
||
_LocationProfile('idle', LocationAccuracy.balanced, 30000, 100);
|
||
|
||
/// متصل وينتظر طلباً: لا حاجة لدقة الملاحة — يكفي أن يعرفه السيرفر بالحي.
|
||
static const _profileWaiting =
|
||
_LocationProfile('waiting', LocationAccuracy.high, 15000, 50);
|
||
|
||
/// قَبِل الطلب وفي طريقه للراكب: الراكب يراقب السهم، نحتاج تحديثاً معقولاً.
|
||
static const _profileEnRoute =
|
||
_LocationProfile('enRoute', LocationAccuracy.high, 5000, 15);
|
||
|
||
/// رحلة جارية (أو وضع الباص): أعلى دقة — هنا تُحتسب المسافة والأجرة.
|
||
static const _profileOnTrip =
|
||
_LocationProfile('onTrip', LocationAccuracy.navigation, 4000, 10);
|
||
|
||
/// بعد هذه المدة من السكون نضاعف الفترة (سائق واقف في مرآب/إشارة طويلة).
|
||
static const Duration stationaryGrace = Duration(seconds: 90);
|
||
static const double stationarySpeedThreshold = 1.0; // م/ث ≈ 3.6 كم/س
|
||
|
||
// ===================================================================
|
||
// ====== Services & Variables ======
|
||
// ===================================================================
|
||
late final Location location = Location();
|
||
final Battery _battery = Battery();
|
||
|
||
IO.Socket? socket;
|
||
bool isSocketConnected = false;
|
||
Timer? _socketHeartbeat;
|
||
|
||
// 🚌 مواصلاتي — وضع الباص: بينما نشط، الموقع يُبَث فقط عبر update_bus_location
|
||
// ولا يدخل حوض الرحلات العادي (geo:drivers:*)
|
||
bool isBusMode = false;
|
||
int? activeBusTripId;
|
||
int? activeBusRouteId;
|
||
|
||
void setBusMode({required bool enabled, int? tripId, int? routeId}) {
|
||
isBusMode = enabled;
|
||
activeBusTripId = enabled ? tripId : null;
|
||
activeBusRouteId = enabled ? routeId : null;
|
||
// نحفظ الحالة في GetStorage ليقرأها background_service في الـ isolate المستقل
|
||
box.write(BoxName.isBusMode, enabled);
|
||
box.write(BoxName.busModeTripId, enabled ? tripId : null);
|
||
box.write(BoxName.busModeRouteId, enabled ? routeId : null);
|
||
Log.print('🚌 Bus mode ${enabled ? "enabled" : "disabled"} (trip: $tripId, route: $routeId)');
|
||
}
|
||
|
||
StreamSubscription<LocationData>? _locSub;
|
||
StreamSubscription<BatteryState>? _batterySub;
|
||
|
||
Timer? _recordTimer;
|
||
Timer? _uploadBatchTimer;
|
||
|
||
late final HomeCaptainController _homeCtrl;
|
||
late final CaptainWalletController _walletCtrl;
|
||
|
||
LatLng myLocation = LatLng(
|
||
box.read('last_lat') ?? 0.0,
|
||
box.read('last_lng') ?? 0.0,
|
||
);
|
||
double heading = box.read('last_heading') ?? 0.0;
|
||
double speed = 0.0;
|
||
double totalDistance = 0.0;
|
||
bool _isReady = false;
|
||
bool _isPowerSavingMode = false;
|
||
bool isOutsideServiceArea = false; // Add flag to track if outside service area
|
||
|
||
/// هل خدمات جوجل متوفرة على الجهاز؟ (ج)
|
||
/// null = لم يُفحص بعد. على أجهزة هواوي بلا GMS يسقط الباكيج إلى
|
||
/// LocationManager الخام: دقة أقل واستهلاك بطارية أعلى ⇒ نخفّف الإعدادات.
|
||
bool? _gmsAvailable;
|
||
static const MethodChannel _deviceServicesChannel =
|
||
MethodChannel('com.siro.siro_driver/device_services');
|
||
|
||
/// آخر إعداد طُبّق فعلياً — لتفادي استدعاء changeSettings بلا تغيير.
|
||
String? _appliedProfileSignature;
|
||
DateTime? _stationarySince;
|
||
|
||
final List<Map<String, dynamic>> _trackBuffer = [];
|
||
final List<Map<String, dynamic>> _behaviorBuffer = [];
|
||
|
||
LatLng? _lastPosForDistance;
|
||
LatLng? _lastRecordedRealLoc;
|
||
DateTime? _lastRecordedTime;
|
||
|
||
LatLng? _lastSqlLoc;
|
||
double? _lastSpeed;
|
||
DateTime? _lastSpeedAt;
|
||
|
||
@override
|
||
Future<void> onInit() async {
|
||
super.onInit();
|
||
print('🚀 LocationController Starting...');
|
||
|
||
// 1. Register Lifecycle Observer
|
||
WidgetsBinding.instance.addObserver(this);
|
||
box.write(BoxName.isAppInForeground, true);
|
||
|
||
// مراقب الحالة (Status Watcher)
|
||
box.listenKey(BoxName.statusDriverLocation, (value) {
|
||
if (value == 'blocked') {
|
||
print("⛔ Driver is Blocked: Force Stopping Location Updates.");
|
||
stopLocationUpdates();
|
||
if (socket != null && socket!.connected) {
|
||
socket!.emit('update_location', {
|
||
'driver_id': box.read(BoxName.driverID),
|
||
'status': 'blocked',
|
||
'lat': myLocation.latitude,
|
||
'lng': myLocation.longitude,
|
||
'heading': heading,
|
||
'speed': speed * 3.6,
|
||
'distance': totalDistance
|
||
});
|
||
socket!.disconnect();
|
||
}
|
||
}
|
||
});
|
||
|
||
bool deps = await _awaitDependencies();
|
||
if (!deps) return;
|
||
|
||
_isReady = true;
|
||
|
||
initSocket();
|
||
await _detectGmsAvailability();
|
||
await _initLocationSettings();
|
||
_listenToBatteryChanges();
|
||
|
||
if (box.read(BoxName.statusDriverLocation) != 'blocked') {
|
||
await startLocationUpdates();
|
||
}
|
||
|
||
print('✅ LocationController Initialized.');
|
||
}
|
||
|
||
@override
|
||
void onClose() {
|
||
WidgetsBinding.instance.removeObserver(this);
|
||
box.write(BoxName.isAppInForeground, false);
|
||
stopLocationUpdates();
|
||
_batterySub?.cancel();
|
||
_stopHeartbeat();
|
||
socket?.dispose();
|
||
super.onClose();
|
||
}
|
||
|
||
// ===================================================================
|
||
// 🔥 Lifecycle Manager (Fixes Freeze & Background issues)
|
||
// ===================================================================
|
||
@override
|
||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||
if (state == AppLifecycleState.resumed) {
|
||
Log.print("📱 Lifecycle: App is in FOREGROUND");
|
||
box.write(BoxName.isAppInForeground, true);
|
||
|
||
// إيقاف خدمة الخلفية
|
||
BackgroundServiceHelper.stopService();
|
||
|
||
// 🔥 Fix: iOS silently kills WebSockets in background.
|
||
// The socket_io_client might still report 'connected' as true for ~20s.
|
||
// We MUST force a disconnect so initSocket() properly creates a fresh connection
|
||
// and immediately emits 'get_pending_orders' to catch missed FCM payloads.
|
||
if (socket != null) {
|
||
socket!.disconnect();
|
||
}
|
||
initSocket();
|
||
|
||
} else if (state == AppLifecycleState.paused ||
|
||
state == AppLifecycleState.detached) {
|
||
Log.print("📱 Lifecycle: App is in BACKGROUND");
|
||
box.write(BoxName.isAppInForeground, false);
|
||
|
||
// تشغيل خدمة الخلفية للأندرويد لضمان بقاء التطبيق حياً
|
||
if (!Platform.isIOS) {
|
||
BackgroundServiceHelper.startService();
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<bool> _awaitDependencies() async {
|
||
int attempts = 0;
|
||
while (attempts < 10) {
|
||
if (Get.isRegistered<HomeCaptainController>() &&
|
||
Get.isRegistered<CaptainWalletController>()) {
|
||
_homeCtrl = Get.find<HomeCaptainController>();
|
||
_walletCtrl = Get.find<CaptainWalletController>();
|
||
return true;
|
||
}
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
attempts++;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ===================================================================
|
||
// ====== Socket Logic (Improved) ======
|
||
// ===================================================================
|
||
|
||
bool _isInitializingSocket = false;
|
||
|
||
void initSocket() {
|
||
// منع الاستدعاءات المتداخلة التي تسبب قتل الاتصال قبل اكتماله
|
||
if (_isInitializingSocket) {
|
||
Log.print("⏳ Socket is already initializing. Skipping redundant call.");
|
||
return;
|
||
}
|
||
|
||
if (socket != null && socket!.connected) {
|
||
Log.print("✅ Socket is already connected. No need to re-init.");
|
||
return;
|
||
}
|
||
|
||
String driverId = box.read(BoxName.driverID).toString();
|
||
String token = box.read(BoxName.tokenDriver).toString();
|
||
String platform = Platform.isIOS ? 'ios' : 'android';
|
||
|
||
_isInitializingSocket = true;
|
||
|
||
// تنظيف السوكيت القديم فقط إذا كان موجوداً وغير متصل
|
||
if (socket != null) {
|
||
print("🧹 Cleaning up old socket instance...");
|
||
socket!.clearListeners();
|
||
socket!.dispose();
|
||
socket = null;
|
||
}
|
||
|
||
print("🟡 [LocationController] Initializing NEW Socket for Driver: $driverId");
|
||
|
||
try {
|
||
// العودة للـ Websocket حصراً لأنه الوحيد الذي ينجح في فتح القناة
|
||
socket = IO.io(
|
||
AppLink.locationSocketUrl,
|
||
IO.OptionBuilder()
|
||
.setTransports(['websocket'])
|
||
// إرسال platform إجباري: driver_socket.php يرسل إشعار FCM
|
||
// احتياطياً عند توزيع الطلب **فقط** إذا كانت المنصّة ios، لأن iOS
|
||
// يخنق أحداث السوكيت في الخلفية. وبلا هذا الحقل يصير الافتراضي
|
||
// 'android' فيفوت كابتن الآيفون الطلب وهو في الخلفية.
|
||
.setQuery({
|
||
'driver_id': driverId,
|
||
'token': token,
|
||
'platform': Platform.isIOS ? 'ios' : 'android',
|
||
'EIO': '3',
|
||
})
|
||
.enableForceNew()
|
||
.build());
|
||
|
||
_setupSocketListeners();
|
||
socket!.connect();
|
||
} catch (e) {
|
||
_isInitializingSocket = false;
|
||
print("❌ Socket Initialization Exception: $e");
|
||
}
|
||
}
|
||
|
||
void _setupSocketListeners() {
|
||
if (socket == null) return;
|
||
|
||
socket!.off('connect');
|
||
socket!.off('disconnect');
|
||
socket!.off('connect_error');
|
||
socket!.off('error');
|
||
|
||
socket!.onConnect((_) {
|
||
_isInitializingSocket = false;
|
||
|
||
// ننتظر قليلاً للتأكد من تعبئة الـ IDs
|
||
Future.delayed(const Duration(milliseconds: 1000), () {
|
||
String? sid = socket?.id;
|
||
String? eid = socket?.io.engine?.id;
|
||
|
||
print(
|
||
'✅ Socket Connected! ID: ${sid ?? eid ?? 'N/A'} (SID: $sid, EID: $eid)');
|
||
|
||
if (sid != null || eid != null) {
|
||
isSocketConnected = true;
|
||
|
||
if (myLocation.latitude != 0) {
|
||
print("🚀 Sending initial location to socket after connect...");
|
||
emitLocationToSocket(myLocation, heading, speed);
|
||
}
|
||
|
||
_startHeartbeat();
|
||
|
||
// 🆕 طلب أي رحلات انتظار فاتتنا أثناء انقطاع الاتصال
|
||
print("📦 Requesting pending orders after reconnect...");
|
||
socket!.emit('get_pending_orders');
|
||
}
|
||
});
|
||
});
|
||
|
||
socket!.onDisconnect((data) {
|
||
_isInitializingSocket = false;
|
||
print('❌ Socket Disconnected: $data');
|
||
isSocketConnected = false;
|
||
_stopHeartbeat();
|
||
});
|
||
|
||
socket!.onConnectError((err) {
|
||
_isInitializingSocket = false;
|
||
print('❌ Socket Connect Error: $err');
|
||
});
|
||
|
||
socket!.onConnectTimeout((data) {
|
||
_isInitializingSocket = false;
|
||
print('❌ Socket Connect Timeout: $data');
|
||
});
|
||
|
||
socket!.onError((err) {
|
||
_isInitializingSocket = false;
|
||
Log.print('❌ Socket General Error: $err');
|
||
});
|
||
|
||
socket!.on('reconnect_attempt', (attempt) {
|
||
Log.print('🔄 Socket Reconnecting... Attempt: $attempt');
|
||
});
|
||
|
||
// 🔥 الاستماع للطلبات الجديدة
|
||
socket!.on('new_ride_request', (data) {
|
||
Log.print("🔔 Socket: New Ride Request Arrived!");
|
||
|
||
// نستخدم Future.microtask لضمان عدم حظر الـ UI Thread
|
||
Future.microtask(() {
|
||
if (data != null) {
|
||
try {
|
||
List<dynamic> rawList = [];
|
||
if (data is String) {
|
||
var decoded = jsonDecode(data);
|
||
if (decoded is List) rawList = decoded;
|
||
} else if (data is List) {
|
||
if (data.isNotEmpty) {
|
||
rawList = (data[0] is List) ? data[0] : data;
|
||
}
|
||
}
|
||
|
||
if (rawList.isNotEmpty) {
|
||
Map<String, dynamic> convertedMap = {};
|
||
for (int i = 0; i < rawList.length; i++) {
|
||
convertedMap[i.toString()] = rawList[i];
|
||
}
|
||
handleIncomingOrder(convertedMap, "Socket");
|
||
}
|
||
} catch (e) {
|
||
Log.print("❌ Error processing socket data: $e");
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// 🔥 الاستماع للإلغاء
|
||
socket!.on('cancel_ride', (data) {
|
||
Log.print("🚫 Socket: Ride Cancelled Event Received");
|
||
String reason = data['reason'] ?? 'No reason provided';
|
||
if (Get.isRegistered<MapDriverController>()) {
|
||
Get.find<MapDriverController>()
|
||
.processRideCancelledByPassenger(reason, source: "Socket");
|
||
}
|
||
});
|
||
}
|
||
|
||
// داخل LocationController
|
||
|
||
Future<void> handleIncomingOrder(
|
||
Map<String, dynamic> rideData, String source) async {
|
||
Log.print("📦 Socket Order Received from ($source)");
|
||
|
||
// 🔴 1. التحقق من حالة التطبيق قبل أي شيء 🔴
|
||
bool isAppInForeground = box.read(BoxName.isAppInForeground) ?? false;
|
||
|
||
if (!isAppInForeground) {
|
||
Log.print(
|
||
"📱 [LocationController] Order received in background (iOS/Android). Source: $source");
|
||
|
||
if (Platform.isIOS) {
|
||
// على iOS، نقوم بإظهار إشعار محلي لأن الـ Overlay غير مدعوم
|
||
NotificationController().showNotification(
|
||
"طلب رحلة جديد 🚖",
|
||
"لديك طلب رحلة جديد، افتح التطبيق للموافقة عليه",
|
||
jsonEncode(rideData),
|
||
'ding.wav');
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 2. التحقق من صحة البيانات
|
||
if (rideData.isEmpty || !rideData.containsKey('16')) {
|
||
Log.print("❌ Socket Error: Invalid Ride Data.");
|
||
return;
|
||
}
|
||
|
||
// 3. تجهيز البيانات (DriverList)
|
||
List<dynamic> driverList = [];
|
||
if (rideData.isNotEmpty) {
|
||
var sortedKeys = rideData.keys
|
||
.where((e) => int.tryParse(e) != null)
|
||
.map((e) => int.parse(e))
|
||
.toList()
|
||
..sort();
|
||
|
||
for (var key in sortedKeys) {
|
||
driverList.add(rideData[key.toString()]);
|
||
}
|
||
}
|
||
|
||
// الحماية ضد البنية غير المكتملة
|
||
if (driverList.length <= 16) {
|
||
Log.print("❌ Socket Error: Parsed driver list is incomplete.");
|
||
return;
|
||
}
|
||
|
||
// 4. إغلاق النافذة (إن وجدت بالخطأ) والتنقل
|
||
try {
|
||
if (await TripOverlayPlugin.isOverlayActive()) {
|
||
Log.print("📲 Closing Overlay because App took control via Socket");
|
||
await TripOverlayPlugin.hideOverlay();
|
||
}
|
||
} catch (e) {
|
||
Log.print("Overlay check error: $e");
|
||
}
|
||
|
||
// 🔥 [Fix Active-Ride Guard] منع فتح صفحة الطلبات أثناء وجود السائق في رحلة نشطة
|
||
// هذا يمنع socket event جديد من تعطيل رحلة جارية
|
||
String? currentRideStatus = box.read(BoxName.rideStatus);
|
||
bool hasActiveRide = (currentRideStatus == 'Begin' ||
|
||
currentRideStatus == 'Apply' ||
|
||
currentRideStatus == 'Arrived');
|
||
String currentRoute = Get.currentRoute;
|
||
bool isOnMapPage = currentRoute.contains('MapPage') ||
|
||
currentRoute.contains('PassengerLocation');
|
||
|
||
if (hasActiveRide || isOnMapPage) {
|
||
Log.print(
|
||
"⛔ [LocationController] Ignoring new ride request — driver has active ride ($currentRideStatus) or is on map page ($currentRoute).");
|
||
return;
|
||
}
|
||
|
||
if (currentRoute != '/OrderRequestPage') {
|
||
Log.print("🚀 Socket: Navigating to OrderRequestPage...");
|
||
Get.toNamed('/OrderRequestPage', arguments: {
|
||
'myListString': jsonEncode(driverList),
|
||
'DriverList': driverList,
|
||
'body': 'New Trip Request via Socket ⚡',
|
||
'is_destination_match': rideData['is_destination_match'] ?? '0',
|
||
// 🆕 "أرباحك أعلى" — نفس الحقل الجاي من FCM، متوفر أيضاً عبر السوكيت
|
||
'driver_earnings_extra': rideData['driver_earnings_extra'],
|
||
'driver_earnings_currency': rideData['driver_earnings_currency'],
|
||
});
|
||
} else {
|
||
Log.print(
|
||
"⚠️ User is already on OrderRequestPage. Skipping navigation.");
|
||
}
|
||
} catch (e) {
|
||
Log.print("❌ Socket Navigation Error: $e");
|
||
}
|
||
}
|
||
|
||
void _startHeartbeat() {
|
||
_socketHeartbeat?.cancel();
|
||
_socketHeartbeat = Timer.periodic(const Duration(seconds: 25), (timer) {
|
||
if (socket == null || !isSocketConnected || myLocation.latitude == 0) return;
|
||
|
||
if (isBusMode) {
|
||
emitBusLocationToSocket(myLocation, heading, speed);
|
||
} else {
|
||
emitLocationToSocket(myLocation, heading, speed);
|
||
}
|
||
});
|
||
}
|
||
|
||
void _stopHeartbeat() {
|
||
_socketHeartbeat?.cancel();
|
||
}
|
||
|
||
// In LocationController.dart
|
||
|
||
void emitLocationToSocket(LatLng pos, double head, double spd) {
|
||
String status = box.read(BoxName.statusDriverLocation) ?? 'off';
|
||
String? currentRideStatus = box.read(BoxName.rideStatus);
|
||
String? storedPassengerId = box.read(BoxName.passengerID);
|
||
String? storedRideId = box.read(BoxName.rideId);
|
||
|
||
// Basic payload
|
||
var payload = {
|
||
'driver_id': box.read(BoxName.driverID),
|
||
'lat': pos.latitude,
|
||
'lng': pos.longitude,
|
||
'heading': head,
|
||
'speed': spd * 3.6,
|
||
'status': status,
|
||
'distance': totalDistance,
|
||
};
|
||
|
||
// 🔥 القرار الذكي: حقن بيانات الراكب إذا كان هناك رحلة نشطة في الـ Box 🔥
|
||
bool hasActiveRide = (currentRideStatus == 'Begin' ||
|
||
currentRideStatus == 'Apply' ||
|
||
currentRideStatus == 'Arrived');
|
||
|
||
if (hasActiveRide && storedPassengerId != null) {
|
||
payload['passenger_id'] = storedPassengerId;
|
||
payload['ride_id'] = storedRideId;
|
||
}
|
||
|
||
// DebugLog.print to verify
|
||
//Log.print('🚀 Emitting Location: $payload');
|
||
|
||
if (socket != null && socket!.connected) {
|
||
socket!.emit('update_location', payload);
|
||
}
|
||
}
|
||
|
||
// 🚌 مواصلاتي — بثّ موقع الباص (بديل عن update_location أثناء وضع الباص)
|
||
void emitBusLocationToSocket(LatLng pos, double head, double spd, {int? currentStopSeq}) {
|
||
if (activeBusTripId == null || activeBusRouteId == null) return;
|
||
if (socket == null || !socket!.connected) return;
|
||
|
||
socket!.emit('update_bus_location', {
|
||
'trip_id': activeBusTripId,
|
||
'route_id': activeBusRouteId,
|
||
'lat': pos.latitude,
|
||
'lng': pos.longitude,
|
||
'heading': head,
|
||
'speed': spd * 3.6,
|
||
if (currentStopSeq != null) 'current_stop_seq': currentStopSeq,
|
||
});
|
||
}
|
||
// ===================================================================
|
||
// ====== Tracking Logic ======
|
||
// ===================================================================
|
||
|
||
Future<void> startLocationUpdates() async {
|
||
_isReady = true;
|
||
String currentStatus = box.read(BoxName.statusDriverLocation) ?? 'off';
|
||
if (currentStatus == 'blocked') {
|
||
stopLocationUpdates();
|
||
return;
|
||
}
|
||
|
||
// Start background service
|
||
await BackgroundServiceHelper.startService();
|
||
|
||
if (socket == null || !socket!.connected) {
|
||
initSocket();
|
||
}
|
||
|
||
if (_locSub != null) return;
|
||
|
||
if (await _ensureServiceAndPermission()) {
|
||
_subscribeLocationStream();
|
||
_startBatchTimers();
|
||
}
|
||
}
|
||
|
||
Future<void> _subscribeLocationStream() async {
|
||
_locSub?.cancel();
|
||
await location.enableBackgroundMode(enable: true);
|
||
// مصدر الحقيقة الوحيد لإعدادات الموقع: _applyProfile.
|
||
await _applyProfile(force: true);
|
||
|
||
_locSub = location.onLocationChanged.listen((LocationData loc) async {
|
||
if (loc.latitude == null || loc.longitude == null) return;
|
||
|
||
final now = DateTime.now();
|
||
final pos = LatLng(loc.latitude!, loc.longitude!);
|
||
|
||
bool isOutside = !CountryPolygons.isServiceSupported(pos);
|
||
if (isOutsideServiceArea != isOutside) {
|
||
isOutsideServiceArea = isOutside;
|
||
}
|
||
|
||
if (isOutside) {
|
||
myLocation = const LatLng(31.9539, 35.9106);
|
||
} else {
|
||
myLocation = pos;
|
||
}
|
||
speed = loc.speed ?? 0.0;
|
||
heading = loc.heading ?? 0.0;
|
||
|
||
// إعادة تقييم ملف التتبّع مع كل قراءة — لا يستدعي changeSettings
|
||
// إلا إذا تغيّرت الحالة فعلاً (حالة السائق/الرحلة/السكون/البطارية).
|
||
_trackStationary();
|
||
await _applyProfile();
|
||
|
||
box.write('last_lat', pos.latitude);
|
||
box.write('last_lng', pos.longitude);
|
||
box.write('last_heading', heading);
|
||
|
||
if (_lastPosForDistance != null) {
|
||
final d = _calculateDistance(_lastPosForDistance!, pos);
|
||
if (d > 5.0) totalDistance += d;
|
||
}
|
||
_lastPosForDistance = pos;
|
||
|
||
update();
|
||
|
||
// 🚌 وضع الباص: بثّ موقع الباص فقط — لا يدخل حوض الرحلات العادي
|
||
if (isBusMode) {
|
||
emitBusLocationToSocket(pos, heading, speed);
|
||
// كشف الوصول للمحطات (جيوفينس)
|
||
if (Get.isRegistered<TransitDriverController>()) {
|
||
Get.find<TransitDriverController>().onLocationUpdate(pos);
|
||
}
|
||
await _saveBehaviorIfMoved(pos, now, currentSpeed: speed);
|
||
return;
|
||
}
|
||
|
||
emitLocationToSocket(pos, heading, speed);
|
||
|
||
if (Get.isRegistered<HomeCaptainController>()) {
|
||
final homeCtrl = Get.find<HomeCaptainController>();
|
||
if (homeCtrl.isActive &&
|
||
homeCtrl.mapHomeCaptainController != null &&
|
||
homeCtrl.isHomeMapActive &&
|
||
homeCtrl.isMapReadyForCommands) {
|
||
homeCtrl.mapHomeCaptainController?.animateCamera(
|
||
CameraUpdate.newLatLngZoom(myLocation, 17.5),
|
||
);
|
||
}
|
||
}
|
||
|
||
if (Get.isRegistered<MapDriverController>()) {
|
||
final mapCtrl = Get.find<MapDriverController>();
|
||
mapCtrl.handleLocationUpdateFromCentral(pos, speed, heading);
|
||
}
|
||
|
||
if (Get.isRegistered<NavigationController>()) {
|
||
final navCtrl = Get.find<NavigationController>();
|
||
navCtrl.handleLocationUpdateFromCentral(pos, speed, heading);
|
||
}
|
||
|
||
// بثّ الموقع للزبون أثناء مهمة توصيل — يركب على هذه النبضة بدل تدفّق
|
||
// GPS ثانٍ، فيستمر بعد إغلاق شاشة التوصيل ما دامت هذه الخدمة حية.
|
||
// يخرج فوراً إن لم تكن هناك مهمة نشطة.
|
||
FoodLocationBroadcaster.maybeSend(
|
||
lat: pos.latitude,
|
||
lng: pos.longitude,
|
||
heading: heading,
|
||
);
|
||
|
||
await _saveBehaviorIfMoved(pos, now, currentSpeed: speed);
|
||
}, onError: (e) => Log.print('❌ Location Stream Error: $e'));
|
||
}
|
||
|
||
Timer? _socketWatchdogTimer;
|
||
|
||
Future<void> stopLocationUpdates() async {
|
||
Log.print("🛑 Stopping Location Updates...");
|
||
|
||
_locSub?.cancel();
|
||
_locSub = null;
|
||
_appliedProfileSignature = null;
|
||
_stationarySince = null;
|
||
_recordTimer?.cancel();
|
||
_uploadBatchTimer?.cancel();
|
||
_socketHeartbeat?.cancel();
|
||
_socketWatchdogTimer?.cancel();
|
||
|
||
if (socket != null) {
|
||
socket!.clearListeners();
|
||
socket!.dispose();
|
||
}
|
||
|
||
if (!Platform.isIOS) {
|
||
await BackgroundServiceHelper.stopService();
|
||
}
|
||
|
||
socket = null;
|
||
isSocketConnected = false;
|
||
_isReady = false;
|
||
}
|
||
|
||
// ===================================================================
|
||
// ====== Batch Logic & Helpers ======
|
||
// ===================================================================
|
||
|
||
void _startBatchTimers() {
|
||
_recordTimer?.cancel();
|
||
_uploadBatchTimer?.cancel();
|
||
_socketWatchdogTimer?.cancel();
|
||
|
||
final recDur =
|
||
_isPowerSavingMode ? recordIntervalPowerSave : recordIntervalNormal;
|
||
final upDur = _isPowerSavingMode
|
||
? uploadBatchIntervalPowerSave
|
||
: uploadBatchIntervalNormal;
|
||
|
||
_recordTimer =
|
||
Timer.periodic(recDur, (_) => _recordCurrentLocationToBuffer());
|
||
_uploadBatchTimer = Timer.periodic(upDur, (_) => _flushBufferToServer());
|
||
|
||
// محاولة إعادة الاتصال بالسوكيت إذا انقطع كل 3 ثواني
|
||
_socketWatchdogTimer = Timer.periodic(const Duration(seconds: 3), (_) {
|
||
if (!isSocketConnected && !_isInitializingSocket) {
|
||
Log.print("🔄 Socket Watchdog: Attempting to reconnect socket...");
|
||
initSocket();
|
||
}
|
||
});
|
||
}
|
||
|
||
void _recordCurrentLocationToBuffer() {
|
||
if (myLocation.latitude == 0) return;
|
||
final now = DateTime.now();
|
||
double distFromLast = 0.0;
|
||
if (_lastRecordedRealLoc != null) {
|
||
distFromLast = _calculateDistance(_lastRecordedRealLoc!, myLocation);
|
||
}
|
||
bool moved = distFromLast > 10.0;
|
||
bool timeForced = _lastRecordedTime == null ||
|
||
now.difference(_lastRecordedTime!).inSeconds >= 60;
|
||
|
||
if ((moved && speed > 0.5) || timeForced) {
|
||
_lastRecordedRealLoc = myLocation;
|
||
_lastRecordedTime = now;
|
||
final point = {
|
||
'lat': double.parse(myLocation.latitude.toStringAsFixed(6)),
|
||
'lng': double.parse(myLocation.longitude.toStringAsFixed(6)),
|
||
'spd': double.parse((speed * 3.6).toStringAsFixed(1)),
|
||
'head': int.parse(heading.toStringAsFixed(0)),
|
||
'st': box.read(BoxName.statusDriverLocation) ?? 'off',
|
||
'ts': now.toIso8601String(),
|
||
};
|
||
_trackBuffer.add(point);
|
||
}
|
||
}
|
||
|
||
/// دفع ما تراكم من نقاط المسار فوراً بدل انتظار المؤقّت.
|
||
/// تستدعيها شاشة الملاحة عند انتهاء الملاحة، بعد أن صار الرفع مركزيّاً هنا
|
||
/// وحده (كان الملّاح يرفع دفعة موازية إلى نفس الواجهة فيتضاعف الحساب).
|
||
Future<void> flushTrackBufferNow() => _flushBufferToServer();
|
||
|
||
Future<void> _flushBufferToServer() async {
|
||
if (_trackBuffer.isEmpty) return;
|
||
|
||
int itemsToTake = _trackBuffer.length > 100 ? 100 : _trackBuffer.length;
|
||
List<Map<String, dynamic>> batch = _trackBuffer.sublist(0, itemsToTake);
|
||
|
||
final String driverId = (box.read(BoxName.driverID) ?? '').toString();
|
||
try {
|
||
var res = await CRUD().post(
|
||
link: '${AppLink.locationServer}/add_batch.php',
|
||
payload: {'driver_id': driverId, 'batch_data': jsonEncode(batch)},
|
||
);
|
||
if (res != 'failure') {
|
||
_trackBuffer.removeRange(0, itemsToTake);
|
||
} else {
|
||
_enforceBufferLimit();
|
||
}
|
||
} catch (e) {
|
||
Log.print('❌ Failed to upload batch: $e');
|
||
_enforceBufferLimit();
|
||
}
|
||
}
|
||
|
||
void _enforceBufferLimit() {
|
||
if (_trackBuffer.length > 500) {
|
||
_trackBuffer.removeRange(0, _trackBuffer.length - 500);
|
||
Log.print("⚠️ Buffer limit enforced. Removed oldest entries.");
|
||
}
|
||
}
|
||
|
||
void _listenToBatteryChanges() async {
|
||
_battery.onBatteryStateChanged.listen((state) async {
|
||
int level = await _battery.batteryLevel;
|
||
bool previousMode = _isPowerSavingMode;
|
||
if (level <= powerSaveTriggerLevel) _isPowerSavingMode = true;
|
||
if (level >= powerSaveExitLevel) _isPowerSavingMode = false;
|
||
if (previousMode != _isPowerSavingMode) {
|
||
_startBatchTimers();
|
||
_applyProfile();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ===================================================================
|
||
// ====== محرّك الملفات التكيّفية (أ + ج) ======
|
||
// ===================================================================
|
||
|
||
/// (ج) فحص توفّر Google Play Services مرة واحدة عند الإقلاع.
|
||
/// النتيجة تُحفظ في الصندوق ليقرأها الـ background isolate أيضاً،
|
||
/// وتُبَث مع بيانات السائق لمعرفة حجم شريحة الأجهزة بلا GMS.
|
||
Future<void> _detectGmsAvailability() async {
|
||
if (!Platform.isAndroid) {
|
||
_gmsAvailable = true; // iOS: CoreLocation دائماً متاح
|
||
return;
|
||
}
|
||
try {
|
||
final bool available =
|
||
await _deviceServicesChannel.invokeMethod<bool>('isGmsAvailable') ??
|
||
true;
|
||
_gmsAvailable = available;
|
||
box.write(BoxName.isGmsAvailable, available);
|
||
Log.print(available
|
||
? "✅ GMS available — FusedLocationProvider in use."
|
||
: "⚠️ No GMS (Huawei/AOSP) — falling back to raw LocationManager, "
|
||
"relaxing location profile to protect battery.");
|
||
} catch (e) {
|
||
// القناة غير مسجّلة (نسخة قديمة) — نفترض التوفّر ولا نغيّر السلوك.
|
||
_gmsAvailable = true;
|
||
Log.print("⚠️ GMS check failed, assuming available: $e");
|
||
}
|
||
}
|
||
|
||
/// تتبّع السكون: سائق واقف لا يحتاج قراءات متلاحقة.
|
||
void _trackStationary() {
|
||
if (speed >= stationarySpeedThreshold) {
|
||
_stationarySince = null;
|
||
} else {
|
||
_stationarySince ??= DateTime.now();
|
||
}
|
||
}
|
||
|
||
bool get _isStationary =>
|
||
_stationarySince != null &&
|
||
DateTime.now().difference(_stationarySince!) >= stationaryGrace;
|
||
|
||
/// يختار الملف المناسب من حالة السائق والرحلة، ثم يطبّق عليه
|
||
/// مُعدِّلات البطارية والسكون وغياب GMS.
|
||
_LocationProfile _resolveProfile() {
|
||
final String driverStatus =
|
||
box.read(BoxName.statusDriverLocation) ?? 'off';
|
||
final String rideStatus = (box.read(BoxName.rideStatus) ?? '').toString();
|
||
|
||
_LocationProfile p;
|
||
if (isBusMode) {
|
||
p = _profileOnTrip;
|
||
} else if (driverStatus == 'off' || driverStatus == 'blocked') {
|
||
p = _profileIdle;
|
||
} else if (rideStatus == 'Begin') {
|
||
p = _profileOnTrip;
|
||
} else if (rideStatus == 'Apply' || rideStatus == 'Arrived') {
|
||
p = _profileEnRoute;
|
||
} else {
|
||
p = _profileWaiting;
|
||
}
|
||
|
||
// وضع توفير الطاقة: ضاعف الفترة والمسافة، وانزل عن دقة الملاحة.
|
||
if (_isPowerSavingMode) {
|
||
p = p.copyWith(
|
||
name: '${p.name}+save',
|
||
accuracy: p.accuracy == LocationAccuracy.navigation
|
||
? LocationAccuracy.high
|
||
: p.accuracy,
|
||
interval: p.interval * 2,
|
||
distanceFilter: p.distanceFilter * 2,
|
||
);
|
||
}
|
||
|
||
// (ج) بلا GMS: كل قراءة تُشعل شريحة الـ GPS منفردة ⇒ خفّف التردد.
|
||
if (_gmsAvailable == false) {
|
||
p = p.copyWith(
|
||
name: '${p.name}+nogms',
|
||
interval: (p.interval * 1.5).round(),
|
||
distanceFilter: p.distanceFilter * 2,
|
||
);
|
||
}
|
||
|
||
// السكون المطوّل: ضاعف الفترة ثلاثاً — إلا في رحلة جارية (عدّاد الانتظار).
|
||
if (_isStationary && p.accuracy != LocationAccuracy.navigation) {
|
||
p = p.copyWith(
|
||
name: '${p.name}+idle',
|
||
interval: p.interval * 3,
|
||
);
|
||
}
|
||
|
||
return p;
|
||
}
|
||
|
||
/// يطبّق الملف الحالي. لا يستدعي changeSettings إلا عند تغيّر فعلي،
|
||
/// لأن كل استدعاء يعيد تشغيل مزوّد الموقع في الطبقة الأصلية.
|
||
Future<void> _applyProfile({bool force = false}) async {
|
||
if (_locSub == null && !force) return;
|
||
|
||
final p = _resolveProfile();
|
||
if (!force && p.signature == _appliedProfileSignature) return;
|
||
|
||
try {
|
||
await location.changeSettings(
|
||
accuracy: p.accuracy,
|
||
interval: p.interval,
|
||
distanceFilter: p.distanceFilter,
|
||
);
|
||
_appliedProfileSignature = p.signature;
|
||
Log.print("📍 Location profile → $p");
|
||
} catch (e) {
|
||
Log.print("❌ Failed to apply location profile $p: $e");
|
||
}
|
||
}
|
||
|
||
Future<void> _saveBehaviorIfMoved(LatLng pos, DateTime now,
|
||
{required double currentSpeed}) async {
|
||
final dist =
|
||
(_lastSqlLoc == null) ? 999.0 : _calculateDistance(_lastSqlLoc!, pos);
|
||
if (dist < 15.0) return;
|
||
|
||
final accel = _calcAcceleration(currentSpeed, now) ?? 0.0;
|
||
_lastSqlLoc = pos;
|
||
|
||
_behaviorBuffer.add({
|
||
'driver_id': (box.read(BoxName.driverID) ?? '').toString(),
|
||
'latitude': pos.latitude,
|
||
'longitude': pos.longitude,
|
||
'acceleration': accel,
|
||
'created_at': now.toIso8601String(),
|
||
'updated_at': now.toIso8601String(),
|
||
});
|
||
|
||
if (_behaviorBuffer.length >= 10) {
|
||
_flushBehaviorBuffer();
|
||
}
|
||
}
|
||
|
||
void _flushBehaviorBuffer() {
|
||
if (_behaviorBuffer.isEmpty) return;
|
||
List<Map<String, dynamic>> batch = List.from(_behaviorBuffer);
|
||
_behaviorBuffer.clear();
|
||
|
||
Future.microtask(() async {
|
||
try {
|
||
for (var data in batch) {
|
||
await sql.insertData(data, TableName.behavior);
|
||
}
|
||
} catch (e) {
|
||
Log.print('SQLite Batch Insert Error: $e');
|
||
}
|
||
});
|
||
}
|
||
|
||
// استبدال دالة Haversine اليدوية بـ Geolocator في باقي الكود أيضاً
|
||
// لأنها تعتمد على C++ في الأندرويد و Obj-C في الآيفون (Native Speed)
|
||
double _calculateDistance(LatLng a, LatLng b) {
|
||
return geo.Geolocator.distanceBetween(
|
||
a.latitude, a.longitude, b.latitude, b.longitude);
|
||
}
|
||
|
||
double? _calcAcceleration(double currentSpeed, DateTime now) {
|
||
if (_lastSpeed != null && _lastSpeedAt != null) {
|
||
final dt = now.difference(_lastSpeedAt!).inMilliseconds / 1000.0;
|
||
if (dt > 0.5) {
|
||
final a = (currentSpeed - _lastSpeed!) / dt;
|
||
_lastSpeed = currentSpeed;
|
||
_lastSpeedAt = now;
|
||
return a;
|
||
}
|
||
}
|
||
_lastSpeed = currentSpeed;
|
||
_lastSpeedAt = now;
|
||
return null;
|
||
}
|
||
|
||
Future<void> _initLocationSettings() async {
|
||
if (await _ensureServiceAndPermission()) {
|
||
try {
|
||
await location.enableBackgroundMode(enable: true);
|
||
// (ب) لا نضبط الإعدادات هنا: كانت interval: 1000 تتعارض مع إعدادات
|
||
// _subscribeLocationStream وتُبقي الـ GPS مشتعلاً كل ثانية.
|
||
// مصدر الحقيقة الوحيد الآن هو _applyProfile.
|
||
} catch (e) {
|
||
Log.print("Warning: $e");
|
||
}
|
||
}
|
||
}
|
||
|
||
// 🔥🔥 هذه هي الدالة المعدلة التي تستخدم ph.Permission 🔥🔥
|
||
Future<bool> _ensureServiceAndPermission() async {
|
||
// 1. طلب إذن الإشعارات أولاً باستخدام permission_handler
|
||
if (Platform.isAndroid) {
|
||
var notificationStatus = await ph.Permission.notification.status;
|
||
if (!notificationStatus.isGranted) {
|
||
await ph.Permission.notification.request();
|
||
}
|
||
}
|
||
|
||
// 2. طلب تفعيل خدمة الموقع (GPS) من بكج location
|
||
bool serviceEnabled = await location.serviceEnabled();
|
||
if (!serviceEnabled) {
|
||
serviceEnabled = await location.requestService();
|
||
if (!serviceEnabled) return false;
|
||
}
|
||
|
||
// 3. طلب إذن الموقع الأساسي من بكج location
|
||
PermissionStatus permissionGranted = await location.hasPermission();
|
||
if (permissionGranted == PermissionStatus.denied) {
|
||
permissionGranted = await location.requestPermission();
|
||
if (permissionGranted != PermissionStatus.granted) return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
// ... (باقي الكود)
|
||
|
||
Future<LocationData?> getLocation() async {
|
||
try {
|
||
if (await _ensureServiceAndPermission()) {
|
||
final locData = await location.getLocation();
|
||
if (locData != null &&
|
||
locData.latitude != null &&
|
||
locData.longitude != null) {
|
||
final rawPos = LatLng(locData.latitude!, locData.longitude!);
|
||
if (!CountryPolygons.isServiceSupported(rawPos)) {
|
||
myLocation = const LatLng(31.9539, 35.9106);
|
||
} else {
|
||
myLocation = rawPos;
|
||
}
|
||
heading = locData.heading ?? 0.0;
|
||
speed = locData.speed ?? 0.0;
|
||
|
||
box.write('last_lat', myLocation.latitude);
|
||
box.write('last_lng', myLocation.longitude);
|
||
box.write('last_heading', heading);
|
||
|
||
update();
|
||
|
||
if (Get.isRegistered<HomeCaptainController>()) {
|
||
final homeCtrl = Get.find<HomeCaptainController>();
|
||
if (homeCtrl.mapHomeCaptainController != null &&
|
||
homeCtrl.isMapReadyForCommands) {
|
||
Log.print(
|
||
"📍 [LocationController] Animating camera to single location update");
|
||
homeCtrl.mapHomeCaptainController?.animateCamera(
|
||
CameraUpdate.newLatLngZoom(myLocation, 17.5),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
return locData;
|
||
}
|
||
} catch (e) {
|
||
Log.print('❌ FAILED to get single location: $e');
|
||
}
|
||
return null;
|
||
}
|
||
}
|