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>
331 lines
11 KiB
Dart
331 lines
11 KiB
Dart
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;
|
|
}
|
|
}
|
|
}
|