initial commit: push everything

This commit is contained in:
2026-07-06 20:27:53 +05:30
commit df4e044d74
329 changed files with 36620 additions and 0 deletions

View File

@@ -0,0 +1,969 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:geolocator/geolocator.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:audioplayers/audioplayers.dart';
import 'dart:math' as math;
import 'package:nearle/providers/deliverylog/deliverylog_provider.dart';
import 'package:nearle/providers/notifications/notificationservce.dart';
import 'package:nearle/views/helpers/constants/apiconstants.dart';
import 'package:nearle/utils/kalman_filter.dart';
/// Background service for managing active delivery logs
class BackgroundDeliveryLog {
static NearleKalmanFilter? _kf;
static DateTime? _lastUpdateTime;
static final CreateDeliveryLogProvider _logProvider =
CreateDeliveryLogProvider();
static final http.Client _httpClient = http.Client();
static final FlutterTts _tts = FlutterTts();
static final AudioPlayer _proximityPlayer = AudioPlayer();
static const double _idleThresholdMeters = 10; // ~5-10m tolerance
static const double _proximityThresholdMeters = 50.0; // 50 meters for arrival alert
static const String _activeDeliveriesKey = 'active_deliveries';
static const String _payloadKeyPrefix = 'delivery_payload_';
static const String _offlineLogKey = 'offline_delivery_logs';
static const String _proximityAlertKeyPrefix = 'delivery_proximity_alerted_';
static bool _isPosting = false;
static bool _ttsReady = false;
static bool _audioReady = false;
/// Process active deliveries: fetch from API, filter active, and post logs
static Future<void> processActiveDeliveries() async {
if (_isPosting) {
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Already posting, skipping...');
return;
}
_isPosting = true;
try {
// Get userid from SharedPreferences
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0;
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Checking for user: $userId');
if (userId == 0) {
debugPrint('[ACTIVE_DELIVERY_LOG][BG] ❌ No user ID found in prefs');
return;
}
// Get current date dynamically (YYYY-MM-DD format)
final now = DateTime.now();
final today = '${now.year}-${_pad(now.month)}-${_pad(now.day)}';
// Build API URL using v1 endpoint (getdeliveries)
final bool isLive = ApiConstants.mainRoute == 'live';
final baseUrl = isLive
? ApiConstants.currentDeliveryLive
: ApiConstants.currentDeliveryDev;
final uri = Uri.parse(baseUrl).replace(
queryParameters: {
'userid': userId.toString(),
'fromdate': today,
'todate': today,
't': DateTime.now().millisecondsSinceEpoch.toString(),
},
);
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Fetching deliveries: $uri');
// Fetch deliveries from API using v1 endpoint
final deliveries = await _fetchDeliveriesFromApi(uri);
debugPrint('[ACTIVE_DELIVERY_LOG][BG] API returned ${deliveries.length} items');
// Filter for active orders only
final activeOrders = deliveries.whereType<Map<String, dynamic>>().where((
order,
) {
final status = (order['orderstatus']?.toString().toLowerCase() ?? '')
.trim();
final isActive = status == 'active';
return isActive;
}).toList();
if (activeOrders.isEmpty) {
debugPrint('[ACTIVE_DELIVERY_LOG][BG] No active orders found');
// ✅ Still check shift end even if no active deliveries
debugPrint('[ACTIVE_DELIVERY_LOG][BG] 🔍 Checking shift end time...');
await checkShiftEnd();
return;
}
debugPrint(
'[ACTIVE_DELIVERY_LOG][BG] Found ${activeOrders.length} active deliveries',
);
// Post logs for each active delivery
for (final order in activeOrders) {
final orderId = (order['orderid'] ?? '').toString();
if (orderId.isEmpty) continue;
// Get current coordinates once per order to reuse for proximity + log
final Map<String, String>? coords = await _getCoordinatesWithFallback();
if (coords != null) {
await _checkProximityAlert(order, coords);
}
// Get payload from SharedPreferences or create new one
Map<String, dynamic>? payload = await _loadPayload(orderId);
if (payload == null) {
// Create new payload from order data with userId from SharedPreferences
payload = _createPayload(order, userId);
await _persistPayload(orderId, payload);
await _markAsActive(orderId);
} else {
// Ensure userid is set correctly in existing payload
payload['userid'] = userId;
}
// Post the log
await _postDeliveryLog(orderId, payload, currentCoords: coords);
}
// ✅ CRITICAL: Check if shift end time has passed (backup to alarm)
await checkShiftEnd();
// Attempt to flush offline logs
await _flushOfflineLogs();
} catch (e) {
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Error processing: $e');
} finally {
_isPosting = false;
}
}
/// Post delivery log for a specific order
static Future<void> _postDeliveryLog(
String orderId,
Map<String, dynamic> payload, {
Map<String, String>? currentCoords,
}) async {
Map<String, dynamic>? payloadWithCoords;
try {
// Get current coordinates
final coords = currentCoords ?? await _getCoordinatesWithFallback();
if (coords == null) {
return;
}
// Distance calculation is now fully handled by LiveTrackingService in the background.
// 03-14: Removed redundant 60-second background calculations to avoid logic conflicts.
final deliveryId = payload['deliveryid']?.toString() ?? '';
// Create log date timestamp
final now = DateTime.now();
final logDate =
'${now.year}-${_pad(now.month)}-${_pad(now.day)} ${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}';
// Retrieve final cumulative KM to send
double riderKmsToSend = 0.0;
if (deliveryId.isNotEmpty && deliveryId != '0') {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.reload(); // Force reload so we get the latest value written by the main isolate
riderKmsToSend = double.tryParse(prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? '0') ?? 0.0;
} catch (_) {}
}
// Build payload with coordinates and timestamp
payloadWithCoords = <String, dynamic>{
...payload,
'logdate': logDate,
'latitude': coords['lat'] ?? '0',
'longitude': coords['lng'] ?? '0',
'raw_latitude': coords['raw_lat'] ?? '0',
'raw_longitude': coords['raw_lng'] ?? '0',
'velocity_lat': coords['velocity_lat'] ?? '0',
'velocity_lng': coords['velocity_lng'] ?? '0',
'speed': coords['speed'] ?? '0',
'heading': coords['heading'] ?? '0',
'riderkms': riderKmsToSend,
'logstatus': await _resolveLogStatus(orderId, coords),
};
// Determine API endpoint
final url = ApiConstants.mainRoute == 'live'
? ApiConstants.createDeliveryLogLive
: ApiConstants.createDeliveryLogDev;
// Post the log
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Posting log for $orderId');
final result = await _logProvider
.createDeliveryLog(url, payloadWithCoords)
.timeout(
const Duration(seconds: 8),
onTimeout: () => throw TimeoutException(
'Delivery log post timeout for $orderId',
),
);
if (result != null) {
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Success for $orderId');
await _persistLastLogLocation(orderId, coords);
} else {
debugPrint(
'[ACTIVE_DELIVERY_LOG][BG] Warning: No response for $orderId',
);
}
} catch (e) {
debugPrint(
'[ACTIVE_DELIVERY_LOG][BG] Failed to post log for $orderId: $e',
);
// Offline fallback
if (payloadWithCoords != null) {
await _saveToOfflineQueue(orderId, payloadWithCoords);
}
}
}
/// Create payload from delivery order data
static Map<String, dynamic> _createPayload(
Map<String, dynamic> delivery,
int userId,
) {
return <String, dynamic>{
'logid': 0,
'tenantid': delivery['tenantid'] ?? 0,
'partnerid': delivery['partnerid'] ?? 0,
'locationid': delivery['locationid'] ?? 0,
'orderheaderid': delivery['orderheaderid'] ?? 0,
'deliveryid': delivery['deliveryid'] ?? 0,
'userid': userId,
'orderid': (delivery['orderid'] ?? '').toString(),
'orderstatus': 'active',
};
}
/// Get coordinates with fallback (current position -> last known -> cached)
static Future<Map<String, String>?> _getCoordinatesWithFallback() async {
Map<String, String> result = {
'lat': '0',
'lng': '0',
'raw_lat': '0',
'raw_lng': '0',
'speed': '0',
'heading': '0',
'velocity_lat': '0',
'velocity_lng': '0',
};
try {
// 1. Check if location services are enabled
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Location services disabled');
return await _getCachedCoordinatesAsMap();
}
// 2. Check permissions
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) {
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Location permission denied');
return await _getCachedCoordinatesAsMap();
}
Position? position;
try {
position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
).timeout(const Duration(seconds: 5));
} catch (e) {
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Failed to get current pos: $e');
position = await Geolocator.getLastKnownPosition();
}
if (position != null) {
// Reject mocked GPS (anti-cheat)
if (position.isMocked) {
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Mocked position — using cached');
return await _getCachedCoordinatesAsMap();
}
// Reject very poor accuracy to avoid phantom movements in delivery logs
if (position.accuracy > 50.0) {
debugPrint(
'[ACTIVE_DELIVERY_LOG][BG] Low-accuracy position (${position.accuracy.toStringAsFixed(0)}m) — using cached',
);
return await _getCachedCoordinatesAsMap();
}
final now = DateTime.now();
double outLat = position.latitude;
double outLng = position.longitude;
double speed = position.speed;
double heading = position.heading;
// Decompose velocity for Kalman
final double headingRadians = heading * (math.pi / 180.0);
final double velocityLng = speed * math.sin(headingRadians);
final double velocityLat = speed * math.cos(headingRadians);
if (_kf == null) {
_kf = NearleKalmanFilter(lat: outLat, lng: outLng);
} else {
final double dt = _lastUpdateTime != null
? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0
: 30.0;
_kf!.predict(dt);
_kf!.update(outLat, outLng);
outLat = _kf!.x[0];
outLng = _kf!.x[1];
}
_lastUpdateTime = now;
result = {
'lat': outLat.toString(),
'lng': outLng.toString(),
'raw_lat': position.latitude.toString(),
'raw_lng': position.longitude.toString(),
'speed': speed.toStringAsFixed(2),
'heading': heading.toStringAsFixed(2),
'velocity_lat': velocityLat.toStringAsFixed(4),
'velocity_lng': velocityLng.toStringAsFixed(4),
};
await _cacheCoordinates(result['lat']!, result['lng']!);
return result;
}
return await _getCachedCoordinatesAsMap();
} catch (e) {
debugPrint('[ACTIVE_DELIVERY_LOG][BG] Error getting coords: $e');
return await _getCachedCoordinatesAsMap();
}
}
static Future<Map<String, String>?> _getCachedCoordinatesAsMap() async {
final coords = await _getCachedCoordinates();
if (coords == null) return null;
return {
'lat': coords.$1,
'lng': coords.$2,
'raw_lat': coords.$1,
'raw_lng': coords.$2,
'speed': '0',
'heading': '0',
'velocity_lat': '0',
'velocity_lng': '0',
};
}
/// Cache coordinates to SharedPreferences
static Future<void> _cacheCoordinates(String lat, String lng) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('last_lat', lat);
await prefs.setString('last_lng', lng);
} catch (_) {}
}
/// Get cached coordinates from SharedPreferences
static Future<(String, String)?> _getCachedCoordinates() async {
try {
final prefs = await SharedPreferences.getInstance();
final lat = (prefs.getString('last_lat') ?? '').trim();
final lng = (prefs.getString('last_lng') ?? '').trim();
if (lat.isNotEmpty && lng.isNotEmpty) {
return (lat, lng);
}
} catch (_) {}
return null;
}
/// Load payload from SharedPreferences
static Future<Map<String, dynamic>?> _loadPayload(String orderId) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString('$_payloadKeyPrefix$orderId');
if (jsonStr == null || jsonStr.isEmpty) return null;
final decoded = jsonDecode(jsonStr);
if (decoded is Map<String, dynamic>) return decoded;
if (decoded is Map) return decoded.cast<String, dynamic>();
} catch (_) {}
return null;
}
/// Persist payload to SharedPreferences
static Future<void> _persistPayload(
String orderId,
Map<String, dynamic> payload,
) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('$_payloadKeyPrefix$orderId', jsonEncode(payload));
} catch (_) {}
}
/// Mark order as active in SharedPreferences
static Future<void> _markAsActive(String orderId) async {
try {
final prefs = await SharedPreferences.getInstance();
final list = prefs.getStringList(_activeDeliveriesKey) ?? <String>[];
if (!list.contains(orderId)) {
list.add(orderId);
await prefs.setStringList(_activeDeliveriesKey, list);
}
} catch (_) {}
}
/// Fetch deliveries from API using v1 endpoint
static Future<List<dynamic>> _fetchDeliveriesFromApi(Uri uri) async {
try {
final res = await _httpClient
.get(uri)
.timeout(const Duration(seconds: 10));
if (res.statusCode >= 200 && res.statusCode < 300) {
final decoded = json.decode(res.body);
final data = decoded is Map<String, dynamic>
? (decoded['details'] ?? decoded['data'] ?? decoded)
: decoded;
if (data is List) {
return data;
}
if (data is Map && data['items'] is List) {
return data['items'] as List;
}
return [];
}
return [];
} catch (e) {
return [];
}
}
/// Helper to pad numbers with leading zero
static String _pad(int n) => n.toString().padLeft(2, '0');
static Future<int> _resolveLogStatus(
String orderId,
Map<String, String> coords,
) async {
try {
final last = await _loadLastLogLocation(orderId);
if (last != null) {
final lastLat = last.$1;
final lastLng = last.$2;
final currentLat = double.tryParse(coords['lat'] ?? '0') ?? 0;
final currentLng = double.tryParse(coords['lng'] ?? '0') ?? 0;
if (currentLat != 0 &&
currentLng != 0 &&
lastLat != 0 &&
lastLng != 0) {
final distance = Geolocator.distanceBetween(
lastLat,
lastLng,
currentLat,
currentLng,
);
if (distance <= _idleThresholdMeters) {
return 1; // idle
}
}
}
} catch (e) {
debugPrint('[ACTIVE_DELIVERY_LOG][BG] logstatus error: $e');
}
return 0; // moving
}
static Future<(double, double)?> _loadLastLogLocation(String orderId) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString('last_log_loc_$orderId');
if (jsonStr == null || jsonStr.isEmpty) return null;
final decoded = jsonDecode(jsonStr);
if (decoded is Map<String, dynamic>) {
final lat = double.tryParse('${decoded['lat']}') ?? 0;
final lng = double.tryParse('${decoded['lng']}') ?? 0;
if (lat != 0 && lng != 0) return (lat, lng);
}
} catch (_) {}
return null;
}
static Future<void> _persistLastLogLocation(
String orderId,
Map<String, String> coords,
) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'last_log_loc_$orderId',
jsonEncode({'lat': coords['lat'], 'lng': coords['lng']}),
);
} catch (_) {}
}
// ---------------- Offline Queue Logic ----------------
static Future<void> _flushOfflineLogs() async {
try {
final prefs = await SharedPreferences.getInstance();
final List<String> queue = prefs.getStringList(_offlineLogKey) ?? [];
if (queue.isEmpty) return;
debugPrint(
'[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Flushing ${queue.length} offline logs...',
);
final List<String> remaining = [];
bool anySuccess = false;
final url = ApiConstants.mainRoute == 'live'
? ApiConstants.createDeliveryLogLive
: ApiConstants.createDeliveryLogDev;
for (final itemStr in queue) {
try {
final Map<String, dynamic> item = jsonDecode(itemStr);
final String orderId = item['orderId'] ?? '';
final Map<String, dynamic> payload = Map<String, dynamic>.from(
item['payload'] ?? {},
);
if (payload.isEmpty) continue;
debugPrint(
'[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Retrying for orderId: $orderId',
);
final result = await _logProvider
.createDeliveryLog(url, payload)
.timeout(const Duration(seconds: 8));
if (result != null) {
debugPrint(
'[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Success for orderId: $orderId',
);
anySuccess = true;
} else {
remaining.add(itemStr);
}
} catch (e) {
remaining.add(itemStr);
}
}
if (anySuccess || remaining.length != queue.length) {
await prefs.setStringList(_offlineLogKey, remaining);
}
} catch (e) {
debugPrint('[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Flush error: $e');
}
}
static Future<void> _saveToOfflineQueue(
String orderId,
Map<String, dynamic> payload,
) async {
try {
final prefs = await SharedPreferences.getInstance();
final List<String> queue = prefs.getStringList(_offlineLogKey) ?? [];
final item = jsonEncode({
'orderId': orderId,
'payload': payload,
'timestamp': DateTime.now().millisecondsSinceEpoch,
});
queue.add(item);
await prefs.setStringList(_offlineLogKey, queue);
debugPrint(
'[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Saved to queue. Total: ${queue.length}',
);
} catch (e) {
debugPrint(
'[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Error saving to queue: $e',
);
}
}
// ---------------- Auto Shift End Logic ----------------
/// Check if shift has ended and trigger auto-break if needed
static Future<void> checkShiftEnd() async {
try {
debugPrint('[AUTO_SHIFT_END] 🔍 Starting shift end check...');
final prefs = await SharedPreferences.getInstance();
// 1. Check if currently On Duty
final int onduty = prefs.getInt('onduty') ?? 0;
debugPrint('[AUTO_SHIFT_END] 📊 Current onduty status: $onduty');
if (onduty != 1) {
debugPrint('[AUTO_SHIFT_END] ⏭️ Rider not on duty, skipping check');
return; // Already offline
}
// 2. Get Shift Timings
final String startTimeStr = prefs.getString('starttime') ?? '';
final String endTimeStr = prefs.getString('endtime') ?? '';
debugPrint('[AUTO_SHIFT_END] ⏰ Shift times - Start: "$startTimeStr", End: "$endTimeStr"');
if (endTimeStr.isEmpty) {
debugPrint('[AUTO_SHIFT_END] ⚠️ No endtime found, cannot check shift end');
return;
}
// 3. Parse Times (Assumed format HH:mm:ss)
final now = DateTime.now();
final endParts = endTimeStr.split(':');
if (endParts.length < 2) return;
final int endHour = int.tryParse(endParts[0]) ?? 0;
final int endMinute = int.tryParse(endParts[1]) ?? 0;
final int endSecond = endParts.length > 2 ? (int.tryParse(endParts[2]) ?? 0) : 0;
// Create DateTime for end time on TODAY
final DateTime endToday = DateTime(
now.year,
now.month,
now.day,
endHour,
endMinute,
endSecond,
);
bool isShiftOver = false;
if (startTimeStr.isNotEmpty) {
final startParts = startTimeStr.split(':');
if (startParts.length >= 2) {
final int startHour = int.tryParse(startParts[0]) ?? 0;
final int startMinute = int.tryParse(startParts[1]) ?? 0;
// Create DateTime for start time on TODAY
final DateTime startToday = DateTime(
now.year,
now.month,
now.day,
startHour,
startMinute,
);
// Check for overnight shift (Start > End in 24-hour format, e.g. 22:00 to 06:00)
// This means shift crosses midnight
final double startVal = startHour + (startMinute / 60.0);
final double endVal = endHour + (endMinute / 60.0);
if (startVal > endVal) {
// ✅ OVERNIGHT SHIFT (crosses midnight, e.g. 22:00 to 06:00)
// Shift is over if current time is AFTER end time today AND BEFORE start time today
// Example: End 06:00, Start 22:00
// - Now 23:00 -> After 22:00 (start) -> Still in shift (ACTIVE)
// - Now 05:00 -> Before 06:00 (end) -> Still in shift from yesterday (ACTIVE)
// - Now 10:00 -> After 06:00 (end) AND Before 22:00 (start) -> Shift OVER
debugPrint('[AUTO_SHIFT_END] 🌙 Overnight shift detected (Start: ${_pad(startHour)}:${_pad(startMinute)}, End: ${_pad(endHour)}:${_pad(endMinute)})');
debugPrint('[AUTO_SHIFT_END] 📅 Current time: ${_pad(now.hour)}:${_pad(now.minute)}');
if (now.isAfter(endToday) && now.isBefore(startToday)) {
// We're in the gap period between end and start -> shift is OVER
isShiftOver = true;
debugPrint('[AUTO_SHIFT_END] ✅ Overnight shift: In gap period -> SHIFT OVER');
} else {
// We're either before end (still in shift from yesterday) or after start (still in shift today)
isShiftOver = false;
debugPrint('[AUTO_SHIFT_END] ✅ Overnight shift: Still active');
}
} else {
// ✅ NORMAL DAY SHIFT (e.g. 09:00 to 17:00, doesn't cross midnight)
// Shift is over if current time is AFTER end time
debugPrint('[AUTO_SHIFT_END] ☀️ Day shift detected (Start: ${_pad(startHour)}:${_pad(startMinute)}, End: ${_pad(endHour)}:${_pad(endMinute)})');
debugPrint('[AUTO_SHIFT_END] 📅 Current time: ${_pad(now.hour)}:${_pad(now.minute)}, End time: ${_pad(endHour)}:${_pad(endMinute)}');
if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) {
isShiftOver = true;
debugPrint('[AUTO_SHIFT_END] ✅ Day shift: Shift ended');
} else {
isShiftOver = false;
debugPrint('[AUTO_SHIFT_END] ✅ Day shift: Still active');
}
}
} else {
// Fallback if start time parse fails: assume day shift
debugPrint('[AUTO_SHIFT_END] ⚠️ Could not parse start time, assuming day shift');
if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) {
isShiftOver = true;
}
}
} else {
// Fallback if no start time: assume day shift
debugPrint('[AUTO_SHIFT_END] ⚠️ No start time provided, assuming day shift');
if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) {
isShiftOver = true;
}
}
// 4. Trigger Auto End if Shift is Over
debugPrint('[AUTO_SHIFT_END] 📅 Time check result - isShiftOver: $isShiftOver, Current: ${_pad(now.hour)}:${_pad(now.minute)}, End: ${_pad(endHour)}:${_pad(endMinute)}');
if (isShiftOver) {
debugPrint('[AUTO_SHIFT_END] ⏰ Shift ended (Start: $startTimeStr, End: $endTimeStr). Current: ${_pad(now.hour)}:${_pad(now.minute)}');
await _autoEndShift(prefs);
} else {
debugPrint('[AUTO_SHIFT_END] ✅ Shift not ended yet, continuing...');
}
} catch (e) {
debugPrint('[AUTO_SHIFT_END] ❌ Error checking shift end: $e');
debugPrint('[AUTO_SHIFT_END] Stack trace: ${StackTrace.current}');
}
}
static Future<void> _autoEndShift(SharedPreferences prefs) async {
try {
debugPrint(
'[AUTO_SHIFT_END] 🚀 Initiating auto-break and offline sequence...',
);
// 1. Get Required IDs
final int userid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0;
final int partnerid =
prefs.getInt('partnerid') ?? prefs.getInt('partnerId') ?? 0;
final int shiftid =
prefs.getInt('shiftid') ?? prefs.getInt('shiftId') ?? 0;
final int logid = prefs.getInt('logid') ?? prefs.getInt('logId') ?? 0;
if (userid == 0) {
debugPrint(
'[AUTO_SHIFT_END] ❌ Missing userid, cannot create break log',
);
return;
}
// 2. Get Location
final Map<String, String>? coords = await _getCoordinatesWithFallback();
final String lat = coords?['lat'] ?? '0';
final String lng = coords?['lng'] ?? '0';
// 3. Prepare Break Log Payload
final now = DateTime.now();
final int localBreakId =
(DateTime.now().millisecondsSinceEpoch % 900) + 100; // Random-ish ID
final String breakdate =
'${now.year}-${_pad(now.month)}-${_pad(now.day)} ${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}';
final String breakstart =
'${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}';
final payload = <String, dynamic>{
"breakid": localBreakId,
"logid": logid,
"breakdate": breakdate,
"userid": userid,
"partnerid": partnerid,
"shiftid": shiftid,
"breakstart": breakstart,
"breakend": "",
"breakhours": 0.0,
"latitude": lat,
"longitude": lng,
};
// 4. Call API to Create Break
final url = ApiConstants.mainRoute == 'live'
? ApiConstants.createBreakRiderLogLive
: ApiConstants.createBreakRiderLogDev;
debugPrint('[AUTO_SHIFT_END] Creating break log: $url');
// We use a separate provider instance or http call if needed,
// but _logProvider is for delivery logs. We need a generic post or use http directly.
// Since we don't have BreakRiderLogProvider here, we'll use http directly for simplicity and isolation.
try {
final response = await _httpClient
.post(
Uri.parse(url),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(payload),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode >= 200 && response.statusCode < 300) {
debugPrint('[AUTO_SHIFT_END] ✅ Break log created successfully');
// Parse response to get server break ID if needed, but mainly we just need to go offline
final body = jsonDecode(response.body);
final det = (body['details'] is Map) ? body['details'] : body;
final serverBreakId = det['breakid'];
if (serverBreakId != null) {
await prefs.setInt(
'breakId',
int.tryParse('$serverBreakId') ?? localBreakId,
);
}
await prefs.setString('breakStart', breakstart);
await prefs.setInt('break_start_epoch', now.millisecondsSinceEpoch);
} else {
debugPrint(
'[AUTO_SHIFT_END] ⚠️ Failed to create break log: ${response.statusCode}',
);
}
} catch (e) {
debugPrint('[AUTO_SHIFT_END] ❌ API Error: $e');
// Even if API fails, we MUST go offline locally to prevent further issues
}
// 5. Set Offline Locally
await prefs.setInt('onduty', 0);
await prefs.setBool('online', false);
// 6. Update Rider Log (Set Duty = 0)
// We should also update the main rider log to say onduty=0
final updateUrl = ApiConstants.mainRoute == 'live'
? ApiConstants.updateRiderLogLive
: ApiConstants.updateRiderLogDev;
final updatePayload = {
"userid": userid,
"onduty": 0,
"latitude": lat,
"longitude": lng,
};
try {
await _httpClient
.post(
Uri.parse(updateUrl),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(updatePayload),
)
.timeout(const Duration(seconds: 5));
debugPrint('[AUTO_SHIFT_END] ✅ Rider status updated to Offline');
} catch (_) {}
debugPrint('[AUTO_SHIFT_END] 🏁 Auto-shift end sequence complete.');
} catch (e) {
debugPrint('[AUTO_SHIFT_END] Critical error in _autoEndShift: $e');
}
}
/// Trigger proximity alert once per delivery when within threshold
static Future<void> _checkProximityAlert(
Map<String, dynamic> order,
Map<String, String> coords,
) async {
try {
final deliveryId =
(order['deliveryid'] ?? order['orderid'] ?? '').toString();
if (deliveryId.isEmpty) return;
final dropLatRaw =
(order['droplat'] ?? order['DropLat'] ?? order['deliverylat'] ?? '')
.toString();
final dropLngRaw = (order['droplon'] ??
order['droplong'] ??
order['DropLon'] ??
order['DropLong'] ??
order['deliverylong'] ??
'')
.toString();
final dropLat = double.tryParse(dropLatRaw) ?? 0.0;
final dropLng = double.tryParse(dropLngRaw) ?? 0.0;
if (dropLat == 0 ||
dropLng == 0 ||
dropLat.abs() > 90 ||
dropLng.abs() > 180) {
return;
}
final riderLat = double.tryParse(coords['lat'] ?? '0') ?? 0.0;
final riderLng = double.tryParse(coords['lng'] ?? '0') ?? 0.0;
if (riderLat == 0 ||
riderLng == 0 ||
riderLat.abs() > 90 ||
riderLng.abs() > 180) {
return;
}
final distanceMeters = Geolocator.distanceBetween(
dropLat,
dropLng,
riderLat,
riderLng,
);
if (distanceMeters <= _proximityThresholdMeters) {
final prefs = await SharedPreferences.getInstance();
final alreadyAlerted = prefs.getBool($_proximityAlertKeyPrefix$deliveryId) ?? false;
if (alreadyAlerted) {
debugPrint([PROXIMITY] Already alerted for deliveryId=$deliveryId, skipping);
return;
}
await NotificationServce.showLocalNotification(
title: Near delivery location,
body: You\ve reached your destination. Please update the status.,
);
final played = await _playProximityAudio();
await prefs.setBool($_proximityAlertKeyPrefix$deliveryId, true);
debugPrint(
[PROXIMITY] Alerted deliveryId=$deliveryId at ${distanceMeters.toStringAsFixed(1)}m
target=($dropLat,$dropLng) rider=($riderLat,$riderLng) played=$played,
);
} else {
debugPrint(
[PROXIMITY] Skipped alert for deliveryId=$deliveryId | distance=${distanceMeters.toStringAsFixed(1)}m,
);
}
} catch (e) {
debugPrint('[PROXIMITY] Error sending alert: $e');
}
}
static Future<bool> _playProximityAudio() async {
try {
if (!_audioReady) {
await _proximityPlayer.setReleaseMode(ReleaseMode.stop);
await _proximityPlayer.setVolume(1.0);
_audioReady = true;
}
// Attempt to play bundled destination audio
await _proximityPlayer.stop();
await _proximityPlayer.play(AssetSource('audio/destination.mp3'));
debugPrint('[PROXIMITY][AUDIO] Playing destination.mp3');
return true;
} catch (e) {
debugPrint('[PROXIMITY][AUDIO] Error playing destination clip: $e');
}
// Fallback to TTS if audio fails
try {
// Initialize once
if (!_ttsReady) {
await _tts.setLanguage('en-US');
await _tts.setSpeechRate(0.9);
await _tts.setVolume(1.0);
await _tts.setPitch(1.0);
_ttsReady = true;
}
// Speak without awaiting completion to avoid blocking
await _tts.speak('You have reached your destination. Please update the delivery status.');
debugPrint('[PROXIMITY][TTS] Spoke destination prompt');
return true;
} catch (e) {
debugPrint('[PROXIMITY][TTS] Error speaking prompt: $e');
}
return false;
}
}

View File

@@ -0,0 +1,461 @@
import 'dart:async';
import 'dart:isolate';
import 'package:flutter/foundation.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'dart:math' as math;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:nearle/views/helpers/constants/apiconstants.dart';
import 'package:nearle/providers/Riderlog/riderlog_provider.dart';
import 'package:nearle/background/backgroundservice.dart';
import 'package:geolocator/geolocator.dart';
import 'package:nearle/utils/kalman_filter.dart';
import 'package:nearle/utils/mqtt_service.dart';
import 'package:nearle/views/helpers/constants/mqtt_constants.dart';
import 'package:battery_plus/battery_plus.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'dart:io';
import 'package:nearle/helpers/http_overrides.dart';
class _BackgroundRiderLog {
static NearleKalmanFilter? _kf;
static DateTime? _lastUpdateTime;
static Future<Map<String, String>> _ensureLatLng() async {
Map<String, String> result = {
'lat': '0',
'lng': '0',
'raw_lat': '0',
'raw_lng': '0',
'speed': '0',
'heading': '0',
'velocity_lat': '0',
'velocity_lng': '0',
'status': 'unknown',
'accuracy': '0',
};
try {
// 1. Check if location services are enabled
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
debugPrint('[BG_RIDER_LOG] Location services are disabled.');
result['status'] = 'disabled';
return result;
}
// 2. Check permissions
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
debugPrint('[BG_RIDER_LOG] Location permission denied.');
result['status'] = 'denied';
return result;
}
if (permission == LocationPermission.deniedForever) {
debugPrint('[BG_RIDER_LOG] Location permission denied forever.');
result['status'] = 'denied_forever';
return result;
}
result['status'] = 'enabled';
// 3. Get position (using non-deprecated LocationSettings + explicit timeout)
final pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
// Reject mocked positions (anti-cheat)
if (pos.isMocked) {
debugPrint('[BG_RIDER_LOG] Mocked position detected — using cached');
return result;
}
final now = DateTime.now();
double outLat = pos.latitude;
double outLng = pos.longitude;
double speed = pos.speed;
double heading = pos.heading;
// Decompose velocity for Kalman
final double headingRadians = heading * (math.pi / 180.0);
final double velocityLng = speed * math.sin(headingRadians);
final double velocityLat = speed * math.cos(headingRadians);
if (_kf == null) {
_kf = NearleKalmanFilter(lat: outLat, lng: outLng);
} else {
final double dt = _lastUpdateTime != null
? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0
: 30.0; // Default background interval
_kf!.predict(dt);
_kf!.update(outLat, outLng);
outLat = _kf!.x[0];
outLng = _kf!.x[1];
}
_lastUpdateTime = now;
return {
'lat': outLat.toStringAsFixed(6),
'lng': outLng.toStringAsFixed(6),
'raw_lat': pos.latitude.toStringAsFixed(6),
'raw_lng': pos.longitude.toStringAsFixed(6),
'speed': speed.toStringAsFixed(2),
'heading': heading.toStringAsFixed(2),
'velocity_lat': velocityLat.toStringAsFixed(4),
'velocity_lng': velocityLng.toStringAsFixed(4),
'status': 'enabled',
'accuracy': pos.accuracy.toStringAsFixed(1),
};
} catch (e) {
debugPrint('[BG_RIDER_LOG] Error getting location: $e');
return result;
}
}
static String _two(int n) => n.toString().padLeft(2, '0');
static String _formatDateTimeFull(DateTime dt) {
final y = dt.year.toString();
final m = _two(dt.month);
final d = _two(dt.day);
final hh = _two(dt.hour);
final mm = _two(dt.minute);
final ss = _two(dt.second);
return "$y-$m-$d $hh:$mm:$ss";
}
static String _formatTime(DateTime dt) {
final hh = _two(dt.hour);
final mm = _two(dt.minute);
final ss = _two(dt.second);
return "$hh:$mm:$ss";
}
/// Accumulates cumulative KMs for active deliveries using the foreground service GPS position.
/// Only runs when LiveTrackingService (main isolate) hasn't updated in the last 10 seconds,
/// which means the app is backgrounded/screen-off/power-saver and the main isolate is dormant.
static Future<void> _accumulateBackgroundKms(
SharedPreferences prefs,
Map<String, String> loc,
) async {
try {
// Check if the main isolate's LiveTrackingService is still actively updating
final lastLiveUpdateMs = prefs.getInt('live_tracking_last_update_ms') ?? 0;
final nowMs = DateTime.now().millisecondsSinceEpoch;
final secondsSinceLiveUpdate = (nowMs - lastLiveUpdateMs) / 1000.0;
if (secondsSinceLiveUpdate < 10.0) {
// Main isolate is active — let it handle KMs to avoid race conditions
debugPrint(
'[BG_KM] LiveTrackingService active (${secondsSinceLiveUpdate.toStringAsFixed(1)}s ago) — skipping background accumulation',
);
return;
}
// Skip if GPS accuracy is too poor for reliable KM tracking
final double accuracy = double.tryParse(loc['accuracy'] ?? '9999') ?? 9999.0;
if (accuracy > 50.0) {
debugPrint(
'[BG_KM] Low-accuracy position (${accuracy.toStringAsFixed(0)}m) — skipping KM accumulation',
);
return;
}
final double currentLat = double.tryParse(loc['lat'] ?? '0') ?? 0.0;
final double currentLng = double.tryParse(loc['lng'] ?? '0') ?? 0.0;
if (currentLat == 0.0 || currentLng == 0.0) return;
final activeDeliveryIds =
prefs.getStringList('active_tracking_delivery_ids') ?? [];
for (final dId in activeDeliveryIds) {
try {
final lastLatStr = prefs.getString('delivery_tracking_${dId}_lastLat') ?? '';
final lastLngStr = prefs.getString('delivery_tracking_${dId}_lastLng') ?? '';
final currentCumKm = double.tryParse(
prefs.getString('delivery_tracking_${dId}_cumulativeKm') ?? '0',
) ??
0.0;
if (lastLatStr.isNotEmpty && lastLngStr.isNotEmpty) {
final lastLat = double.tryParse(lastLatStr) ?? 0.0;
final lastLng = double.tryParse(lastLngStr) ?? 0.0;
if (lastLat != 0.0 && lastLng != 0.0) {
final distanceMeters = Geolocator.distanceBetween(
lastLat,
lastLng,
currentLat,
currentLng,
);
// Speed-based jump guard: reject if implied speed > 120 km/h (33.3 m/s).
// Uses elapsed time since last recorded position so the threshold scales
// correctly whether the background interval is 30s, 60s, or longer.
final lastUpdateMs =
prefs.getInt('delivery_tracking_${dId}_lastUpdateMs') ?? 0;
final nowMs = DateTime.now().millisecondsSinceEpoch;
final elapsedSeconds = lastUpdateMs > 0
? (nowMs - lastUpdateMs) / 1000.0
: 60.0; // conservative default
final maxRealisticMeters = elapsedSeconds * 33.3; // 120 km/h ceiling
if (distanceMeters > maxRealisticMeters && distanceMeters > 50.0) {
// GPS jumped — update anchor without counting phantom distance
debugPrint(
'[BG_KM] GPS jump for $dId: ${distanceMeters.toStringAsFixed(0)}m '
'in ${elapsedSeconds.toStringAsFixed(1)}s (max: ${maxRealisticMeters.toStringAsFixed(0)}m) — resetting anchor',
);
await prefs.setString('delivery_tracking_${dId}_lastLat', currentLat.toString());
await prefs.setString('delivery_tracking_${dId}_lastLng', currentLng.toString());
await prefs.setInt('delivery_tracking_${dId}_lastUpdateMs', nowMs);
} else if (distanceMeters >= 5.0) {
final newCumKm = currentCumKm + (distanceMeters / 1000.0);
await prefs.setString(
'delivery_tracking_${dId}_cumulativeKm',
newCumKm.toStringAsFixed(4),
);
await prefs.setString('delivery_tracking_${dId}_lastLat', currentLat.toString());
await prefs.setString('delivery_tracking_${dId}_lastLng', currentLng.toString());
await prefs.setInt('delivery_tracking_${dId}_lastUpdateMs', nowMs);
debugPrint(
'[BG_KM] +${(distanceMeters / 1000.0).toStringAsFixed(4)} km for $dId '
'in ${elapsedSeconds.toStringAsFixed(1)}s (total: ${newCumKm.toStringAsFixed(4)} km)',
);
}
}
} else {
// No anchor yet — set initial position
await prefs.setString('delivery_tracking_${dId}_lastLat', currentLat.toString());
await prefs.setString('delivery_tracking_${dId}_lastLng', currentLng.toString());
await prefs.setInt(
'delivery_tracking_${dId}_lastUpdateMs',
DateTime.now().millisecondsSinceEpoch,
);
}
} catch (_) {}
}
} catch (e) {
debugPrint('[BG_KM] Error in background KM accumulation: $e');
}
}
static Future<void> createLoginNow() async {
try {
final prefs = await SharedPreferences.getInstance();
// Reload from disk so we see the latest values written by the main isolate
await prefs.reload();
final int onduty = prefs.getInt('onduty') ?? 0;
if (onduty != 1) {
return;
}
final int? userid = prefs.getInt('userId') ?? prefs.getInt('userid');
final int? partnerid = prefs.getInt('partnerId') ?? prefs.getInt('partnerid');
final int? shiftid = prefs.getInt('shiftId') ?? prefs.getInt('shiftid');
if ((userid ?? 0) == 0) return;
// Prefer explicit username, then fallback to stored full name or first/last
String? username = prefs.getString('username');
username ??= prefs.getString('user_name');
if (username == null || username.trim().isEmpty) {
final first = prefs.getString('firstname') ?? '';
final last = prefs.getString('lastname') ?? '';
final combined = ('$first $last').trim();
if (combined.isNotEmpty) {
username = combined;
}
}
// ✅ Check if there are active deliveries to set status
final bool hasActiveDeliveries = prefs.getBool('has_live_deliveries') ?? false;
final String riderStatus = hasActiveDeliveries ? 'active' : 'idle';
final now = DateTime.now();
final iso = _formatDateTimeFull(now);
final loginTime = _formatTime(now);
final loc = await _ensureLatLng();
// Accumulate KMs in background when LiveTrackingService (main isolate) is not active
await _accumulateBackgroundKms(prefs, loc);
final int? tenantid = prefs.getInt('tenantid');
final int? locationid = prefs.getInt('locationid');
final int? applocationid = prefs.getInt('applocationid');
final String? userfcmtoken = prefs.getString('userfcmtoken');
final int? logid = prefs.getInt('logId') ?? prefs.getInt('logid');
final String orderId = prefs.getString('current_riding_order_id') ?? '';
final payload = {
"logid": logid ?? 0,
"userid": userid,
"partnerid": partnerid,
"shiftid": shiftid,
"logdate": iso,
"login": loginTime,
"latitude": loc['lat'] ?? '0',
"longitude": loc['lng'] ?? '0',
"raw_latitude": loc['raw_lat'] ?? '0',
"raw_longitude": loc['raw_lng'] ?? '0',
"velocity_lat": loc['velocity_lat'] ?? '0',
"velocity_lng": loc['velocity_lng'] ?? '0',
"speed": loc['speed'] ?? '0',
"heading": loc['heading'] ?? '0',
"onduty": 1,
"status": riderStatus,
"contactno": prefs.getString('contactno') ?? '',
"tenantid": tenantid ?? 0,
"locationid": locationid ?? 0,
"applocationid": applocationid ?? 0,
"userfcmtoken": userfcmtoken ?? '',
"username": (username ?? '').trim(),
"orderid": orderId,
};
final firstName = prefs.getString('firstname') ?? '';
final lastName = prefs.getString('lastname') ?? '';
if (firstName.trim().isNotEmpty) {
payload['firstname'] = firstName.trim();
}
if (lastName.trim().isNotEmpty) {
payload['lastname'] = lastName.trim();
}
final base = ApiConstants.mainRoute == 'live'
? ApiConstants.createRiderLogLive
: ApiConstants.createRiderLogDev;
final provider = CreateRiderLogProvider();
final resp = await provider.createRiderLog(base, payload);
if (resp == null || resp.isEmpty) return;
final det = (resp['details'] is Map<String, dynamic>)
? (resp['details'] as Map<String, dynamic>)
: resp;
final newLogId = int.tryParse('${det['logid'] ?? 0}') ?? (det['logid'] as int? ?? 0);
await prefs.setInt('logid', newLogId);
await prefs.setInt('logId', newLogId);
// ✅ MQTT BACKGROUND PUBLISH ( Lane Split )
final mqttService = NearleMqttService();
if (!mqttService.isConnected) {
// Use a slightly different client ID for background to avoid kicking the main one off
await mqttService.connect();
}
if (mqttService.isConnected) {
// Gather Telemetry
final battery = Battery();
final int batteryLevel = await battery.batteryLevel;
final BatteryState batteryState = await battery.batteryState;
final isCharging = batteryState == BatteryState.charging || batteryState == BatteryState.full;
final connectivity = await Connectivity().checkConnectivity();
final String connType = connectivity.isNotEmpty ? connectivity.first.toString().split('.').last : 'none';
// 1. Direct Telemetry (Feeding the /full API)
mqttService.publish('battery', '$batteryLevel%');
mqttService.publish('charging', isCharging ? 'yes' : 'no');
mqttService.publish('speed', loc['speed'] ?? '0');
mqttService.publish('connection', connType);
mqttService.publish('accuracy', loc['accuracy'] ?? '0');
// 2. Alert if Location is Off
final String locStatus = loc['status'] ?? 'unknown';
if (locStatus != 'enabled') {
mqttService.publish('alerts', {
'userid': userid,
'username': (username ?? '').trim(),
'event': 'location_turned_off',
'error_type': locStatus,
'battery': '$batteryLevel%',
'is_charging': isCharging,
'connection': connType,
'logdate': iso,
});
}
// 3. Low Battery Alert
if (batteryLevel < 15 && !isCharging) {
mqttService.publish('alerts', {
'userid': userid,
'username': (username ?? '').trim(),
'event': 'low_battery_warning',
'battery': '$batteryLevel%',
'logdate': iso,
});
}
// 4. Poor GPS Accuracy Alert
final double accuracy = double.tryParse(loc['accuracy'] ?? '0') ?? 0;
if (accuracy > 30) {
mqttService.publish('alerts', {
'userid': userid,
'username': (username ?? '').trim(),
'event': 'poor_gps_signal',
'accuracy': '${accuracy.toStringAsFixed(1)}m',
'logdate': iso,
});
}
// 5. Lane: Status
mqttService.updateStatus(riderStatus == 'active' ? 'Active' : MqttConstants.statusOnline);
// 6. Lane: Periodic Log (Comprehensive Snapshot)
mqttService.publishLog('rider_periodic_log', {
'userid': userid,
'username': username,
'logdate': iso,
'latitude': loc['lat'] ?? '0',
'longitude': loc['lng'] ?? '0',
'speed': loc['speed'] ?? '0',
'heading': loc['heading'] ?? '0',
'accuracy': loc['accuracy'] ?? '0',
'status': riderStatus,
'orderid': orderId,
'battery': '$batteryLevel%',
'is_charging': isCharging,
'connection': connType,
'location_service': locStatus,
'is_background': true,
});
}
} catch (e) {
// ignore background errors
}
}
}
class RiderLogTaskHandler extends TaskHandler {
Timer? _timer; // not used; plugin provides repeat callback, but keep safety
@override
Future<void> onStart(DateTime timestamp, SendPort? sendPort) async {
// No-op
}
@override
Future<void> onRepeatEvent(DateTime timestamp, SendPort? sendPort) async {
// 1. Rider Log (existing)
await _BackgroundRiderLog.createLoginNow();
// 2. Delivery Log (new)
await BackgroundDeliveryLog.processActiveDeliveries();
// 3. Auto Shift End (new)
await BackgroundDeliveryLog.checkShiftEnd();
}
@override
Future<void> onDestroy(DateTime timestamp, SendPort? sendPort) async {
_timer?.cancel();
_timer = null;
}
}
@pragma('vm:entry-point')
void riderLogCallback() {
HttpOverrides.global = MyHttpOverrides();
FlutterForegroundTask.setTaskHandler(RiderLogTaskHandler());
}

View File

@@ -0,0 +1,292 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'dart:math' as math;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:nearle/utils/kalman_filter.dart';
import 'package:nearle/utils/mqtt_service.dart';
import 'package:battery_plus/battery_plus.dart';
class LiveTrackingService {
static final LiveTrackingService _instance = LiveTrackingService._internal();
factory LiveTrackingService() => _instance;
LiveTrackingService._internal();
StreamSubscription<Position>? _positionStreamSubscription;
bool _isTracking = false;
bool _isProcessing = false;
NearleKalmanFilter? _kf;
DateTime? _lastUpdateTime;
DateTime? _lastSentTime;
DateTime? _lastTelemetrySent;
Timer? _watchdogTimer;
// Thresholds
static const double _maxAccuracyMeters = 50.0; // Reject GPS > 50m accuracy
static const double _minDistanceMeters = 5.0; // Minimum movement to count
static const double _maxJumpMeters = 200.0; // Reject single-step jumps > 200m
static const int _rateLimitSeconds = 3; // Minimum seconds between updates
static const int _watchdogSeconds = 45; // Restart if no update in 45s
void startTracking() async {
if (_isTracking) return;
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
debugPrint('[LIVE TRACKING] Location services disabled.');
return;
}
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
debugPrint('[LIVE TRACKING] Location permission denied.');
return;
}
_isTracking = true;
_lastSentTime = null;
debugPrint('[LIVE TRACKING] Starting high-frequency tracking...');
_startStream();
_startWatchdog();
}
void _startStream() {
_positionStreamSubscription?.cancel();
const locationSettings = LocationSettings(
accuracy: LocationAccuracy.bestForNavigation,
distanceFilter: 0,
);
_positionStreamSubscription = Geolocator.getPositionStream(
locationSettings: locationSettings,
).listen(
(Position position) async {
await _sendToKalmanBackend(position);
},
onError: (error) {
debugPrint('[LIVE TRACKING] Stream error: $error — restarting in 5s');
_positionStreamSubscription?.cancel();
_positionStreamSubscription = null;
if (_isTracking) {
Future.delayed(const Duration(seconds: 5), () {
if (_isTracking) _startStream();
});
}
},
cancelOnError: true,
);
}
void _startWatchdog() {
_watchdogTimer?.cancel();
_watchdogTimer = Timer.periodic(
const Duration(seconds: _watchdogSeconds),
(_) {
if (!_isTracking) return;
final lastSent = _lastSentTime;
if (lastSent == null) return;
final staleSeconds = DateTime.now().difference(lastSent).inSeconds;
if (staleSeconds > _watchdogSeconds) {
debugPrint(
'[LIVE TRACKING] Watchdog: stream stale for ${staleSeconds}s — restarting',
);
_positionStreamSubscription?.cancel();
_positionStreamSubscription = null;
_kf = null;
_lastUpdateTime = null;
_startStream();
}
},
);
}
void stopTracking() {
if (!_isTracking) return;
debugPrint('[LIVE TRACKING] Stopping tracking.');
_watchdogTimer?.cancel();
_watchdogTimer = null;
_positionStreamSubscription?.cancel();
_positionStreamSubscription = null;
_kf = null;
_lastUpdateTime = null;
_isTracking = false;
}
Future<void> _sendToKalmanBackend(Position position) async {
final now = DateTime.now();
// Rate limiter
if (_lastSentTime != null &&
now.difference(_lastSentTime!).inSeconds < _rateLimitSeconds) {
return;
}
// Mutex — prevent concurrent processing
if (_isProcessing) return;
_isProcessing = true;
_lastSentTime = now;
try {
// Reject mocked GPS (anti-cheat)
if (position.isMocked) {
debugPrint('[LIVE TRACKING] Skipped mocked position');
return;
}
// Reject poor-accuracy GPS (power saver / other apps degrading GPS)
if (position.accuracy > _maxAccuracyMeters) {
debugPrint(
'[LIVE TRACKING] Skipped low-accuracy position: ${position.accuracy.toStringAsFixed(0)}m',
);
return;
}
final prefs = await SharedPreferences.getInstance();
final userid = prefs.getInt('userId') ?? prefs.getInt('userid');
final currentOrderId = prefs.getString('current_riding_order_id') ?? '';
final double headingRadians = position.heading * (math.pi / 180.0);
final double velocityLng = position.speed * math.sin(headingRadians);
final double velocityLat = position.speed * math.cos(headingRadians);
double displayLat = position.latitude;
double displayLng = position.longitude;
if (_kf == null) {
_kf = NearleKalmanFilter(
lat: position.latitude,
lng: position.longitude,
);
} else {
final double dt = _lastUpdateTime != null
? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0
: _rateLimitSeconds.toDouble();
_kf!.predict(dt);
_kf!.update(position.latitude, position.longitude);
displayLat = _kf!.x[0];
displayLng = _kf!.x[1];
}
_lastUpdateTime = now;
// Stamp that the main isolate is actively tracking.
// The foreground service reads this to avoid double-counting KMs.
await prefs.setInt('live_tracking_last_update_ms', now.millisecondsSinceEpoch);
final payload = {
'userid': userid,
'orderid': currentOrderId,
'lat': displayLat,
'lng': displayLng,
'raw_lat': position.latitude,
'raw_lng': position.longitude,
'speed': position.speed,
'heading': position.heading,
'velocity_lat': velocityLat,
'velocity_lng': velocityLng,
'timestamp': now.toIso8601String(),
};
// Accumulate cumulative KMs for active deliveries
final activeDeliveryIds =
prefs.getStringList('active_tracking_delivery_ids') ?? [];
for (final dId in activeDeliveryIds) {
try {
final lastLatStr =
prefs.getString('delivery_tracking_${dId}_lastLat') ?? '';
final lastLngStr =
prefs.getString('delivery_tracking_${dId}_lastLng') ?? '';
final currentCumKm = double.tryParse(
prefs.getString('delivery_tracking_${dId}_cumulativeKm') ?? '0',
) ??
0.0;
if (lastLatStr.isNotEmpty && lastLngStr.isNotEmpty) {
final lastLat = double.tryParse(lastLatStr) ?? 0.0;
final lastLng = double.tryParse(lastLngStr) ?? 0.0;
if (lastLat != 0 && lastLng != 0 && displayLat != 0 && displayLng != 0) {
final distanceMeters = Geolocator.distanceBetween(
lastLat,
lastLng,
displayLat,
displayLng,
);
// Speed-based jump guard: reject if implied speed > 120 km/h (33.3 m/s)
final lastUpdateMs = prefs.getInt('delivery_tracking_${dId}_lastUpdateMs') ?? 0;
final elapsedSeconds = lastUpdateMs > 0
? (now.millisecondsSinceEpoch - lastUpdateMs) / 1000.0
: _rateLimitSeconds.toDouble();
final maxRealisticMeters = elapsedSeconds * 33.3; // 120 km/h ceiling
if (distanceMeters > maxRealisticMeters && distanceMeters > _maxJumpMeters) {
// GPS jumped — update anchor without counting the phantom distance
debugPrint(
'[LIVE TRACKING] GPS jump for $dId: ${distanceMeters.toStringAsFixed(0)}m '
'in ${elapsedSeconds.toStringAsFixed(1)}s (max realistic: ${maxRealisticMeters.toStringAsFixed(0)}m) — resetting anchor',
);
await prefs.setString('delivery_tracking_${dId}_lastLat', displayLat.toString());
await prefs.setString('delivery_tracking_${dId}_lastLng', displayLng.toString());
await prefs.setInt('delivery_tracking_${dId}_lastUpdateMs', now.millisecondsSinceEpoch);
} else if (distanceMeters >= _minDistanceMeters) {
final newCumKm = currentCumKm + (distanceMeters / 1000.0);
await prefs.setString(
'delivery_tracking_${dId}_cumulativeKm',
newCumKm.toStringAsFixed(4),
);
await prefs.setString('delivery_tracking_${dId}_lastLat', displayLat.toString());
await prefs.setString('delivery_tracking_${dId}_lastLng', displayLng.toString());
await prefs.setInt('delivery_tracking_${dId}_lastUpdateMs', now.millisecondsSinceEpoch);
}
// else: too small — skip without moving anchor (avoids GPS noise accumulation)
}
} else {
// First point for this delivery — set anchor only
await prefs.setString('delivery_tracking_${dId}_lastLat', displayLat.toString());
await prefs.setString('delivery_tracking_${dId}_lastLng', displayLng.toString());
await prefs.setInt('delivery_tracking_${dId}_lastUpdateMs', now.millisecondsSinceEpoch);
}
} catch (_) {}
}
final mqttService = NearleMqttService();
if (mqttService.isConnected) {
mqttService.publishLocation(payload);
debugPrint(
'[LIVE TRACKING] ${displayLat.toStringAsFixed(6)}, ${displayLng.toStringAsFixed(6)} '
'(acc: ${position.accuracy.toStringAsFixed(0)}m, speed: ${position.speed.toStringAsFixed(1)} m/s)',
);
} else {
debugPrint('[LIVE TRACKING] MQTT not connected — attempting reconnect');
mqttService.connect();
}
// Telemetry once every 5 minutes (timestamp guard prevents duplicate fires
// across the 3-second GPS update window at the same minute mark)
final shouldSendTelemetry = _lastTelemetrySent == null ||
now.difference(_lastTelemetrySent!).inMinutes >= 5;
if (now.minute % 5 == 0 && shouldSendTelemetry) {
_lastTelemetrySent = now;
try {
final battery = Battery();
final batteryLevel = await battery.batteryLevel;
mqttService.publishTelemetry({
'battery_level': batteryLevel,
'gps_accuracy': position.accuracy,
'mocked': position.isMocked,
'timestamp': now.toIso8601String(),
});
} catch (_) {}
}
} catch (e) {
debugPrint('[LIVE TRACKING] Error: $e');
} finally {
_isProcessing = false;
}
}
}