366 lines
12 KiB
Dart
366 lines
12 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
|
|
final locationServiceProvider = Provider<LocationService>((ref) {
|
|
final service = LocationService();
|
|
ref.onDispose(() => service.dispose());
|
|
return service;
|
|
});
|
|
|
|
final locationStreamProvider = StreamProvider<Position>((ref) {
|
|
final service = ref.watch(locationServiceProvider);
|
|
return service.getFilteredPositionStream();
|
|
});
|
|
|
|
final locationPermissionGrantedProvider = FutureProvider<bool>((ref) async {
|
|
final service = ref.watch(locationServiceProvider);
|
|
return await service.hasPermissions();
|
|
});
|
|
|
|
class LocationService {
|
|
Position? _lastValidPosition;
|
|
double? _emaSpeed;
|
|
|
|
final StreamController<Position> _controller = StreamController<Position>.broadcast();
|
|
StreamSubscription<Position>? _rawPositionSub;
|
|
Timer? _watchdogTimer;
|
|
bool _isDisposed = false;
|
|
|
|
// Filtering thresholds
|
|
static const double _maxRealisticSpeed = 100.0; // roughly 223 MPH in m/s
|
|
static const double _maxAcceleration = 30.0; // m/s^2 (allows extreme superbike launch / braking while rejecting GPS teleportation)
|
|
static const double _speedEmaAlpha = 0.35; // Smoothing factor for speed
|
|
|
|
void Function(Position position, bool accepted, String? rejectionReason)? onGpsDiagnostic;
|
|
|
|
LocationService() {
|
|
_initStream();
|
|
}
|
|
|
|
Future<bool> requestPermissions() async {
|
|
try {
|
|
// 1. Geolocator native permission check & request
|
|
LocationPermission geoPermission = await Geolocator.checkPermission();
|
|
if (geoPermission == LocationPermission.denied) {
|
|
geoPermission = await Geolocator.requestPermission();
|
|
}
|
|
|
|
// 2. Permission handler check & request
|
|
final locStatus = await Permission.locationWhenInUse.request();
|
|
await Permission.notification.request();
|
|
|
|
if (geoPermission == LocationPermission.whileInUse ||
|
|
geoPermission == LocationPermission.always ||
|
|
locStatus.isGranted) {
|
|
try {
|
|
await Permission.locationAlways.request();
|
|
} catch (_) {}
|
|
// Restart/refresh GPS stream now that permissions are granted
|
|
_startListening();
|
|
return true;
|
|
}
|
|
} catch (_) {}
|
|
return false;
|
|
}
|
|
|
|
Future<bool> hasPermissions() async {
|
|
try {
|
|
final geoPermission = await Geolocator.checkPermission();
|
|
if (geoPermission == LocationPermission.whileInUse ||
|
|
geoPermission == LocationPermission.always) {
|
|
return true;
|
|
}
|
|
final status = await Permission.locationWhenInUse.status;
|
|
return status.isGranted;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Stream<Position> getFilteredPositionStream() {
|
|
return _controller.stream;
|
|
}
|
|
|
|
void _initStream() {
|
|
_startListening();
|
|
_startWatchdog();
|
|
}
|
|
|
|
Future<void> _startListening() async {
|
|
if (_isDisposed) return;
|
|
await _rawPositionSub?.cancel();
|
|
_rawPositionSub = null;
|
|
|
|
try {
|
|
final isServiceEnabled = await Geolocator.isLocationServiceEnabled();
|
|
if (!isServiceEnabled) {
|
|
return;
|
|
}
|
|
|
|
// Immediately bootstrap with last known position if available
|
|
final lastPos = await Geolocator.getLastKnownPosition();
|
|
if (lastPos != null && !_isDisposed) {
|
|
_handleRawPosition(lastPos);
|
|
}
|
|
|
|
// Platform-specific LocationSettings
|
|
LocationSettings locationSettings;
|
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
|
locationSettings = AndroidSettings(
|
|
accuracy: LocationAccuracy.high,
|
|
distanceFilter: 0,
|
|
intervalDuration: const Duration(milliseconds: 200),
|
|
forceLocationManager: true, // Directly access GPS hardware receiver
|
|
);
|
|
} else {
|
|
locationSettings = const LocationSettings(
|
|
accuracy: LocationAccuracy.high,
|
|
distanceFilter: 0,
|
|
);
|
|
}
|
|
|
|
_rawPositionSub = Geolocator.getPositionStream(locationSettings: locationSettings).listen(
|
|
(pos) {
|
|
_handleRawPosition(pos);
|
|
},
|
|
onError: (err) {
|
|
onGpsDiagnostic?.call(
|
|
Position(
|
|
longitude: _lastValidPosition?.longitude ?? 0.0,
|
|
latitude: _lastValidPosition?.latitude ?? 0.0,
|
|
timestamp: DateTime.now(),
|
|
accuracy: 999.0,
|
|
altitude: 0.0,
|
|
heading: 0.0,
|
|
speed: 0.0,
|
|
speedAccuracy: 0.0,
|
|
altitudeAccuracy: 0.0,
|
|
headingAccuracy: 0.0,
|
|
),
|
|
false,
|
|
'GPS Stream Error: $err',
|
|
);
|
|
// Try to reconnect in 2 seconds
|
|
Future.delayed(const Duration(seconds: 2), () {
|
|
if (!_isDisposed) _startListening();
|
|
});
|
|
},
|
|
cancelOnError: false,
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
|
|
void _startWatchdog() {
|
|
_watchdogTimer?.cancel();
|
|
// Watchdog every 1000ms: if stream has gone quiet (>2s since last fix), poll getCurrentPosition actively
|
|
_watchdogTimer = Timer.periodic(const Duration(milliseconds: 1000), (_) async {
|
|
if (_isDisposed) return;
|
|
final now = DateTime.now();
|
|
final lastFixAge = _lastValidPosition == null
|
|
? 999
|
|
: now.difference(_lastValidPosition!.timestamp).inMilliseconds;
|
|
|
|
if (lastFixAge > 2000) {
|
|
try {
|
|
final hasPerm = await hasPermissions();
|
|
if (!hasPerm) return;
|
|
|
|
Position? pos;
|
|
try {
|
|
pos = await Geolocator.getCurrentPosition(
|
|
locationSettings: const LocationSettings(
|
|
accuracy: LocationAccuracy.high,
|
|
timeLimit: Duration(seconds: 2),
|
|
),
|
|
);
|
|
} catch (_) {}
|
|
|
|
if (pos != null && !_isDisposed) {
|
|
_handleRawPosition(pos);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
});
|
|
}
|
|
|
|
void _handleRawPosition(Position position) {
|
|
if (_isValidPosition(position)) {
|
|
final filtered = _applyEmaFilter(position);
|
|
if (!_controller.isClosed) {
|
|
_controller.add(filtered);
|
|
}
|
|
}
|
|
}
|
|
|
|
bool _isValidPosition(Position position) {
|
|
// Dynamic accuracy threshold: allow wider accuracy during satellite lock & highway speeds
|
|
final currentSpeed = _emaSpeed ?? position.speed;
|
|
final maxAcceptableAccuracy = _lastValidPosition == null
|
|
? 80.0
|
|
: (currentSpeed > 10.0 ? 60.0 : 45.0);
|
|
|
|
if (position.accuracy > maxAcceptableAccuracy) {
|
|
onGpsDiagnostic?.call(
|
|
position,
|
|
false,
|
|
'Accuracy poor (${position.accuracy.toStringAsFixed(1)}m > ${maxAcceptableAccuracy.toStringAsFixed(1)}m)',
|
|
);
|
|
return false; // Reject extremely poor accuracy
|
|
}
|
|
|
|
// Reject 0.0 MPH glitch if accuracy is degraded while motorcycle was previously at speed
|
|
if (position.speed == 0.0 && currentSpeed > 10.0 && position.accuracy > 30.0) {
|
|
onGpsDiagnostic?.call(
|
|
position,
|
|
false,
|
|
'Transient zero-speed cell glitch rejected (${position.accuracy.toStringAsFixed(1)}m)',
|
|
);
|
|
return false;
|
|
}
|
|
|
|
if (position.speed > _maxRealisticSpeed) {
|
|
onGpsDiagnostic?.call(
|
|
position,
|
|
false,
|
|
'Impossible speed (${(position.speed * 2.23694).toStringAsFixed(1)} MPH > 223 MPH)',
|
|
);
|
|
return false; // Reject impossible speeds (>223 MPH)
|
|
}
|
|
|
|
if (_lastValidPosition != null) {
|
|
final timeDiff = (position.timestamp.difference(_lastValidPosition!.timestamp)).inMilliseconds / 1000.0;
|
|
|
|
// If signal was lost for > 10 seconds (e.g. tunnel or stop), treat as new baseline fix
|
|
if (timeDiff > 10.0) {
|
|
_lastValidPosition = position;
|
|
_emaSpeed = position.speed;
|
|
onGpsDiagnostic?.call(position, true, 'Baseline reset after ${timeDiff.toStringAsFixed(1)}s gap');
|
|
return true;
|
|
}
|
|
|
|
if (timeDiff >= 0.25) {
|
|
// Acceleration check (only compute over stable time windows >= 250ms)
|
|
final accel = (position.speed - _lastValidPosition!.speed).abs() / timeDiff;
|
|
if (accel > _maxAcceleration) {
|
|
onGpsDiagnostic?.call(
|
|
position,
|
|
false,
|
|
'Impossible acceleration (${accel.toStringAsFixed(1)} m/s² > $_maxAcceleration m/s²)',
|
|
);
|
|
return false; // Reject impossible acceleration spikes
|
|
}
|
|
|
|
// Path consistency check (distance jump vs time)
|
|
final distanceJump = Geolocator.distanceBetween(
|
|
_lastValidPosition!.latitude,
|
|
_lastValidPosition!.longitude,
|
|
position.latitude,
|
|
position.longitude,
|
|
);
|
|
|
|
final impliedSpeed = distanceJump / timeDiff;
|
|
if (impliedSpeed > _maxRealisticSpeed * 1.5) {
|
|
onGpsDiagnostic?.call(
|
|
position,
|
|
false,
|
|
'Position jump anomaly (${(impliedSpeed * 2.23694).toStringAsFixed(1)} MPH implied speed)',
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
_lastValidPosition = position;
|
|
onGpsDiagnostic?.call(position, true, null);
|
|
return true;
|
|
}
|
|
|
|
bool _isStationaryLocked = true;
|
|
Position? _stationaryAnchorPosition;
|
|
DateTime? _lowSpeedStartTime;
|
|
|
|
Position _applyEmaFilter(Position position) {
|
|
// Robust stationary hysteresis filter:
|
|
// GPS Doppler noise and multipath jitter typically fluctuates between 0.2 m/s and 0.7 m/s (0.5 - 1.6 MPH)
|
|
// while the motorcycle is completely stationary.
|
|
final rawSpeed = position.speed;
|
|
|
|
if (rawSpeed < 0.6) {
|
|
_lowSpeedStartTime ??= position.timestamp;
|
|
final lowSpeedDuration = position.timestamp.difference(_lowSpeedStartTime!).inMilliseconds;
|
|
|
|
_stationaryAnchorPosition ??= position;
|
|
|
|
final driftFromAnchor = Geolocator.distanceBetween(
|
|
_stationaryAnchorPosition!.latitude,
|
|
_stationaryAnchorPosition!.longitude,
|
|
position.latitude,
|
|
position.longitude,
|
|
);
|
|
|
|
// Lock to absolute 0.0 MPH if below threshold for > 800ms or within 2.5m drift radius
|
|
if (lowSpeedDuration >= 800 || driftFromAnchor < 2.5) {
|
|
_isStationaryLocked = true;
|
|
_emaSpeed = 0.0;
|
|
}
|
|
} else if (rawSpeed >= 0.8) {
|
|
// Break out of stationary lock only upon genuine sustained movement
|
|
double moveDistance = 0.0;
|
|
if (_stationaryAnchorPosition != null) {
|
|
moveDistance = Geolocator.distanceBetween(
|
|
_stationaryAnchorPosition!.latitude,
|
|
_stationaryAnchorPosition!.longitude,
|
|
position.latitude,
|
|
position.longitude,
|
|
);
|
|
}
|
|
|
|
if (!_isStationaryLocked || moveDistance >= 2.0 || rawSpeed >= 1.2) {
|
|
_isStationaryLocked = false;
|
|
_stationaryAnchorPosition = null;
|
|
_lowSpeedStartTime = null;
|
|
|
|
if (_emaSpeed == null || _emaSpeed == 0.0) {
|
|
_emaSpeed = rawSpeed;
|
|
} else {
|
|
_emaSpeed = _speedEmaAlpha * rawSpeed + (1 - _speedEmaAlpha) * _emaSpeed!;
|
|
}
|
|
} else {
|
|
_emaSpeed = 0.0;
|
|
}
|
|
} else {
|
|
// Transition zone (0.6 - 0.8 m/s): maintain current stationary lock state
|
|
if (_isStationaryLocked) {
|
|
_emaSpeed = 0.0;
|
|
} else {
|
|
_emaSpeed = _speedEmaAlpha * rawSpeed + (1 - _speedEmaAlpha) * _emaSpeed!;
|
|
}
|
|
}
|
|
|
|
return Position(
|
|
longitude: position.longitude,
|
|
latitude: position.latitude,
|
|
timestamp: position.timestamp,
|
|
accuracy: position.accuracy,
|
|
altitude: position.altitude,
|
|
heading: position.heading,
|
|
speed: _emaSpeed ?? 0.0,
|
|
speedAccuracy: position.speedAccuracy,
|
|
altitudeAccuracy: position.altitudeAccuracy,
|
|
headingAccuracy: position.headingAccuracy,
|
|
isMocked: position.isMocked,
|
|
floor: position.floor,
|
|
);
|
|
}
|
|
|
|
void dispose() {
|
|
_isDisposed = true;
|
|
_watchdogTimer?.cancel();
|
|
_rawPositionSub?.cancel();
|
|
_controller.close();
|
|
}
|
|
}
|