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
@pragma("vm:entry-point")
void overlayMain() {
print('[Overlay] 🚀 overlayMain() called');
runApp(const MaterialApp(
debugShowCheckedModeBanner: false,
home: OverlaySpeedometer(),
@@ -58,6 +59,7 @@ class _SpeedometerScreenState extends State<SpeedometerScreen> with WidgetsBindi
StreamSubscription<Position>? _positionSubscription;
String _errorMessage = '';
bool _isInBackground = false;
bool _isOverlayActive = false;
@override
void initState() {
@@ -149,49 +151,93 @@ class _SpeedometerScreenState extends State<SpeedometerScreen> with WidgetsBindi
_heading = position.heading;
_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() {
setState(() {
_currentUnit = _currentUnit.next;
});
// Share unit change with overlay if active
FlutterOverlayWindow.shareData({
'action': 'updateSettings',
'unitIndex': _currentUnit.index,
'themeIndex': _currentThemeIndex,
});
// Push updated display data to overlay
_pushDataToOverlay();
}
void _cycleTheme() {
setState(() {
_currentThemeIndex = ColorThemes.getNextThemeIndex(_currentThemeIndex);
});
// Share theme change with overlay if active
FlutterOverlayWindow.shareData({
'action': 'updateSettings',
'themeIndex': _currentThemeIndex,
'unitIndex': _currentUnit.index,
});
// Push updated display data to overlay
_pushDataToOverlay();
}
Future<void> _showFloatingWindow() async {
try {
// Get screen dimensions for proportional sizing
final screenSize = MediaQuery.of(context).size;
final overlayWidth = (screenSize.width * 0.4).round(); // 40% of screen width
// Get actual system screen resolution for proportional sizing
final window = WidgetsBinding.instance.platformDispatcher.views.first;
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
// 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({
'action': 'updateSettings',
'action': 'updateDisplay',
'speedText': speedText,
'headingText': headingText,
'heading': _heading,
'unitIndex': _currentUnit.index,
'themeIndex': _currentThemeIndex,
'screenWidth': screenSize.width,
'screenHeight': screenSize.height,
'systemScreenWidth': systemSize.width,
'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(
enableDrag: true,
overlayTitle: "GPS Speedometer",
@@ -199,20 +245,34 @@ class _SpeedometerScreenState extends State<SpeedometerScreen> with WidgetsBindi
flag: OverlayFlag.defaultFlag,
visibility: NotificationVisibility.visibilityPublic,
positionGravity: PositionGravity.none,
// Use proportional sizing with minimum reasonable dimensions
width: overlayWidth.clamp(280, 400),
height: overlayHeight.clamp(140, 300),
// Use system-based proportional sizing with reasonable limits
width: overlayWidth.clamp(280, 480),
height: overlayHeight.clamp(140, 240),
);
print('[Main] 🟢 OVERLAY LAUNCHED - Size: ${overlayWidth.clamp(280, 480)}x${overlayHeight.clamp(140, 240)}');
setState(() {
_isOverlayActive = true;
});
} catch (e) {
// Silent error handling - floating window issues shouldn't crash main app
}
}
Future<void> _closeFloatingWindow() async {
if (!_isOverlayActive) {
print('[Main] ⚠️ No overlay to close');
return;
}
try {
await FlutterOverlayWindow.closeOverlay();
print('[Main] 🔴 OVERLAY CLOSED - Requested from main app');
setState(() {
_isOverlayActive = false;
});
} 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(
flex: 32,
child: GestureDetector(
onTap: _showFloatingWindow, // Tap navigation icon to show floating window
onLongPress: _closeFloatingWindow, // Long press to close floating window
onTap: () {
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(
width: double.infinity,
color: Colors.transparent,
@@ -427,7 +497,11 @@ class _SpeedometerScreenState extends State<SpeedometerScreen> with WidgetsBindi
)
else ...[
Flexible(
flex: 4,
flex: 1,
child: FittedBox(),
),
Flexible(
flex: 20,
child: GestureDetector(
onTap: _cycleTheme,
child: FittedBox(
@@ -437,7 +511,7 @@ class _SpeedometerScreenState extends State<SpeedometerScreen> with WidgetsBindi
),
),
Flexible(
flex: 1,
flex: 7,
child: GestureDetector(
onTap: _cycleUnit,
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(
flex: 33,
child: GestureDetector(
onTap: _showFloatingWindow,
onLongPress: _closeFloatingWindow, // Long press to close floating window
onTap: () {
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(
height: double.infinity,
color: Colors.transparent,
@@ -517,83 +605,95 @@ class OverlaySpeedometer extends StatefulWidget {
}
class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
double _speed = 0.0;
String _speedText = '--';
String _headingText = 'N/A';
double _heading = -1.0;
SpeedUnit _currentUnit = SpeedUnit.kmh;
int _currentThemeIndex = 0;
StreamSubscription<Position>? _positionSubscription;
double _screenWidth = 400;
double _systemScreenWidth = 400;
double _overlayWidth = 280;
double _overlayHeight = 140;
@override
void initState() {
super.initState();
print('[Overlay] 🟢 CREATED - Floating window initialized');
_getSystemScreenSize();
_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() {
FlutterOverlayWindow.overlayListener.listen((data) {
if (data is Map) {
if (data.containsKey('unitIndex')) {
setState(() {
_currentUnit = SpeedUnit.values[data['unitIndex'] ?? 0];
});
}
if (data.containsKey('themeIndex')) {
setState(() {
_currentThemeIndex = data['themeIndex'] ?? 0;
});
}
if (data.containsKey('screenWidth')) {
setState(() {
_screenWidth = data['screenWidth']?.toDouble() ?? 400;
});
}
if (data is Map && data['action'] == 'updateDisplay') {
setState(() {
_speedText = data['speedText'] ?? '--';
_headingText = data['headingText'] ?? 'N/A';
_heading = data['heading']?.toDouble() ?? -1.0;
_currentThemeIndex = data['themeIndex'] ?? 0;
// Update screen size and overlay dimensions if provided by main app
if (data.containsKey('systemScreenWidth')) {
_systemScreenWidth = data['systemScreenWidth']?.toDouble() ?? _systemScreenWidth;
}
if (data.containsKey('overlayWidth')) {
_overlayWidth = data['overlayWidth']?.toDouble() ?? _overlayWidth;
}
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
void dispose() {
_positionSubscription?.cancel();
print('[Overlay] 🔴 DESTROYED - Floating window disposed');
// No GPS subscription to cancel - overlay is display-only
super.dispose();
}
@override
Widget build(BuildContext context) {
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
final overlayWidth = (_screenWidth * 0.4);
final overlayHeight = (overlayWidth * 0.5);
final fontSize = (overlayWidth * 0.2); // Font size proportional to width
// Use overlay dimensions provided by main app (not calculated locally)
final fontSize = (_overlayWidth * 0.2); // Font size proportional to actual overlay width
print('[Overlay] 📐 Window size: ${_overlayWidth.round()}x${_overlayHeight.round()}, fontSize: ${fontSize.round()}');
return Material(
color: Colors.transparent,
@@ -603,21 +703,10 @@ class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
height: double.infinity,
child: Container(
decoration: BoxDecoration(
// Use proper color opacity without deprecated properties
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(),
),
color: currentTheme.background.withOpacity(0.85),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Color.fromARGB(
((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(),
),
color: currentTheme.speedText.withOpacity(0.3),
width: 1
),
),
@@ -635,7 +724,7 @@ class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
child: FittedBox(
fit: BoxFit.contain,
child: Text(
speedText,
_speedText,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w300,
@@ -674,7 +763,7 @@ class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
child: FittedBox(
fit: BoxFit.contain,
child: Text(
GpsService.formatHeading(_heading),
_headingText,
style: TextStyle(
fontSize: fontSize * 0.4,
color: currentTheme.headingText,
@@ -696,21 +785,20 @@ class _OverlaySpeedometerState extends State<OverlaySpeedometer> {
child: GestureDetector(
onTap: () => FlutterOverlayWindow.closeOverlay(),
child: Container(
width: overlayHeight * 0.2,
height: overlayHeight * 0.2,
width: _overlayHeight * 0.2,
height: _overlayHeight * 0.2,
decoration: BoxDecoration(
// Avoid deprecated withOpacity - use Color.fromARGB instead
color: const Color.fromARGB(153, 244, 67, 54), // red with 60% opacity
borderRadius: BorderRadius.circular(overlayHeight * 0.1),
color: Colors.red.withOpacity(0.6),
borderRadius: BorderRadius.circular(_overlayHeight * 0.1),
border: Border.all(
color: const Color.fromARGB(204, 255, 255, 255), // white with 80% opacity
color: Colors.white.withOpacity(0.8),
width: 1
),
),
child: Icon(
Icons.close,
color: const Color.fromARGB(229, 255, 255, 255), // white with 90% opacity
size: overlayHeight * 0.15,
color: Colors.white.withOpacity(0.9),
size: _overlayHeight * 0.15,
),
),
),