feat(04-01): implement lifecycle-aware GPS engine with precision switching
- Add WidgetsBindingObserver mixin for app lifecycle tracking - Add overlay provider reference for background decision-making - Add state tracking: current accuracy, first-fix status, foreground/GPS active state - Fix default ProcessedGpsData to non-const with timestamp - Register lifecycle observer in initialize(), remove in dispose() - Set first-fix state and time after initial GPS fix - Update position handler to include timestamp in ProcessedGpsData - Implement precision switching with hysteresis (8/12 km/h thresholds) - Gate precision switching until 5s after first fix - Implement lifecycle handlers: paused (7s grace period) and resumed (restart GPS) - Add grace period expiration with race condition protection (re-check overlay) - Add GPS stop/restart methods preserving last known data - Implement conditional wake lock: GPS active AND (overlay OR foreground) - Cancel all timers in dispose() including background grace period - Remove unnecessary flutter/foundation import (widgets provides ChangeNotifier) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,31 +1,46 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
import '../gps_service.dart';
|
import '../gps_service.dart';
|
||||||
import '../config/speed_unit.dart';
|
import '../config/speed_unit.dart';
|
||||||
import '../config/gps_constants.dart';
|
import '../config/gps_constants.dart';
|
||||||
|
import '../config/timing_constants.dart';
|
||||||
import '../models/processed_gps_data.dart';
|
import '../models/processed_gps_data.dart';
|
||||||
|
import '../providers/overlay_provider.dart';
|
||||||
import 'logger.dart';
|
import 'logger.dart';
|
||||||
|
|
||||||
class GpsDataManager extends ChangeNotifier {
|
class GpsDataManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
GpsDataManager();
|
GpsDataManager();
|
||||||
|
|
||||||
StreamSubscription<Position>? _gpsSubscription;
|
StreamSubscription<Position>? _gpsSubscription;
|
||||||
final StreamController<ProcessedGpsData> _dataController =
|
final StreamController<ProcessedGpsData> _dataController =
|
||||||
StreamController<ProcessedGpsData>.broadcast();
|
StreamController<ProcessedGpsData>.broadcast();
|
||||||
|
|
||||||
ProcessedGpsData _currentData = const ProcessedGpsData(
|
ProcessedGpsData _currentData = ProcessedGpsData(
|
||||||
speed: 0.0,
|
speed: 0.0,
|
||||||
heading: -1.0,
|
heading: -1.0,
|
||||||
displaySpeed: '--',
|
displaySpeed: '--',
|
||||||
displayHeading: 'N/A',
|
displayHeading: 'N/A',
|
||||||
isSpeedValid: false,
|
isSpeedValid: false,
|
||||||
isHeadingValid: false,
|
isHeadingValid: false,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Timer? _staleDataTimer;
|
Timer? _staleDataTimer;
|
||||||
|
Timer? _backgroundGracePeriodTimer;
|
||||||
bool _isDisposed = false;
|
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
|
// Public stream for UI components to subscribe to
|
||||||
Stream<ProcessedGpsData> get dataStream => _dataController.stream;
|
Stream<ProcessedGpsData> get dataStream => _dataController.stream;
|
||||||
|
|
||||||
@@ -34,6 +49,11 @@ class GpsDataManager extends ChangeNotifier {
|
|||||||
|
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
/// Set overlay provider reference for background GPS decision-making
|
||||||
|
void setOverlayProvider(OverlayProvider provider) {
|
||||||
|
_overlayProvider = provider;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (_isInitialized) {
|
if (_isInitialized) {
|
||||||
Logger.warn('Already initialized, skipping...', 'GpsDataManager');
|
Logger.warn('Already initialized, skipping...', 'GpsDataManager');
|
||||||
@@ -42,6 +62,9 @@ class GpsDataManager extends ChangeNotifier {
|
|||||||
|
|
||||||
Logger.info('Starting GPS manager initialization...', 'GpsDataManager');
|
Logger.info('Starting GPS manager initialization...', 'GpsDataManager');
|
||||||
|
|
||||||
|
// Register lifecycle observer
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
|
||||||
// Check GPS permissions and services first
|
// Check GPS permissions and services first
|
||||||
final serviceEnabled = await GpsService.isLocationServiceEnabled();
|
final serviceEnabled = await GpsService.isLocationServiceEnabled();
|
||||||
Logger.debug('Location service enabled: $serviceEnabled', 'GpsDataManager');
|
Logger.debug('Location service enabled: $serviceEnabled', 'GpsDataManager');
|
||||||
@@ -82,11 +105,13 @@ class GpsDataManager extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Logger.info('Initial GPS fix obtained', 'GpsDataManager');
|
Logger.info('Initial GPS fix obtained', 'GpsDataManager');
|
||||||
|
_hasFirstFix = true;
|
||||||
|
_firstFixTime = DateTime.now();
|
||||||
_onPositionUpdate(initialPosition);
|
_onPositionUpdate(initialPosition);
|
||||||
|
|
||||||
// Then start position stream with shorter timeout for updates
|
// Then start position stream with current accuracy
|
||||||
Logger.debug('Starting GPS stream with short update timeout...', 'GpsDataManager');
|
Logger.debug('Starting GPS stream with accuracy: $_currentAccuracy', 'GpsDataManager');
|
||||||
_gpsSubscription = GpsService.positionStream.listen(
|
_gpsSubscription = GpsService.createPositionStream(accuracy: _currentAccuracy).listen(
|
||||||
(Position position) {
|
(Position position) {
|
||||||
Logger.debug('GPS callback triggered', 'GpsDataManager');
|
Logger.debug('GPS callback triggered', 'GpsDataManager');
|
||||||
_onPositionUpdate(position);
|
_onPositionUpdate(position);
|
||||||
@@ -104,6 +129,8 @@ class GpsDataManager extends ChangeNotifier {
|
|||||||
Logger.info('GPS stream subscription created', 'GpsDataManager');
|
Logger.info('GPS stream subscription created', 'GpsDataManager');
|
||||||
Logger.debug('Subscription details: ${_gpsSubscription.runtimeType}', 'GpsDataManager');
|
Logger.debug('Subscription details: ${_gpsSubscription.runtimeType}', 'GpsDataManager');
|
||||||
|
|
||||||
|
_isGpsActive = true;
|
||||||
|
_updateWakeLock();
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
|
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
@@ -150,9 +177,18 @@ class GpsDataManager extends ChangeNotifier {
|
|||||||
displayHeading: displayHeading,
|
displayHeading: displayHeading,
|
||||||
isSpeedValid: speedValid,
|
isSpeedValid: speedValid,
|
||||||
isHeadingValid: headingValid,
|
isHeadingValid: headingValid,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
_updateData(processedData);
|
_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) {
|
void _onGpsError(dynamic error) {
|
||||||
@@ -215,19 +251,171 @@ class GpsDataManager extends ChangeNotifier {
|
|||||||
return convertedSpeed < 1.0 ? '--' : convertedSpeed.toStringAsFixed(1);
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_isDisposed = true;
|
_isDisposed = true;
|
||||||
Logger.info('Disposing GPS manager...', 'GpsDataManager');
|
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) {
|
if (_gpsSubscription != null) {
|
||||||
Logger.debug('Cancelling GPS subscription...', 'GpsDataManager');
|
Logger.debug('Cancelling GPS subscription...', 'GpsDataManager');
|
||||||
_gpsSubscription?.cancel();
|
_gpsSubscription?.cancel();
|
||||||
_gpsSubscription = null;
|
_gpsSubscription = null;
|
||||||
}
|
}
|
||||||
Logger.debug('Closing data controller...', 'GpsDataManager');
|
|
||||||
_staleDataTimer?.cancel();
|
_staleDataTimer?.cancel();
|
||||||
_staleDataTimer = null;
|
_staleDataTimer = null;
|
||||||
|
|
||||||
|
Logger.debug('Closing data controller...', 'GpsDataManager');
|
||||||
_dataController.close();
|
_dataController.close();
|
||||||
|
|
||||||
|
// Disable wake lock as safety net
|
||||||
|
WakelockPlus.disable();
|
||||||
|
|
||||||
Logger.info('GPS manager disposed', 'GpsDataManager');
|
Logger.info('GPS manager disposed', 'GpsDataManager');
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user