浏览代码

feat: add GPS speed fallback without OBD

When no OBD adapter is connected and precise location is already allowed, poll GPS updates and show a compact speed card above the volume controls. Starting never requests permission; GPS must be enabled and fine location previously granted. Stationary/noisy speeds below 2 km/h display as 0. Hide the card when OBD is connected, permission is missing, GPS is off, or the fix is stale. Update tests, docs, screenshots and bump to 0.10.0.
Krasimir Kazakov 1 周之前
父节点
当前提交
e2d4f7ab03

+ 3 - 2
AGENTS.md

@@ -37,7 +37,7 @@ ADB lives at `/opt/android-sdk/platform-tools/adb`; the SDK is at `/opt/android-
 - `lib/data/models/` — `AppEntry`, `SlotType`, `SlotAssignment`, `SystemStatus`, `VolumeInfo`, `MediaPlayback`, OBD/weather models.
 - `lib/data/services/` — `NativeBridge`, `TelemetryBridge`, `WeatherService`, `PinService`, secure-store adapter.
 - `lib/data/repositories/` — `SettingsRepository`, the SSOT for theme, night window, slot assignments, visibility flags and pinned apps.
-- `lib/features/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, equalizer, night overlay.
+- `lib/features/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, `GpsSpeedController`, equalizer, night overlay.
 - `lib/features/apps/` — All Apps screen and app-picker sheet.
 - `lib/features/settings/` — General, Media, Car data and Security pages.
 - `lib/features/lock/` — PIN pad, lock screen, cold-start `LockController`.
@@ -77,7 +77,7 @@ notifyListeners();
 - NEVER change the application ID, HOME intent filter, landscape lock, or minSdk without human confirmation.
 - NEVER re-lock on resume, screen-off, or route changes; no "Lock now" action. PIN is requested only on cold start.
 - NEVER store, log, or display a plaintext PIN.
-- INTERNET, location and Bluetooth permissions are approved only for the OBD/Open-Meteo weather feature; notification access only for MediaSession title/progress; no notification content, scanning or background location.
+- INTERNET, location and Bluetooth permissions are approved only for OBD, Open-Meteo weather and the GPS speed fallback; notification access only for MediaSession title/progress; no notification content, scanning or background location.
 - NEVER commit `android/local.properties`, keystores, build outputs, APKs, or secrets.
 - REQUIRE human confirmation before changing signing configuration, package visibility permissions, or the repository remote.
 
@@ -96,6 +96,7 @@ notifyListeners();
 - `FLAG_KEEP_SCREEN_ON` is controlled by `DisplayController` from `display.keepScreenOn` (default on) through `SystemBridge`; do not hard-code it in `MainActivity` or move it to a wake lock.
 - The Music equalizer follows `AudioManager.isMusicActive`. Now-playing title/progress use the notification-listener component and active MediaSession; never read or store notification content.
 - Night mode is a full-screen black overlay with a dim clock. Long-press only on empty background; tap anywhere to dismiss. It does not change brightness or add a wake lock.
+- GPS speed is a fallback only when no OBD adapter is connected. It starts only with already-granted fine location and GPS; never prompt from that path, and clamp stationary noise below 2 km/h to 0.
 - ADB installs: always try at most twice per connected device. If both attempts fail, stop silently rather than looping or switching install methods.
 - OBD is Bluetooth Classic SPP, bonded-only and read-only, and still needs a real-car test; weather uses Open-Meteo and one-shot location and stores no coordinates.
 - The circular ring is song progress; its centre is play/pause and the Mute pill is separate. Playback uses Android media keys; title/progress need the user-granted notification access, otherwise the card falls back to Playing / Nothing playing.

+ 12 - 3
README.md

@@ -30,6 +30,7 @@ Also: [Home with top-right Settings](docs/screenshots/home-16.png) ·
 [Settings · Media access](docs/screenshots/settings-media.png) ·
 [Now playing](docs/screenshots/home-now-playing.png) ·
 [1280×720](docs/screenshots/home-1280x720.png) ·
+[GPS speed](docs/screenshots/home-gps-speed.png) ·
 [Night mode](docs/screenshots/home-night-mode.png) ·
 [Blue accent](docs/screenshots/settings-blue-accent.png) ·
 [user-app icon on Home](docs/screenshots/home-user-icon.png) ·
@@ -48,6 +49,10 @@ Also: [Home with top-right Settings](docs/screenshots/home-16.png) ·
 - **OBD-II car data** — optional Bluetooth Classic ELM327 adapter. Shows digital
   speed, a throttle bar and rolling 5 km MAF economy, plus ambient temperature
   when the adapter provides it.
+- **GPS speed fallback** — when no OBD adapter is connected and precise location
+  is already allowed, show speed from GPS; it reads 0 km/h when stationary.
+  CarDash never prompts for this; GPS must be enabled and location previously
+  allowed (for example through the weather toggle).
 - **Location temperature fallback** — if OBD has no ambient value, an optional
   Settings toggle fetches the current temperature from Open-Meteo using the
   device location. The row reads `Outside, 7°C` for OBD or `Sofia, 7°C` for
@@ -170,6 +175,7 @@ first run shows a one-time tip pointing to this setting.
 | Long-press an added app on Home | Rename, Change icon or Remove |
 | Settings → Car data | Pick a paired ELM327 or None |
 | Settings → Car data → Location temperature | Enable GPS weather fallback |
+| No OBD + allowed location + GPS on | Shows GPS speed above the volume card; 0 when stopped |
 | Temperature row | OBD ambient, or location weather when OBD has none |
 | Settings | Theme, accent, media access, battery, home buttons, default-home role, PIN |
 | All apps | Search every launchable app; tap to open, hold to pin |
@@ -256,12 +262,12 @@ flutter test
 ./tool/check.sh
 ```
 
-The 44 tests cover PIN hashing/persistence, settings persistence, theme/accent,
+The 47 tests cover PIN hashing/persistence, settings persistence, theme/accent,
 dark accent tinting, battery visibility, first-run state, slot visibility,
 pinned apps, custom labels/icons, the 16-button cap at 1280×800, 1280×720 and
 1024×600, the dense shortcut grid, the equalizer animation, OBD economy, the OBD
 card, media-key playback, media metadata/progress, the marquee, the play/pause
-ring, the keep-awake preference and the night overlay. Native package
+ring, the keep-awake preference, the night overlay and GPS speed fallback. Native package
 enumeration, the HOME role, volume, app launching and PIN flows were verified on
 API 35 and API 29 emulators. Location permission, reverse geocoding and the
 Open-Meteo fallback were verified live on the API 35 emulator. Now-playing
@@ -269,7 +275,7 @@ title, artist, progress and pause/resume were verified against a live Chrome
 MediaSession. The OBD hardware path compiles and is covered by the parser/economy
 design, but no ELM327 adapter is available on this machine; socket behavior still
 needs a car test. Release
-0.9.1 is installed on the emulator and the physical Android 17 HyperOS tablet.
+0.10.0 is installed on the emulator and the physical Android 17 HyperOS tablet.
 
 ## Troubleshooting the emulator
 
@@ -325,6 +331,9 @@ Android 15 AVD is left running headlessly after development; use
 - **Song progress/title require notification access.** Without it, Android does
   not expose MediaSession metadata to a normal launcher; the card still controls
   playback and shows Nothing playing or Playing.
+- **GPS speed fallback** requires precise location already granted and the GPS
+  provider enabled; CarDash does not request permission from this path. OBD speed
+  wins whenever an adapter is connected, and stale/no-fix GPS values are hidden.
 - **OBD-II is a subset**: Bluetooth Classic SPP only, paired devices only,
   read-only. Speed, throttle, MAF economy and ambient temperature are implemented;
   RPM, coolant, voltage, MIL/DTCs, fuel level and BLE/Wi-Fi adapters are not.

+ 145 - 0
android/app/src/main/kotlin/com/wasp/cardash_launcher/TelemetryBridge.kt

@@ -39,6 +39,13 @@ class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCall
     private var obdService: ObdService? = null
     private val mainHandler = Handler(Looper.getMainLooper())
     private val locationExecutor = Executors.newSingleThreadExecutor()
+    private var gpsManager: LocationManager? = null
+    private var gpsListener: LocationListener? = null
+    private var gpsLastLocation: Location? = null
+    private var gpsLastFixAt: Long = 0L
+    @Volatile private var gpsHasFix = false
+    @Volatile private var gpsSpeedKmh: Double? = null
+    @Volatile private var gpsUpdatedAt: Long = 0L
     private var pendingPermissionResult: MethodChannel.Result? = null
     private var pendingPermissionKind = 0
     @Volatile private var disposed = false
@@ -51,6 +58,7 @@ class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCall
         disposed = true
         obdService?.stop()
         obdService = null
+        stopGpsSpeed()
         channel?.setMethodCallHandler(null)
         channel = null
         locationExecutor.shutdownNow()
@@ -70,6 +78,12 @@ class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCall
                 result.success(true)
             }
             "getObdState" -> result.success(obdService?.snapshot() ?: emptyObdState())
+            "startGpsSpeed" -> result.success(startGpsSpeed())
+            "stopGpsSpeed" -> {
+                stopGpsSpeed()
+                result.success(true)
+            }
+            "getGpsSpeed" -> result.success(gpsSpeedState())
             else -> result.notImplemented()
         }
     }
@@ -120,6 +134,137 @@ class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCall
         return fine || coarse
     }
 
+
+    private fun hasFineLocationPermission(): Boolean {
+        return activity.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
+            PackageManager.PERMISSION_GRANTED
+    }
+
+    /**
+     * Starts GPS speed updates without requesting permission. Returns false when
+     * precise location is not already granted or the GPS provider is disabled.
+     */
+    private fun startGpsSpeed(): Boolean {
+        if (!hasFineLocationPermission()) {
+            stopGpsSpeed()
+            return false
+        }
+        val manager = try {
+            activity.getSystemService(LocationManager::class.java)
+        } catch (_: Throwable) {
+            null
+        } ?: run {
+            stopGpsSpeed()
+            return false
+        }
+        val enabled = try {
+            manager.isProviderEnabled(LocationManager.GPS_PROVIDER)
+        } catch (_: Throwable) {
+            false
+        }
+        if (!enabled) {
+            stopGpsSpeed()
+            return false
+        }
+
+        stopGpsSpeed()
+        gpsManager = manager
+        val listener = object : LocationListener {
+            override fun onLocationChanged(location: Location) {
+                updateGpsSpeed(location)
+            }
+
+            override fun onProviderDisabled(provider: String) {
+                if (provider == LocationManager.GPS_PROVIDER) {
+                    gpsHasFix = false
+                    gpsSpeedKmh = null
+                    gpsUpdatedAt = 0L
+                }
+            }
+
+            override fun onProviderEnabled(provider: String) {}
+            override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
+        }
+        gpsListener = listener
+        return try {
+            manager.requestLocationUpdates(
+                LocationManager.GPS_PROVIDER,
+                500L,
+                0f,
+                listener,
+                Looper.getMainLooper(),
+            )
+            val last = try {
+                manager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
+            } catch (_: Throwable) {
+                null
+            }
+            if (last != null && System.currentTimeMillis() - last.time < 5_000L) {
+                updateGpsSpeed(last)
+            }
+            true
+        } catch (_: Throwable) {
+            stopGpsSpeed()
+            false
+        }
+    }
+
+    private fun stopGpsSpeed() {
+        val manager = gpsManager
+        val listener = gpsListener
+        if (manager != null && listener != null) {
+            try {
+                manager.removeUpdates(listener)
+            } catch (_: Throwable) {
+                // Best effort.
+            }
+        }
+        gpsManager = null
+        gpsListener = null
+        gpsLastLocation = null
+        gpsLastFixAt = 0L
+        gpsHasFix = false
+        gpsSpeedKmh = null
+        gpsUpdatedAt = 0L
+    }
+
+    private fun updateGpsSpeed(location: Location) {
+        if (!hasFineLocationPermission()) return
+        val now = System.currentTimeMillis()
+        val previous = gpsLastLocation
+        val previousAt = gpsLastFixAt
+        var speed = when {
+            location.hasSpeed() -> location.speed * 3.6
+            previous != null && previousAt > 0L -> {
+                val seconds = (now - previousAt) / 1000.0
+                if (seconds in 0.2..5.0) {
+                    val meters = previous.distanceTo(location)
+                    if (meters < 2.0) 0.0 else meters / seconds * 3.6
+                } else {
+                    0.0
+                }
+            }
+            else -> 0.0
+        }
+        if (!speed.isFinite() || speed < 2.0) speed = 0.0
+        gpsLastLocation = location
+        gpsLastFixAt = now
+        gpsSpeedKmh = speed
+        gpsUpdatedAt = now
+        gpsHasFix = true
+    }
+
+    private fun gpsSpeedState(): Map<String, Any?> {
+        val permission = hasFineLocationPermission()
+        val valid = permission && gpsHasFix
+        return mapOf(
+            "hasPermission" to permission,
+            "hasFix" to valid,
+            "speedKmh" to if (valid) gpsSpeedKmh else null,
+            "updatedAt" to if (permission) gpsUpdatedAt else 0L,
+        )
+    }
+
     private fun ensurePermission(kind: Int, result: MethodChannel.Result) {
         val granted = if (kind == KIND_BLUETOOTH) {
             hasBluetoothPermission()

+ 10 - 1
docs/IMPLEMENTATION_PLAN.md

@@ -38,6 +38,8 @@ compatibility:
   bar, rolling 5 km MAF economy and ambient temperature.
 - Optional location weather fallback when OBD has no ambient value; red below
   3°C; no row when unavailable.
+- GPS speed fallback when no OBD adapter is connected and precise location was
+  already granted; stationary shows 0 km/h, with no permission prompt.
 - Dark surfaces are tinted by the selected accent instead of retaining a green base.
 - PIN is requested **only on a cold start**, never on resume or screen-off.
 - The screen stays awake while CarDash is the foreground window.
@@ -72,6 +74,7 @@ compatibility:
 | OBD values | Speed, throttle (`49`/`11`), MAF economy, ambient (`46`) | The requested dashboard subset; unsupported PIDs stay hidden. |
 | Economy | 5 km MAF rolling window, petrol constants, fuel-cut guard | Matches the design note; show `—` until 0.5 km. |
 | Weather fallback | Open-Meteo + one-shot device location, Settings toggle | No API key/account; hide the row on any failure. |
+| GPS speed fallback | Poll GPS updates only when no OBD is connected and fine location is already granted | No permission prompt; 0 below 2 km/h to suppress stationary drift. |
 | Temperature priority | OBD ambient, else location weather | OBD wins whenever a fresh value exists. |
 | Accent background | Dark canvas/surfaces/borders blend toward the accent | Fixes the green base showing through when Blue is selected. |
 | Screen | `FLAG_KEEP_SCREEN_ON` controlled by the Settings keep-awake switch | Default on; no wake-lock permission, and off restores the system display timeout. |
@@ -94,6 +97,7 @@ Flutter
     LauncherController  -> apps, slots, pinned apps, 16-button cap, default-home state
     SystemController    -> battery/Wi-Fi/volume/media state + media keys
     DisplayController   -> persisted keep-awake window flag
+    GpsSpeedController  -> GPS speed fallback (permission already granted)
     HomeScreen          -> NightModeOverlay on empty-background long press
   AllAppsScreen / AppPickerSheet
   SettingsScreen -> General / Car data / Security
@@ -137,6 +141,7 @@ Android (Kotlin)
 | M13 | Play/pause volume ring, Settings gear in status area, 16-button cap without Settings | Done |
 | M14 | Song-progress ring, scrolling now-playing title, notification-access Settings card | Done |
 | M15 | Keep-awake Settings switch (default on), night mode, 1280×720 layout | Done |
+| M16 | GPS speed fallback when no OBD adapter and location is already allowed | Done |
 
 ## 5. Verification checklist
 
@@ -184,7 +189,8 @@ Android (Kotlin)
   both the ring and scroll position, resume continues.
 - [x] Settings → Media grants notification access and shows Granted.
 - [x] Settings gear is next to Wi-Fi; the grid ends with All apps.
-- [x] Release 0.9.1 installed on the emulator and physical Android 17 tablet.
+- [x] GPS speed shows 0 km/h when stationary with no OBD adapter; no permission prompt.
+- [x] Release 0.10.0 installed on the emulator and physical Android 17 tablet.
 - [x] Screenshots in `docs/screenshots/`; README/AGENTS/plan/OBD doc updated.
 
 ## 6. Known risks and mitigations
@@ -273,5 +279,8 @@ Android (Kotlin)
   with a dim clock, and verified the 16-button layout at 1280×720. Verified the
   native window flag toggles off/on, night mode/card long-press isolation, 44 tests
   and 0.9.0 on emulator/tablet.
+- 2026-10-29: Added GPS speed fallback when no OBD adapter is connected and fine
+  location is already allowed. No permission prompt; stationary values below
+  2 km/h render as 0. Verified 47 tests and 0.10.0 on emulator/tablet.
 - 2026-10-29: Centered the now-playing title across the whole media card instead of
   the right-hand control column. Verified 0.9.1 on emulator/tablet.

二进制
docs/screenshots/home-1280x720.png


二进制
docs/screenshots/home-16-1024.png


二进制
docs/screenshots/home-16.png


二进制
docs/screenshots/home-gps-speed.png


+ 5 - 0
lib/app.dart

@@ -8,6 +8,7 @@ import 'core/theme/app_theme.dart';
 import 'core/display_controller.dart';
 import 'core/theme/theme_controller.dart';
 import 'features/home/home_screen.dart';
+import 'features/home/gps_speed_controller.dart';
 import 'features/home/launcher_controller.dart';
 import 'features/home/obd_controller.dart';
 import 'features/home/system_controller.dart';
@@ -32,6 +33,7 @@ class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
       _applyImmersive();
       context.read<SystemController>().start();
       unawaited(context.read<ObdController>().start());
+      unawaited(context.read<GpsSpeedController>().start());
       unawaited(context.read<WeatherController>().start());
     });
   }
@@ -53,6 +55,8 @@ class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
         system.start();
         unawaited(context.read<DisplayController>().apply());
         unawaited(context.read<ObdController>().start());
+        unawaited(context.read<GpsSpeedController>().start());
+        unawaited(context.read<GpsSpeedController>().start());
         unawaited(context.read<WeatherController>().start());
         context.read<LauncherController>().refresh();
       case AppLifecycleState.inactive:
@@ -65,6 +69,7 @@ class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
       case AppLifecycleState.detached:
         system.stop();
         context.read<ObdController>().stop();
+        context.read<GpsSpeedController>().stop();
         context.read<WeatherController>().stop();
     }
   }

+ 44 - 0
lib/data/models/gps_speed.dart

@@ -0,0 +1,44 @@
+import 'package:flutter/foundation.dart';
+
+/// Native GPS speed snapshot. Speed is in km/h; a stationary fix is 0.
+@immutable
+class GpsSpeed {
+  const GpsSpeed({
+    this.hasPermission = false,
+    this.hasFix = false,
+    this.speedKmh,
+    this.updatedAt = 0,
+  });
+
+  final bool hasPermission;
+  final bool hasFix;
+  final double? speedKmh;
+  final int updatedAt;
+
+  bool isFresh(DateTime now) {
+    if (!hasFix || speedKmh == null || updatedAt <= 0) return false;
+    final age = now.millisecondsSinceEpoch - updatedAt;
+    return age >= 0 && age < 5000;
+  }
+
+  factory GpsSpeed.fromMap(Map<dynamic, dynamic> map) {
+    return GpsSpeed(
+      hasPermission: map['hasPermission'] == true,
+      hasFix: map['hasFix'] == true,
+      speedKmh: (map['speedKmh'] as num?)?.toDouble(),
+      updatedAt: (map['updatedAt'] as num?)?.toInt() ?? 0,
+    );
+  }
+
+  @override
+  bool operator ==(Object other) {
+    return other is GpsSpeed &&
+        other.hasPermission == hasPermission &&
+        other.hasFix == hasFix &&
+        other.speedKmh == speedKmh &&
+        other.updatedAt == updatedAt;
+  }
+
+  @override
+  int get hashCode => Object.hash(hasPermission, hasFix, speedKmh, updatedAt);
+}

+ 42 - 0
lib/data/services/telemetry_bridge.dart

@@ -1,6 +1,7 @@
 import 'package:flutter/services.dart';
 
 import '../models/bluetooth_device_info.dart';
+import '../models/gps_speed.dart';
 import '../models/location_reading.dart';
 import '../models/obd_state.dart';
 
@@ -31,6 +32,13 @@ abstract class TelemetryPlatform {
   Future<void> disconnectObd();
 
   Future<ObdState> getObdState();
+
+  /// Starts GPS speed updates only when precise location is already allowed.
+  Future<bool> startGpsSpeed();
+
+  Future<void> stopGpsSpeed();
+
+  Future<GpsSpeed> getGpsSpeed();
 }
 
 class NativeTelemetryBridge implements TelemetryPlatform {
@@ -127,6 +135,40 @@ class NativeTelemetryBridge implements TelemetryPlatform {
     }
   }
 
+  @override
+  Future<bool> startGpsSpeed() async {
+    try {
+      return await _channel.invokeMethod<bool>('startGpsSpeed') ?? false;
+    } on PlatformException {
+      return false;
+    } on MissingPluginException {
+      return false;
+    }
+  }
+
+  @override
+  Future<void> stopGpsSpeed() async {
+    try {
+      await _channel.invokeMethod<dynamic>('stopGpsSpeed');
+    } on PlatformException {
+      // Nothing useful to do when already stopped.
+    } on MissingPluginException {
+      // No-op outside Android.
+    }
+  }
+
+  @override
+  Future<GpsSpeed> getGpsSpeed() async {
+    try {
+      final raw = await _channel.invokeMethod<dynamic>('getGpsSpeed');
+      return raw is Map ? GpsSpeed.fromMap(raw) : const GpsSpeed();
+    } on PlatformException {
+      return const GpsSpeed();
+    } on MissingPluginException {
+      return const GpsSpeed();
+    }
+  }
+
   @override
   Future<ObdState> getObdState() async {
     try {

+ 76 - 0
lib/features/home/gps_speed_controller.dart

@@ -0,0 +1,76 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+
+import '../../data/models/gps_speed.dart';
+import '../../data/services/telemetry_bridge.dart';
+
+/// Polls the native GPS speed snapshot. Starting never asks for permission; it
+/// simply returns false when precise location is not already allowed or GPS is
+/// disabled.
+class GpsSpeedController extends ChangeNotifier {
+  GpsSpeedController(this._platform, {DateTime Function()? clock})
+    : _clock = clock ?? DateTime.now;
+
+  final TelemetryPlatform _platform;
+  final DateTime Function() _clock;
+
+  GpsSpeed _state = const GpsSpeed();
+  Timer? _timer;
+  bool _active = false;
+  bool _polling = false;
+  bool _disposed = false;
+
+  bool get hasPermission => _state.hasPermission;
+
+  bool get hasSpeed => _state.isFresh(_clock());
+
+  double? get speedKmh => hasSpeed ? _state.speedKmh : null;
+
+  Future<void> start() async {
+    if (_active) return;
+    _active = true;
+    final started = await _platform.startGpsSpeed();
+    if (!started) {
+      _notify();
+      return;
+    }
+    _timer = Timer.periodic(const Duration(seconds: 1), (_) => _poll());
+    await _poll();
+  }
+
+  void stop() {
+    _active = false;
+    _timer?.cancel();
+    _timer = null;
+    _state = const GpsSpeed();
+    unawaited(_platform.stopGpsSpeed());
+    _notify();
+  }
+
+  Future<void> _poll() async {
+    if (!_active || _polling) return;
+    _polling = true;
+    try {
+      final next = await _platform.getGpsSpeed();
+      if (next != _state) {
+        _state = next;
+        _notify();
+      }
+    } finally {
+      _polling = false;
+    }
+  }
+
+  void _notify() {
+    if (!_disposed) notifyListeners();
+  }
+
+  @override
+  void dispose() {
+    _disposed = true;
+    _timer?.cancel();
+    unawaited(_platform.stopGpsSpeed());
+    super.dispose();
+  }
+}

+ 9 - 0
lib/features/home/home_screen.dart

@@ -11,12 +11,14 @@ import '../../data/models/slot_type.dart';
 import '../apps/all_apps_screen.dart';
 import '../apps/app_picker_sheet.dart';
 import '../settings/settings_screen.dart';
+import 'gps_speed_controller.dart';
 import 'launcher_controller.dart';
 import 'obd_controller.dart';
 import 'pinned_app_actions.dart';
 import 'system_controller.dart';
 import 'weather_controller.dart';
 import 'widgets/clock_block.dart';
+import 'widgets/gps_speed_card.dart';
 import 'widgets/night_mode_overlay.dart';
 import 'widgets/obd_card.dart';
 import 'widgets/shortcut_grid.dart';
@@ -51,6 +53,7 @@ class _HomeScreenState extends State<HomeScreen> {
     final system = context.watch<SystemController>();
     final appearance = context.watch<ThemeController>();
     final obd = context.watch<ObdController>();
+    final gps = context.watch<GpsSpeedController>();
     final weather = context.watch<WeatherController>();
     final screen = MediaQuery.sizeOf(context);
     final compact = screen.height < 700 || screen.width < 1100;
@@ -210,6 +213,12 @@ class _HomeScreenState extends State<HomeScreen> {
                                     compact: compact,
                                   ),
                                   SizedBox(height: compact ? 12 : 14),
+                                ] else if (!obd.connected && gps.hasSpeed) ...[
+                                  GpsSpeedCard(
+                                    speedKmh: gps.speedKmh ?? 0,
+                                    compact: compact,
+                                  ),
+                                  SizedBox(height: compact ? 12 : 14),
                                 ],
                                 VolumeCard(
                                   volume: system.volume,

+ 89 - 0
lib/features/home/widgets/gps_speed_card.dart

@@ -0,0 +1,89 @@
+import 'package:flutter/material.dart';
+
+import '../../../core/theme/app_colors.dart';
+import '../../../core/widgets/surface_card.dart';
+
+/// Speed from GPS, shown only when no OBD-II adapter is connected.
+class GpsSpeedCard extends StatelessWidget {
+  const GpsSpeedCard({super.key, required this.speedKmh, this.compact = false});
+
+  final double speedKmh;
+  final bool compact;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+
+    return Semantics(
+      label: '${speedKmh.round()} kilometers per hour from GPS',
+      child: SurfaceCard(
+        padding: EdgeInsets.fromLTRB(
+          compact ? 16 : 20,
+          compact ? 13 : 16,
+          compact ? 16 : 20,
+          compact ? 13 : 16,
+        ),
+        radius: 24,
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Row(
+              children: [
+                Container(
+                  width: 7,
+                  height: 7,
+                  decoration: BoxDecoration(
+                    color: colors.accent,
+                    shape: BoxShape.circle,
+                  ),
+                ),
+                const SizedBox(width: 8),
+                Text(
+                  'GPS',
+                  style: theme.textTheme.labelMedium?.copyWith(
+                    color: colors.inkMuted,
+                    letterSpacing: 0.8,
+                  ),
+                ),
+                const Spacer(),
+                Text(
+                  'FROM LOCATION',
+                  style: theme.textTheme.labelSmall?.copyWith(
+                    color: colors.inkFaint,
+                    fontSize: compact ? 9 : 10,
+                    letterSpacing: 0.5,
+                  ),
+                ),
+              ],
+            ),
+            SizedBox(height: compact ? 6 : 8),
+            Row(
+              crossAxisAlignment: CrossAxisAlignment.end,
+              children: [
+                Text(
+                  speedKmh.round().toString(),
+                  style: theme.textTheme.displaySmall?.copyWith(
+                    fontSize: compact ? 38 : 46,
+                    height: 0.95,
+                    fontFeatures: const [FontFeature.tabularFigures()],
+                  ),
+                ),
+                const SizedBox(width: 7),
+                Padding(
+                  padding: const EdgeInsets.only(bottom: 5),
+                  child: Text(
+                    'km/h',
+                    style: theme.textTheme.bodySmall?.copyWith(
+                      color: colors.inkMuted,
+                    ),
+                  ),
+                ),
+              ],
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 1 - 1
lib/features/settings/settings_screen.dart

@@ -334,7 +334,7 @@ class SettingsScreen extends StatelessWidget {
                     const SizedBox(height: 28),
                     Center(
                       child: Text(
-                        'CarDash 0.9.1 · Android 10+',
+                        'CarDash 0.10.0 · Android 10+',
                         style: theme.textTheme.bodySmall?.copyWith(
                           color: colors.inkFaint,
                         ),

+ 3 - 0
lib/main.dart

@@ -11,6 +11,7 @@ import 'data/services/native_bridge.dart';
 import 'data/services/pin_service.dart';
 import 'data/services/secure_store.dart';
 import 'data/services/telemetry_bridge.dart';
+import 'features/home/gps_speed_controller.dart';
 import 'features/home/launcher_controller.dart';
 import 'features/home/obd_controller.dart';
 import 'features/home/system_controller.dart';
@@ -47,6 +48,7 @@ Future<void> main() async {
     ..initialize();
   final systemController = SystemController(platform);
   final obdController = ObdController(telemetry, settings)..initialize();
+  final gpsSpeedController = GpsSpeedController(telemetry);
   final weatherController = WeatherController(telemetry, settings)
     ..initialize();
   final lockController = LockController(PinService(FlutterSecureStore()));
@@ -60,6 +62,7 @@ Future<void> main() async {
         ChangeNotifierProvider.value(value: launcherController),
         ChangeNotifierProvider.value(value: systemController),
         ChangeNotifierProvider.value(value: obdController),
+        ChangeNotifierProvider.value(value: gpsSpeedController),
         ChangeNotifierProvider.value(value: weatherController),
         ChangeNotifierProvider.value(value: lockController),
       ],

+ 1 - 1
pubspec.yaml

@@ -1,7 +1,7 @@
 name: cardash_launcher
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 publish_to: "none"
-version: 0.9.1+10
+version: 0.10.0+11
 
 environment:
   sdk: ^3.13.7

+ 105 - 0
test/gps_speed_test.dart

@@ -0,0 +1,105 @@
+import 'package:cardash_launcher/core/theme/app_theme.dart';
+import 'package:cardash_launcher/data/models/bluetooth_device_info.dart';
+import 'package:cardash_launcher/data/models/gps_speed.dart';
+import 'package:cardash_launcher/data/models/location_reading.dart';
+import 'package:cardash_launcher/data/models/obd_state.dart';
+import 'package:cardash_launcher/data/services/telemetry_bridge.dart';
+import 'package:cardash_launcher/features/home/gps_speed_controller.dart';
+import 'package:cardash_launcher/features/home/widgets/gps_speed_card.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+class _GpsPlatform implements TelemetryPlatform {
+  _GpsPlatform({this.permission = true, this.speedKmh = 0});
+
+  final bool permission;
+  final double speedKmh;
+  var started = false;
+
+  @override
+  Future<bool> startGpsSpeed() async {
+    started = true;
+    return permission;
+  }
+
+  @override
+  Future<void> stopGpsSpeed() async {}
+
+  @override
+  Future<GpsSpeed> getGpsSpeed() async {
+    if (!permission) return const GpsSpeed();
+    return GpsSpeed(
+      hasPermission: true,
+      hasFix: true,
+      speedKmh: speedKmh,
+      updatedAt: DateTime(2026).millisecondsSinceEpoch,
+    );
+  }
+
+  @override
+  Future<List<BluetoothDeviceInfo>> listBondedDevices() async => const [];
+
+  @override
+  Future<bool> isBluetoothEnabled() async => false;
+
+  @override
+  Future<bool> ensureBluetoothPermission() async => false;
+
+  @override
+  Future<bool> ensureLocationPermission() async => permission;
+
+  @override
+  Future<LocationReading> getLocation() async =>
+      const LocationReading(latitude: 0, longitude: 0, place: '');
+
+  @override
+  Future<void> connectObd(String address) async {}
+
+  @override
+  Future<void> disconnectObd() async {}
+
+  @override
+  Future<ObdState> getObdState() async => const ObdState();
+}
+
+void main() {
+  test('stationary GPS fix reports 0 km/h', () async {
+    final platform = _GpsPlatform(speedKmh: 0);
+    final controller = GpsSpeedController(
+      platform,
+      clock: () => DateTime(2026),
+    );
+    addTearDown(controller.dispose);
+    await controller.start();
+    expect(platform.started, isTrue);
+    expect(controller.speedKmh, 0);
+    controller.stop();
+  });
+
+  test('no precise location means no prompt and no speed', () async {
+    final platform = _GpsPlatform(permission: false);
+    final controller = GpsSpeedController(
+      platform,
+      clock: () => DateTime(2026),
+    );
+    addTearDown(controller.dispose);
+    await controller.start();
+    expect(controller.hasPermission, isFalse);
+    expect(controller.hasSpeed, isFalse);
+    controller.stop();
+  });
+
+  testWidgets('GPS card displays 0 km/h', (tester) async {
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: AppTheme.dark(),
+        home: const Scaffold(
+          body: SizedBox(width: 380, child: GpsSpeedCard(speedKmh: 0)),
+        ),
+      ),
+    );
+    expect(find.text('0'), findsOneWidget);
+    expect(find.text('km/h'), findsOneWidget);
+    expect(find.text('GPS'), findsOneWidget);
+  });
+}