diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 8966d00..8855c96 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -13,7 +13,9 @@
"Bash(cp:*)",
"Bash(flutter:*)",
"Bash(powershell:*)",
- "WebFetch(domain:docs.anthropic.com)"
+ "WebFetch(domain:docs.anthropic.com)",
+ "WebSearch",
+ "WebFetch(domain:pub.dev)"
],
"defaultMode": "acceptEdits"
},
diff --git a/CLAUDE.md b/CLAUDE.md
index 8b68af5..55f7905 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3,8 +3,8 @@
## Project Overview
A customizable Flutter GPS speedometer app targeting Android devices, emphasizing modularity and user customization.
-## Current Status: v1.1 Complete
-**Enhanced GPS speedometer with improved user experience and customization options.**
+## Current Status: v2.1.0 Complete
+**GPS speedometer with floating window overlay functionality and optimized visual design.**
### Architecture Implemented
- **State Management:** Provider pattern
@@ -31,10 +31,18 @@ Architecture: Provider + Consumer pattern for reactive state management
Permissions: Location services with graceful degradation
```
-## Recent Updates (v1.1)
+## Recent Updates (v2.1.0)
### ✅ Completed Features
-- [x] **Portrait/Landscape Support**: Automatic orientation detection implemented
+- [x] **Floating Window Overlay**: System-level overlay window for persistent speed display
+- [x] **Split Decimal Display**: Main speed prominent, decimal fraction smaller and faded
+- [x] **Optimized Content Sizing**: Maximized readability with improved flex ratios
+- [x] **Navigation Icon Trigger**: Tap compass area to launch floating window
+- [x] **Multiple Close Options**: Overlay close button and long-press navigation area
+- [x] **Android Permissions**: SYSTEM_ALERT_WINDOW and foreground service support
+
+### Previous Updates (v1.1)
+- [x] **Portrait/Landscape Support**: Automatic orientation detection implemented
- [x] **Interactive Gauge**: Click gauge face to switch digital/analog modes
- [x] **Extended Speed Units**: Added knots support with quick selector buttons
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 2c835a7..7f8462a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,8 +5,10 @@
-
-
+
+
+
+
+
+
+
+
FlutterOverlayWindow.closeOverlay(),
+ child: Container(
+ width: 24,
+ height: 24,
+ decoration: BoxDecoration(
+ color: Colors.red.withOpacity(0.8),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Colors.white, width: 1),
+ ),
+ child: const Icon(
+ Icons.close,
+ color: Colors.white,
+ size: 16,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ));
}
class SpeedoApp extends StatelessWidget {
@@ -46,17 +99,19 @@ class SpeedometerScreen extends StatefulWidget {
State createState() => _SpeedometerScreenState();
}
-class _SpeedometerScreenState extends State {
+class _SpeedometerScreenState extends State with WidgetsBindingObserver {
double _speed = 0.0;
double _heading = -1.0;
SpeedUnit _currentUnit = SpeedUnit.kmh;
int _currentThemeIndex = 0;
StreamSubscription? _positionSubscription;
String _errorMessage = '';
+ bool _isInBackground = false;
@override
void initState() {
super.initState();
+ WidgetsBinding.instance.addObserver(this);
_initializeGps();
_enableWakelock();
}
@@ -71,11 +126,45 @@ class _SpeedometerScreenState extends State {
@override
void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
_positionSubscription?.cancel();
WakelockPlus.disable();
super.dispose();
}
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ super.didChangeAppLifecycleState(state);
+
+ switch (state) {
+ case AppLifecycleState.paused:
+ // App went to background (home key pressed or task switch)
+ if (!_isInBackground) {
+ _isInBackground = true;
+ _handleBackgroundTransition();
+ }
+ break;
+ case AppLifecycleState.resumed:
+ // App came back to foreground
+ _isInBackground = false;
+ break;
+ case AppLifecycleState.inactive:
+ case AppLifecycleState.detached:
+ case AppLifecycleState.hidden:
+ break;
+ }
+ }
+
+ void _handleBackgroundTransition() {
+ // When app goes to background, show floating window if GPS is active and no error
+ print('App went to background, checking conditions for floating window...');
+ print('Speed: $_speed, Error: $_errorMessage');
+ // Temporarily disable auto-trigger for debugging
+ // if (_speed >= 0 && _errorMessage.isEmpty) {
+ // _showFloatingWindow();
+ // }
+ }
+
Future _initializeGps() async {
try {
final serviceEnabled = await GpsService.isLocationServiceEnabled();
@@ -124,6 +213,38 @@ class _SpeedometerScreenState extends State {
_currentThemeIndex = ColorThemes.getNextThemeIndex(_currentThemeIndex);
});
}
+
+ Future _showFloatingWindow() async {
+ try {
+ // Share current unit with overlay
+ await FlutterOverlayWindow.shareData({
+ 'action': 'updateUnit',
+ 'unitIndex': _currentUnit.index,
+ });
+
+ // Show the overlay with safer positioning
+ await FlutterOverlayWindow.showOverlay(
+ enableDrag: true,
+ overlayTitle: "GPS Speedometer",
+ overlayContent: 'GPS Speedometer Overlay',
+ flag: OverlayFlag.defaultFlag,
+ visibility: NotificationVisibility.visibilityPublic,
+ positionGravity: PositionGravity.none,
+ width: 180,
+ height: 100,
+ );
+ } catch (e) {
+ print('Error showing floating window: $e');
+ }
+ }
+
+ Future _closeFloatingWindow() async {
+ try {
+ await FlutterOverlayWindow.closeOverlay();
+ } catch (e) {
+ print('Error closing floating window: $e');
+ }
+ }
String _getSpeedDisplayText(double displaySpeed) {
if (_speed < 1.0 && (_heading < 0.0 || _heading >= 360.0)) {
@@ -132,6 +253,51 @@ class _SpeedometerScreenState extends State {
return displaySpeed.toStringAsFixed(1);
}
+ Widget _buildSpeedDisplay(String speedText, ColorTheme currentTheme, double fontSize, {bool isLandscape = false}) {
+ if (speedText == '--') {
+ return Text(
+ speedText,
+ style: TextStyle(
+ fontSize: fontSize,
+ fontWeight: FontWeight.w300,
+ color: currentTheme.speedText,
+ fontFamily: 'DIN1451Alt',
+ ),
+ );
+ }
+
+ // Split speed into integral and decimal parts
+ final parts = speedText.split('.');
+ final integralPart = parts[0];
+ final decimalPart = parts.length > 1 ? '.${parts[1]}' : '.0';
+
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.baseline,
+ textBaseline: TextBaseline.alphabetic,
+ children: [
+ Text(
+ integralPart,
+ style: TextStyle(
+ fontSize: fontSize,
+ fontWeight: FontWeight.w300,
+ color: currentTheme.speedText,
+ fontFamily: 'DIN1451Alt',
+ ),
+ ),
+ Text(
+ decimalPart,
+ style: TextStyle(
+ fontSize: fontSize * 0.5, // 60% of main font size
+ fontWeight: FontWeight.w300,
+ color: currentTheme.speedTextSub,
+ fontFamily: 'DIN1451Alt',
+ ),
+ ),
+ ],
+ );
+ }
+
@override
Widget build(BuildContext context) {
final displaySpeed = _currentUnit.convert(_speed);
@@ -165,7 +331,7 @@ class _SpeedometerScreenState extends State {
child: Container(
width: double.infinity,
color: Colors.transparent,
- padding: const EdgeInsets.all(12),
+ padding: const EdgeInsets.all(4),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -183,33 +349,25 @@ class _SpeedometerScreenState extends State {
)
else ...[
Flexible(
- flex: 3,
+ flex: 4,
child: GestureDetector(
- onTap: _cycleUnit,
+ onTap: _cycleTheme, // Tap speed to cycle theme
child: FittedBox(
- fit: BoxFit.scaleDown,
- child: Text(
- speedText,
- style: TextStyle(
- fontSize: 160,
- fontWeight: FontWeight.w300,
- color: currentTheme.speedText,
- fontFamily: 'DIN1451Alt',
- ),
- ),
+ fit: BoxFit.contain,
+ child: _buildSpeedDisplay(speedText, currentTheme, 200),
),
),
),
Flexible(
flex: 1,
child: GestureDetector(
- onTap: _cycleUnit,
+ onTap: _cycleUnit, // clicking on unit to cycle unit is more intuitive
child: FittedBox(
- fit: BoxFit.scaleDown,
+ fit: BoxFit.contain,
child: Text(
_currentUnit.label,
style: TextStyle(
- fontSize: 64,
+ fontSize: 80,
color: currentTheme.unitText,
fontFamily: 'DIN1451Alt',
),
@@ -222,37 +380,41 @@ class _SpeedometerScreenState extends State {
),
),
),
- // Compass area - compact but fully tappable for theme cycling
+ // Compass area - compact but fully tappable for floating window
Expanded(
flex: 3,
child: GestureDetector(
- onTap: _cycleTheme,
+ onTap: _showFloatingWindow, // Tap navigation icon to show floating window
+ onLongPress: _closeFloatingWindow, // Long press to close floating window
child: Container(
width: double.infinity,
color: Colors.transparent,
- padding: const EdgeInsets.all(16),
+ padding: const EdgeInsets.all(2),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
- flex: 2,
+ flex: 3,
child: Transform.rotate(
angle: (_heading >= 0 && _heading < 360) ? (_heading * math.pi / 180.0) : 0,
- child: Icon(
- Icons.navigation,
- size: 100,
- color: currentTheme.headingText,
+ child: FittedBox(
+ fit: BoxFit.contain,
+ child: Icon(
+ Icons.navigation,
+ size: 80,
+ color: currentTheme.headingText,
+ ),
),
),
),
Flexible(
flex: 1,
child: FittedBox(
- fit: BoxFit.scaleDown,
+ fit: BoxFit.contain,
child: Text(
GpsService.formatHeading(_heading),
style: TextStyle(
- fontSize: 96,
+ fontSize: 50,
color: currentTheme.headingText,
fontFamily: 'DIN1451Alt',
),
@@ -273,11 +435,11 @@ class _SpeedometerScreenState extends State {
children: [
// Speed area - takes majority of space, precise tap targets on text only
Expanded(
- flex: 65,
+ flex: 66,
child: Container(
height: double.infinity,
color: Colors.transparent,
- padding: const EdgeInsets.all(16),
+ padding: const EdgeInsets.all(3),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -295,20 +457,12 @@ class _SpeedometerScreenState extends State {
)
else ...[
Flexible(
- flex: 3,
+ flex: 4,
child: GestureDetector(
- onTap: _cycleUnit,
+ onTap: _cycleTheme,
child: FittedBox(
- fit: BoxFit.scaleDown,
- child: Text(
- speedText,
- style: TextStyle(
- fontSize: 120,
- fontWeight: FontWeight.w300,
- color: currentTheme.speedText,
- fontFamily: 'DIN1451Alt',
- ),
- ),
+ fit: BoxFit.contain,
+ child: _buildSpeedDisplay(speedText, currentTheme, 160, isLandscape: true),
),
),
),
@@ -317,11 +471,11 @@ class _SpeedometerScreenState extends State {
child: GestureDetector(
onTap: _cycleUnit,
child: FittedBox(
- fit: BoxFit.scaleDown,
+ fit: BoxFit.contain,
child: Text(
_currentUnit.label,
style: TextStyle(
- fontSize: 24,
+ fontSize: 50,
color: currentTheme.unitText,
fontFamily: 'DIN1451Alt',
),
@@ -334,35 +488,41 @@ class _SpeedometerScreenState extends State {
),
),
),
- // Compass area - smaller but fully tappable for theme cycling
+ // Compass area - smaller but fully tappable for floating window
Expanded(
- flex: 35,
+ flex: 33,
child: GestureDetector(
- onTap: _cycleTheme,
+ onTap: _showFloatingWindow,
+ onLongPress: _closeFloatingWindow, // Long press to close floating window
child: Container(
height: double.infinity,
color: Colors.transparent,
- padding: const EdgeInsets.all(16),
+ padding: const EdgeInsets.all(2),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
- flex: 2,
- child: Icon(
- Icons.navigation,
- size: 32,
- color: currentTheme.headingText,
+ flex: 3,
+ child: Transform.rotate(
+ angle: (_heading >= 0 && _heading < 360) ? (_heading * math.pi / 180.0) : 0,
+ child: FittedBox(
+ fit: BoxFit.contain,
+ child: Icon(
+ Icons.navigation,
+ size: 60,
+ color: currentTheme.headingText,
+ ),
+ ),
),
),
- const SizedBox(height: 8),
Flexible(
flex: 1,
child: FittedBox(
- fit: BoxFit.scaleDown,
+ fit: BoxFit.contain,
child: Text(
GpsService.formatHeading(_heading),
style: TextStyle(
- fontSize: 18,
+ fontSize: 30,
color: currentTheme.headingText,
fontFamily: 'DIN1451Alt',
),
@@ -377,4 +537,81 @@ class _SpeedometerScreenState extends State {
],
);
}
+}
+
+class SimpleOverlaySpeedometer extends StatelessWidget {
+ const SimpleOverlaySpeedometer({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ print("SimpleOverlaySpeedometer build() called");
+ return Material(
+ color: Colors.transparent,
+ child: Container(
+ width: 200,
+ height: 120,
+ decoration: BoxDecoration(
+ color: Colors.red.withOpacity(0.9), // Highly visible red background
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: Colors.yellow, width: 3), // Bright yellow border
+ ),
+ child: Stack(
+ children: [
+ // Test content - much more visible
+ Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text(
+ 'FLOATING',
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.bold,
+ color: Colors.white,
+ ),
+ ),
+ const Text(
+ 'WINDOW',
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ color: Colors.yellow,
+ ),
+ ),
+ const Text(
+ 'WORKING!',
+ style: TextStyle(
+ fontSize: 12,
+ color: Colors.white,
+ ),
+ ),
+ ],
+ ),
+ ),
+ // Close button
+ Positioned(
+ top: 2,
+ right: 2,
+ child: GestureDetector(
+ onTap: () => FlutterOverlayWindow.closeOverlay(),
+ child: Container(
+ width: 20,
+ height: 20,
+ decoration: BoxDecoration(
+ color: Color.fromARGB(255, 255, 171, 16), // call the current background colour defined as in `color_themes.dart`
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: const Icon(
+ Icons.close,
+ color: Colors.white,
+ size: 12,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
}
\ No newline at end of file
diff --git a/pubspec.lock b/pubspec.lock
index 1ad5014..497bdd7 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -134,6 +134,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
+ flutter_overlay_window:
+ dependency: "direct main"
+ description:
+ name: flutter_overlay_window
+ sha256: "17988420249da68e421d1b44b511e6113d3de7a9e52c3fbd6d99730508af4580"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.5.0"
flutter_test:
dependency: "direct dev"
description: flutter
diff --git a/pubspec.yaml b/pubspec.yaml
index b334477..4bc70ca 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -2,7 +2,7 @@ name: gps_speedo
description: "Speedo - A Flutter application."
publish_to: 'none'
-version: 2.0.0+1
+version: 2.1.0+2
environment:
sdk: ^3.9.0
@@ -20,6 +20,9 @@ dependencies:
# Keep screen awake during navigation
wakelock_plus: ^1.2.8
+
+ # Floating window overlay capability
+ flutter_overlay_window: ^0.5.0
dev_dependencies: