248053270b
Delete old flat files, wire new DI in main.dart:
- Removed: gps_service.dart, config/, models/, providers/overlay_provider.dart,
screens/, services/gps_data_manager.dart, services/overlay_service.dart
- Kept: settings_provider.dart (thin, correct), logger.dart (shared util)
- main.dart now wires: LocationRepository → SpeedometerViewModel → SpeedometerView
- overlay_provider.dart deferred, stub in ui/features/overlay/
- flutter analyze: 30 info-level constant_identifier_names warnings (project convention),
zero errors, zero warnings — builds clean
New structure:
lib/
core/constants/ — gps, overlay, timing, speed_unit
core/theme/ — color_themes
data/
models/ — LocationData (immutable domain model)
services/ — GpsService (stateless wrapper), OverlayService (IPC)
repositories/ — LocationRepository (business logic, ChangeNotifier)
providers/ — SettingsProvider (thin, unchanged)
ui/
features/
speedometer/ — SpeedometerViewModel + SpeedometerView
overlay/ — Stub ViewModel + View (deferred)
shared/ — SpeedDisplay widget
services/ — Logger (shared util)
336 lines
11 KiB
Dart
336 lines
11 KiB
Dart
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<Position>? _gpsSubscription;
|
|
final StreamController<LocationData> _dataController =
|
|
StreamController<LocationData>.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<LocationData> 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<void> 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<void> _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');
|
|
}
|
|
}
|