Miler rider app: surface system, visible design language, backend lifecycle
Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1012
lib/xpress/background/backgroundservice.dart
Normal file
1012
lib/xpress/background/backgroundservice.dart
Normal file
File diff suppressed because it is too large
Load Diff
500
lib/xpress/background/foreground_service.dart
Normal file
500
lib/xpress/background/foreground_service.dart
Normal file
@@ -0,0 +1,500 @@
|
||||
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/xpress/views/helpers/constants/apiconstants.dart';
|
||||
import 'package:miler/xpress/providers/Riderlog/riderlog_provider.dart';
|
||||
import 'package:miler/xpress/background/backgroundservice.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:miler/xpress/utils/kalman_filter.dart';
|
||||
import 'package:miler/xpress/utils/mqtt_service.dart';
|
||||
import 'package:miler/xpress/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 DoormileKalmanFilter? _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 = DoormileKalmanFilter(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 = DoormileMqttService();
|
||||
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());
|
||||
}
|
||||
330
lib/xpress/background/live_tracking_service.dart
Normal file
330
lib/xpress/background/live_tracking_service.dart
Normal file
@@ -0,0 +1,330 @@
|
||||
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/xpress/utils/kalman_filter.dart';
|
||||
import 'package:miler/xpress/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;
|
||||
DoormileKalmanFilter? _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 = DoormileKalmanFilter(
|
||||
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 = DoormileMqttService();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user