diff --git a/lib/main.dart b/lib/main.dart index e9eed26..8db8776 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,19 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; // For kDebugMode -import 'package:flutter/services.dart'; // For HapticFeedback -import 'dart:async'; -import 'package:wakelock_plus/wakelock_plus.dart'; -import 'package:flutter_overlay_window/flutter_overlay_window.dart'; -import 'package:bg_launcher/bg_launcher.dart'; // For bringing app to foreground -import 'config/speed_unit.dart'; -import 'config/color_themes.dart'; -import 'config/timing_constants.dart'; -import 'config/overlay_constants.dart'; -import 'models/overlay_message.dart'; -import 'models/processed_gps_data.dart'; -import 'services/gps_data_manager.dart'; +import 'screens/speedometer_screen.dart'; import 'services/logger.dart'; import 'dart:math' as math; // Import for math.pi +import 'package:flutter_overlay_window/flutter_overlay_window.dart'; +import 'config/color_themes.dart'; +import 'config/overlay_constants.dart'; +import 'config/timing_constants.dart'; +import 'models/overlay_message.dart'; void main() { runApp(const SpeedoApp()); @@ -49,789 +43,6 @@ class SpeedoApp extends StatelessWidget { } } -class SpeedometerScreen extends StatefulWidget { - final bool isOverlayMode; - - const SpeedometerScreen({super.key, this.isOverlayMode = false}); - - @override - State createState() => _SpeedometerScreenState(); -} - -class _SpeedometerScreenState extends State with WidgetsBindingObserver { - SpeedUnit _currentUnit = SpeedUnit.kmh; - int _currentThemeIndex = 0; - StreamSubscription? _gpsDataSubscription; - StreamSubscription? _overlaySubscription; - ProcessedGpsData _currentGpsData = const ProcessedGpsData( - speed: 0.0, - heading: -1.0, - displaySpeed: '--', - displayHeading: 'N/A', - isSpeedValid: false, - isHeadingValid: false, - ); - String _errorMessage = ''; - bool _isInBackground = false; - bool _isOverlayActive = false; - bool _tapCloseRequested = false; - Timer? _tapCloseTimer; - - // Single source of truth for overlay sizing - Map _getOverlaySize() { - final window = WidgetsBinding.instance.platformDispatcher.views.first; - final physicalSize = window.physicalSize; - - // Use actual physical pixels for overlay sizing - final overlayWidth = (physicalSize.width * OverlayConfig.WIDTH_PERCENTAGE).round(); - final overlayHeight = (overlayWidth * OverlayConfig.ASPECT_RATIO).round(); - - return {'width': overlayWidth, 'height': overlayHeight}; - } - - Timer? _backgroundHeartbeatTimer; - Timer? _overlayStatusCheckTimer; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - _initializeGpsManager(); - _enableWakelock(); - _ensureOverlayPermission(); - // Overlay listener will be created lazily when overlay is shown - _startBackgroundHeartbeat(); - } - - void _startListeningToOverlayMessages() { - // Only create listener when overlay is actually shown - if (_overlaySubscription != null) { - return; - } - _overlaySubscription = FlutterOverlayWindow.overlayListener.listen( - (data) { - try { - if (data is Map) { - final action = data['action']; - switch (action) { - case 'overlayClosed': - _handleOverlayClose(); - break; - case 'longPressClose': - _tapCloseRequested = true; // Reusing the flag for long press - // Set a timer to reset this flag if overlay doesn't close soon - _tapCloseTimer?.cancel(); - _tapCloseTimer = Timer(TimingConfig.TAP_CLOSE_DELAY, () { - _tapCloseRequested = false; - }); - break; - // 'bringToFront' action handler removed per NOTES.txt (tap to bring front functionality disabled) - default: - Logger.warn('Unknown overlay action: "$action"', 'Main'); - } - } - } catch (e, stackTrace) { - Logger.error('Error processing overlay message: $e', 'Main'); - Logger.error('Stack trace: $stackTrace', 'Main'); - } - }, - onError: (error) { - Logger.error('Overlay listener error: $error', 'Main'); - }, - ); - } - - void _stopListeningToOverlayMessages() { - _overlaySubscription?.cancel(); - _overlaySubscription = null; - } - - void _startOverlayStatusCheck() { - // Cancel any existing timer first - _overlayStatusCheckTimer?.cancel(); - - _overlayStatusCheckTimer = Timer.periodic(TimingConfig.OVERLAY_STATUS_CHECK_INTERVAL, (timer) async { - if (_isOverlayActive) { - try { - final isActive = await FlutterOverlayWindow.isActive(); - if (!isActive) { - timer.cancel(); // Cancel the timer before handling close - _handleOverlayClose(); - } - } catch (e) { - Logger.error('Overlay status check failed: $e', 'Main'); - } - } else { - // If overlay is not supposed to be active, cancel the timer - timer.cancel(); - } - }); - Logger.debug('Started overlay status monitoring', 'Main'); - } - - void _handleOverlayClose({bool bringToForeground = false}) { - // Check if this was a long-press close based on recent signal - final shouldBringToFront = bringToForeground || _tapCloseRequested; - - // Cancel timers and reset flag - _tapCloseTimer?.cancel(); - _overlayStatusCheckTimer?.cancel(); - _tapCloseRequested = false; - - setState(() { - _isOverlayActive = false; - }); - _stopListeningToOverlayMessages(); // Clean up listener when overlay closes - - // Bring app to foreground on long press close, otherwise quiet close - if (shouldBringToFront) { - _bringAppToFront(); - } - } - - - void _startBackgroundHeartbeat() { - // Aggressive heartbeat to keep main app process active for overlay communication - _backgroundHeartbeatTimer = Timer.periodic(TimingConfig.HEARTBEAT_INTERVAL, (timer) { - if (_isInBackground && _isOverlayActive) { - // Minimal activity to prevent hibernation - if (mounted) { - // Force data push to overlay to maintain communication - _pushDataToOverlay(); - // Small state update to keep Flutter engine active - setState(() { - // Tiny update that doesn't affect UI but keeps engine alive - _isInBackground = _isInBackground; - }); - } - } else if (_isInBackground) { - if (mounted) { - // Lighter heartbeat when no overlay is active - setState(() { - _isInBackground = _isInBackground; - }); - } - } - }); - } - - void _bringAppToFront() async { - try { - // Use proper Android method to bring app to foreground - BgLauncher.bringAppToForeground(); - - // Update app state to reflect foreground status - if (mounted) { - setState(() { - _isInBackground = false; - }); - } - } catch (e) { - Logger.error('BgLauncher failed to bring app to front: $e', 'Main'); - - // Enhanced fallback: try alternative approach - try { - if (mounted) { - setState(() { - _isInBackground = false; - }); - } - } catch (fallbackError) { - Logger.error('Fallback method also failed: $fallbackError', 'Main'); - } - } - } - - Future _enableWakelock() async { - try { - // Force enable wake lock even if already enabled for robustness - await WakelockPlus.enable(); - final isEnabled = await WakelockPlus.enabled; - - // Double-check wake lock status for debugging - if (!isEnabled) { - Logger.warn('Wake lock not properly enabled, retrying...', 'Main'); - await WakelockPlus.enable(); - } - } catch (e) { - Logger.error('Wake lock failed: $e', 'Main'); - // Wake lock not supported on this platform, continue normally - } - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _gpsDataSubscription?.cancel(); - _overlaySubscription?.cancel(); - _backgroundHeartbeatTimer?.cancel(); - _overlayStatusCheckTimer?.cancel(); - _tapCloseTimer?.cancel(); - WakelockPlus.disable(); - Logger.info('Main app disposed - all subscriptions and timers canceled', 'Main'); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - super.didChangeAppLifecycleState(state); - - switch (state) { - case AppLifecycleState.paused: - // App went to background (home key pressed or task switch) - if (!_isInBackground) { - _isInBackground = true; - _handleBackgroundTransition(); - } - break; - case AppLifecycleState.resumed: - // App came back to foreground - _isInBackground = false; - break; - case AppLifecycleState.inactive: - case AppLifecycleState.detached: - case AppLifecycleState.hidden: - break; - } - } - - void _handleBackgroundTransition() { - // Enhanced background persistence - keep GPS stream active - _enableWakelock(); - _requestBatteryOptimizationExemption(); - - // If overlay is active, ensure more frequent data pushing - if (_isOverlayActive) { - _pushDataToOverlay(); - } - } - - Future _requestBatteryOptimizationExemption() async { - try { - // This would show Android's battery optimization whitelist dialog - // Note: This requires platform-specific implementation which we'd need to add via method channel - // For now, we'll rely on other persistence mechanisms - } catch (e) { - Logger.warn('Battery optimization exemption not available: $e', 'Main'); - } - } - - Future _ensureOverlayPermission() async { - try { - // Check if overlay permission is granted (required for bg_launcher to work properly) - final isGranted = await FlutterOverlayWindow.isPermissionGranted(); - - if (!isGranted) { - await FlutterOverlayWindow.requestPermission(); - } - } catch (e) { - Logger.error('Error checking overlay permission: $e', 'Main'); - } - } - - Future _initializeGpsManager() async { - Logger.info('Starting GPS manager initialization...', 'Main'); - try { - // Initialize the GPS manager - Logger.debug('Calling GpsDataManager.instance.initialize()...', 'Main'); - await GpsDataManager.instance.initialize(); - Logger.info('GPS manager initialization completed', 'Main'); - - // Subscribe to processed GPS data - Logger.debug('Setting up data stream subscription...', 'Main'); - _gpsDataSubscription = GpsDataManager.instance.dataStream.listen( - _onGpsDataUpdate, - onError: (error) { - Logger.error('GPS data stream error: $error', 'Main'); - setState(() => _errorMessage = 'GPS manager error: $error'); - }, - ); - Logger.info('GPS data stream subscription active', 'Main'); - - Logger.info('GPS manager initialized and subscribed', 'Main'); - } catch (e) { - Logger.error('GPS manager initialization exception: $e', 'Main'); - setState(() => _errorMessage = 'Failed to initialize GPS manager'); - Logger.error('GPS manager initialization failed: $e', 'Main'); - } - } - - void _onGpsDataUpdate(ProcessedGpsData gpsData) { - Logger.debug('Received GPS data: ${gpsData.speed.toStringAsFixed(1)} m/s, ${gpsData.displayHeading}', 'Main'); - - setState(() { - _currentGpsData = gpsData; - _errorMessage = ''; - }); - - Logger.debug('State updated with GPS data', 'Main'); - final convertedSpeed = _currentUnit.convert(gpsData.speed); - Logger.debug('Converted for display: ${convertedSpeed.toStringAsFixed(1)} ${_currentUnit.label}, ${gpsData.displayHeading}', 'Main'); - - // Always push display data to overlay if active - critical for background communication - if (_isOverlayActive) { - _pushDataToOverlay(); - Logger.debug('Data pushed to overlay from GPS update', 'Main'); - } - } - - - void _pushDataToOverlay() { - // Only push data if overlay is actually active (listener check was causing race conditions) - if (!_isOverlayActive) { - Logger.debug('Skipping data push - no active overlay (active: $_isOverlayActive, subscription: ${_overlaySubscription != null})', 'Main'); - return; - } - - Logger.debug('Overlay guard passed - pushing data (active: $_isOverlayActive, subscription: ${_overlaySubscription != null})', 'Main'); - - // Get formatted speed for current unit from GPS manager - final speedText = GpsDataManager.instance.getFormattedSpeed(_currentUnit); - final unitText = _currentUnit.label.toString(); - final headingText = _currentGpsData.displayHeading; - - Logger.debug('SENDING to overlay:', 'Main'); - Logger.debug(' speedText: "$speedText"', 'Main'); - Logger.debug(' headingText: "$headingText"', 'Main'); - Logger.debug(' direction: ${_currentGpsData.heading.toStringAsFixed(1)}°', 'Main'); - Logger.debug(' unit: $unitText, theme: $_currentThemeIndex', 'Main'); - - FlutterOverlayWindow.shareData(OverlayMessage.updateDisplay( - speedText: speedText, - unitText: unitText, - headingText: headingText, - heading: _currentGpsData.heading, - unitIndex: _currentUnit.index, - themeIndex: _currentThemeIndex, - ).toMap()); - } - - void _cycleUnit() { - HapticFeedback.lightImpact(); - setState(() { - _currentUnit = _currentUnit.next; - }); - // Push updated display data to overlay - _pushDataToOverlay(); - } - - void _cycleTheme() { - HapticFeedback.lightImpact(); - setState(() { - _currentThemeIndex = ColorThemes.getNextThemeIndex(_currentThemeIndex); - }); - // Push updated display data to overlay - _pushDataToOverlay(); - } - - Future _showFloatingWindow() async { - try { - // Use centralized sizing function - final overlaySize = _getOverlaySize(); - - // Send initial display data WITH size info (only needed on creation) - final speedText = GpsDataManager.instance.getFormattedSpeed(_currentUnit); - final headingText = _currentGpsData.displayHeading; - final unitText = _currentUnit.label.toString(); - - await FlutterOverlayWindow.shareData(OverlayMessage.updateDisplay( - speedText: speedText, - unitText: unitText, - headingText: headingText, - heading: _currentGpsData.heading, - unitIndex: _currentUnit.index, - themeIndex: _currentThemeIndex, - overlayWidth: overlaySize['width']!.toDouble(), - overlayHeight: overlaySize['height']!.toDouble(), - ).toMap()); - - // Show the overlay with proportional sizing based on system resolution - await FlutterOverlayWindow.showOverlay( - enableDrag: true, - overlayTitle: "Speedometer", - overlayContent: 'Speedo overlay active', - flag: OverlayFlag.defaultFlag, - visibility: NotificationVisibility.visibilityPublic, - positionGravity: PositionGravity.none, - // Use consistent sizing from centralized function - width: overlaySize['width']!, - height: overlaySize['height']!, - ); - - Logger.info('OVERLAY LAUNCHED - Size: ${overlaySize['width']}x${overlaySize['height']}', 'Main'); - - // Set overlay active immediately and synchronously - _isOverlayActive = true; - Logger.debug('_isOverlayActive set to: $_isOverlayActive', 'Main'); - - // Start listening to overlay messages after state is set - _startListeningToOverlayMessages(); - - // Start periodic overlay status check since message-based detection is unreliable - _startOverlayStatusCheck(); - - // Force UI update - if (mounted) setState(() {}); - } catch (e) { - // Silent error handling - floating window issues shouldn't crash main app - } - } - - Future _closeFloatingWindow() async { - if (!_isOverlayActive) { - Logger.warn('No overlay to close', 'Main'); - return; - } - - try { - await FlutterOverlayWindow.closeOverlay(); - Logger.info('OVERLAY CLOSED - Requested from main app', 'Main'); - _handleOverlayClose(); // This will set _isOverlayActive = false and stop listener - } catch (e) { - Logger.error('Error closing overlay: $e', 'Main'); - } - } - - String _getSpeedDisplayText(double displaySpeed) { - // TEMPORARILY disabled for indoor testing - show all speeds - // TODO: Re-enable: if (_currentGpsData.speed < 1.0 && (_currentGpsData.heading < 0.0 || _currentGpsData.heading >= 360.0)) return '--'; - return displaySpeed.toStringAsFixed(1); - } - - Widget _buildSpeedDisplay(String speedText, ColorTheme currentTheme, double fontSize, {bool isLandscape = false}) { - if (speedText == '--') { - return Text( - speedText, - style: TextStyle( - fontSize: fontSize, - fontWeight: FontWeight.w300, - color: currentTheme.speedText, - 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, - fontFamily: 'DIN1451Alt', - ), - ), - Text( - decimalPart, - style: TextStyle( - fontSize: fontSize * 0.5, // 60% of main font size - fontWeight: FontWeight.w300, - color: currentTheme.speedTextSub, - fontFamily: 'DIN1451Alt', - ), - ), - ], - ); - } - - @override - Widget build(BuildContext context) { - final displaySpeed = _currentUnit.convert(_currentGpsData.speed); - final speedText = _getSpeedDisplayText(displaySpeed); - final currentTheme = ColorThemes.getTheme(_currentThemeIndex); - - return Scaffold( - backgroundColor: currentTheme.background, - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - final isLandscape = constraints.maxWidth > constraints.maxHeight; - - if (isLandscape) { - return _buildLandscapeLayout(speedText, currentTheme); - } else { - return _buildPortraitLayout(speedText, currentTheme); - } - }, - ), - ), - ); - } - - Widget _buildPortraitLayout(String speedText, 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: GestureDetector( - onTap: _cycleTheme, // Tap speed to cycle theme - child: FittedBox( - fit: BoxFit.contain, - child: _buildSpeedDisplay(speedText, currentTheme, 200), - ), - ), - ), - Flexible( - flex: 1, - child: FittedBox( - fit: BoxFit.contain, - ), - ), - Flexible( - flex: 2, - child: GestureDetector( - onTap: _cycleUnit, // clicking on unit to cycle unit is more intuitive - child: FittedBox( - fit: BoxFit.contain, - child: Text( - _currentUnit.label, - 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(); - Logger.debug('Navigation area tapped - overlay active: $_isOverlayActive', 'Main'); - if (_isOverlayActive) { - _closeFloatingWindow(); - } else { - _showFloatingWindow(); - } - }, - onLongPress: () { - Logger.debug('Navigation area long-pressed - overlay active: $_isOverlayActive', 'Main'); - _closeFloatingWindow(); - }, - child: Container( - width: double.infinity, - color: Colors.transparent, - padding: const EdgeInsets.all(2), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - flex: 3, - child: Transform.rotate( - angle: (_currentGpsData.heading >= 0 && _currentGpsData.heading < 360) ? (_currentGpsData.heading * math.pi / 180.0) : 0, - child: FittedBox( - fit: BoxFit.contain, - child: Icon( - Icons.navigation, - size: 80, - color: currentTheme.headingText, - ), - ), - ), - ), - Flexible( - flex: 1, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - _currentGpsData.displayHeading, - style: TextStyle( - fontSize: 50, - color: currentTheme.headingText, - fontFamily: 'DIN1451Alt', - ), - ), - ), - ), - ], - ), - ), - ), - ), - ], - ); - } - - Widget _buildLandscapeLayout(String speedText, 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: GestureDetector( - onTap: _cycleTheme, - child: FittedBox( - fit: BoxFit.contain, - child: _buildSpeedDisplay(speedText, currentTheme, 160, isLandscape: true), - ), - ), - ), - Flexible( - flex: 7, - child: GestureDetector( - onTap: _cycleUnit, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - _currentUnit.label, - 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(); - Logger.debug('Navigation area tapped (landscape) - overlay active: $_isOverlayActive', 'Main'); - if (_isOverlayActive) { - _closeFloatingWindow(); - } else { - _showFloatingWindow(); - } - }, - onLongPress: () { - Logger.debug('Navigation area long-pressed (landscape) - overlay active: $_isOverlayActive', 'Main'); - _closeFloatingWindow(); - }, - child: Container( - height: double.infinity, - color: Colors.transparent, - padding: const EdgeInsets.all(2), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - flex: 3, - child: Transform.rotate( - angle: (_currentGpsData.heading >= 0 && _currentGpsData.heading < 360) ? (_currentGpsData.heading * math.pi / 180.0) : 0, - child: FittedBox( - fit: BoxFit.contain, - child: Icon( - Icons.navigation, - size: 60, - color: currentTheme.headingText, - ), - ), - ), - ), - Flexible( - flex: 1, - child: FittedBox( - fit: BoxFit.contain, - child: Text( - _currentGpsData.displayHeading, - style: TextStyle( - fontSize: 30, - color: currentTheme.headingText, - fontFamily: 'DIN1451Alt', - ), - ), - ), - ), - ], - ), - ), - ), - ), - Expanded( - flex: 1, - child: FittedBox( - fit: BoxFit.contain, - ), - ), - ], - ); - } -} - class OverlaySpeedometer extends StatefulWidget { const OverlaySpeedometer({super.key}); diff --git a/lib/screens/speedometer_screen.dart b/lib/screens/speedometer_screen.dart new file mode 100644 index 0000000..0ab73d7 --- /dev/null +++ b/lib/screens/speedometer_screen.dart @@ -0,0 +1,798 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; // HapticFeedback +import 'dart:async'; +import 'dart:math' as math; +import 'package:wakelock_plus/wakelock_plus.dart'; +import 'package:flutter_overlay_window/flutter_overlay_window.dart'; +import 'package:bg_launcher/bg_launcher.dart'; +import '../config/speed_unit.dart'; +import '../config/color_themes.dart'; +import '../config/timing_constants.dart'; +import '../config/overlay_constants.dart'; +import '../models/overlay_message.dart'; +import '../models/processed_gps_data.dart'; +import '../services/gps_data_manager.dart'; +import '../services/logger.dart'; + +class SpeedometerScreen extends StatefulWidget { + final bool isOverlayMode; + + const SpeedometerScreen({super.key, this.isOverlayMode = false}); + + @override + State createState() => _SpeedometerScreenState(); +} + +class _SpeedometerScreenState extends State with WidgetsBindingObserver { + SpeedUnit _currentUnit = SpeedUnit.kmh; + int _currentThemeIndex = 0; + StreamSubscription? _gpsDataSubscription; + StreamSubscription? _overlaySubscription; + ProcessedGpsData _currentGpsData = const ProcessedGpsData( + speed: 0.0, + heading: -1.0, + displaySpeed: '--', + displayHeading: 'N/A', + isSpeedValid: false, + isHeadingValid: false, + ); + String _errorMessage = ''; + bool _isInBackground = false; + bool _isOverlayActive = false; + bool _tapCloseRequested = false; + Timer? _tapCloseTimer; + + // Single source of truth for overlay sizing + Map _getOverlaySize() { + final window = WidgetsBinding.instance.platformDispatcher.views.first; + final physicalSize = window.physicalSize; + + // Use actual physical pixels for overlay sizing + final overlayWidth = (physicalSize.width * OverlayConfig.WIDTH_PERCENTAGE).round(); + final overlayHeight = (overlayWidth * OverlayConfig.ASPECT_RATIO).round(); + + return {'width': overlayWidth, 'height': overlayHeight}; + } + + Timer? _backgroundHeartbeatTimer; + Timer? _overlayStatusCheckTimer; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _initializeGpsManager(); + _enableWakelock(); + _ensureOverlayPermission(); + // Overlay listener will be created lazily when overlay is shown + _startBackgroundHeartbeat(); + } + + void _startListeningToOverlayMessages() { + // Only create listener when overlay is actually shown + if (_overlaySubscription != null) { + return; + } + _overlaySubscription = FlutterOverlayWindow.overlayListener.listen( + (data) { + try { + if (data is Map) { + final action = data['action']; + switch (action) { + case 'overlayClosed': + _handleOverlayClose(); + break; + case 'longPressClose': + _tapCloseRequested = true; // Reusing the flag for long press + // Set a timer to reset this flag if overlay doesn't close soon + _tapCloseTimer?.cancel(); + _tapCloseTimer = Timer(TimingConfig.TAP_CLOSE_DELAY, () { + _tapCloseRequested = false; + }); + break; + // 'bringToFront' action handler removed per NOTES.txt (tap to bring front functionality disabled) + default: + Logger.warn('Unknown overlay action: "$action"', 'Main'); + } + } + } catch (e, stackTrace) { + Logger.error('Error processing overlay message: $e', 'Main'); + Logger.error('Stack trace: $stackTrace', 'Main'); + } + }, + onError: (error) { + Logger.error('Overlay listener error: $error', 'Main'); + }, + ); + } + + void _stopListeningToOverlayMessages() { + _overlaySubscription?.cancel(); + _overlaySubscription = null; + } + + void _startOverlayStatusCheck() { + // Cancel any existing timer first + _overlayStatusCheckTimer?.cancel(); + + _overlayStatusCheckTimer = Timer.periodic(TimingConfig.OVERLAY_STATUS_CHECK_INTERVAL, (timer) async { + if (_isOverlayActive) { + try { + final isActive = await FlutterOverlayWindow.isActive(); + if (!isActive) { + timer.cancel(); // Cancel the timer before handling close + _handleOverlayClose(); + } + } catch (e) { + Logger.error('Overlay status check failed: $e', 'Main'); + } + } else { + // If overlay is not supposed to be active, cancel the timer + timer.cancel(); + } + }); + Logger.debug('Started overlay status monitoring', 'Main'); + } + + void _handleOverlayClose({bool bringToForeground = false}) { + // Check if this was a long-press close based on recent signal + final shouldBringToFront = bringToForeground || _tapCloseRequested; + + // Cancel timers and reset flag + _tapCloseTimer?.cancel(); + _overlayStatusCheckTimer?.cancel(); + _tapCloseRequested = false; + + setState(() { + _isOverlayActive = false; + }); + _stopListeningToOverlayMessages(); // Clean up listener when overlay closes + + // Bring app to foreground on long press close, otherwise quiet close + if (shouldBringToFront) { + _bringAppToFront(); + } + } + + + void _startBackgroundHeartbeat() { + // Aggressive heartbeat to keep main app process active for overlay communication + _backgroundHeartbeatTimer = Timer.periodic(TimingConfig.HEARTBEAT_INTERVAL, (timer) { + if (_isInBackground && _isOverlayActive) { + // Minimal activity to prevent hibernation + if (mounted) { + // Force data push to overlay to maintain communication + _pushDataToOverlay(); + // Small state update to keep Flutter engine active + setState(() { + // Tiny update that doesn't affect UI but keeps engine alive + _isInBackground = _isInBackground; + }); + } + } else if (_isInBackground) { + if (mounted) { + // Lighter heartbeat when no overlay is active + setState(() { + _isInBackground = _isInBackground; + }); + } + } + }); + } + + void _bringAppToFront() async { + try { + // Use proper Android method to bring app to foreground + BgLauncher.bringAppToForeground(); + + // Update app state to reflect foreground status + if (mounted) { + setState(() { + _isInBackground = false; + }); + } + } catch (e) { + Logger.error('BgLauncher failed to bring app to front: $e', 'Main'); + + // Enhanced fallback: try alternative approach + try { + if (mounted) { + setState(() { + _isInBackground = false; + }); + } + } catch (fallbackError) { + Logger.error('Fallback method also failed: $fallbackError', 'Main'); + } + } + } + + Future _enableWakelock() async { + try { + // Force enable wake lock even if already enabled for robustness + await WakelockPlus.enable(); + final isEnabled = await WakelockPlus.enabled; + + // Double-check wake lock status for debugging + if (!isEnabled) { + Logger.warn('Wake lock not properly enabled, retrying...', 'Main'); + await WakelockPlus.enable(); + } + } catch (e) { + Logger.error('Wake lock failed: $e', 'Main'); + // Wake lock not supported on this platform, continue normally + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _gpsDataSubscription?.cancel(); + _overlaySubscription?.cancel(); + _backgroundHeartbeatTimer?.cancel(); + _overlayStatusCheckTimer?.cancel(); + _tapCloseTimer?.cancel(); + WakelockPlus.disable(); + Logger.info('Main app disposed - all subscriptions and timers canceled', 'Main'); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + switch (state) { + case AppLifecycleState.paused: + // App went to background (home key pressed or task switch) + if (!_isInBackground) { + _isInBackground = true; + _handleBackgroundTransition(); + } + break; + case AppLifecycleState.resumed: + // App came back to foreground + _isInBackground = false; + break; + case AppLifecycleState.inactive: + case AppLifecycleState.detached: + case AppLifecycleState.hidden: + break; + } + } + + void _handleBackgroundTransition() { + // Enhanced background persistence - keep GPS stream active + _enableWakelock(); + _requestBatteryOptimizationExemption(); + + // If overlay is active, ensure more frequent data pushing + if (_isOverlayActive) { + _pushDataToOverlay(); + } + } + + Future _requestBatteryOptimizationExemption() async { + try { + // This would show Android's battery optimization whitelist dialog + // Note: This requires platform-specific implementation which we'd need to add via method channel + // For now, we'll rely on other persistence mechanisms + } catch (e) { + Logger.warn('Battery optimization exemption not available: $e', 'Main'); + } + } + + Future _ensureOverlayPermission() async { + try { + // Check if overlay permission is granted (required for bg_launcher to work properly) + final isGranted = await FlutterOverlayWindow.isPermissionGranted(); + + if (!isGranted) { + await FlutterOverlayWindow.requestPermission(); + } + } catch (e) { + Logger.error('Error checking overlay permission: $e', 'Main'); + } + } + + Future _initializeGpsManager() async { + Logger.info('Starting GPS manager initialization...', 'Main'); + try { + // Initialize the GPS manager + Logger.debug('Calling GpsDataManager.instance.initialize()...', 'Main'); + await GpsDataManager.instance.initialize(); + Logger.info('GPS manager initialization completed', 'Main'); + + // Subscribe to processed GPS data + Logger.debug('Setting up data stream subscription...', 'Main'); + _gpsDataSubscription = GpsDataManager.instance.dataStream.listen( + _onGpsDataUpdate, + onError: (error) { + Logger.error('GPS data stream error: $error', 'Main'); + setState(() => _errorMessage = 'GPS manager error: $error'); + }, + ); + Logger.info('GPS data stream subscription active', 'Main'); + + Logger.info('GPS manager initialized and subscribed', 'Main'); + } catch (e) { + Logger.error('GPS manager initialization exception: $e', 'Main'); + setState(() => _errorMessage = 'Failed to initialize GPS manager'); + Logger.error('GPS manager initialization failed: $e', 'Main'); + } + } + + void _onGpsDataUpdate(ProcessedGpsData gpsData) { + Logger.debug('Received GPS data: ${gpsData.speed.toStringAsFixed(1)} m/s, ${gpsData.displayHeading}', 'Main'); + + setState(() { + _currentGpsData = gpsData; + _errorMessage = ''; + }); + + Logger.debug('State updated with GPS data', 'Main'); + final convertedSpeed = _currentUnit.convert(gpsData.speed); + Logger.debug('Converted for display: ${convertedSpeed.toStringAsFixed(1)} ${_currentUnit.label}, ${gpsData.displayHeading}', 'Main'); + + // Always push display data to overlay if active - critical for background communication + if (_isOverlayActive) { + _pushDataToOverlay(); + Logger.debug('Data pushed to overlay from GPS update', 'Main'); + } + } + + + void _pushDataToOverlay() { + // Only push data if overlay is actually active (listener check was causing race conditions) + if (!_isOverlayActive) { + Logger.debug('Skipping data push - no active overlay (active: $_isOverlayActive, subscription: ${_overlaySubscription != null})', 'Main'); + return; + } + + Logger.debug('Overlay guard passed - pushing data (active: $_isOverlayActive, subscription: ${_overlaySubscription != null})', 'Main'); + + // Get formatted speed for current unit from GPS manager + final speedText = GpsDataManager.instance.getFormattedSpeed(_currentUnit); + final unitText = _currentUnit.label.toString(); + final headingText = _currentGpsData.displayHeading; + + Logger.debug('SENDING to overlay:', 'Main'); + Logger.debug(' speedText: "$speedText"', 'Main'); + Logger.debug(' headingText: "$headingText"', 'Main'); + Logger.debug(' direction: ${_currentGpsData.heading.toStringAsFixed(1)}°', 'Main'); + Logger.debug(' unit: $unitText, theme: $_currentThemeIndex', 'Main'); + + FlutterOverlayWindow.shareData(OverlayMessage.updateDisplay( + speedText: speedText, + unitText: unitText, + headingText: headingText, + heading: _currentGpsData.heading, + unitIndex: _currentUnit.index, + themeIndex: _currentThemeIndex, + ).toMap()); + } + + void _cycleUnit() { + HapticFeedback.lightImpact(); + setState(() { + _currentUnit = _currentUnit.next; + }); + // Push updated display data to overlay + _pushDataToOverlay(); + } + + void _cycleTheme() { + HapticFeedback.lightImpact(); + setState(() { + _currentThemeIndex = ColorThemes.getNextThemeIndex(_currentThemeIndex); + }); + // Push updated display data to overlay + _pushDataToOverlay(); + } + + Future _showFloatingWindow() async { + try { + // Use centralized sizing function + final overlaySize = _getOverlaySize(); + + // Send initial display data WITH size info (only needed on creation) + final speedText = GpsDataManager.instance.getFormattedSpeed(_currentUnit); + final headingText = _currentGpsData.displayHeading; + final unitText = _currentUnit.label.toString(); + + await FlutterOverlayWindow.shareData(OverlayMessage.updateDisplay( + speedText: speedText, + unitText: unitText, + headingText: headingText, + heading: _currentGpsData.heading, + unitIndex: _currentUnit.index, + themeIndex: _currentThemeIndex, + overlayWidth: overlaySize['width']!.toDouble(), + overlayHeight: overlaySize['height']!.toDouble(), + ).toMap()); + + // Show the overlay with proportional sizing based on system resolution + await FlutterOverlayWindow.showOverlay( + enableDrag: true, + overlayTitle: "Speedometer", + overlayContent: 'Speedo overlay active', + flag: OverlayFlag.defaultFlag, + visibility: NotificationVisibility.visibilityPublic, + positionGravity: PositionGravity.none, + // Use consistent sizing from centralized function + width: overlaySize['width']!, + height: overlaySize['height']!, + ); + + Logger.info('OVERLAY LAUNCHED - Size: ${overlaySize['width']}x${overlaySize['height']}', 'Main'); + + // Set overlay active immediately and synchronously + _isOverlayActive = true; + Logger.debug('_isOverlayActive set to: $_isOverlayActive', 'Main'); + + // Start listening to overlay messages after state is set + _startListeningToOverlayMessages(); + + // Start periodic overlay status check since message-based detection is unreliable + _startOverlayStatusCheck(); + + // Force UI update + if (mounted) setState(() {}); + } catch (e) { + // Silent error handling - floating window issues shouldn't crash main app + } + } + + Future _closeFloatingWindow() async { + if (!_isOverlayActive) { + Logger.warn('No overlay to close', 'Main'); + return; + } + + try { + await FlutterOverlayWindow.closeOverlay(); + Logger.info('OVERLAY CLOSED - Requested from main app', 'Main'); + _handleOverlayClose(); // This will set _isOverlayActive = false and stop listener + } catch (e) { + Logger.error('Error closing overlay: $e', 'Main'); + } + } + + String _getSpeedDisplayText(double displaySpeed) { + // TEMPORARILY disabled for indoor testing - show all speeds + // TODO: Re-enable: if (_currentGpsData.speed < 1.0 && (_currentGpsData.heading < 0.0 || _currentGpsData.heading >= 360.0)) return '--'; + return displaySpeed.toStringAsFixed(1); + } + + Widget _buildSpeedDisplay(String speedText, ColorTheme currentTheme, double fontSize, {bool isLandscape = false}) { + if (speedText == '--') { + return Text( + speedText, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w300, + color: currentTheme.speedText, + 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, + fontFamily: 'DIN1451Alt', + ), + ), + Text( + decimalPart, + style: TextStyle( + fontSize: fontSize * 0.5, // 60% of main font size + fontWeight: FontWeight.w300, + color: currentTheme.speedTextSub, + fontFamily: 'DIN1451Alt', + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final displaySpeed = _currentUnit.convert(_currentGpsData.speed); + final speedText = _getSpeedDisplayText(displaySpeed); + final currentTheme = ColorThemes.getTheme(_currentThemeIndex); + + return Scaffold( + backgroundColor: currentTheme.background, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final isLandscape = constraints.maxWidth > constraints.maxHeight; + + if (isLandscape) { + return _buildLandscapeLayout(speedText, currentTheme); + } else { + return _buildPortraitLayout(speedText, currentTheme); + } + }, + ), + ), + ); + } + + Widget _buildPortraitLayout(String speedText, 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: GestureDetector( + onTap: _cycleTheme, // Tap speed to cycle theme + child: FittedBox( + fit: BoxFit.contain, + child: _buildSpeedDisplay(speedText, currentTheme, 200), + ), + ), + ), + Flexible( + flex: 1, + child: FittedBox( + fit: BoxFit.contain, + ), + ), + Flexible( + flex: 2, + child: GestureDetector( + onTap: _cycleUnit, // clicking on unit to cycle unit is more intuitive + child: FittedBox( + fit: BoxFit.contain, + child: Text( + _currentUnit.label, + 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(); + Logger.debug('Navigation area tapped - overlay active: $_isOverlayActive', 'Main'); + if (_isOverlayActive) { + _closeFloatingWindow(); + } else { + _showFloatingWindow(); + } + }, + onLongPress: () { + Logger.debug('Navigation area long-pressed - overlay active: $_isOverlayActive', 'Main'); + _closeFloatingWindow(); + }, + child: Container( + width: double.infinity, + color: Colors.transparent, + padding: const EdgeInsets.all(2), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + flex: 3, + child: Transform.rotate( + angle: (_currentGpsData.heading >= 0 && _currentGpsData.heading < 360) ? (_currentGpsData.heading * math.pi / 180.0) : 0, + child: FittedBox( + fit: BoxFit.contain, + child: Icon( + Icons.navigation, + size: 80, + color: currentTheme.headingText, + ), + ), + ), + ), + Flexible( + flex: 1, + child: FittedBox( + fit: BoxFit.contain, + child: Text( + _currentGpsData.displayHeading, + style: TextStyle( + fontSize: 50, + color: currentTheme.headingText, + fontFamily: 'DIN1451Alt', + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ); + } + + Widget _buildLandscapeLayout(String speedText, 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: GestureDetector( + onTap: _cycleTheme, + child: FittedBox( + fit: BoxFit.contain, + child: _buildSpeedDisplay(speedText, currentTheme, 160, isLandscape: true), + ), + ), + ), + Flexible( + flex: 7, + child: GestureDetector( + onTap: _cycleUnit, + child: FittedBox( + fit: BoxFit.contain, + child: Text( + _currentUnit.label, + 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(); + Logger.debug('Navigation area tapped (landscape) - overlay active: $_isOverlayActive', 'Main'); + if (_isOverlayActive) { + _closeFloatingWindow(); + } else { + _showFloatingWindow(); + } + }, + onLongPress: () { + Logger.debug('Navigation area long-pressed (landscape) - overlay active: $_isOverlayActive', 'Main'); + _closeFloatingWindow(); + }, + child: Container( + height: double.infinity, + color: Colors.transparent, + padding: const EdgeInsets.all(2), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + flex: 3, + child: Transform.rotate( + angle: (_currentGpsData.heading >= 0 && _currentGpsData.heading < 360) ? (_currentGpsData.heading * math.pi / 180.0) : 0, + child: FittedBox( + fit: BoxFit.contain, + child: Icon( + Icons.navigation, + size: 60, + color: currentTheme.headingText, + ), + ), + ), + ), + Flexible( + flex: 1, + child: FittedBox( + fit: BoxFit.contain, + child: Text( + _currentGpsData.displayHeading, + style: TextStyle( + fontSize: 30, + color: currentTheme.headingText, + fontFamily: 'DIN1451Alt', + ), + ), + ), + ), + ], + ), + ), + ), + ), + Expanded( + flex: 1, + child: FittedBox( + fit: BoxFit.contain, + ), + ), + ], + ); + } +}