first commit
This commit is contained in:
981
lib/background/backgroundservice.dart
Normal file
981
lib/background/backgroundservice.dart
Normal file
@@ -0,0 +1,981 @@
|
||||
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:miler/providers/pickuplog/pickuplog_provider.dart';
|
||||
import 'package:miler/providers/notifications/notificationservce.dart';
|
||||
import 'package:miler/views/helpers/constants/apiconstants.dart';
|
||||
import 'package:miler/utils/kalman_filter.dart';
|
||||
|
||||
/// Background service for managing active pickup logs
|
||||
class BackgroundCollectionLog {
|
||||
static MilerKalmanFilter? _kf;
|
||||
static DateTime? _lastUpdateTime;
|
||||
|
||||
static final CreatePickupLogProvider _logProvider = CreatePickupLogProvider();
|
||||
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 _activePickupsKey = 'active_pickup';
|
||||
static const String _payloadKeyPrefix = 'pickup_payload_';
|
||||
static const String _offlineLogKey = 'offline_pickup_logs';
|
||||
static const String _proximityAlertKeyPrefix = 'pickup_proximity_alerted_';
|
||||
|
||||
static bool _isPosting = false;
|
||||
static bool _ttsReady = false;
|
||||
static bool _audioReady = false;
|
||||
|
||||
/// Process active Pickups: fetch from API, filter active, and post logs
|
||||
static Future<void> processActivePickups() async {
|
||||
if (_isPosting) {
|
||||
debugPrint('[ACTIVE_COLLECTION_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_COLLECTION_LOG][BG] Checking for user: $userId');
|
||||
|
||||
if (userId == 0) {
|
||||
debugPrint('[ACTIVE_COLLECTION_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 (getpickup)
|
||||
final bool isLive = ApiConstants.mainRoute == 'live';
|
||||
final baseUrl = isLive
|
||||
? ApiConstants.currentPickupLive
|
||||
: ApiConstants.currentPickupDev;
|
||||
|
||||
final uri = Uri.parse(baseUrl).replace(
|
||||
queryParameters: {
|
||||
'userid': userId.toString(),
|
||||
'fromdate': today,
|
||||
'todate': today,
|
||||
't': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('[ACTIVE_COLLECTION_LOG][BG] Fetching Pickup: $uri');
|
||||
|
||||
// Fetch pickup from API using v1 endpoint
|
||||
final pickups = await _fetchPickupsFromApi(uri);
|
||||
|
||||
debugPrint(
|
||||
'[ACTIVE_COLLECTION_LOG][BG] API returned ${pickups.length} items',
|
||||
);
|
||||
|
||||
// Filter for active bookings only
|
||||
final activeOrders = pickups.whereType<Map<String, dynamic>>().where((
|
||||
Booking,
|
||||
) {
|
||||
final status = (Booking['orderstatus']?.toString().toLowerCase() ?? '')
|
||||
.trim();
|
||||
final isActive = status == 'active';
|
||||
return isActive;
|
||||
}).toList();
|
||||
|
||||
if (activeOrders.isEmpty) {
|
||||
debugPrint('[ACTIVE_COLLECTION_LOG][BG] No active bookings found');
|
||||
// ✅ Still check shift end even if no active pickup
|
||||
debugPrint('[ACTIVE_COLLECTION_LOG][BG] 🔍 Checking shift end time...');
|
||||
await checkShiftEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[ACTIVE_COLLECTION_LOG][BG] Found ${activeOrders.length} active Pickup',
|
||||
);
|
||||
|
||||
// Post logs for each active pickup
|
||||
for (final Booking in activeOrders) {
|
||||
final orderId = (Booking['orderid'] ?? '').toString();
|
||||
if (orderId.isEmpty) continue;
|
||||
|
||||
// Get current coordinates once per Booking to reuse for proximity + log
|
||||
final Map<String, String>? coords = await _getCoordinatesWithFallback();
|
||||
if (coords != null) {
|
||||
await _checkProximityAlert(Booking, coords);
|
||||
}
|
||||
|
||||
// Get payload from SharedPreferences or create new one
|
||||
Map<String, dynamic>? payload = await _loadPayload(orderId);
|
||||
if (payload == null) {
|
||||
// Create new payload from Booking data with userId from SharedPreferences
|
||||
payload = _createPayload(Booking, userId);
|
||||
await _persistPayload(orderId, payload);
|
||||
await _markAsActive(orderId);
|
||||
} else {
|
||||
// Ensure userid is set correctly in existing payload
|
||||
payload['userid'] = userId;
|
||||
}
|
||||
|
||||
// Post the log
|
||||
await _postPickupLog(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_COLLECTION_LOG][BG] Error processing: $e');
|
||||
} finally {
|
||||
_isPosting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Post pickup log for a specific Booking
|
||||
static Future<void> _postPickupLog(
|
||||
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 pickupId = payload['pickupid']?.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 (pickupId.isNotEmpty) {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
riderKmsToSend =
|
||||
double.tryParse(
|
||||
prefs.getString('pickup_tracking_${pickupId}_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.createPickupLogLive
|
||||
: ApiConstants.createPickupLogDev;
|
||||
|
||||
// Post the log
|
||||
debugPrint('[ACTIVE_COLLECTION_LOG][BG] Posting pickup log for $orderId');
|
||||
|
||||
final result = await _logProvider
|
||||
.createPickupLog(payloadWithCoords)
|
||||
.timeout(
|
||||
const Duration(seconds: 8),
|
||||
onTimeout: () =>
|
||||
throw TimeoutException('Pickup log post timeout for $orderId'),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
debugPrint('[ACTIVE_COLLECTION_LOG][BG] Success for $orderId');
|
||||
await _persistLastLogLocation(orderId, coords);
|
||||
} else {
|
||||
debugPrint(
|
||||
'[ACTIVE_COLLECTION_LOG][BG] Warning: No response for $orderId',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[ACTIVE_COLLECTION_LOG][BG] Failed to post log for $orderId: $e',
|
||||
);
|
||||
// Offline fallback
|
||||
if (payloadWithCoords != null) {
|
||||
await _saveToOfflineQueue(orderId, payloadWithCoords);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create payload from pickup Booking data
|
||||
static Map<String, dynamic> _createPayload(
|
||||
Map<String, dynamic> pickup,
|
||||
int userId,
|
||||
) {
|
||||
return <String, dynamic>{
|
||||
'logid': 0,
|
||||
'tenantid': pickup['tenantid'] ?? 0,
|
||||
'partnerid': pickup['partnerid'] ?? 0,
|
||||
'locationid': pickup['locationid'] ?? 0,
|
||||
'orderheaderid': pickup['orderheaderid'] ?? 0,
|
||||
'pickupid': pickup['pickupid'] ?? 0,
|
||||
'userid': userId,
|
||||
'orderid': (pickup['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_COLLECTION_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_COLLECTION_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_COLLECTION_LOG][BG] Failed to get current pos: $e');
|
||||
position = await Geolocator.getLastKnownPosition();
|
||||
}
|
||||
|
||||
if (position != null) {
|
||||
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 = MilerKalmanFilter(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_COLLECTION_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 Booking as active in SharedPreferences
|
||||
static Future<void> _markAsActive(String orderId) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = prefs.getStringList(_activePickupsKey) ?? <String>[];
|
||||
if (!list.contains(orderId)) {
|
||||
list.add(orderId);
|
||||
await prefs.setStringList(_activePickupsKey, list);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Fetch pickup from API using v1 endpoint
|
||||
static Future<List<dynamic>> _fetchPickupsFromApi(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_COLLECTION_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_COLLECTION_LOG][BG][OFFLINE] Flushing ${queue.length} offline logs...',
|
||||
);
|
||||
|
||||
final List<String> remaining = [];
|
||||
bool anySuccess = false;
|
||||
|
||||
final url = ApiConstants.mainRoute == 'live'
|
||||
? ApiConstants.createPickupLogLive
|
||||
: ApiConstants.createPickupLogDev;
|
||||
|
||||
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_COLLECTION_LOG][BG][OFFLINE] Retrying for orderId: $orderId',
|
||||
);
|
||||
|
||||
final result = await _logProvider
|
||||
.createPickupLog(payload)
|
||||
.timeout(const Duration(seconds: 8));
|
||||
|
||||
if (result != null) {
|
||||
debugPrint(
|
||||
'[ACTIVE_COLLECTION_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_COLLECTION_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_PICKUP_LOG][BG][OFFLINE] Saved to queue. Total: ${queue.length}',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[ACTIVE_PICKUP_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 pickup 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 pickup when within threshold
|
||||
static Future<void> _checkProximityAlert(
|
||||
Map<String, dynamic> Booking,
|
||||
Map<String, String> coords,
|
||||
) async {
|
||||
try {
|
||||
final pickupId = (Booking['pickupid'] ?? Booking['orderid'] ?? '')
|
||||
.toString();
|
||||
if (pickupId.isEmpty) return;
|
||||
|
||||
final dropLatRaw =
|
||||
(Booking['droplat'] ??
|
||||
Booking['DropLat'] ??
|
||||
Booking['pickuplat'] ??
|
||||
'')
|
||||
.toString();
|
||||
final dropLngRaw =
|
||||
(Booking['droplon'] ??
|
||||
Booking['droplong'] ??
|
||||
Booking['DropLon'] ??
|
||||
Booking['DropLong'] ??
|
||||
Booking['pickuplong'] ??
|
||||
'')
|
||||
.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) {
|
||||
await NotificationServce.showLocalNotification(
|
||||
title: 'Near pickup location',
|
||||
body: 'You’ve reached your destination. Please update the status.',
|
||||
);
|
||||
|
||||
final played = await _playProximityAudio();
|
||||
|
||||
debugPrint(
|
||||
'[PROXIMITY] Alerted pickupId=$pickupId at ${distanceMeters.toStringAsFixed(1)}m '
|
||||
'target=($dropLat,$dropLng) rider=($riderLat,$riderLng) played=$played',
|
||||
);
|
||||
} else {
|
||||
debugPrint(
|
||||
'[PROXIMITY] Skipped alert for pickupId=$pickupId | 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 pickup status.',
|
||||
);
|
||||
debugPrint('[PROXIMITY][TTS] Spoke destination prompt');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[PROXIMITY][TTS] Error speaking prompt: $e');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
345
lib/background/foreground_service.dart
Normal file
345
lib/background/foreground_service.dart
Normal file
@@ -0,0 +1,345 @@
|
||||
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:miler/views/helpers/constants/apiconstants.dart';
|
||||
import 'package:miler/providers/Riderlog/riderlog_provider.dart';
|
||||
import 'package:miler/background/backgroundservice.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:miler/utils/kalman_filter.dart';
|
||||
import 'package:miler/utils/mqtt_service.dart';
|
||||
import 'package:miler/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:miler/helpers/http_overrides.dart';
|
||||
|
||||
class _BackgroundRiderLog {
|
||||
static MilerKalmanFilter? _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,
|
||||
),
|
||||
);
|
||||
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 = MilerKalmanFilter(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";
|
||||
}
|
||||
|
||||
static Future<void> createLoginNow() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
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 pickup to set status
|
||||
final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false;
|
||||
final String riderStatus = hasActivePickups ? 'active' : 'idle';
|
||||
|
||||
final now = DateTime.now();
|
||||
final iso = _formatDateTimeFull(now);
|
||||
final loginTime = _formatTime(now);
|
||||
final loc = await _ensureLatLng();
|
||||
|
||||
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(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);
|
||||
|
||||
// ✅ MQTT BACKGROUND PUBLISH ( Lane Split )
|
||||
final mqttService = MilerMqttService();
|
||||
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. Pickup Log (new)
|
||||
await BackgroundCollectionLog.processActivePickups();
|
||||
|
||||
// 3. Auto Shift End (new)
|
||||
await BackgroundCollectionLog.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());
|
||||
}
|
||||
246
lib/background/live_tracking_service.dart
Normal file
246
lib/background/live_tracking_service.dart
Normal file
@@ -0,0 +1,246 @@
|
||||
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:miler/utils/kalman_filter.dart';
|
||||
import 'package:miler/utils/mqtt_service.dart';
|
||||
import 'package:battery_plus/battery_plus.dart';
|
||||
|
||||
class LiveTrackingService {
|
||||
static final LiveTrackingService _instance = LiveTrackingService._internal();
|
||||
|
||||
factory LiveTrackingService() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
LiveTrackingService._internal();
|
||||
|
||||
StreamSubscription<Position>? _positionStreamSubscription;
|
||||
bool _isTracking = false;
|
||||
MilerKalmanFilter? _kf;
|
||||
DateTime? _lastUpdateTime;
|
||||
|
||||
void startTracking() async {
|
||||
if (_isTracking) return;
|
||||
|
||||
// Check permissions before starting
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
debugPrint('[LIVE TRACKING] Location services are disabled.');
|
||||
return;
|
||||
}
|
||||
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied ||
|
||||
permission == LocationPermission.deniedForever) {
|
||||
debugPrint('[LIVE TRACKING] Location permission denied.');
|
||||
return;
|
||||
}
|
||||
|
||||
_isTracking = true;
|
||||
debugPrint('[LIVE TRACKING] Started high-frequency live tracking...');
|
||||
|
||||
final LocationSettings locationSettings = const LocationSettings(
|
||||
accuracy: LocationAccuracy
|
||||
.bestForNavigation, // High precision required for Kalman
|
||||
distanceFilter: 0, // Request updates regardless of distance moved
|
||||
);
|
||||
|
||||
// Some Android devices might drop points if we don't have a foreground service,
|
||||
// but the app already uses flutter_foreground_task elsewhere.
|
||||
_positionStreamSubscription =
|
||||
Geolocator.getPositionStream(
|
||||
locationSettings: locationSettings,
|
||||
).listen((Position? position) async {
|
||||
if (position != null) {
|
||||
// Implement your own custom debounce if it happens too fast, e.g limit to 2-3 secs
|
||||
_sendToKalmanBackend(position);
|
||||
}
|
||||
});
|
||||
|
||||
_positionStreamSubscription?.onError((error) {
|
||||
debugPrint('[LIVE TRACKING] Stream error: $error');
|
||||
});
|
||||
}
|
||||
|
||||
void stopTracking() {
|
||||
if (!_isTracking) return;
|
||||
debugPrint('[LIVE TRACKING] Stopped live tracking.');
|
||||
_positionStreamSubscription?.cancel();
|
||||
_positionStreamSubscription = null;
|
||||
_kf = null; // Reset filter state
|
||||
_lastUpdateTime = null;
|
||||
_isTracking = false;
|
||||
}
|
||||
|
||||
// Rate limiter details
|
||||
DateTime? _lastSentTime;
|
||||
|
||||
Future<void> _sendToKalmanBackend(Position position) async {
|
||||
final now = DateTime.now();
|
||||
// Only send if it has been at least 3 seconds
|
||||
if (_lastSentTime != null && now.difference(_lastSentTime!).inSeconds < 3) {
|
||||
return;
|
||||
}
|
||||
_lastSentTime = now;
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final userid = prefs.getInt('userId') ?? prefs.getInt('userid');
|
||||
final currentOrderId = prefs.getString('current_riding_order_id') ?? '';
|
||||
|
||||
// Decompose velocity for logging/backend reference
|
||||
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);
|
||||
|
||||
// --- APPLY LOCAL KALMAN FILTER ---
|
||||
double displayLat = position.latitude;
|
||||
double displayLng = position.longitude;
|
||||
|
||||
if (_kf == null) {
|
||||
// Initialize filter with first measurement
|
||||
_kf = MilerKalmanFilter(
|
||||
lat: position.latitude,
|
||||
lng: position.longitude,
|
||||
);
|
||||
} else {
|
||||
// Calculate dt (delta time) since last update
|
||||
final double dt = _lastUpdateTime != null
|
||||
? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0
|
||||
: 3.0; // Default to 3s if unknown
|
||||
|
||||
_kf!.predict(dt);
|
||||
_kf!.update(position.latitude, position.longitude);
|
||||
|
||||
// Extract smoothed coordinates from state vector [lat, lng, v_lat, v_lng]
|
||||
displayLat = _kf!.x[0];
|
||||
displayLng = _kf!.x[1];
|
||||
}
|
||||
_lastUpdateTime = now;
|
||||
|
||||
final payload = {
|
||||
'userid': userid,
|
||||
'orderid': currentOrderId,
|
||||
'lat': displayLat, // Smoothed Latitude
|
||||
'lng': displayLng, // Smoothed Longitude
|
||||
'raw_lat':
|
||||
position.latitude, // Kept for server-side verification if needed
|
||||
'raw_lng': position.longitude,
|
||||
'speed': position.speed,
|
||||
'heading': position.heading,
|
||||
'velocity_lat': velocityLat,
|
||||
'velocity_lng': velocityLng,
|
||||
'timestamp': now.toIso8601String(),
|
||||
};
|
||||
|
||||
// --- TRACK ACTIVE PICKUPS DISTANCE ---
|
||||
final activePickupIds =
|
||||
prefs.getStringList('active_tracking_pickup_ids') ?? [];
|
||||
|
||||
for (String dId in activePickupIds) {
|
||||
try {
|
||||
final lastLatStr =
|
||||
prefs.getString('pickup_tracking_${dId}_lastLat') ?? '';
|
||||
final lastLngStr =
|
||||
prefs.getString('pickup_tracking_${dId}_lastLng') ?? '';
|
||||
final currentCumulativeKm =
|
||||
double.tryParse(
|
||||
prefs.getString('pickup_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,
|
||||
);
|
||||
|
||||
if (distanceMeters >= 5.0) {
|
||||
// minimum 5 meters to reduce drift accumulation
|
||||
final distanceKm = distanceMeters / 1000.0;
|
||||
final newCumulativeKm = currentCumulativeKm + distanceKm;
|
||||
|
||||
await prefs.setString(
|
||||
'pickup_tracking_${dId}_cumulativeKm',
|
||||
newCumulativeKm.toStringAsFixed(4),
|
||||
);
|
||||
await prefs.setString(
|
||||
'pickup_tracking_${dId}_lastLat',
|
||||
displayLat.toString(),
|
||||
);
|
||||
await prefs.setString(
|
||||
'pickup_tracking_${dId}_lastLng',
|
||||
displayLng.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await prefs.setString(
|
||||
'pickup_tracking_${dId}_lastLat',
|
||||
displayLat.toString(),
|
||||
);
|
||||
await prefs.setString(
|
||||
'pickup_tracking_${dId}_lastLng',
|
||||
displayLng.toString(),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// OPTION 1: MQTT PUBLICATION (Integrated)
|
||||
// -------------------------------------------------------------
|
||||
final mqttService = MilerMqttService();
|
||||
if (mqttService.isConnected) {
|
||||
mqttService.publishLocation(payload);
|
||||
debugPrint(
|
||||
'[LIVE TRACKING] Dispatched to MQTT: ${payload['lat']}, ${payload['lng']}',
|
||||
);
|
||||
} else {
|
||||
// Fallback or attempt reconnect if on-duty
|
||||
debugPrint(
|
||||
'[LIVE TRACKING] MQTT not connected. Skipping MQTT dispatch.',
|
||||
);
|
||||
mqttService.connect(); // Attempt to restore connection
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// OPTION 2: HTTP POST PLACEHOLDER (Wait for further instruction)
|
||||
// -------------------------------------------------------------
|
||||
// final url = Uri.parse('https://your-python-backend.com/kalman/track');
|
||||
// http.post(url, headers: {'Content-Type': 'application/json'}, body: jsonEncode(payload));
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// OPTION 3: TELEMETRY (Battery/System logs)
|
||||
// -------------------------------------------------------------
|
||||
if (now.minute % 5 == 0 && now.second < 5) {
|
||||
// Log every 5 mins
|
||||
final battery = Battery();
|
||||
final batteryLevel = await battery.batteryLevel;
|
||||
mqttService.publishTelemetry({
|
||||
'battery_level': batteryLevel,
|
||||
'gps_accuracy': position.accuracy,
|
||||
'mocked': position.isMocked,
|
||||
'timestamp': now.toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[LIVE TRACKING] Kalman processed: ${payload['lat']}, ${payload['lng']} (speed: ${position.speed})',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[LIVE TRACKING] Error sending to backend: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user