From cc11a3d31abd2fa15fd1e86ac6288d33d8ac22e4 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Wed, 11 Feb 2026 19:24:05 +0900 Subject: [PATCH] feat(03-02): add staleness detection to overlay screen and error surfacing to SpeedometerScreen Overlay screen staleness: - Add _lastUpdateTime field to track GPS data freshness - Add _stalenessCheckTimer for periodic staleness monitoring (1s interval) - Update _lastUpdateTime from message.timestamp in _listenToMainAppMessages - Start staleness monitoring in initState, cancel in dispose - Calculate staleness opacity in build() (0.5 alpha after 3s) - Apply staleness dimming to speed text, unit text, heading icon, and heading text - Show "--" for speed after 10s of no data (setState in timer callback) - Immediate snap-back when fresh data arrives (no AnimatedOpacity) SpeedometerScreen error surfacing: - Import permission_handler for openAppSettings - Register error callback on OverlayProvider in initState postFrameCallback - Implement _handleOverlayError to show snackbars for overlay errors - Permission error: snackbar with "Settings" action button that opens app settings - Creation failure: snackbar with error message (no action button) - Communication degraded: snackbar with error message (no action button) - Clean up error callback in dispose (try-catch for disposed provider) Co-Authored-By: Claude Opus 4.6 --- lib/screens/overlay_screen.dart | 32 ++++++++++++++++++++--- lib/screens/speedometer_screen.dart | 40 ++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/lib/screens/overlay_screen.dart b/lib/screens/overlay_screen.dart index 6d62c66..269d4b7 100644 --- a/lib/screens/overlay_screen.dart +++ b/lib/screens/overlay_screen.dart @@ -1,4 +1,5 @@ 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'; @@ -22,6 +23,8 @@ class _OverlaySpeedometerState extends State { double _systemScreenWidth = 400; double _overlayWidth = 280; double _overlayHeight = 140; + DateTime _lastUpdateTime = DateTime.now(); + Timer? _stalenessCheckTimer; @override void initState() { @@ -29,10 +32,12 @@ class _OverlaySpeedometerState extends State { 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(); } @@ -75,6 +80,7 @@ class _OverlaySpeedometerState extends State { _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!; @@ -96,12 +102,30 @@ class _OverlaySpeedometerState extends State { }); } + 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); @@ -146,7 +170,7 @@ class _OverlaySpeedometerState extends State { style: TextStyle( fontSize: fontSize, fontWeight: FontWeight.w300, - color: currentTheme.speedText, + color: currentTheme.speedText.withValues(alpha: staleOpacity), fontFamily: 'DIN1451Alt', ), ), @@ -161,7 +185,7 @@ class _OverlaySpeedometerState extends State { style: TextStyle( fontSize: fontSize * OverlayConfig.UNIT_FONT_RATIO, fontWeight: FontWeight.w300, - color: currentTheme.unitText, + color: currentTheme.unitText.withValues(alpha: staleOpacity), fontFamily: 'DIN1451Alt', ), ), @@ -189,7 +213,7 @@ class _OverlaySpeedometerState extends State { child: Icon( Icons.navigation, size: fontSize * OverlayConfig.ICON_SIZE_RATIO, - color: currentTheme.headingText, + color: currentTheme.headingText.withValues(alpha: staleOpacity), ), ), ), @@ -202,7 +226,7 @@ class _OverlaySpeedometerState extends State { _headingText, style: TextStyle( fontSize: fontSize * OverlayConfig.HEADING_FONT_RATIO, - color: currentTheme.headingText, + color: currentTheme.headingText.withValues(alpha: staleOpacity), fontFamily: 'DIN1451Alt', ), ), diff --git a/lib/screens/speedometer_screen.dart b/lib/screens/speedometer_screen.dart index cb0c036..ab243ae 100644 --- a/lib/screens/speedometer_screen.dart +++ b/lib/screens/speedometer_screen.dart @@ -1,6 +1,7 @@ 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 'package:wakelock_plus/wakelock_plus.dart'; @@ -28,9 +29,10 @@ class _SpeedometerScreenState extends State with WidgetsBindi void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - // Delay GPS init to after first frame so context is available + // Delay GPS init and error callback setup to after first frame so context is available WidgetsBinding.instance.addPostFrameCallback((_) { _initializeGpsManager(); + context.read().onError = _handleOverlayError; }); _enableWakelock(); _ensureOverlayPermission(); @@ -42,6 +44,11 @@ class _SpeedometerScreenState extends State with WidgetsBindi WidgetsBinding.instance.removeObserver(this); _backgroundHeartbeatTimer?.cancel(); WakelockPlus.disable(); + try { + context.read().onError = null; + } catch (e) { + // Provider might already be disposed + } Logger.info('SpeedometerScreen disposed', 'Main'); super.dispose(); } @@ -59,6 +66,37 @@ class _SpeedometerScreenState extends State with WidgetsBindi } } + 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 _enableWakelock() async { try { await WakelockPlus.enable();