diff --git a/lib/data/repositories/location_repository.dart b/lib/data/repositories/location_repository.dart index bb082f3..75dd3ac 100644 --- a/lib/data/repositories/location_repository.dart +++ b/lib/data/repositories/location_repository.dart @@ -13,7 +13,7 @@ import '../services/gps_service.dart'; /// - GPS stream management (start/stop/restart) /// - Data processing (staleness, precision switching, validation) /// - Lifecycle management (app foreground/background, wake lock) -class LocationRepository extends WidgetsBindingObserver { +class LocationRepository extends ChangeNotifier with WidgetsBindingObserver { StreamSubscription? _gpsSubscription; final StreamController _dataController = StreamController.broadcast(); @@ -238,6 +238,7 @@ class LocationRepository extends WidgetsBindingObserver { if (_isDisposed) return; _currentData = newData; _dataController.add(newData); + notifyListeners(); Logger.debug('Broadcasting data: ${newData.displaySpeed} ${newData.displayHeading}', 'LocationRepository'); } @@ -315,7 +316,9 @@ class LocationRepository extends WidgetsBindingObserver { ); } + @override void dispose() { + super.dispose(); _isDisposed = true; Logger.info('Disposing location repository...', 'LocationRepository'); WidgetsBinding.instance.removeObserver(this); diff --git a/lib/gps_service.dart b/lib/gps_service.dart deleted file mode 100644 index 452d5e6..0000000 --- a/lib/gps_service.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'dart:async'; -import 'package:geolocator/geolocator.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'config/gps_constants.dart'; - -class GpsService { - static double? _lastValidHeading; - - /// Create a position stream with dynamic accuracy setting - static Stream createPositionStream({ - LocationAccuracy accuracy = LocationAccuracy.high, - }) { - return Geolocator.getPositionStream( - locationSettings: LocationSettings( - accuracy: accuracy, - distanceFilter: 0, - ), - ); - } - - /// Deprecated: Use createPositionStream() instead - @Deprecated('Use createPositionStream(accuracy: LocationAccuracy.bestForNavigation) instead') - static Stream get positionStream { - return createPositionStream(accuracy: LocationAccuracy.bestForNavigation); - } - - static Future requestPermissions() async { - final permission = await Permission.locationWhenInUse.request(); - return permission.isGranted; - } - - static Future isLocationServiceEnabled() async { - return await Geolocator.isLocationServiceEnabled(); - } - - static double getCachedHeading(double heading) { - if (heading >= GpsConfig.VALID_HEADING_MIN && heading <= GpsConfig.VALID_HEADING_MAX) { - _lastValidHeading = heading; - return heading; - } - return _lastValidHeading ?? 0.0; - } - - static String getCompassDirection(double heading) { - final cachedHeading = getCachedHeading(heading); - - const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; - final index = ((cachedHeading + 22.5) / 45).floor() % 8; - return directions[index]; - } - - static String formatHeading(double heading) { - final cachedHeading = getCachedHeading(heading); - return '${cachedHeading.round()} ${getCompassDirection(heading)}'; // don't add that degree symbol, this is cooler - } -} diff --git a/lib/main.dart b/lib/main.dart index 7369c34..5b2732d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,19 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'screens/speedometer_screen.dart'; -import 'screens/overlay_screen.dart'; -import 'services/gps_data_manager.dart'; -import 'services/overlay_service.dart'; +import 'ui/features/speedometer/speedometer_view.dart'; +import 'ui/features/speedometer/speedometer_view_model.dart'; import 'providers/settings_provider.dart'; -import 'providers/overlay_provider.dart'; -import 'services/logger.dart'; +import 'data/repositories/location_repository.dart'; + +/// Stub ViewModel for the overlay feature. +/// +/// [Overlay] has been deferred — see ui/features/overlay/ for details. +class OverlayViewModel extends ChangeNotifier { + bool get isOverlayActive => false; + void toggleOverlay() {} + void closeOverlay() {} +} void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -16,22 +22,29 @@ void main() async { runApp( MultiProvider( providers: [ + // Shared Provider.value(value: prefs), - Provider(create: (_) => OverlayService()), - ChangeNotifierProvider( - create: (context) => SettingsProvider(context.read()), + // Data layer + ChangeNotifierProvider( + create: (_) { + final repo = LocationRepository(); + repo.initialize(); + return repo; + }, ), - ChangeNotifierProvider( - create: (_) => GpsDataManager(), + // UI layer — ViewModels subscribe to repositories + ChangeNotifierProvider( + create: (context) => SpeedometerViewModel( + context.read(), + ), ), - ChangeNotifierProxyProvider2( - create: (context) => OverlayProvider(context.read()), - update: (_, gpsManager, settings, overlay) => overlay! - ..updateDependencies( - gpsManager: gpsManager, - currentUnit: settings.currentUnit, - currentThemeIndex: settings.currentThemeIndex, - ), + ChangeNotifierProvider( + create: (_) => OverlayViewModel(), + ), + ChangeNotifierProvider( + create: (context) => SettingsProvider( + context.read(), + ), ), ], child: const SpeedoApp(), @@ -39,15 +52,6 @@ void main() async { ); } -@pragma("vm:entry-point") -void overlayMain() { - Logger.info('overlayMain() called', 'Overlay'); - runApp(const MaterialApp( - debugShowCheckedModeBanner: false, - home: OverlaySpeedometer(), - )); -} - class SpeedoApp extends StatelessWidget { const SpeedoApp({super.key}); @@ -61,7 +65,7 @@ class SpeedoApp extends StatelessWidget { brightness: Brightness.dark, colorSchemeSeed: Colors.green, ), - home: const SpeedometerScreen(), + home: const SpeedometerView(), ); } -} +} \ No newline at end of file diff --git a/lib/models/overlay_message.dart b/lib/models/overlay_message.dart deleted file mode 100644 index c587c09..0000000 --- a/lib/models/overlay_message.dart +++ /dev/null @@ -1,128 +0,0 @@ -/// Typed message class for overlay IPC communication -/// Replaces unvalidated Map data with type-safe serialization -class OverlayMessage { - final String action; - final String? speedText; - final String? unitText; - final String? headingText; - final double? heading; - final int? unitIndex; - final int? themeIndex; - final double? overlayWidth; - final double? overlayHeight; - final DateTime timestamp; - - OverlayMessage({ - required this.action, - this.speedText, - this.unitText, - this.headingText, - this.heading, - this.unitIndex, - this.themeIndex, - this.overlayWidth, - this.overlayHeight, - required this.timestamp, - }); - - /// Factory constructor for display update messages - factory OverlayMessage.updateDisplay({ - required String speedText, - required String unitText, - required String headingText, - required double heading, - required int unitIndex, - required int themeIndex, - double? overlayWidth, - double? overlayHeight, - }) { - return OverlayMessage( - action: 'updateDisplay', - speedText: speedText, - unitText: unitText, - headingText: headingText, - heading: heading, - unitIndex: unitIndex, - themeIndex: themeIndex, - overlayWidth: overlayWidth, - overlayHeight: overlayHeight, - timestamp: DateTime.now(), - ); - } - - /// Factory constructor for long press close signal - factory OverlayMessage.longPressClose() { - return OverlayMessage( - action: 'longPressClose', - timestamp: DateTime.now(), - ); - } - - /// Factory constructor for overlay closed notification - factory OverlayMessage.overlayClosed() { - return OverlayMessage( - action: 'overlayClosed', - timestamp: DateTime.now(), - ); - } - - /// Serialize to Map for overlay IPC - Map toMap() { - final map = {'action': action}; - if (speedText != null) map['speedText'] = speedText; - if (unitText != null) map['unitText'] = unitText; - if (headingText != null) map['headingText'] = headingText; - if (heading != null) map['heading'] = heading; - if (unitIndex != null) map['unitIndex'] = unitIndex; - if (themeIndex != null) map['themeIndex'] = themeIndex; - if (overlayWidth != null) map['overlayWidth'] = overlayWidth; - if (overlayHeight != null) map['overlayHeight'] = overlayHeight; - map['timestamp'] = timestamp.millisecondsSinceEpoch; - return map; - } - - /// Deserialize from Map received via overlay IPC - factory OverlayMessage.fromMap(Map map) { - return OverlayMessage( - action: map['action'] as String? ?? 'unknown', - speedText: map['speedText'] as String?, - unitText: map['unitText'] as String?, - headingText: map['headingText'] as String?, - heading: (map['heading'] as num?)?.toDouble(), - unitIndex: map['unitIndex'] as int?, - themeIndex: map['themeIndex'] as int?, - overlayWidth: (map['overlayWidth'] as num?)?.toDouble(), - overlayHeight: (map['overlayHeight'] as num?)?.toDouble(), - timestamp: DateTime.fromMillisecondsSinceEpoch( - map['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch, - ), - ); - } - - /// Create a copy with updated fields - OverlayMessage copyWith({ - String? action, - String? speedText, - String? unitText, - String? headingText, - double? heading, - int? unitIndex, - int? themeIndex, - double? overlayWidth, - double? overlayHeight, - DateTime? timestamp, - }) { - return OverlayMessage( - action: action ?? this.action, - speedText: speedText ?? this.speedText, - unitText: unitText ?? this.unitText, - headingText: headingText ?? this.headingText, - heading: heading ?? this.heading, - unitIndex: unitIndex ?? this.unitIndex, - themeIndex: themeIndex ?? this.themeIndex, - overlayWidth: overlayWidth ?? this.overlayWidth, - overlayHeight: overlayHeight ?? this.overlayHeight, - timestamp: timestamp ?? this.timestamp, - ); - } -} diff --git a/lib/models/processed_gps_data.dart b/lib/models/processed_gps_data.dart deleted file mode 100644 index 404ef82..0000000 --- a/lib/models/processed_gps_data.dart +++ /dev/null @@ -1,39 +0,0 @@ -class ProcessedGpsData { - final double speed; // Raw speed in m/s - final double heading; // Raw heading in degrees - final String displaySpeed; // Formatted speed text (with -- logic) - final String displayHeading; // Formatted heading text - final bool isSpeedValid; // True if from recent GPS - final bool isHeadingValid; // True if from recent GPS - final DateTime timestamp; // Timestamp for staleness detection - - const ProcessedGpsData({ - required this.speed, - required this.heading, - required this.displaySpeed, - required this.displayHeading, - required this.isSpeedValid, - required this.isHeadingValid, - required this.timestamp, - }); - - ProcessedGpsData copyWith({ - double? speed, - double? heading, - String? displaySpeed, - String? displayHeading, - bool? isSpeedValid, - bool? isHeadingValid, - DateTime? timestamp, - }) { - return ProcessedGpsData( - speed: speed ?? this.speed, - heading: heading ?? this.heading, - displaySpeed: displaySpeed ?? this.displaySpeed, - displayHeading: displayHeading ?? this.displayHeading, - isSpeedValid: isSpeedValid ?? this.isSpeedValid, - isHeadingValid: isHeadingValid ?? this.isHeadingValid, - timestamp: timestamp ?? this.timestamp, - ); - } -} diff --git a/lib/models/theme_model.dart b/lib/models/theme_model.dart deleted file mode 100644 index bf4375b..0000000 --- a/lib/models/theme_model.dart +++ /dev/null @@ -1,32 +0,0 @@ -import '../config/color_themes.dart'; - -/// Theme model that wraps ColorThemes with rotation and state management. -/// Holds all possible color palettes via delegation, exposing only the current set. -class ThemeModel { - final int _currentIndex; - - const ThemeModel({int currentIndex = 0}) : _currentIndex = currentIndex; - - /// Get the current active color theme - ColorTheme get current => ColorThemes.getTheme(_currentIndex); - - /// Get the name of the current theme - String get currentSetName => current.name; - - /// Get the current theme index - int get currentIndex => _currentIndex; - - /// Rotate to the next theme (circular) - ThemeModel rotate() { - return ThemeModel( - currentIndex: ColorThemes.getNextThemeIndex(_currentIndex), - ); - } - - /// Create a copy with an updated theme index - ThemeModel copyWith({int? currentIndex}) { - return ThemeModel( - currentIndex: currentIndex ?? _currentIndex, - ); - } -} diff --git a/lib/providers/overlay_provider.dart b/lib/providers/overlay_provider.dart deleted file mode 100644 index 88318f3..0000000 --- a/lib/providers/overlay_provider.dart +++ /dev/null @@ -1,274 +0,0 @@ -import 'dart:async'; -import 'package:flutter/widgets.dart'; -import 'package:bg_launcher/bg_launcher.dart'; -import '../config/overlay_constants.dart'; -import '../config/timing_constants.dart'; -import '../config/speed_unit.dart'; -import '../models/overlay_message.dart'; -import '../models/processed_gps_data.dart'; -import '../services/gps_data_manager.dart'; -import '../services/logger.dart'; -import '../services/overlay_service.dart'; - -enum OverlayError { permission, creationFailed, communicationDegraded } - -class OverlayProvider extends ChangeNotifier { - final OverlayService _overlayService; - - bool _isOverlayActive = false; - bool _isDisposed = false; - bool _tapCloseRequested = false; - - StreamSubscription? _gpsSubscription; - StreamSubscription? _overlayMessageSubscription; - Timer? _overlayStatusCheckTimer; - Timer? _tapCloseTimer; - - // Dependencies — set via update() from ProxyProvider - GpsDataManager? _gpsManager; - SpeedUnit _currentUnit = SpeedUnit.kmh; - int _currentThemeIndex = 0; - - // Error callback — set by SpeedometerScreen after provider is available - void Function(OverlayError)? onError; - - OverlayProvider(this._overlayService); - - bool get isOverlayActive => _isOverlayActive; - - /// Called by ChangeNotifierProxyProvider2 when GpsDataManager or SettingsProvider changes. - /// Sets up GPS stream subscription on first call, updates settings references on subsequent calls. - void updateDependencies({ - required GpsDataManager gpsManager, - required SpeedUnit currentUnit, - required int currentThemeIndex, - }) { - if (_isDisposed) return; - - _currentUnit = currentUnit; - _currentThemeIndex = currentThemeIndex; - - // Subscribe to GPS stream on first update (only once) - if (_gpsManager == null) { - _gpsManager = gpsManager; - _gpsSubscription = gpsManager.dataStream.listen((data) { - if (_isOverlayActive && !_isDisposed) { - _pushDataToOverlay(data); - } - }); - Logger.debug('GPS stream subscription created for overlay', 'OverlayProvider'); - } - - // Push updated settings to overlay if active - if (_isOverlayActive && _gpsManager != null) { - _pushDataToOverlay(_gpsManager!.currentData); - } - } - - /// Show the floating overlay window - Future showOverlay() async { - if (_isDisposed) return; - - try { - // Check permission before attempting to show overlay - final hasPermission = await _overlayService.checkPermission(); - if (!hasPermission) { - Logger.warn('Overlay permission denied', 'OverlayProvider'); - onError?.call(OverlayError.permission); - return; - } - - final overlaySize = _getOverlaySize(); - final gpsData = _gpsManager?.currentData; - - // Send initial display data WITH size info - final speedText = _gpsManager?.getFormattedSpeed(_currentUnit) ?? '--'; - final headingText = gpsData?.displayHeading ?? 'N/A'; - final unitText = _currentUnit.label; - - await _overlayService.shareData(OverlayMessage.updateDisplay( - speedText: speedText, - unitText: unitText, - headingText: headingText, - heading: gpsData?.heading ?? -1.0, - unitIndex: _currentUnit.index, - themeIndex: _currentThemeIndex, - overlayWidth: overlaySize['width']!.toDouble(), - overlayHeight: overlaySize['height']!.toDouble(), - ).toMap()); - - final success = await _overlayService.showOverlay( - width: overlaySize['width']!, - height: overlaySize['height']!, - ); - - if (!success) { - Logger.error('Overlay creation failed or verification failed', 'OverlayProvider'); - onError?.call(OverlayError.creationFailed); - return; - } - - Logger.info('OVERLAY LAUNCHED - Size: ${overlaySize['width']}x${overlaySize['height']}', 'OverlayProvider'); - - _isOverlayActive = true; - _startListeningToOverlayMessages(); - _startOverlayStatusCheck(); - notifyListeners(); - } catch (e) { - Logger.error('Failed to show overlay: $e', 'OverlayProvider'); - onError?.call(OverlayError.creationFailed); - } - } - - /// Close the floating overlay window - Future closeOverlay() async { - if (!_isOverlayActive || _isDisposed) return; - - try { - await _overlayService.closeOverlay(); - Logger.info('OVERLAY CLOSED - Requested from main app', 'OverlayProvider'); - _handleOverlayClose(); - } catch (e) { - Logger.error('Error closing overlay: $e', 'OverlayProvider'); - } - } - - /// Toggle overlay state (show if hidden, close if active) - Future toggleOverlay() async { - if (_isOverlayActive) { - await closeOverlay(); - } else { - await showOverlay(); - } - } - - Map _getOverlaySize() { - final window = WidgetsBinding.instance.platformDispatcher.views.first; - final physicalSize = window.physicalSize; - final overlayWidth = (physicalSize.width * OverlayConfig.WIDTH_PERCENTAGE).round(); - final overlayHeight = (overlayWidth * OverlayConfig.ASPECT_RATIO).round(); - return {'width': overlayWidth, 'height': overlayHeight}; - } - - void _pushDataToOverlay(ProcessedGpsData gpsData) { - if (!_isOverlayActive || _isDisposed) return; - - final speedText = _gpsManager?.getFormattedSpeed(_currentUnit) ?? '--'; - final unitText = _currentUnit.label; - final headingText = gpsData.displayHeading; - - Logger.debug('Pushing to overlay: speed="$speedText", heading="$headingText"', 'OverlayProvider'); - - _overlayService.shareData(OverlayMessage.updateDisplay( - speedText: speedText, - unitText: unitText, - headingText: headingText, - heading: gpsData.heading, - unitIndex: _currentUnit.index, - themeIndex: _currentThemeIndex, - ).toMap()); - } - - void _startListeningToOverlayMessages() { - if (_overlayMessageSubscription != null) return; - - _overlayMessageSubscription = _overlayService.overlayListener.listen( - (data) { - try { - if (data is Map) { - final action = data['action']; - switch (action) { - case 'overlayClosed': - _handleOverlayClose(); - break; - case 'longPressClose': - _tapCloseRequested = true; - _tapCloseTimer?.cancel(); - _tapCloseTimer = Timer(TimingConfig.TAP_CLOSE_DELAY, () { - _tapCloseRequested = false; - }); - break; - default: - Logger.warn('Unknown overlay action: "$action"', 'OverlayProvider'); - } - } - } catch (e) { - Logger.error('Error processing overlay message: $e', 'OverlayProvider'); - } - }, - onError: (error) { - Logger.error('Overlay listener error: $error', 'OverlayProvider'); - }, - ); - } - - void _stopListeningToOverlayMessages() { - _overlayMessageSubscription?.cancel(); - _overlayMessageSubscription = null; - } - - void _startOverlayStatusCheck() { - _overlayStatusCheckTimer?.cancel(); - - _overlayStatusCheckTimer = Timer.periodic(TimingConfig.OVERLAY_STATUS_CHECK_INTERVAL, (timer) async { - if (_isOverlayActive && !_isDisposed) { - try { - final isActive = await _overlayService.isActive(); - if (!isActive) { - timer.cancel(); - _handleOverlayClose(); - } - } catch (e) { - Logger.error('Overlay status check failed: $e', 'OverlayProvider'); - } - } else { - timer.cancel(); - } - }); - Logger.debug('Started overlay status monitoring', 'OverlayProvider'); - } - - void _handleOverlayClose({bool bringToForeground = false}) { - if (_isDisposed) return; - - final shouldBringToFront = bringToForeground || _tapCloseRequested; - - _tapCloseTimer?.cancel(); - _overlayStatusCheckTimer?.cancel(); - _tapCloseRequested = false; - - _isOverlayActive = false; - _stopListeningToOverlayMessages(); - notifyListeners(); - - if (shouldBringToFront) { - _bringAppToFront(); - } - } - - void _bringAppToFront() { - try { - BgLauncher.bringAppToForeground(); - } catch (e) { - Logger.error('BgLauncher failed to bring app to front: $e', 'OverlayProvider'); - } - } - - /// Push current data to overlay — used by background heartbeat - void pushCurrentData() { - if (_isOverlayActive && _gpsManager != null && !_isDisposed) { - _pushDataToOverlay(_gpsManager!.currentData); - } - } - - @override - void dispose() { - _isDisposed = true; - _gpsSubscription?.cancel(); - _overlayMessageSubscription?.cancel(); - _overlayStatusCheckTimer?.cancel(); - _tapCloseTimer?.cancel(); - Logger.info('OverlayProvider disposed', 'OverlayProvider'); - super.dispose(); - } -} diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 784f8f1..989b255 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -1,8 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../config/speed_unit.dart'; -import '../config/color_themes.dart'; +import '../core/constants/speed_unit.dart'; +import '../core/theme/color_themes.dart'; import '../services/logger.dart'; class SettingsProvider extends ChangeNotifier { diff --git a/lib/screens/overlay_screen.dart b/lib/screens/overlay_screen.dart deleted file mode 100644 index 269d4b7..0000000 --- a/lib/screens/overlay_screen.dart +++ /dev/null @@ -1,326 +0,0 @@ -import 'package:flutter/material.dart'; -import 'dart:async'; -import 'dart:math' as math; -import 'package:flutter_overlay_window/flutter_overlay_window.dart'; -import '../config/color_themes.dart'; -import '../config/overlay_constants.dart'; -import '../models/overlay_message.dart'; -import '../services/logger.dart'; - -class OverlaySpeedometer extends StatefulWidget { - const OverlaySpeedometer({super.key}); - - @override - State createState() => _OverlaySpeedometerState(); -} - -class _OverlaySpeedometerState extends State { - String _speedText = '--'; - String _unitText = 'km/h'; - String _headingText = 'N/A'; - double _heading = -1.0; - int _currentThemeIndex = 0; - double _systemScreenWidth = 400; - double _overlayWidth = 280; - double _overlayHeight = 140; - DateTime _lastUpdateTime = DateTime.now(); - Timer? _stalenessCheckTimer; - - @override - void initState() { - super.initState(); - Logger.info('CREATED - Floating window initialized (${DateTime.now().millisecondsSinceEpoch})', 'Overlay'); - _getSystemScreenSize(); - _listenToMainAppMessages(); - _startStalenessMonitoring(); - } - - @override - void dispose() { - _stalenessCheckTimer?.cancel(); - Logger.info('DISPOSING - Overlay widget disposed (${DateTime.now().millisecondsSinceEpoch})', 'Overlay'); - super.dispose(); - } - - void _getSystemScreenSize() { - try { - // Get actual system screen resolution, not just app window size - final window = WidgetsBinding.instance.platformDispatcher.views.first; - final physicalSize = window.physicalSize; - final devicePixelRatio = window.devicePixelRatio; - final systemSize = physicalSize / devicePixelRatio; - - Logger.debug('Screen detection:', 'Overlay'); - Logger.debug(' physicalSize: ${physicalSize.width.round()}x${physicalSize.height.round()}', 'Overlay'); - Logger.debug(' devicePixelRatio: $devicePixelRatio', 'Overlay'); - Logger.debug(' calculated systemSize: ${systemSize.width.round()}x${systemSize.height.round()}', 'Overlay'); - - setState(() { - _systemScreenWidth = systemSize.width; - }); - - Logger.debug('_systemScreenWidth set to: ${_systemScreenWidth.round()}', 'Overlay'); - } catch (e) { - Logger.error('Error getting system screen size: $e', 'Overlay'); - // Fallback to reasonable default - setState(() { - _systemScreenWidth = OverlayConfig.DEFAULT_SCREEN_WIDTH; - }); - } - } - - void _listenToMainAppMessages() { - FlutterOverlayWindow.overlayListener.listen((data) { - if (data is Map) { - final message = OverlayMessage.fromMap(data); - if (message.action == 'updateDisplay') { - setState(() { - _speedText = message.speedText ?? '--'; - _unitText = message.unitText ?? ''; - _headingText = message.headingText ?? 'N/A'; - _heading = message.heading ?? -1.0; - _currentThemeIndex = message.themeIndex ?? 0; - _lastUpdateTime = message.timestamp; - // Update overlay dimensions if provided by main app - if (message.overlayWidth != null) { - _overlayWidth = message.overlayWidth!; - } - if (message.overlayHeight != null) { - _overlayHeight = message.overlayHeight!; - } - }); - - Logger.debug('UPDATE received:', 'Overlay'); - Logger.debug(' speedText: "$_speedText"', 'Overlay'); - Logger.debug(' headingText: "$_headingText"', 'Overlay'); - Logger.debug(' direction: ${_heading.toStringAsFixed(1)}°', 'Overlay'); - Logger.debug(' themeIndex: $_currentThemeIndex', 'Overlay'); - Logger.debug(' systemScreenWidth: ${_systemScreenWidth.round()}', 'Overlay'); - Logger.debug(' overlaySize from main: ${_overlayWidth.round()}x${_overlayHeight.round()}', 'Overlay'); - } - } - }); - } - - void _startStalenessMonitoring() { - _stalenessCheckTimer = Timer.periodic(OverlayConfig.STALENESS_CHECK_INTERVAL, (timer) { - if (!mounted) return; - - final age = DateTime.now().difference(_lastUpdateTime); - if (age >= OverlayConfig.STALENESS_DASH_THRESHOLD && _speedText != '--') { - setState(() { - _speedText = '--'; - }); - } - }); - } - - - @override - Widget build(BuildContext context) { - final buildTimestamp = DateTime.now().millisecondsSinceEpoch; - final currentTheme = ColorThemes.getTheme(_currentThemeIndex); - - // Calculate staleness opacity - final age = DateTime.now().difference(_lastUpdateTime); - final isStale = age >= OverlayConfig.STALENESS_DIM_THRESHOLD; - final staleOpacity = isStale ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0; - - // Use overlay dimensions provided by main app (not calculated locally) - final fontSize = (_overlayWidth * OverlayConfig.FONT_SIZE_RATIO); - - Logger.debug('Building overlay widget ($buildTimestamp)', 'Overlay'); - Logger.debug('Window size: ${_overlayWidth.round()}x${_overlayHeight.round()}, fontSize: ${fontSize.round()}', 'Overlay'); - - return Material( - color: Colors.transparent, - child: SizedBox( - // Use SizedBox to fill the overlay window completely - width: double.infinity, - height: double.infinity, - child: Container( - decoration: BoxDecoration( - color: currentTheme.background.withValues(alpha: OverlayConfig.BACKGROUND_OPACITY), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: currentTheme.speedText.withValues(alpha: OverlayConfig.BORDER_OPACITY), - width: 1 - ), - ), - child: Stack( - children: [ - // Main content - landscape layout (1:1 flex as per NOTES.txt) - Row( - children: [ - // Speed section - left half - Expanded( - flex: 55, - child: Container( - padding: const EdgeInsets.all(6), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - flex: 3, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - (_speedText), - style: TextStyle( - fontSize: fontSize, - fontWeight: FontWeight.w300, - color: currentTheme.speedText.withValues(alpha: staleOpacity), - fontFamily: 'DIN1451Alt', - ), - ), - ), - ), - Flexible( - flex: 1, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - _unitText, - style: TextStyle( - fontSize: fontSize * OverlayConfig.UNIT_FONT_RATIO, - fontWeight: FontWeight.w300, - color: currentTheme.unitText.withValues(alpha: staleOpacity), - fontFamily: 'DIN1451Alt', - ), - ), - ), - ), - ], - ), - ), - ), - ), - // Direction section - right half (same as full-screen behavior) - Expanded( - flex: 45, - child: Container( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - flex: 5, - child: Transform.rotate( - angle: (_heading >= 0 && _heading < 360) ? (_heading * math.pi / 180.0) : 0, - child: FittedBox( - fit: BoxFit.contain, - child: Icon( - Icons.navigation, - size: fontSize * OverlayConfig.ICON_SIZE_RATIO, - color: currentTheme.headingText.withValues(alpha: staleOpacity), - ), - ), - ), - ), - Flexible( - flex: 2, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - _headingText, - style: TextStyle( - fontSize: fontSize * OverlayConfig.HEADING_FONT_RATIO, - color: currentTheme.headingText.withValues(alpha: staleOpacity), - fontFamily: 'DIN1451Alt', - ), - ), - ), - ), - ], - ), - ), - ), - ], - ), - // Transparent full-overlay gesture detection layer - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.translucent, // Ensure gestures are captured - onPanStart: (details) { - Logger.debug('Pan start detected - gestures are working!', 'Overlay'); - }, - onLongPress: () async { - final timestamp = DateTime.now().millisecondsSinceEpoch; - Logger.info('Long press detected - closing overlay and bringing main app to front ($timestamp)', 'Overlay'); - try { - // Skip haptic feedback in overlay - platform services not available - Logger.debug('Skipping haptic feedback (overlay context limitation)', 'Overlay'); - - // Signal main app to bring to foreground when overlay closes (non-blocking) - Logger.debug('Sending bring-to-front close signal...', 'Overlay'); - FlutterOverlayWindow.shareData(OverlayMessage.longPressClose().toMap()).then((_) { - Logger.debug('Long press close signal sent successfully', 'Overlay'); - }).catchError((error) { - Logger.warn('Long press close signal failed (expected): $error', 'Overlay'); - }); - - // Small delay to give signal a chance - await Future.delayed(const Duration(milliseconds: 50)); - - // Close the overlay - Logger.info('Attempting to close overlay...', 'Overlay'); - await FlutterOverlayWindow.closeOverlay(); - Logger.info('Overlay closed successfully via long press', 'Overlay'); - - } catch (e, stackTrace) { - Logger.error('Long press close failed: $e', 'Overlay'); - Logger.error('Stack trace: $stackTrace', 'Overlay'); - // Fallback: try to close anyway - try { - Logger.debug('Attempting fallback close...', 'Overlay'); - await FlutterOverlayWindow.closeOverlay(); - Logger.info('Fallback close succeeded', 'Overlay'); - } catch (fallbackError) { - Logger.error('Fallback close also failed: $fallbackError', 'Overlay'); - } - } - }, - // Tap to bring main app to front functionality removed per NOTES.txt - // onTap: () async { - // final timestamp = DateTime.now().millisecondsSinceEpoch; - // customDebugPrint('[Overlay] 👆 Tap detected - bringing main app to foreground ($timestamp)'); - // try { - // // Try to signal main app to come to foreground (non-blocking) - // customDebugPrint('[Overlay] 📤 Attempting to signal bring-to-front...'); - // FlutterOverlayWindow.shareData({ - // 'action': 'bringToFront', - // }).catchError((error) { - // customDebugPrint('[Overlay] ❌ Bring-to-front signal failed (expected): $error'); - // }); - // - // customDebugPrint('[Overlay] ✅ Tap gesture completed (overlay stays open)'); - // - // } catch (e, stackTrace) { - // customDebugPrint('[Overlay] ❌ Tap gesture failed: $e'); - // customDebugPrint('[Overlay] 📚 Stack trace: $stackTrace'); - // } - // }, - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.transparent, - child: Center( - child: Text( - 'GESTURE', - style: TextStyle( - color: Colors.transparent, - fontSize: 1, - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/screens/speedometer_screen.dart b/lib/screens/speedometer_screen.dart deleted file mode 100644 index 9e8389c..0000000 --- a/lib/screens/speedometer_screen.dart +++ /dev/null @@ -1,516 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'dart:async'; -import 'dart:math' as math; -import '../config/timing_constants.dart'; -import '../config/overlay_constants.dart'; -import '../config/color_themes.dart'; -import '../services/gps_data_manager.dart'; -import '../models/processed_gps_data.dart'; -import '../providers/settings_provider.dart'; -import '../providers/overlay_provider.dart'; -import '../services/logger.dart'; - -class SpeedometerScreen extends StatefulWidget { - const SpeedometerScreen({super.key}); - - @override - State createState() => _SpeedometerScreenState(); -} - -class _SpeedometerScreenState extends State { - // Local widget state only (not shared app state) - String _errorMessage = ''; - Timer? _backgroundHeartbeatTimer; - Timer? _stalenessCheckTimer; - - @override - void initState() { - super.initState(); - // Delay GPS init and error callback setup to after first frame so context is available - WidgetsBinding.instance.addPostFrameCallback((_) { - _initializeGpsManager(); - context.read().onError = _handleOverlayError; - // Wire overlay provider into GPS manager for lifecycle decisions - context.read().setOverlayProvider(context.read()); - }); - _ensureOverlayPermission(); - _startBackgroundHeartbeat(); - _stalenessCheckTimer = Timer.periodic(OverlayConfig.STALENESS_CHECK_INTERVAL, (_) { - if (mounted) setState(() {}); // Trigger rebuild to re-evaluate staleness - }); - } - - @override - void dispose() { - _backgroundHeartbeatTimer?.cancel(); - _stalenessCheckTimer?.cancel(); - try { - context.read().onError = null; - } catch (e) { - // Provider might already be disposed - } - Logger.info('SpeedometerScreen disposed', 'Main'); - super.dispose(); - } - - Future _initializeGpsManager() async { - Logger.info('Starting GPS manager initialization...', 'Main'); - try { - await context.read().initialize(); - Logger.info('GPS manager initialized', 'Main'); - } catch (e) { - Logger.error('GPS manager initialization failed: $e', 'Main'); - if (mounted) { - setState(() => _errorMessage = 'Failed to initialize GPS manager'); - } - } - } - - void _handleOverlayError(OverlayError error) { - if (!mounted) return; - - String message; - SnackBarAction? action; - - switch (error) { - case OverlayError.permission: - message = 'Overlay permission required'; - action = SnackBarAction( - label: 'Settings', - onPressed: () => openAppSettings(), - ); - break; - case OverlayError.creationFailed: - message = 'Failed to create overlay window'; - break; - case OverlayError.communicationDegraded: - message = 'Overlay communication degraded'; - break; - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - action: action, - duration: const Duration(seconds: 4), - ), - ); - } - - Future _ensureOverlayPermission() async { - try { - // Note: Overlay permission request is handled by OverlayProvider, - // but we check here to avoid issues at app startup - } catch (e) { - Logger.error('Error checking overlay permission: $e', 'Main'); - } - } - - void _startBackgroundHeartbeat() { - _backgroundHeartbeatTimer = Timer.periodic(TimingConfig.HEARTBEAT_INTERVAL, (timer) { - if (!mounted) return; - final overlay = context.read(); - if (overlay.isOverlayActive) { - overlay.pushCurrentData(); - } - }); - } - - Widget _buildSpeedDisplay(String speedText, ColorTheme currentTheme, double fontSize, double opacity) { - if (speedText == '--') { - return Text( - speedText, - style: TextStyle( - fontSize: fontSize, - fontWeight: FontWeight.w300, - color: currentTheme.speedText.withValues(alpha: opacity), - fontFamily: 'DIN1451Alt', - ), - ); - } - - // Split speed into integral and decimal parts - final parts = speedText.split('.'); - final integralPart = parts[0]; - final decimalPart = parts.length > 1 ? '.${parts[1]}' : '.0'; - - return Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text( - integralPart, - style: TextStyle( - fontSize: fontSize, - fontWeight: FontWeight.w300, - color: currentTheme.speedText.withValues(alpha: opacity), - fontFamily: 'DIN1451Alt', - ), - ), - Text( - decimalPart, - style: TextStyle( - fontSize: fontSize * 0.5, - fontWeight: FontWeight.w300, - color: currentTheme.speedTextSub.withValues(alpha: opacity), - fontFamily: 'DIN1451Alt', - ), - ), - ], - ); - } - - @override - Widget build(BuildContext context) { - return Selector( - selector: (_, settings) => settings.currentTheme, - builder: (context, currentTheme, _) { - return Scaffold( - backgroundColor: currentTheme.background, - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - final isLandscape = constraints.maxWidth > constraints.maxHeight; - if (isLandscape) { - return _buildLandscapeLayout(currentTheme); - } else { - return _buildPortraitLayout(currentTheme); - } - }, - ), - ), - ); - }, - ); - } - - Widget _buildPortraitLayout(ColorTheme currentTheme) { - return Column( - children: [ - // Speed area - takes most space, precise tap targets on text only - Expanded( - flex: 65, - child: Container( - width: double.infinity, - color: Colors.transparent, - padding: const EdgeInsets.all(4), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (_errorMessage.isNotEmpty) - Flexible( - child: Text( - _errorMessage, - style: TextStyle( - color: currentTheme.speedText, - fontSize: 18, - fontFamily: 'DIN1451Alt', - ), - textAlign: TextAlign.center, - ), - ) - else ...[ - Flexible( - flex: 7, - child: Selector( - selector: (_, gps) => gps.currentData, - builder: (context, data, _) { - final settings = context.read(); - final displaySpeed = settings.currentUnit.convert(data.speed); - - // Calculate staleness - final age = DateTime.now().difference(data.timestamp); - final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD; - final showDashes = age >= OverlayConfig.STALENESS_DASH_THRESHOLD; - final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0; - - final speedText = showDashes ? '--' : displaySpeed.toStringAsFixed(1); - - return GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - context.read().cycleTheme(); - }, - child: FittedBox( - fit: BoxFit.contain, - child: _buildSpeedDisplay(speedText, currentTheme, 200, staleOpacity), - ), - ); - }, - ), - ), - Flexible( - flex: 1, - child: FittedBox( - fit: BoxFit.contain, - ), - ), - Flexible( - flex: 2, - child: Selector( - selector: (_, settings) => settings.currentUnit.label, - builder: (context, unitLabel, _) { - return GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - context.read().cycleUnit(); - }, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - unitLabel, - style: TextStyle( - fontSize: 80, - color: currentTheme.unitText, - fontFamily: 'DIN1451Alt', - ), - ), - ), - ); - }, - ), - ), - Flexible( - flex: 1, - child: FittedBox( - fit: BoxFit.contain, - ), - ), - ], - ], - ), - ), - ), - // Compass area - compact but fully tappable for floating window - Expanded( - flex: 32, - child: GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - context.read().toggleOverlay(); - }, - onLongPress: () { - context.read().closeOverlay(); - }, - child: Container( - width: double.infinity, - color: Colors.transparent, - padding: const EdgeInsets.all(2), - child: Selector( - selector: (_, gps) => gps.currentData, - builder: (context, data, _) { - // Calculate staleness - final age = DateTime.now().difference(data.timestamp); - final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD; - final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0; - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - flex: 3, - child: Transform.rotate( - angle: (data.heading >= 0 && data.heading < 360) ? (data.heading * math.pi / 180.0) : 0, - child: FittedBox( - fit: BoxFit.contain, - child: Icon( - Icons.navigation, - size: 80, - color: currentTheme.headingText.withValues(alpha: staleOpacity), - ), - ), - ), - ), - Flexible( - flex: 1, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - data.displayHeading, - style: TextStyle( - fontSize: 50, - color: currentTheme.headingText.withValues(alpha: staleOpacity), - fontFamily: 'DIN1451Alt', - ), - ), - ), - ), - ], - ); - }, - ), - ), - ), - ), - ], - ); - } - - Widget _buildLandscapeLayout(ColorTheme currentTheme) { - return Row( - children: [ - // Speed area - takes majority of space, precise tap targets on text only - Expanded( - flex: 60, - child: Container( - height: double.infinity, - color: Colors.transparent, - padding: const EdgeInsets.all(3), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (_errorMessage.isNotEmpty) - Flexible( - child: Text( - _errorMessage, - style: TextStyle( - color: currentTheme.speedText, - fontSize: 16, - fontFamily: 'DIN1451Alt', - ), - textAlign: TextAlign.center, - ), - ) - else ...[ - Flexible( - flex: 1, - child: FittedBox(), - ), - Flexible( - flex: 20, - child: Selector( - selector: (_, gps) => gps.currentData, - builder: (context, data, _) { - final settings = context.read(); - final displaySpeed = settings.currentUnit.convert(data.speed); - - // Calculate staleness - final age = DateTime.now().difference(data.timestamp); - final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD; - final showDashes = age >= OverlayConfig.STALENESS_DASH_THRESHOLD; - final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0; - - final speedText = showDashes ? '--' : displaySpeed.toStringAsFixed(1); - - return GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - context.read().cycleTheme(); - }, - child: FittedBox( - fit: BoxFit.contain, - child: _buildSpeedDisplay(speedText, currentTheme, 160, staleOpacity), - ), - ); - }, - ), - ), - Flexible( - flex: 7, - child: Selector( - selector: (_, settings) => settings.currentUnit.label, - builder: (context, unitLabel, _) { - return GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - context.read().cycleUnit(); - }, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - unitLabel, - style: TextStyle( - fontSize: 50, - color: currentTheme.unitText, - fontFamily: 'DIN1451Alt', - ), - ), - ), - ); - }, - ), - ), - Flexible( - flex: 5, - child: FittedBox(), - ), - ], - ], - ), - ), - ), - // Compass area - smaller but fully tappable for floating window - Expanded( - flex: 35, - child: GestureDetector( - onTap: () { - HapticFeedback.lightImpact(); - context.read().toggleOverlay(); - }, - onLongPress: () { - context.read().closeOverlay(); - }, - child: Container( - height: double.infinity, - color: Colors.transparent, - padding: const EdgeInsets.all(2), - child: Selector( - selector: (_, gps) => gps.currentData, - builder: (context, data, _) { - // Calculate staleness - final age = DateTime.now().difference(data.timestamp); - final isDimmed = age >= OverlayConfig.STALENESS_DIM_THRESHOLD; - final staleOpacity = isDimmed ? OverlayConfig.STALENESS_DIM_OPACITY : 1.0; - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - flex: 3, - child: Transform.rotate( - angle: (data.heading >= 0 && data.heading < 360) ? (data.heading * math.pi / 180.0) : 0, - child: FittedBox( - fit: BoxFit.contain, - child: Icon( - Icons.navigation, - size: 60, - color: currentTheme.headingText.withValues(alpha: staleOpacity), - ), - ), - ), - ), - Flexible( - flex: 1, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - data.displayHeading, - style: TextStyle( - fontSize: 30, - color: currentTheme.headingText.withValues(alpha: staleOpacity), - fontFamily: 'DIN1451Alt', - ), - ), - ), - ), - ], - ); - }, - ), - ), - ), - ), - Expanded( - flex: 1, - child: FittedBox( - fit: BoxFit.contain, - ), - ), - ], - ); - } -} diff --git a/lib/services/gps_data_manager.dart b/lib/services/gps_data_manager.dart deleted file mode 100644 index 3818187..0000000 --- a/lib/services/gps_data_manager.dart +++ /dev/null @@ -1,422 +0,0 @@ -import 'dart:async'; -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 with WidgetsBindingObserver { - GpsDataManager(); - - StreamSubscription? _gpsSubscription; - final StreamController _dataController = - StreamController.broadcast(); - - 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; - - // Get current data synchronously (for immediate access) - 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'); - return; - } - - 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'); - if (!serviceEnabled) { - Logger.error('GPS service disabled', 'GpsDataManager'); - _updateData(_currentData.copyWith( - displaySpeed: 'GPS OFF', - displayHeading: 'GPS OFF' - )); - _isInitialized = true; // Mark as initialized even if failed - return; - } - - final permissionGranted = await GpsService.requestPermissions(); - Logger.debug('GPS permission granted: $permissionGranted', 'GpsDataManager'); - if (!permissionGranted) { - Logger.error('GPS permission denied', 'GpsDataManager'); - _updateData(_currentData.copyWith( - displaySpeed: 'NO PERM', - displayHeading: 'NO PERM' - )); - _isInitialized = true; // Mark as initialized even if failed - return; - } - - // Create GPS stream subscription - Logger.debug('Creating GPS stream subscription...', 'GpsDataManager'); - - try { - Logger.debug('Getting initial GPS position with long timeout...', 'GpsDataManager'); - - // First, get initial position with long timeout for cold start - final initialPosition = await Geolocator.getCurrentPosition( - locationSettings: LocationSettings( - accuracy: LocationAccuracy.bestForNavigation, - timeLimit: GpsConfig.INITIAL_FIX_TIMEOUT, - ), - ); - - Logger.info('Initial GPS fix obtained', 'GpsDataManager'); - _hasFirstFix = true; - _firstFixTime = DateTime.now(); - _onPositionUpdate(initialPosition); - - // 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); - }, - onError: (error) { - Logger.error('GPS stream error callback: $error', 'GpsDataManager'); - _onGpsError(error); - }, - onDone: () { - Logger.warn('GPS stream done callback - stream ended', 'GpsDataManager'); - }, - cancelOnError: false, - ); - - Logger.info('GPS stream subscription created', 'GpsDataManager'); - Logger.debug('Subscription details: ${_gpsSubscription.runtimeType}', 'GpsDataManager'); - - _isGpsActive = true; - _updateWakeLock(); - _isInitialized = true; - - } catch (e, stackTrace) { - Logger.error('GPS subscription creation failed: $e', 'GpsDataManager'); - Logger.error('Stack trace: $stackTrace', 'GpsDataManager'); - _updateData(_currentData.copyWith( - displaySpeed: 'GPS ERR', - displayHeading: 'GPS ERR' - )); - _isInitialized = true; // Mark as initialized even if failed - return; - } - - Logger.info('GPS manager initialized successfully', 'GpsDataManager'); - } - - void _onPositionUpdate(Position position) { - Logger.debug('Raw GPS update: speed=${position.speed.toStringAsFixed(2)} m/s, heading=${position.heading.toStringAsFixed(1)}°', 'GpsDataManager'); - - _scheduleStaleDataTimer(); - - final rawSpeed = position.speed; - final bool speedValid = rawSpeed.isFinite && rawSpeed >= 0; - final double speed = speedValid ? rawSpeed : 0.0; - - 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; - - // Basic display logic - TEMPORARILY show all speeds for indoor testing - // TODO: Re-enable low-speed logic later: (speed < GpsConfig.LOW_SPEED_THRESHOLD && (heading < GpsConfig.VALID_HEADING_MIN || heading >= GpsConfig.VALID_HEADING_MAX)) ? '--' : ... - final displaySpeed = speed.toStringAsFixed(1); - - final displayHeading = headingValid - ? GpsService.formatHeading(heading) - : _currentData.displayHeading; - - Logger.debug('Processed: displaySpeed="$displaySpeed", displayHeading="$displayHeading"', 'GpsDataManager'); - - final processedData = ProcessedGpsData( - speed: speed, - heading: heading, - displaySpeed: displaySpeed, - 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) { - if (_isDisposed) return; - // Handle GPS errors gracefully - 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; - // Avoid overriding explicit GPS error states or repeated stale notifications - 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', 'GpsDataManager'); - - final staleData = _currentData.copyWith( - displaySpeed: '--', - displayHeading: displayHeading, - isSpeedValid: false, - isHeadingValid: headingStillValid, - ); - - _staleDataTimer = null; - _updateData(staleData); - } - - void _updateData(ProcessedGpsData newData) { - if (_isDisposed) return; - Logger.debug('Broadcasting data: ${newData.displaySpeed} ${newData.displayHeading}', 'GpsDataManager'); - _currentData = newData; - _dataController.add(newData); - notifyListeners(); - Logger.debug('Data broadcast complete', 'GpsDataManager'); - } - - // 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; - } - - _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(); - } -} diff --git a/lib/services/overlay_service.dart b/lib/services/overlay_service.dart deleted file mode 100644 index 7578339..0000000 --- a/lib/services/overlay_service.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:flutter_overlay_window/flutter_overlay_window.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:retry/retry.dart'; -import 'logger.dart'; - -/// Service wrapper for FlutterOverlayWindow platform calls with retry logic -/// Encapsulates all overlay operations with error handling and verification -class OverlayService { - /// Retry configuration for platform operations - static final RetryOptions _retryOptions = RetryOptions( - maxAttempts: 3, - delayFactor: const Duration(milliseconds: 200), - ); - - /// Show overlay window with retry logic and verification - /// Returns true if overlay successfully opened and verified active - Future showOverlay({ - required int width, - required int height, - }) async { - try { - await _retryOptions.retry( - () async { - await FlutterOverlayWindow.showOverlay( - enableDrag: true, - overlayTitle: "Speedometer", - overlayContent: 'Speedo overlay active', - flag: OverlayFlag.defaultFlag, - visibility: NotificationVisibility.visibilityPublic, - positionGravity: PositionGravity.none, - width: width, - height: height, - ); - }, - ); - - // Verify overlay actually opened - final isActive = await FlutterOverlayWindow.isActive(); - if (!isActive) { - Logger.error('Overlay showOverlay succeeded but isActive returned false', 'OverlayService'); - return false; - } - - return true; - } catch (e) { - Logger.error('Failed to show overlay after retries: $e', 'OverlayService'); - return false; - } - } - - /// Close overlay window with retry logic - /// Returns true on success, false on failure after retries - Future closeOverlay() async { - try { - await _retryOptions.retry( - () async { - await FlutterOverlayWindow.closeOverlay(); - }, - ); - return true; - } catch (e) { - Logger.error('Failed to close overlay after retries: $e', 'OverlayService'); - return false; - } - } - - /// Share data with overlay window (fire-and-forget) - /// Does NOT retry - data loss is expected and handled by staleness detection - Future shareData(Map data) async { - try { - await FlutterOverlayWindow.shareData(data); - } catch (e) { - Logger.warn('Failed to share data with overlay: $e', 'OverlayService'); - } - } - - /// Check if overlay window is currently active - /// Returns false on error - Future isActive() async { - try { - return await FlutterOverlayWindow.isActive(); - } catch (e) { - Logger.error('Failed to check overlay active status: $e', 'OverlayService'); - return false; - } - } - - /// Stream of messages from overlay window - /// Direct delegation to platform channel - Stream get overlayListener => FlutterOverlayWindow.overlayListener; - - /// Check if system alert window permission is granted - Future checkPermission() async { - return await Permission.systemAlertWindow.isGranted; - } -} diff --git a/lib/ui/features/speedometer/speedometer_view_model.dart b/lib/ui/features/speedometer/speedometer_view_model.dart index b7f96d1..ba09ce4 100644 --- a/lib/ui/features/speedometer/speedometer_view_model.dart +++ b/lib/ui/features/speedometer/speedometer_view_model.dart @@ -1,9 +1,9 @@ -import 'dart:async'; import 'package:flutter/widgets.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:provider/provider.dart'; import '../../../core/constants/speed_unit.dart'; import '../../../data/models/location_data.dart'; import '../../../data/repositories/location_repository.dart'; +import '../../../providers/settings_provider.dart'; /// ViewModel for the speedometer UI. /// @@ -11,71 +11,24 @@ import '../../../data/repositories/location_repository.dart'; /// the current display state to the view. No data-layer logic here. class SpeedometerViewModel extends ChangeNotifier { final LocationRepository _repository; - final SharedPreferences _prefs; + SettingsProvider? _settings; - SpeedUnit _displayUnit = SpeedUnit.kmh; - LocationData _displayData = LocationData( - speed: 0.0, - heading: -1.0, - displaySpeed: '--', - displayHeading: 'N/A', - isSpeedValid: false, - isHeadingValid: false, - timestamp: DateTime.now(), - ); + SpeedUnit get displayUnit => _settings?.currentUnit ?? SpeedUnit.kmh; + LocationData get displayData => _repository.currentData; + String get displaySpeedText => _repository.currentData.displaySpeed; + String get displayHeading => _repository.currentData.displayHeading; + bool get isSpeedValid => _repository.currentData.isSpeedValid; + bool get isHeadingValid => _repository.currentData.isHeadingValid; - LocationData get displayData => _displayData; - SpeedUnit get displayUnit => _displayUnit; + SpeedometerViewModel(this._repository); - SpeedometerViewModel({ - required LocationRepository repository, - required SharedPreferences prefs, - }) : _repository = repository, - _prefs = prefs; - - void init() { - _repository.locationStream.listen(_onLocationData); - _loadSettings(); - } - - void _onLocationData(LocationData data) { - _displayData = data; - notifyListeners(); - } - - Future _loadSettings() async { - _displayUnit = _parseSpeedUnit(_prefs.getString('speed_unit') ?? 'km/h'); - notifyListeners(); - } - - static SpeedUnit _parseSpeedUnit(String unit) { - switch (unit) { - case 'mph': - return SpeedUnit.mph; - case 'kts': - return SpeedUnit.knots; - default: - return SpeedUnit.kmh; - } + /// Call when the ViewModel is inserted into the provider tree so it + /// can listen to SettingsProvider and repository state. + void attach(BuildContext context) { + _settings = context.read(); } double get displaySpeed { - return _displayUnit.convert(_displayData.speed); - } - - String get displaySpeedText { - return _displayData.displaySpeed; - } - - String get displayHeading { - return _displayData.displayHeading; - } - - bool get isSpeedValid { - return _displayData.isSpeedValid; - } - - bool get isHeadingValid { - return _displayData.isHeadingValid; + return displayUnit.convert(_repository.currentData.speed); } }