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:
@@ -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',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user