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 <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2026-02-11 19:24:05 +09:00
parent 66bdd35995
commit cc11a3d31a
2 changed files with 67 additions and 5 deletions
+28 -4
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter_overlay_window/flutter_overlay_window.dart'; import 'package:flutter_overlay_window/flutter_overlay_window.dart';
import '../config/color_themes.dart'; import '../config/color_themes.dart';
@@ -22,6 +23,8 @@ class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
double _systemScreenWidth = 400; double _systemScreenWidth = 400;
double _overlayWidth = 280; double _overlayWidth = 280;
double _overlayHeight = 140; double _overlayHeight = 140;
DateTime _lastUpdateTime = DateTime.now();
Timer? _stalenessCheckTimer;
@override @override
void initState() { void initState() {
@@ -29,10 +32,12 @@ class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
Logger.info('CREATED - Floating window initialized (${DateTime.now().millisecondsSinceEpoch})', 'Overlay'); Logger.info('CREATED - Floating window initialized (${DateTime.now().millisecondsSinceEpoch})', 'Overlay');
_getSystemScreenSize(); _getSystemScreenSize();
_listenToMainAppMessages(); _listenToMainAppMessages();
_startStalenessMonitoring();
} }
@override @override
void dispose() { void dispose() {
_stalenessCheckTimer?.cancel();
Logger.info('DISPOSING - Overlay widget disposed (${DateTime.now().millisecondsSinceEpoch})', 'Overlay'); Logger.info('DISPOSING - Overlay widget disposed (${DateTime.now().millisecondsSinceEpoch})', 'Overlay');
super.dispose(); super.dispose();
} }
@@ -75,6 +80,7 @@ class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
_headingText = message.headingText ?? 'N/A'; _headingText = message.headingText ?? 'N/A';
_heading = message.heading ?? -1.0; _heading = message.heading ?? -1.0;
_currentThemeIndex = message.themeIndex ?? 0; _currentThemeIndex = message.themeIndex ?? 0;
_lastUpdateTime = message.timestamp;
// Update overlay dimensions if provided by main app // Update overlay dimensions if provided by main app
if (message.overlayWidth != null) { if (message.overlayWidth != null) {
_overlayWidth = message.overlayWidth!; _overlayWidth = message.overlayWidth!;
@@ -96,12 +102,30 @@ class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
}); });
} }
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final buildTimestamp = DateTime.now().millisecondsSinceEpoch; final buildTimestamp = DateTime.now().millisecondsSinceEpoch;
final currentTheme = ColorThemes.getTheme(_currentThemeIndex); 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) // Use overlay dimensions provided by main app (not calculated locally)
final fontSize = (_overlayWidth * OverlayConfig.FONT_SIZE_RATIO); final fontSize = (_overlayWidth * OverlayConfig.FONT_SIZE_RATIO);
@@ -146,7 +170,7 @@ class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
style: TextStyle( style: TextStyle(
fontSize: fontSize, fontSize: fontSize,
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
color: currentTheme.speedText, color: currentTheme.speedText.withValues(alpha: staleOpacity),
fontFamily: 'DIN1451Alt', fontFamily: 'DIN1451Alt',
), ),
), ),
@@ -161,7 +185,7 @@ class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
style: TextStyle( style: TextStyle(
fontSize: fontSize * OverlayConfig.UNIT_FONT_RATIO, fontSize: fontSize * OverlayConfig.UNIT_FONT_RATIO,
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
color: currentTheme.unitText, color: currentTheme.unitText.withValues(alpha: staleOpacity),
fontFamily: 'DIN1451Alt', fontFamily: 'DIN1451Alt',
), ),
), ),
@@ -189,7 +213,7 @@ class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
child: Icon( child: Icon(
Icons.navigation, Icons.navigation,
size: fontSize * OverlayConfig.ICON_SIZE_RATIO, size: fontSize * OverlayConfig.ICON_SIZE_RATIO,
color: currentTheme.headingText, color: currentTheme.headingText.withValues(alpha: staleOpacity),
), ),
), ),
), ),
@@ -202,7 +226,7 @@ class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
_headingText, _headingText,
style: TextStyle( style: TextStyle(
fontSize: fontSize * OverlayConfig.HEADING_FONT_RATIO, fontSize: fontSize * OverlayConfig.HEADING_FONT_RATIO,
color: currentTheme.headingText, color: currentTheme.headingText.withValues(alpha: staleOpacity),
fontFamily: 'DIN1451Alt', fontFamily: 'DIN1451Alt',
), ),
), ),
+39 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
@@ -28,9 +29,10 @@ class _SpeedometerScreenState extends State<SpeedometerScreen> with WidgetsBindi
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); 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((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeGpsManager(); _initializeGpsManager();
context.read<OverlayProvider>().onError = _handleOverlayError;
}); });
_enableWakelock(); _enableWakelock();
_ensureOverlayPermission(); _ensureOverlayPermission();
@@ -42,6 +44,11 @@ class _SpeedometerScreenState extends State<SpeedometerScreen> with WidgetsBindi
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_backgroundHeartbeatTimer?.cancel(); _backgroundHeartbeatTimer?.cancel();
WakelockPlus.disable(); WakelockPlus.disable();
try {
context.read<OverlayProvider>().onError = null;
} catch (e) {
// Provider might already be disposed
}
Logger.info('SpeedometerScreen disposed', 'Main'); Logger.info('SpeedometerScreen disposed', 'Main');
super.dispose(); super.dispose();
} }
@@ -59,6 +66,37 @@ class _SpeedometerScreenState extends State<SpeedometerScreen> 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<void> _enableWakelock() async { Future<void> _enableWakelock() async {
try { try {
await WakelockPlus.enable(); await WakelockPlus.enable();