import 'dart:async'; import 'dart:developer' as developer; import 'package:flutter/widgets.dart'; import 'package:geolocator/geolocator.dart'; import '../../core/constants/gps_constants.dart'; import '../../services/logger.dart'; import '../models/location_data.dart'; import '../services/gps_service.dart'; /// Repository that wraps GpsService and applies GPS business logic. /// /// Single source of truth for location data. Handles: /// - GPS stream management (start/stop/restart) /// - Data processing (staleness, precision switching, validation) /// - Lifecycle management (app foreground/background, wake lock) class LocationRepository extends ChangeNotifier with WidgetsBindingObserver { StreamSubscription? _gpsSubscription; final StreamController _dataController = StreamController.broadcast(); // Use late to defer DateTime.now() — const constructors can't call methods late LocationData _currentData = LocationData( speed: 0.0, heading: -1.0, displaySpeed: '--', displayHeading: 'N/A', isSpeedValid: false, isHeadingValid: false, timestamp: DateTime.now(), ); Timer? _staleDataTimer; Timer? _backgroundGracePeriodTimer; bool _isDisposed = false; // 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 locationStream => _dataController.stream; // Get current data synchronously LocationData get currentData => _currentData; bool get isInitialized => _isInitialized; bool get isGpsActive => _isGpsActive; bool _isInitialized = false; /// Initialize GPS: check permissions, get first fix, start stream. Future initialize() async { if (_isInitialized) { Logger.warn('Already initialized, skipping...', 'LocationRepository'); return; } Logger.info('Starting location repository initialization...', 'LocationRepository'); WidgetsBinding.instance.addObserver(this); // Check GPS permissions and services first final serviceEnabled = await GpsService.isLocationServiceEnabled(); Logger.debug('Location service enabled: $serviceEnabled', 'LocationRepository'); if (!serviceEnabled) { Logger.error('GPS service disabled', 'LocationRepository'); _updateData(_currentData.copyWith( displaySpeed: 'GPS OFF', displayHeading: 'GPS OFF', )); _isInitialized = true; return; } final permissionGranted = await GpsService.requestPermissions(); Logger.debug('GPS permission granted: $permissionGranted', 'LocationRepository'); if (!permissionGranted) { Logger.error('GPS permission denied', 'LocationRepository'); _updateData(_currentData.copyWith( displaySpeed: 'NO PERM', displayHeading: 'NO PERM', )); _isInitialized = true; return; } try { // Get initial position with long timeout for cold start final initialPosition = await GpsService.getCurrentPosition( accuracy: LocationAccuracy.bestForNavigation, timeLimit: GpsConfig.INITIAL_FIX_TIMEOUT, ); if (initialPosition == null) { Logger.error('Initial GPS fix failed — null position', 'LocationRepository'); _updateData(_currentData.copyWith( displaySpeed: 'GPS ERR', displayHeading: 'GPS ERR', )); _isInitialized = true; return; } Logger.info('Initial GPS fix obtained', 'LocationRepository'); _hasFirstFix = true; _firstFixTime = DateTime.now(); _onPositionUpdate(initialPosition); // Start position stream Logger.debug('Starting GPS stream with accuracy: $_currentAccuracy', 'LocationRepository'); _startPositionStream(_currentAccuracy); _isGpsActive = true; await GpsService.setWakeLock(true); _isInitialized = true; } catch (e, stackTrace) { developer.log('GPS subscription creation failed: $e', error: e, stackTrace: stackTrace, name: 'LocationRepository'); _updateData(_currentData.copyWith( displaySpeed: 'GPS ERR', displayHeading: 'GPS ERR', )); _isInitialized = true; } Logger.info('Location repository initialized successfully', 'LocationRepository'); } void _startPositionStream(LocationAccuracy accuracy) { _gpsSubscription = GpsService.createPositionStream(accuracy: accuracy).listen( (Position position) { Logger.debug('GPS callback triggered', 'LocationRepository'); _onPositionUpdate(position); }, onError: (error) { Logger.error('GPS stream error callback: $error', 'LocationRepository'); _onGpsError(error); }, onDone: () { Logger.warn('GPS stream done callback - stream ended', 'LocationRepository'); }, cancelOnError: false, ); } void _onPositionUpdate(Position position) { Logger.debug('Raw GPS update: speed=${position.speed.toStringAsFixed(2)} m/s, heading=${position.heading.toStringAsFixed(1)}°', 'LocationRepository'); _scheduleStaleDataTimer(); // Validate speed final rawSpeed = position.speed; final bool speedValid = rawSpeed.isFinite && rawSpeed >= 0; final double speed = speedValid ? rawSpeed : 0.0; // Validate heading final rawHeading = position.heading; final bool headingValid = rawHeading.isFinite && rawHeading >= GpsConfig.VALID_HEADING_MIN && rawHeading < GpsConfig.VALID_HEADING_MAX; final double heading = headingValid ? rawHeading : _currentData.heading; // Format display final displaySpeed = speed.toStringAsFixed(1); final displayHeading = headingValid ? GpsService.formatHeading(heading) : _currentData.displayHeading; Logger.debug('Processed: displaySpeed="$displaySpeed", displayHeading="$displayHeading"', 'LocationRepository'); final locationData = LocationData( speed: speed, heading: heading, displaySpeed: displaySpeed, displayHeading: displayHeading, isSpeedValid: speedValid, isHeadingValid: headingValid, timestamp: DateTime.now(), ); _updateData(locationData); // 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) { if (_isDisposed) return; final errorString = error.toString().toLowerCase(); if (!errorString.contains('timeout') && !errorString.contains('temporarily')) { _updateData(_currentData.copyWith( displaySpeed: 'GPS ERR', displayHeading: 'GPS ERR', isSpeedValid: false, isHeadingValid: false, )); } } void _scheduleStaleDataTimer() { _staleDataTimer?.cancel(); _staleDataTimer = Timer(GpsConfig.STALE_DATA_THRESHOLD, _handleStaleDataTimeout); } void _handleStaleDataTimeout() { if (_isDisposed) return; final displayText = _currentData.displaySpeed; if (displayText == '--' || displayText.startsWith('GPS') || displayText == 'NO PERM') { return; } final headingStillValid = _currentData.isHeadingValid && _currentData.heading >= GpsConfig.VALID_HEADING_MIN && _currentData.heading < GpsConfig.VALID_HEADING_MAX; final displayHeading = headingStillValid ? _currentData.displayHeading : '--'; Logger.warn('No GPS updates within ${GpsConfig.STALE_DATA_THRESHOLD.inSeconds}s - marking data as stale', 'LocationRepository'); final staleData = _currentData.copyWith( displaySpeed: '--', displayHeading: displayHeading, isSpeedValid: false, isHeadingValid: headingStillValid, ); _staleDataTimer = null; _updateData(staleData); } void _updateData(LocationData newData) { if (_isDisposed) return; _currentData = newData; _dataController.add(newData); notifyListeners(); Logger.debug('Broadcasting data: ${newData.displaySpeed} ${newData.displayHeading}', 'LocationRepository'); } /// Check if GPS precision should be switched based on speed. void _checkPrecisionSwitch(double speedMps) { if (_currentAccuracy == LocationAccuracy.high) { if (speedMps < GpsConfig.PRECISION_DOWN_THRESHOLD_MPS) { _switchPrecision(LocationAccuracy.medium); } } else if (_currentAccuracy == LocationAccuracy.medium) { 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', 'LocationRepository'); _currentAccuracy = newAccuracy; _gpsSubscription?.cancel(); _startPositionStream(newAccuracy); } /// Handle app lifecycle state changes. @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.paused) { _isAppInForeground = false; // No overlay logic here — overlay is deferred _updateWakeLock(); } else if (state == AppLifecycleState.resumed) { _isAppInForeground = true; _backgroundGracePeriodTimer?.cancel(); _backgroundGracePeriodTimer = null; // Restart GPS if it was stopped if (_gpsSubscription == null && _isInitialized) { restartGps(); } _updateWakeLock(); } } /// Stop GPS tracking. void stopGps() { _gpsSubscription?.cancel(); _gpsSubscription = null; _isGpsActive = false; _staleDataTimer?.cancel(); _updateWakeLock(); Logger.info('GPS stopped', 'LocationRepository'); } /// Restart GPS tracking. void restartGps() { Logger.info('Restarting GPS', 'LocationRepository'); _startPositionStream(_currentAccuracy); _isGpsActive = true; _updateWakeLock(); } /// Update wake lock based on GPS state and app visibility. Future _updateWakeLock() async { final shouldKeepAwake = _isGpsActive && _isAppInForeground; await GpsService.setWakeLock(shouldKeepAwake); Logger.debug( 'Wake lock ${shouldKeepAwake ? "enabled" : "disabled"} (GPS: $_isGpsActive, foreground: $_isAppInForeground)', 'LocationRepository', ); } @override void dispose() { super.dispose(); _isDisposed = true; Logger.info('Disposing location repository...', 'LocationRepository'); WidgetsBinding.instance.removeObserver(this); _backgroundGracePeriodTimer?.cancel(); _backgroundGracePeriodTimer = null; _gpsSubscription?.cancel(); _gpsSubscription = null; _staleDataTimer?.cancel(); _staleDataTimer = null; _dataController.close(); GpsService.setWakeLock(false); Logger.info('Location repository disposed', 'LocationRepository'); } }