MotorSignal-App/lib/services/location_service.dart

290 lines
9.0 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);
}
// 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
);
} 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 (_) {}
}
});
}
void _handleRawPosition(Position position) {
if (_isValidPosition(position)) {
final filtered = _applyEmaFilter(position);
if (!_controller.isClosed) {
_controller.add(filtered);
}
}
}
bool _isValidPosition(Position position) {
// Allow valid GPS readings with accuracy up to 100m during initial lock
final currentSpeed = _emaSpeed ?? position.speed;
final maxAcceptableAccuracy = _lastValidPosition == null ? 120.0 : (currentSpeed > 10.0 ? 80.0 : 65.0);
if (position.accuracy > maxAcceptableAccuracy) {
onGpsDiagnostic?.call(
position,
false,
'Accuracy poor (${position.accuracy.toStringAsFixed(1)}m > ${maxAcceptableAccuracy.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;
}
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;
onGpsDiagnostic?.call(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) {
onGpsDiagnostic?.call(
position,
false,
'Impossible acceleration (${accel.toStringAsFixed(1)} m/s² > $_maxAcceleration m/s²)',
);
return false;
}
}
}
_lastValidPosition = position;
onGpsDiagnostic?.call(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();
}
}