Files
Siro/siro_driver/lib/controller/functions/location_controller.dart
T

1109 lines
39 KiB
Dart
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}