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((ref) { final service = LocationService(); ref.onDispose(() => service.dispose()); return service; }); final locationStreamProvider = StreamProvider((ref) { final service = ref.watch(locationServiceProvider); return service.getFilteredPositionStream(); }); final locationPermissionGrantedProvider = FutureProvider((ref) async { final service = ref.watch(locationServiceProvider); return await service.hasPermissions(); }); class LocationService { Position? _lastValidPosition; double? _emaSpeed; final StreamController _controller = StreamController.broadcast(); StreamSubscription? _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 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 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 getFilteredPositionStream() { return _controller.stream; } void _initStream() { _startListening(); _startWatchdog(); } Future _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(); } }