diff --git a/lib/services/gps_data_manager.dart b/lib/services/gps_data_manager.dart index 7838d3b..3818187 100644 --- a/lib/services/gps_data_manager.dart +++ b/lib/services/gps_data_manager.dart @@ -1,31 +1,46 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; import '../gps_service.dart'; import '../config/speed_unit.dart'; import '../config/gps_constants.dart'; +import '../config/timing_constants.dart'; import '../models/processed_gps_data.dart'; +import '../providers/overlay_provider.dart'; import 'logger.dart'; -class GpsDataManager extends ChangeNotifier { +class GpsDataManager extends ChangeNotifier with WidgetsBindingObserver { GpsDataManager(); StreamSubscription? _gpsSubscription; - final StreamController _dataController = + final StreamController _dataController = StreamController.broadcast(); - - ProcessedGpsData _currentData = const ProcessedGpsData( + + ProcessedGpsData _currentData = ProcessedGpsData( speed: 0.0, heading: -1.0, displaySpeed: '--', displayHeading: 'N/A', isSpeedValid: false, isHeadingValid: false, + timestamp: DateTime.now(), ); - + Timer? _staleDataTimer; + Timer? _backgroundGracePeriodTimer; bool _isDisposed = false; + // Overlay provider reference for background decision-making + OverlayProvider? _overlayProvider; + + // Lifecycle and precision state + LocationAccuracy _currentAccuracy = LocationAccuracy.high; + bool _hasFirstFix = false; + DateTime? _firstFixTime; + bool _isAppInForeground = true; + bool _isGpsActive = false; + // Public stream for UI components to subscribe to Stream get dataStream => _dataController.stream; @@ -33,7 +48,12 @@ class GpsDataManager extends ChangeNotifier { ProcessedGpsData get currentData => _currentData; bool _isInitialized = false; - + + /// Set overlay provider reference for background GPS decision-making + void setOverlayProvider(OverlayProvider provider) { + _overlayProvider = provider; + } + Future initialize() async { if (_isInitialized) { Logger.warn('Already initialized, skipping...', 'GpsDataManager'); @@ -42,6 +62,9 @@ class GpsDataManager extends ChangeNotifier { Logger.info('Starting GPS manager initialization...', 'GpsDataManager'); + // Register lifecycle observer + WidgetsBinding.instance.addObserver(this); + // Check GPS permissions and services first final serviceEnabled = await GpsService.isLocationServiceEnabled(); Logger.debug('Location service enabled: $serviceEnabled', 'GpsDataManager'); @@ -82,11 +105,13 @@ class GpsDataManager extends ChangeNotifier { ); Logger.info('Initial GPS fix obtained', 'GpsDataManager'); + _hasFirstFix = true; + _firstFixTime = DateTime.now(); _onPositionUpdate(initialPosition); - // Then start position stream with shorter timeout for updates - Logger.debug('Starting GPS stream with short update timeout...', 'GpsDataManager'); - _gpsSubscription = GpsService.positionStream.listen( + // Then start position stream with current accuracy + Logger.debug('Starting GPS stream with accuracy: $_currentAccuracy', 'GpsDataManager'); + _gpsSubscription = GpsService.createPositionStream(accuracy: _currentAccuracy).listen( (Position position) { Logger.debug('GPS callback triggered', 'GpsDataManager'); _onPositionUpdate(position); @@ -104,6 +129,8 @@ class GpsDataManager extends ChangeNotifier { Logger.info('GPS stream subscription created', 'GpsDataManager'); Logger.debug('Subscription details: ${_gpsSubscription.runtimeType}', 'GpsDataManager'); + _isGpsActive = true; + _updateWakeLock(); _isInitialized = true; } catch (e, stackTrace) { @@ -150,9 +177,18 @@ class GpsDataManager extends ChangeNotifier { displayHeading: displayHeading, isSpeedValid: speedValid, isHeadingValid: headingValid, + timestamp: DateTime.now(), ); _updateData(processedData); + + // Check precision switching after first-fix gate + if (_hasFirstFix && _firstFixTime != null) { + final timeSinceFirstFix = DateTime.now().difference(_firstFixTime!); + if (timeSinceFirstFix >= GpsConfig.PRECISION_SWITCH_FIRST_FIX_DELAY) { + _checkPrecisionSwitch(position.speed); + } + } } void _onGpsError(dynamic error) { @@ -210,24 +246,176 @@ class GpsDataManager extends ChangeNotifier { // Format speed for specific unit (used by UI) String getFormattedSpeed(SpeedUnit unit) { if (_currentData.displaySpeed == '--') return '--'; - + final convertedSpeed = unit.convert(_currentData.speed); return convertedSpeed < 1.0 ? '--' : convertedSpeed.toStringAsFixed(1); } + + /// Check if GPS precision should be switched based on speed + void _checkPrecisionSwitch(double speedMps) { + if (_currentAccuracy == LocationAccuracy.high) { + // Switch to MEDIUM if speed drops below down-threshold + if (speedMps < GpsConfig.PRECISION_DOWN_THRESHOLD_MPS) { + _switchPrecision(LocationAccuracy.medium); + } + } else if (_currentAccuracy == LocationAccuracy.medium) { + // Switch to HIGH if speed exceeds up-threshold + if (speedMps > GpsConfig.PRECISION_UP_THRESHOLD_MPS) { + _switchPrecision(LocationAccuracy.high); + } + } + } + + /// Switch GPS precision and restart stream with new accuracy + void _switchPrecision(LocationAccuracy newAccuracy) { + if (_currentAccuracy == newAccuracy) return; + + final speedKmh = _currentData.speed * 3.6; + Logger.debug('Precision switched to $newAccuracy at ${speedKmh.toStringAsFixed(1)} km/h', 'GpsDataManager'); + + _currentAccuracy = newAccuracy; + + // Cancel existing subscription and create new one with new accuracy + _gpsSubscription?.cancel(); + _gpsSubscription = GpsService.createPositionStream(accuracy: newAccuracy).listen( + (Position position) { + Logger.debug('GPS callback triggered', 'GpsDataManager'); + _onPositionUpdate(position); + }, + onError: (error) { + Logger.error('GPS stream error callback: $error', 'GpsDataManager'); + _onGpsError(error); + }, + onDone: () { + Logger.warn('GPS stream done callback - stream ended', 'GpsDataManager'); + }, + cancelOnError: false, + ); + } + + /// Handle app lifecycle state changes for background GPS management + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + // App going to background + _isAppInForeground = false; + final overlayActive = _overlayProvider?.isOverlayActive ?? false; + + if (overlayActive) { + Logger.info('App backgrounded with overlay active - maintaining full GPS', 'GpsDataManager'); + } else { + Logger.info('App backgrounded without overlay - starting ${TimingConfig.BACKGROUND_GRACE_PERIOD.inSeconds}s grace period', 'GpsDataManager'); + _backgroundGracePeriodTimer?.cancel(); + _backgroundGracePeriodTimer = Timer(TimingConfig.BACKGROUND_GRACE_PERIOD, _onGracePeriodExpired); + } + + _updateWakeLock(); + } else if (state == AppLifecycleState.resumed) { + // App returning to foreground + _isAppInForeground = true; + + // Cancel grace period if running + _backgroundGracePeriodTimer?.cancel(); + _backgroundGracePeriodTimer = null; + + // Restart GPS if it was stopped + if (_gpsSubscription == null && _isInitialized) { + _restartGps(); + } + + _updateWakeLock(); + } + } + + /// Grace period timer callback - re-check overlay state before stopping GPS + void _onGracePeriodExpired() { + // Race condition protection: overlay may have been activated during grace period + final overlayActive = _overlayProvider?.isOverlayActive ?? false; + + if (overlayActive) { + Logger.info('Overlay became active during grace period - keeping GPS', 'GpsDataManager'); + _backgroundGracePeriodTimer = null; + return; + } + + _stopGps(); + _backgroundGracePeriodTimer = null; + } + + /// Stop GPS tracking (backgrounded without overlay) + void _stopGps() { + _gpsSubscription?.cancel(); + _gpsSubscription = null; + _isGpsActive = false; + _staleDataTimer?.cancel(); + _updateWakeLock(); + Logger.info('GPS stopped (backgrounded without overlay)', 'GpsDataManager'); + } + + /// Restart GPS tracking (app foregrounded) + void _restartGps() { + Logger.info('Restarting GPS (app foregrounded)', 'GpsDataManager'); + + _gpsSubscription = GpsService.createPositionStream(accuracy: _currentAccuracy).listen( + (Position position) { + Logger.debug('GPS callback triggered', 'GpsDataManager'); + _onPositionUpdate(position); + }, + onError: (error) { + Logger.error('GPS stream error callback: $error', 'GpsDataManager'); + _onGpsError(error); + }, + onDone: () { + Logger.warn('GPS stream done callback - stream ended', 'GpsDataManager'); + }, + cancelOnError: false, + ); + + _isGpsActive = true; + _updateWakeLock(); + } + + /// Update wake lock based on GPS state and app visibility + void _updateWakeLock() { + final overlayActive = _overlayProvider?.isOverlayActive ?? false; + final shouldKeepAwake = _isGpsActive && (overlayActive || _isAppInForeground); + + if (shouldKeepAwake) { + WakelockPlus.enable(); + Logger.debug('Wake lock enabled (GPS: $_isGpsActive, overlay: $overlayActive, foreground: $_isAppInForeground)', 'GpsDataManager'); + } else { + WakelockPlus.disable(); + Logger.debug('Wake lock disabled (GPS: $_isGpsActive, overlay: $overlayActive, foreground: $_isAppInForeground)', 'GpsDataManager'); + } + } @override void dispose() { _isDisposed = true; Logger.info('Disposing GPS manager...', 'GpsDataManager'); + + // Remove lifecycle observer BEFORE super.dispose() + WidgetsBinding.instance.removeObserver(this); + + // Cancel all timers and subscriptions + _backgroundGracePeriodTimer?.cancel(); + _backgroundGracePeriodTimer = null; + if (_gpsSubscription != null) { Logger.debug('Cancelling GPS subscription...', 'GpsDataManager'); _gpsSubscription?.cancel(); _gpsSubscription = null; } - Logger.debug('Closing data controller...', 'GpsDataManager'); + _staleDataTimer?.cancel(); _staleDataTimer = null; + + Logger.debug('Closing data controller...', 'GpsDataManager'); _dataController.close(); + + // Disable wake lock as safety net + WakelockPlus.disable(); + Logger.info('GPS manager disposed', 'GpsDataManager'); super.dispose(); }