339 lines
11 KiB
Dart
339 lines
11 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)
|
|
|
|
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);
|
|
}
|
|
|
|
// Proactively trigger immediate high-accuracy position to warm up GNSS hardware
|
|
Geolocator.getCurrentPosition(
|
|
locationSettings: const LocationSettings(
|
|
accuracy: LocationAccuracy.bestForNavigation,
|
|
timeLimit: Duration(seconds: 4),
|
|
),
|
|
).then((pos) {
|
|
if (!_isDisposed) _handleRawPosition(pos);
|
|
}).catchError((_) {});
|
|
|
|
// Platform-specific LocationSettings
|
|
LocationSettings locationSettings;
|
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
|
locationSettings = AndroidSettings(
|
|
accuracy: LocationAccuracy.bestForNavigation,
|
|
distanceFilter: 0,
|
|
intervalDuration: const Duration(milliseconds: 100),
|
|
forceLocationManager: false, // Use Google Fused Location Provider for best accuracy & responsiveness
|
|
foregroundNotificationConfig: const ForegroundNotificationConfig(
|
|
notificationText: 'MotorSignal is recording ride telemetry',
|
|
notificationTitle: 'MotorSignal Active Ride',
|
|
enableWakeLock: true,
|
|
),
|
|
);
|
|
} else {
|
|
locationSettings = const LocationSettings(
|
|
accuracy: LocationAccuracy.bestForNavigation,
|
|
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.bestForNavigation,
|
|
timeLimit: Duration(seconds: 2),
|
|
),
|
|
);
|
|
} catch (_) {}
|
|
|
|
if (pos != null && !_isDisposed) {
|
|
_handleRawPosition(pos);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
});
|
|
}
|
|
|
|
String? _lastDiagnosticReason;
|
|
DateTime? _lastDiagnosticTime;
|
|
|
|
void _emitGpsDiagnostic(Position position, bool accepted, String? reason) {
|
|
if (!accepted && reason != null) {
|
|
final now = DateTime.now();
|
|
if (_lastDiagnosticReason == reason &&
|
|
_lastDiagnosticTime != null &&
|
|
now.difference(_lastDiagnosticTime!).inMilliseconds < 500) {
|
|
return; // Debounce duplicate diagnostic message within 500ms
|
|
}
|
|
_lastDiagnosticReason = reason;
|
|
_lastDiagnosticTime = now;
|
|
}
|
|
onGpsDiagnostic?.call(position, accepted, reason);
|
|
}
|
|
|
|
void _handleRawPosition(Position position) {
|
|
if (_isValidPosition(position)) {
|
|
final filtered = _applyEmaFilter(position);
|
|
if (!_controller.isClosed) {
|
|
_controller.add(filtered);
|
|
}
|
|
}
|
|
}
|
|
|
|
bool _isValidPosition(Position position) {
|
|
// Allow initial fixes up to 200m so GPS lock begins immediately
|
|
final currentSpeed = _emaSpeed ?? position.speed;
|
|
final maxAcceptableAccuracy = _lastValidPosition == null ? 200.0 : (currentSpeed > 10.0 ? 100.0 : 80.0);
|
|
|
|
if (position.accuracy > maxAcceptableAccuracy) {
|
|
_emitGpsDiagnostic(
|
|
position,
|
|
false,
|
|
'Accuracy poor (${position.accuracy.toStringAsFixed(1)}m > ${maxAcceptableAccuracy.toStringAsFixed(1)}m)',
|
|
);
|
|
return false;
|
|
}
|
|
|
|
if (position.speed > _maxRealisticSpeed) {
|
|
_emitGpsDiagnostic(
|
|
position,
|
|
false,
|
|
'Impossible speed (${(position.speed * 2.23694).toStringAsFixed(1)} MPH > 223 MPH)',
|
|
);
|
|
return false;
|
|
}
|
|
|
|
if (_lastValidPosition != null) {
|
|
final timeDiff = (position.timestamp.difference(_lastValidPosition!.timestamp)).inMilliseconds / 1000.0;
|
|
|
|
// If signal was lost for > 8 seconds, treat as new baseline fix
|
|
if (timeDiff > 8.0) {
|
|
_lastValidPosition = position;
|
|
_emaSpeed = position.speed;
|
|
_emitGpsDiagnostic(position, true, 'Baseline reset after ${timeDiff.toStringAsFixed(1)}s gap');
|
|
return true;
|
|
}
|
|
|
|
if (timeDiff >= 0.2) {
|
|
// Acceleration check
|
|
final accel = (position.speed - _lastValidPosition!.speed).abs() / timeDiff;
|
|
if (accel > _maxAcceleration) {
|
|
_emitGpsDiagnostic(
|
|
position,
|
|
false,
|
|
'Impossible acceleration (${accel.toStringAsFixed(1)} m/s² > $_maxAcceleration m/s²)',
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// Distance teleportation / derived velocity check
|
|
final distMeters = Geolocator.distanceBetween(
|
|
_lastValidPosition!.latitude,
|
|
_lastValidPosition!.longitude,
|
|
position.latitude,
|
|
position.longitude,
|
|
);
|
|
final derivedSpeedMps = distMeters / timeDiff;
|
|
if (derivedSpeedMps > _maxRealisticSpeed && distMeters > 50.0) {
|
|
_emitGpsDiagnostic(
|
|
position,
|
|
false,
|
|
'GPS position teleportation (${distMeters.toStringAsFixed(0)}m in ${timeDiff.toStringAsFixed(1)}s)',
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
_lastValidPosition = position;
|
|
_emitGpsDiagnostic(position, true, null);
|
|
return true;
|
|
}
|
|
|
|
Position _applyEmaFilter(Position position) {
|
|
final rawSpeed = position.speed;
|
|
|
|
// Stationary jitter deadband: Speeds under 0.6 m/s (~1.3 MPH) are treated as stationary 0.0 MPH
|
|
if (rawSpeed < 0.6) {
|
|
_emaSpeed = 0.0;
|
|
} else {
|
|
if (_emaSpeed == null || _emaSpeed == 0.0) {
|
|
_emaSpeed = rawSpeed;
|
|
} else {
|
|
// Fast dynamic alpha: 0.90 when accelerating/braking so speedometer tracks instantly, 0.75 when cruising
|
|
final delta = (rawSpeed - _emaSpeed!).abs();
|
|
final alpha = delta > 0.4 ? 0.90 : 0.75;
|
|
_emaSpeed = alpha * rawSpeed + (1 - alpha) * _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();
|
|
}
|
|
}
|