Complete working floating window implementation

Major improvements to floating window overlay:

Architecture:
- Clean client-server model: main app as GPS backend, overlay as display client
- Single GPS stream eliminates resource conflicts and sync issues
- Real-time data push: speed, heading, theme, and unit changes sync instantly

Functionality:
- Proper system screen resolution detection and size synchronization
- Main app calculates overlay size, shares exact dimensions with overlay
- Navigation area tap toggles overlay (show/close), long-press always closes
- Comprehensive debug metrics for troubleshooting

Technical fixes:
- Eliminated duplicate GPS streams for better battery life
- Fixed size calculation mismatches between main app and overlay
- Proper overlay state tracking with _isOverlayActive boolean
- Reverted to withOpacity() per NOTES.txt (simpler than Color.fromARGB)

Debug metrics include:
- Creation/destruction signals with 🟢/🔴 indicators
- Real-time data updates with 📡/📤 send/receive tracking
- Window sizing info with 📐 dimensions and font calculations
- User interaction feedback with 🖱️ tap/long-press detection

This implementation provides reliable floating window functionality
with proper data synchronization and efficient resource usage.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2025-08-26 23:36:16 +09:00
parent 9cd497f4f5
commit 7ae660156e
+194 -106
View File
@@ -15,6 +15,7 @@ void main() {
// Entry point for overlay window // Entry point for overlay window
@pragma("vm:entry-point") @pragma("vm:entry-point")
void overlayMain() { void overlayMain() {
print('[Overlay] 🚀 overlayMain() called');
runApp(const MaterialApp( runApp(const MaterialApp(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
home: OverlaySpeedometer(), home: OverlaySpeedometer(),
@@ -58,6 +59,7 @@ class _SpeedometerScreenState extends State<SpeedometerScreen> with WidgetsBindi
StreamSubscription<Position>? _positionSubscription; StreamSubscription<Position>? _positionSubscription;
String _errorMessage = ''; String _errorMessage = '';
bool _isInBackground = false; bool _isInBackground = false;
bool _isOverlayActive = false;
@override @override
void initState() { void initState() {
@@ -149,49 +151,93 @@ class _SpeedometerScreenState extends State<SpeedometerScreen> with WidgetsBindi
_heading = position.heading; _heading = position.heading;
_errorMessage = ''; _errorMessage = '';
}); });
// Push display data to overlay if active
_pushDataToOverlay();
}
void _pushDataToOverlay() {
// Send complete display data to overlay including screen size
final displaySpeed = _currentUnit.convert(_speed);
final speedText = displaySpeed < 1.0 ? '--' : displaySpeed.toInt().toString();
final headingText = GpsService.formatHeading(_heading);
// Get system screen size to share with overlay
final window = WidgetsBinding.instance.platformDispatcher.views.first;
final systemSize = window.physicalSize / window.devicePixelRatio;
print('[Main] 📤 SENDING to overlay:');
print(' speedText: "$speedText"');
print(' headingText: "$headingText"');
print(' direction: ${_heading.toStringAsFixed(1)}°');
print(' unit: ${_currentUnit.label}, theme: $_currentThemeIndex');
print(' systemScreen: ${systemSize.width.round()}x${systemSize.height.round()}');
// Calculate overlay size the same way as when launching
final overlayWidth = (systemSize.width * 0.4).round();
final overlayHeight = (overlayWidth * 0.5).round();
FlutterOverlayWindow.shareData({
'action': 'updateDisplay',
'speedText': speedText,
'headingText': headingText,
'heading': _heading,
'unitIndex': _currentUnit.index,
'themeIndex': _currentThemeIndex,
'systemScreenWidth': systemSize.width,
'systemScreenHeight': systemSize.height,
'overlayWidth': overlayWidth.clamp(280, 480).toDouble(),
'overlayHeight': overlayHeight.clamp(140, 240).toDouble(),
});
} }
void _cycleUnit() { void _cycleUnit() {
setState(() { setState(() {
_currentUnit = _currentUnit.next; _currentUnit = _currentUnit.next;
}); });
// Share unit change with overlay if active // Push updated display data to overlay
FlutterOverlayWindow.shareData({ _pushDataToOverlay();
'action': 'updateSettings',
'unitIndex': _currentUnit.index,
'themeIndex': _currentThemeIndex,
});
} }
void _cycleTheme() { void _cycleTheme() {
setState(() { setState(() {
_currentThemeIndex = ColorThemes.getNextThemeIndex(_currentThemeIndex); _currentThemeIndex = ColorThemes.getNextThemeIndex(_currentThemeIndex);
}); });
// Share theme change with overlay if active // Push updated display data to overlay
FlutterOverlayWindow.shareData({ _pushDataToOverlay();
'action': 'updateSettings',
'themeIndex': _currentThemeIndex,
'unitIndex': _currentUnit.index,
});
} }
Future<void> _showFloatingWindow() async { Future<void> _showFloatingWindow() async {
try { try {
// Get screen dimensions for proportional sizing // Get actual system screen resolution for proportional sizing
final screenSize = MediaQuery.of(context).size; final window = WidgetsBinding.instance.platformDispatcher.views.first;
final overlayWidth = (screenSize.width * 0.4).round(); // 40% of screen width final systemSize = window.physicalSize / window.devicePixelRatio;
final overlayWidth = (systemSize.width * 0.4).round(); // 40% of system screen width
final overlayHeight = (overlayWidth * 0.5).round(); // 2:1 aspect ratio final overlayHeight = (overlayWidth * 0.5).round(); // 2:1 aspect ratio
// Share current unit and theme with overlay // Debug info for development
// print('[Main] System resolution: ${systemSize.width.round()}x${systemSize.height.round()}');
// print('[Main] Calculated overlay size: ${overlayWidth}x${overlayHeight}');
// Send initial display data to overlay including screen size
final displaySpeed = _currentUnit.convert(_speed);
final speedText = displaySpeed < 1.0 ? '--' : displaySpeed.toInt().toString();
final headingText = GpsService.formatHeading(_heading);
await FlutterOverlayWindow.shareData({ await FlutterOverlayWindow.shareData({
'action': 'updateSettings', 'action': 'updateDisplay',
'speedText': speedText,
'headingText': headingText,
'heading': _heading,
'unitIndex': _currentUnit.index, 'unitIndex': _currentUnit.index,
'themeIndex': _currentThemeIndex, 'themeIndex': _currentThemeIndex,
'screenWidth': screenSize.width, 'systemScreenWidth': systemSize.width,
'screenHeight': screenSize.height, 'systemScreenHeight': systemSize.height,
'overlayWidth': overlayWidth.clamp(280, 480).toDouble(),
'overlayHeight': overlayHeight.clamp(140, 240).toDouble(),
}); });
// Show the overlay with hybrid sizing - proportional but with reasonable limits // Show the overlay with proportional sizing based on system resolution
await FlutterOverlayWindow.showOverlay( await FlutterOverlayWindow.showOverlay(
enableDrag: true, enableDrag: true,
overlayTitle: "GPS Speedometer", overlayTitle: "GPS Speedometer",
@@ -199,20 +245,34 @@ class _SpeedometerScreenState extends State<SpeedometerScreen> with WidgetsBindi
flag: OverlayFlag.defaultFlag, flag: OverlayFlag.defaultFlag,
visibility: NotificationVisibility.visibilityPublic, visibility: NotificationVisibility.visibilityPublic,
positionGravity: PositionGravity.none, positionGravity: PositionGravity.none,
// Use proportional sizing with minimum reasonable dimensions // Use system-based proportional sizing with reasonable limits
width: overlayWidth.clamp(280, 400), width: overlayWidth.clamp(280, 480),
height: overlayHeight.clamp(140, 300), height: overlayHeight.clamp(140, 240),
); );
print('[Main] 🟢 OVERLAY LAUNCHED - Size: ${overlayWidth.clamp(280, 480)}x${overlayHeight.clamp(140, 240)}');
setState(() {
_isOverlayActive = true;
});
} catch (e) { } catch (e) {
// Silent error handling - floating window issues shouldn't crash main app // Silent error handling - floating window issues shouldn't crash main app
} }
} }
Future<void> _closeFloatingWindow() async { Future<void> _closeFloatingWindow() async {
if (!_isOverlayActive) {
print('[Main] ⚠️ No overlay to close');
return;
}
try { try {
await FlutterOverlayWindow.closeOverlay(); await FlutterOverlayWindow.closeOverlay();
print('[Main] 🔴 OVERLAY CLOSED - Requested from main app');
setState(() {
_isOverlayActive = false;
});
} catch (e) { } catch (e) {
// Silent error handling - floating window issues shouldn't crash main app print('[Main] ❌ Error closing overlay: $e');
} }
} }
@@ -354,8 +414,18 @@ class _SpeedometerScreenState extends State<SpeedometerScreen> with WidgetsBindi
Expanded( Expanded(
flex: 32, flex: 32,
child: GestureDetector( child: GestureDetector(
onTap: _showFloatingWindow, // Tap navigation icon to show floating window onTap: () {
onLongPress: _closeFloatingWindow, // Long press to close floating window print('[Main] 🖱️ Navigation area tapped - overlay active: $_isOverlayActive');
if (_isOverlayActive) {
_closeFloatingWindow();
} else {
_showFloatingWindow();
}
},
onLongPress: () {
print('[Main] 🖱️ Navigation area long-pressed - overlay active: $_isOverlayActive');
_closeFloatingWindow();
},
child: Container( child: Container(
width: double.infinity, width: double.infinity,
color: Colors.transparent, color: Colors.transparent,
@@ -427,7 +497,11 @@ class _SpeedometerScreenState extends State<SpeedometerScreen> with WidgetsBindi
) )
else ...[ else ...[
Flexible( Flexible(
flex: 4, flex: 1,
child: FittedBox(),
),
Flexible(
flex: 20,
child: GestureDetector( child: GestureDetector(
onTap: _cycleTheme, onTap: _cycleTheme,
child: FittedBox( child: FittedBox(
@@ -437,7 +511,7 @@ class _SpeedometerScreenState extends State<SpeedometerScreen> with WidgetsBindi
), ),
), ),
Flexible( Flexible(
flex: 1, flex: 7,
child: GestureDetector( child: GestureDetector(
onTap: _cycleUnit, onTap: _cycleUnit,
child: FittedBox( child: FittedBox(
@@ -453,6 +527,10 @@ class _SpeedometerScreenState extends State<SpeedometerScreen> with WidgetsBindi
), ),
), ),
), ),
Flexible(
flex: 5,
child: FittedBox(),
),
], ],
], ],
), ),
@@ -462,8 +540,18 @@ class _SpeedometerScreenState extends State<SpeedometerScreen> with WidgetsBindi
Expanded( Expanded(
flex: 33, flex: 33,
child: GestureDetector( child: GestureDetector(
onTap: _showFloatingWindow, onTap: () {
onLongPress: _closeFloatingWindow, // Long press to close floating window print('[Main] 🖱️ Navigation area tapped (landscape) - overlay active: $_isOverlayActive');
if (_isOverlayActive) {
_closeFloatingWindow();
} else {
_showFloatingWindow();
}
},
onLongPress: () {
print('[Main] 🖱️ Navigation area long-pressed (landscape) - overlay active: $_isOverlayActive');
_closeFloatingWindow();
},
child: Container( child: Container(
height: double.infinity, height: double.infinity,
color: Colors.transparent, color: Colors.transparent,
@@ -517,83 +605,95 @@ class OverlaySpeedometer extends StatefulWidget {
} }
class _OverlaySpeedometerState extends State<OverlaySpeedometer> { class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
double _speed = 0.0; String _speedText = '--';
String _headingText = 'N/A';
double _heading = -1.0; double _heading = -1.0;
SpeedUnit _currentUnit = SpeedUnit.kmh;
int _currentThemeIndex = 0; int _currentThemeIndex = 0;
StreamSubscription<Position>? _positionSubscription; double _systemScreenWidth = 400;
double _screenWidth = 400; double _overlayWidth = 280;
double _overlayHeight = 140;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
print('[Overlay] 🟢 CREATED - Floating window initialized');
_getSystemScreenSize();
_listenToMainAppMessages(); _listenToMainAppMessages();
_initializeGps(); }
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;
print('[Overlay] 🔍 Screen detection:');
print(' physicalSize: ${physicalSize.width.round()}x${physicalSize.height.round()}');
print(' devicePixelRatio: $devicePixelRatio');
print(' calculated systemSize: ${systemSize.width.round()}x${systemSize.height.round()}');
setState(() {
_systemScreenWidth = systemSize.width;
});
print('[Overlay] ✅ _systemScreenWidth set to: ${_systemScreenWidth.round()}');
} catch (e) {
print('[Overlay] ❌ Error getting system screen size: $e');
// Fallback to reasonable default
setState(() {
_systemScreenWidth = 400;
});
}
} }
void _listenToMainAppMessages() { void _listenToMainAppMessages() {
FlutterOverlayWindow.overlayListener.listen((data) { FlutterOverlayWindow.overlayListener.listen((data) {
if (data is Map) { if (data is Map && data['action'] == 'updateDisplay') {
if (data.containsKey('unitIndex')) { setState(() {
setState(() { _speedText = data['speedText'] ?? '--';
_currentUnit = SpeedUnit.values[data['unitIndex'] ?? 0]; _headingText = data['headingText'] ?? 'N/A';
}); _heading = data['heading']?.toDouble() ?? -1.0;
} _currentThemeIndex = data['themeIndex'] ?? 0;
if (data.containsKey('themeIndex')) { // Update screen size and overlay dimensions if provided by main app
setState(() { if (data.containsKey('systemScreenWidth')) {
_currentThemeIndex = data['themeIndex'] ?? 0; _systemScreenWidth = data['systemScreenWidth']?.toDouble() ?? _systemScreenWidth;
}); }
} if (data.containsKey('overlayWidth')) {
if (data.containsKey('screenWidth')) { _overlayWidth = data['overlayWidth']?.toDouble() ?? _overlayWidth;
setState(() { }
_screenWidth = data['screenWidth']?.toDouble() ?? 400; if (data.containsKey('overlayHeight')) {
}); _overlayHeight = data['overlayHeight']?.toDouble() ?? _overlayHeight;
} }
});
print('[Overlay] 📡 UPDATE received:');
print(' speedText: "$_speedText"');
print(' headingText: "$_headingText"');
print(' direction: ${_heading.toStringAsFixed(1)}°');
print(' themeIndex: $_currentThemeIndex');
print(' systemScreenWidth: ${_systemScreenWidth.round()}');
print(' overlaySize from main: ${_overlayWidth.round()}x${_overlayHeight.round()}');
} }
}); });
} }
Future<void> _initializeGps() async {
try {
final serviceEnabled = await GpsService.isLocationServiceEnabled();
if (!serviceEnabled) return;
final permissionGranted = await GpsService.requestPermissions();
if (!permissionGranted) return;
_positionSubscription = GpsService.positionStream.listen(
(position) {
setState(() {
_speed = position.speed;
_heading = position.heading;
});
},
onError: (error) {
// Silent error handling for overlay
},
);
} catch (e) {
// Silent error handling for overlay
}
}
@override @override
void dispose() { void dispose() {
_positionSubscription?.cancel(); print('[Overlay] 🔴 DESTROYED - Floating window disposed');
// No GPS subscription to cancel - overlay is display-only
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentTheme = ColorThemes.getTheme(_currentThemeIndex); final currentTheme = ColorThemes.getTheme(_currentThemeIndex);
final displaySpeed = _currentUnit.convert(_speed);
// Per NOTES.txt: Speed section shows only integral part (no decimal, no unit)
final speedText = displaySpeed < 1.0 ? '--' : displaySpeed.toInt().toString();
// Calculate proportional dimensions // Use overlay dimensions provided by main app (not calculated locally)
final overlayWidth = (_screenWidth * 0.4); final fontSize = (_overlayWidth * 0.2); // Font size proportional to actual overlay width
final overlayHeight = (overlayWidth * 0.5);
final fontSize = (overlayWidth * 0.2); // Font size proportional to width print('[Overlay] 📐 Window size: ${_overlayWidth.round()}x${_overlayHeight.round()}, fontSize: ${fontSize.round()}');
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
@@ -603,21 +703,10 @@ class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
height: double.infinity, height: double.infinity,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
// Use proper color opacity without deprecated properties color: currentTheme.background.withOpacity(0.85),
color: Color.fromARGB(
((currentTheme.background.a * 255.0).round() * 0.85).round(),
(currentTheme.background.r * 255.0).round(),
(currentTheme.background.g * 255.0).round(),
(currentTheme.background.b * 255.0).round(),
),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: Color.fromARGB( color: currentTheme.speedText.withOpacity(0.3),
((currentTheme.speedText.a * 255.0).round() * 0.3).round(),
(currentTheme.speedText.r * 255.0).round(),
(currentTheme.speedText.g * 255.0).round(),
(currentTheme.speedText.b * 255.0).round(),
),
width: 1 width: 1
), ),
), ),
@@ -635,7 +724,7 @@ class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
child: FittedBox( child: FittedBox(
fit: BoxFit.contain, fit: BoxFit.contain,
child: Text( child: Text(
speedText, _speedText,
style: TextStyle( style: TextStyle(
fontSize: fontSize, fontSize: fontSize,
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
@@ -674,7 +763,7 @@ class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
child: FittedBox( child: FittedBox(
fit: BoxFit.contain, fit: BoxFit.contain,
child: Text( child: Text(
GpsService.formatHeading(_heading), _headingText,
style: TextStyle( style: TextStyle(
fontSize: fontSize * 0.4, fontSize: fontSize * 0.4,
color: currentTheme.headingText, color: currentTheme.headingText,
@@ -696,21 +785,20 @@ class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
child: GestureDetector( child: GestureDetector(
onTap: () => FlutterOverlayWindow.closeOverlay(), onTap: () => FlutterOverlayWindow.closeOverlay(),
child: Container( child: Container(
width: overlayHeight * 0.2, width: _overlayHeight * 0.2,
height: overlayHeight * 0.2, height: _overlayHeight * 0.2,
decoration: BoxDecoration( decoration: BoxDecoration(
// Avoid deprecated withOpacity - use Color.fromARGB instead color: Colors.red.withOpacity(0.6),
color: const Color.fromARGB(153, 244, 67, 54), // red with 60% opacity borderRadius: BorderRadius.circular(_overlayHeight * 0.1),
borderRadius: BorderRadius.circular(overlayHeight * 0.1),
border: Border.all( border: Border.all(
color: const Color.fromARGB(204, 255, 255, 255), // white with 80% opacity color: Colors.white.withOpacity(0.8),
width: 1 width: 1
), ),
), ),
child: Icon( child: Icon(
Icons.close, Icons.close,
color: const Color.fromARGB(229, 255, 255, 255), // white with 90% opacity color: Colors.white.withOpacity(0.9),
size: overlayHeight * 0.15, size: _overlayHeight * 0.15,
), ),
), ),
), ),