Răsfoiți Sursa

feat: add OBD-II telemetry and location temperature fallback

Add a read-only Bluetooth Classic ELM327 path for speed, throttle, MAF economy and ambient temperature. Add the Car data Settings card with a paired-device picker, Bluetooth permission flow, and an opt-in Open-Meteo location temperature fallback with one-shot GPS permission. Show the temperature row below the date (red below 3 C) and a compact live OBD card above the volume card. Also tint the dark canvas, surfaces and borders toward the selected accent so Blue no longer shows a green base. Bump to 0.6.0 and update tests, README, AGENTS, plan, OBD design note and screenshots.
Krasimir Kazakov 1 săptămână în urmă
părinte
comite
16e1f66eb4

+ 6 - 5
AGENTS.md

@@ -35,13 +35,13 @@ ADB lives at `/opt/android-sdk/platform-tools/adb`; the SDK is at `/opt/android-
 
 - `lib/core/` — theme tokens (`AppColors`, `AccentChoice`), `AppTheme`, `ThemeController` (theme/accent/battery), `AppClock`, reusable widgets.
 - `lib/data/models/` — `AppEntry`, `SlotType`, `SlotAssignment`, `SystemStatus`, `VolumeInfo`.
-- `lib/data/services/` — `NativeBridge` (platform channels), `PinService`, secure-store adapter.
+- `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` (apps, slots, pinned apps, 16-button cap, HOME role), `SystemController` (volume/battery/Wi-Fi/media polling), equalizer.
+- `lib/features/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, equalizer.
 - `lib/features/apps/` — All Apps screen and app-picker sheet.
-- `lib/features/settings/` — General and Security pages.
+- `lib/features/settings/` — General, Car data and Security pages.
 - `lib/features/lock/` — PIN pad, lock screen, cold-start `LockController`.
-- `android/app/src/main/kotlin/com/wasp/cardash_launcher/` — `MainActivity` (keep-screen-on, volume keys), `LauncherBridge` (packages, icons, system flag, role), `SystemBridge` (battery, Wi-Fi, volume, media activity).
+- `android/app/src/main/kotlin/com/wasp/cardash_launcher/` — `MainActivity`, `LauncherBridge`, `SystemBridge`, `TelemetryBridge`, `ObdService` (Classic SPP ELM327).
 - `tool/` — emulator/install/check scripts. `docs/` — implementation plan and design notes.
 
 Rules:
@@ -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.
-- NEVER add the INTERNET permission or weather without human confirmation.
+- INTERNET, location and Bluetooth permissions are approved only for the OBD/Open-Meteo weather feature; no 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,4 +96,5 @@ notifyListeners();
 - `FLAG_KEEP_SCREEN_ON` in `MainActivity` is intentional. It applies while CarDash is the foreground window; do not move it to a permission-based wake lock.
 - The Music equalizer follows `AudioManager.isMusicActive`, not the playing package. Exact attribution needs notification access and is out of scope unless requested.
 - 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.
 - Reinstalling the app can clear its HOME role; re-run `tool/install.sh` or use Settings > Home app.

+ 57 - 19
README.md

@@ -11,7 +11,8 @@ mechanism. No overlays, no root, no kiosk setup.
 
 <p align="center">
   <em>Verified on Android 15 and Android 10 tablet emulators at 1280×800 and
-  1024×600. Runs on a physical Android 17 HyperOS tablet. Landscape only.</em>
+  1024×600. Runs on a physical Android 17 HyperOS tablet. OBD-II UI/parser is
+  implemented; the ELM327 socket path still needs a real-car test. Landscape only.</em>
 </p>
 
 ## Screenshots
@@ -25,6 +26,7 @@ Also: [16-button home](docs/screenshots/home-16.png) ·
 [All apps](docs/screenshots/all-apps.png) ·
 [app picker](docs/screenshots/app-picker.png) ·
 [Settings · accent & buttons](docs/screenshots/settings-buttons.png) ·
+[Settings · Car data](docs/screenshots/settings-car-data.png) ·
 [Blue accent](docs/screenshots/settings-blue-accent.png) ·
 [user-app icon on Home](docs/screenshots/home-user-icon.png) ·
 [pinned-app menu](docs/screenshots/home-app-menu.png) ·
@@ -39,6 +41,13 @@ Also: [16-button home](docs/screenshots/home-16.png) ·
 - **Large clock and date** with the device's 12/24-hour preference.
 - **Battery, charging and Wi-Fi** status, with one switch to hide the battery
   level and charging bolt.
+- **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.
+- **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
+  weather, and turns red below 3°C. No network/selection means no row.
 - **Media volume** — ring, slider, step and mute controls. While CarDash is in
   front, the hardware volume keys are routed to the same media stream, so the
   dashboard and the keys always agree. This is the phone's media stream, not a
@@ -71,9 +80,10 @@ Also: [16-button home](docs/screenshots/home-16.png) ·
 - **Optional PIN** — four digits, stored as a salted PBKDF2 hash in
   Keystore-backed storage. It is requested **only when the launcher process
   cold-starts**, never when returning from an app.
-- **Standard Android home role** — no special overlays or permissions.
+- **Standard Android home role** — no overlay, kiosk or root.
 - **Android 10+**, landscape-only, tablet-first (verified at 1280×800).
-- No network permission, no accounts, no telemetry, no T&C screen.
+- No accounts, no telemetry, no T&C screen. Network access is used only for the
+  optional Open-Meteo temperature fallback.
 
 ## Requirements
 
@@ -83,6 +93,8 @@ Also: [16-button home](docs/screenshots/home-16.png) ·
 - Android 10 (API 29) or newer device/emulator
 - A landscape tablet or an emulator; the app locks itself to landscape
 - The prepared tablet AVDs use the **host GPU**; a working GPU driver is recommended
+- Optional: a Bluetooth Classic ELM327 adapter already paired in Android
+- Optional: location access for the Open-Meteo temperature fallback
 
 The Android application ID is `com.wasp.cardash_launcher`. Android package
 segments cannot contain hyphens, so the requested `com.wasp.cardash-launcher`
@@ -145,6 +157,9 @@ first run shows a one-time tip pointing to this setting.
 | Settings → Home buttons switches | Show or hide Navigation, Music, Radio |
 | Long-press an app in All Apps | Add it to Home (up to 16 buttons) |
 | 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 |
+| Temperature row | OBD ambient, or location weather when OBD has none |
 | Settings | Theme, accent, battery, home buttons, default-home role, PIN |
 | All apps | Search every launchable app; tap to open, hold to pin |
 | Music tile while media is active | Shows the selected app icon + equalizer |
@@ -168,6 +183,13 @@ the app resumes.
 - Assign the app for each slot; Settings and All apps are fixed and last.
 - Home role status and shortcut to the system prompt.
 
+**Car data**
+- OBD-II adapter dropdown: None plus paired Bluetooth Classic devices.
+- Connection status and a Find/Allow action for Bluetooth permission.
+- Location temperature toggle: “Show temperature from current location if no
+  OBD-II connected.” Enabling it asks for location permission.
+- Read-only note: CarDash never writes to the ECU.
+
 **Security**
 - Set, change or remove a four-digit PIN.
 - The PIN is requested only on a cold start. It does not re-lock when switching
@@ -184,22 +206,26 @@ lib/
   core/        design tokens, theme controller, icons, shared widgets
   data/        models, platform services, SettingsRepository (single source of truth)
   features/
-    home/      home page, shortcut grid, volume card, controllers
+    home/      home page, shortcut grid, volume card, OBD/weather controllers
     apps/      All Apps, app-picker sheet
-    settings/  General + Security
+    settings/  General, Car data + Security
     lock/      PIN pad, cold-start lock screen
-android/...    MainActivity, LauncherBridge, SystemBridge (Kotlin)
+android/...    MainActivity, LauncherBridge, SystemBridge, TelemetryBridge, ObdService
 tool/          emulator, install, check scripts
 docs/          implementation plan, design notes
 ```
 
-- Flutter owns all UI. Kotlin only exposes package enumeration/launching, the
-  HOME role, battery/Wi-Fi and media volume over two `MethodChannel`s.
+- Flutter owns all UI. Kotlin exposes package enumeration/launching, the HOME
+  role, battery/Wi-Fi, media volume, Bluetooth OBD and one-shot location over
+  method channels.
+- The OBD layer is read-only, Bluetooth Classic SPP, bonded devices only. A
+  worker thread serializes ELM327 requests; Flutter polls the latest snapshot.
 - `SettingsRepository` is the only persistence path. Controllers are
   `ChangeNotifier`s injected with `provider`.
 - External apps are separate activities; Android has no public API to embed
   them inside the launcher. This app deliberately accepts full-screen launching
   for maximum compatibility.
+- Full OBD/weather design and hardware notes: [docs/OBD_TEMPERATURE.md](docs/OBD_TEMPERATURE.md).
 
 ## Testing
 
@@ -209,15 +235,16 @@ flutter test
 ./tool/check.sh
 ```
 
-The 23 tests cover PIN hashing/persistence, settings persistence, theme and
-accent resolution, battery visibility, first-run state, slot visibility, pinned
-apps, custom labels/icons, the 16-button cap, the dense shortcut grid and the
-equalizer animation. Native package enumeration, system/user app flags, the HOME
-role, volume, app launching and PIN flows were verified on API 35 and API 29
-emulators, including the release APK. The home-button, pinning, menu,
-custom-icon, user-icon and keep-awake flows were checked at 1280×800 and
-1024×600. Release 0.4.0 is installed on the emulator and the physical Android 17
-HyperOS tablet.
+The 30 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, the dense shortcut grid,
+the equalizer animation, OBD economy and the OBD card. 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. 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.6.0 is installed on the emulator and the physical Android 17 HyperOS tablet.
 
 ## Troubleshooting the emulator
 
@@ -244,6 +271,8 @@ Android 15 AVD is left running headlessly after development; use
   Apps show an original app icon by default; the predefined slot buttons,
   Settings and All apps stay stylized. A pinned icon can be replaced with the
   Default app icon or the 29 stylized choices.
+- **Accent-tinted dark theme** — dark canvas, surfaces and borders blend toward
+  the selected accent, so Blue no longer shows a green base.
 - **Media equalizer** reacts to system media activity, not a per-app media
   session. Android does not expose the playing package to a normal launcher
   without notification access; adding that permission is deliberately out of
@@ -260,11 +289,19 @@ Android 15 AVD is left running headlessly after development; use
   theme. Default restores the pinned app's own icon.
 - **No global play/pause control.** Volume is on the home screen; playback
   buttons live in the media app itself.
-- **No weather, Bluetooth, calls or climate** in this version.
+- **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.
+- **Weather fallback** uses Open-Meteo and the device location. It requires
+  location permission and network access; if either is unavailable, the
+  temperature row is hidden. OBD ambient wins whenever present.
+- **Location data is not stored.** Only the selected adapter address and the
+  weather toggle are persisted.
 - **`QUERY_ALL_PACKAGES`** is used to list all launchable apps. It is reasonable
   for a launcher but requires a Play policy declaration if the app is ever
   distributed; this is a local/sideload build for now.
-- **No internet permission.** The app does not fetch maps, weather or updates.
+- **Network access** is only for the optional Open-Meteo temperature fallback;
+  there are no accounts, ads, telemetry or update checks.
 - The launcher cannot replace Android's lock screen. The PIN does not protect a
   running session, by design.
 
@@ -281,6 +318,7 @@ numerals and quiet line icons. It is not a copy of that UI.
 ## Roadmap
 
 - Optional compact now-playing control beside the volume card.
+- Real-car ELM327 soak test (speed/throttle/MAF/ambient).
 - Exact per-app media detection via notification access, if desired.
 - Release signing configuration.
 - Real-device soak test on an Android 10 car tablet.

+ 9 - 0
android/app/src/main/AndroidManifest.xml

@@ -4,6 +4,15 @@
     <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
     <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
+    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
+    <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
+    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
+    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+
+    <uses-feature android:name="android.hardware.bluetooth" android:required="false" />
+    <uses-feature android:name="android.hardware.location.gps" android:required="false" />
 
     <application
         android:label="CarDash"

+ 15 - 0
android/app/src/main/kotlin/com/wasp/cardash_launcher/MainActivity.kt

@@ -10,6 +10,7 @@ import io.flutter.embedding.engine.FlutterEngine
 class MainActivity : FlutterActivity() {
     private var launcherBridge: LauncherBridge? = null
     private var systemBridge: SystemBridge? = null
+    private var telemetryBridge: TelemetryBridge? = null
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
@@ -28,6 +29,18 @@ class MainActivity : FlutterActivity() {
         systemBridge = SystemBridge(this).also {
             it.attach(flutterEngine.dartExecutor.binaryMessenger)
         }
+        telemetryBridge = TelemetryBridge(this).also {
+            it.attach(flutterEngine.dartExecutor.binaryMessenger)
+        }
+    }
+
+    override fun onRequestPermissionsResult(
+        requestCode: Int,
+        permissions: Array<out String>,
+        grantResults: IntArray,
+    ) {
+        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+        telemetryBridge?.onRequestPermissionsResult(requestCode, grantResults)
     }
 
     /**
@@ -67,8 +80,10 @@ class MainActivity : FlutterActivity() {
     override fun onDestroy() {
         launcherBridge?.dispose()
         systemBridge?.dispose()
+        telemetryBridge?.dispose()
         launcherBridge = null
         systemBridge = null
+        telemetryBridge = null
         super.onDestroy()
     }
 }

+ 222 - 0
android/app/src/main/kotlin/com/wasp/cardash_launcher/ObdService.kt

@@ -0,0 +1,222 @@
+package com.wasp.cardash_launcher
+
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothSocket
+import java.io.InputStream
+import java.io.OutputStream
+import java.util.UUID
+import kotlin.math.min
+
+/**
+ * Minimal read-only ELM327 client over Bluetooth Classic SPP.
+ *
+ * It deliberately only polls the values CarDash displays: speed, throttle, MAF
+ * and ambient temperature. All writes happen through one thread; the Flutter
+ * side polls [snapshot] for the latest values.
+ */
+class ObdService(
+    private val adapter: BluetoothAdapter,
+    private val device: BluetoothDevice,
+) {
+    companion object {
+        private val SPP_UUID: UUID =
+            UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
+        private const val READ_TIMEOUT_MS = 2_000L
+        private const val AMBIENT_EVERY = 30
+    }
+
+    private var thread: Thread? = null
+    @Volatile private var running = true
+    @Volatile private var connected = false
+    @Volatile private var connecting = true
+    @Volatile private var speedKmh: Double? = null
+    @Volatile private var throttlePercent: Double? = null
+    @Volatile private var mafGramsPerSecond: Double? = null
+    @Volatile private var ambientCelsius: Double? = null
+    @Volatile private var error: String? = null
+    @Volatile private var lastPollAt: Long = 0L
+
+    private var socket: BluetoothSocket? = null
+    private var input: InputStream? = null
+    private var output: OutputStream? = null
+
+    fun start() {
+        thread = Thread({ runLoop() }, "cardash-obd").also {
+            it.isDaemon = true
+            it.start()
+        }
+    }
+
+    fun stop() {
+        running = false
+        connected = false
+        connecting = false
+        closeSocket()
+        thread?.interrupt()
+        thread = null
+    }
+
+    fun snapshot(): Map<String, Any?> {
+        return mapOf(
+            "connected" to connected,
+            "connecting" to connecting,
+            "speedKmh" to speedKmh,
+            "throttle" to throttlePercent,
+            "maf" to mafGramsPerSecond,
+            "ambient" to ambientCelsius,
+            "error" to error,
+            "updatedAt" to lastPollAt,
+        )
+    }
+
+    private fun runLoop() {
+        var backoff = 1_000L
+        while (running) {
+            try {
+                connecting = true
+                connected = false
+                error = null
+                socket = device.createRfcommSocketToServiceRecord(SPP_UUID)
+                try {
+                    adapter.cancelDiscovery()
+                } catch (_: Throwable) {
+                    // Discovery is optional; some stacks reject this without scan permission.
+                }
+                socket?.connect()
+                input = socket?.inputStream
+                output = socket?.outputStream
+                handshake()
+                connected = true
+                connecting = false
+                error = null
+                backoff = 1_000L
+                pollLoop()
+            } catch (problem: Throwable) {
+                connected = false
+                connecting = false
+                error = problem.message ?: "OBD connection lost"
+                closeSocket()
+                if (running) {
+                    try {
+                        Thread.sleep(backoff)
+                    } catch (_: InterruptedException) {
+                        Thread.currentThread().interrupt()
+                    }
+                    backoff = min(backoff * 2, 10_000L)
+                }
+            }
+        }
+        connected = false
+        connecting = false
+    }
+
+    private fun handshake() {
+        for (command in listOf("ATZ", "ATE0", "ATL0", "ATS0", "ATSP0")) {
+            send(command)
+            readResponse(if (command == "ATZ") 3_000L else 2_000L)
+        }
+    }
+
+    private fun pollLoop() {
+        var tick = 0
+        while (running && connected) {
+            val speed = readPid("010D", 1)?.firstOrNull()?.toInt()?.and(0xFF)?.toDouble()
+            val throttle = readThrottle()
+            val maf = readPid("0110", 2)?.let { bytes ->
+                (((bytes[0].toInt() and 0xFF) shl 8) or (bytes[1].toInt() and 0xFF)) / 100.0
+            }
+            if (tick % AMBIENT_EVERY == 0) {
+                ambientCelsius = readAmbient()
+            }
+            speedKmh = speed
+            throttlePercent = throttle
+            mafGramsPerSecond = maf
+            lastPollAt = System.currentTimeMillis()
+            tick++
+            try {
+                Thread.sleep(700)
+            } catch (_: InterruptedException) {
+                Thread.currentThread().interrupt()
+                return
+            }
+        }
+    }
+
+    private fun readThrottle(): Double? {
+        val pedal = readPid("0149", 1)?.firstOrNull()?.toInt()?.and(0xFF)
+        if (pedal != null) return pedal * 100.0 / 255.0
+        val plate = readPid("0111", 1)?.firstOrNull()?.toInt()?.and(0xFF)
+        return plate?.let { it * 100.0 / 255.0 }
+    }
+
+    private fun readAmbient(): Double? {
+        val raw = readPid("0146", 1)?.firstOrNull()?.toInt()?.and(0xFF) ?: return null
+        val celsius = raw - 40.0
+        if (celsius < -40.0 || celsius > 215.0) return null
+        return celsius
+    }
+
+    private fun readPid(command: String, expectedBytes: Int): ByteArray? {
+        send(command)
+        val response = readResponse(READ_TIMEOUT_MS)
+        val compact = response.uppercase()
+            .replace("\r", "")
+            .replace("\n", "")
+            .replace(" ", "")
+        val marker = "41" + command.substring(2)
+        val markerAt = compact.indexOf(marker)
+        if (markerAt < 0) return null
+        val data = compact.substring(markerAt + marker.length)
+        if (data.length < expectedBytes * 2) return null
+        return ByteArray(expectedBytes) { index ->
+            data.substring(index * 2, index * 2 + 2).toInt(16).toByte()
+        }
+    }
+
+    private fun send(command: String) {
+        val out = output ?: error("OBD socket is not open")
+        out.write("$command\r".toByteArray())
+        out.flush()
+    }
+
+    private fun readResponse(timeoutMs: Long): String {
+        val input = input ?: return ""
+        val deadline = System.currentTimeMillis() + timeoutMs
+        val bytes = StringBuilder()
+        val buffer = ByteArray(512)
+        while (running && System.currentTimeMillis() < deadline) {
+            val available = try {
+                input.available()
+            } catch (_: Throwable) {
+                0
+            }
+            if (available > 0) {
+                val count = input.read(buffer)
+                if (count > 0) {
+                    bytes.append(String(buffer, 0, count, Charsets.US_ASCII))
+                    if (bytes.contains('>')) break
+                }
+            } else {
+                try {
+                    Thread.sleep(15)
+                } catch (_: InterruptedException) {
+                    Thread.currentThread().interrupt()
+                    break
+                }
+            }
+        }
+        return bytes.toString()
+    }
+
+    private fun closeSocket() {
+        try {
+            socket?.close()
+        } catch (_: Throwable) {
+            // Best effort.
+        }
+        socket = null
+        input = null
+        output = null
+    }
+}

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

@@ -0,0 +1,340 @@
+package com.wasp.cardash_launcher
+
+import android.Manifest
+import android.app.Activity
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothManager
+import android.content.pm.PackageManager
+import android.location.Geocoder
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Build
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import io.flutter.plugin.common.BinaryMessenger
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+import java.util.Locale
+import java.util.concurrent.Executors
+
+/**
+ * Bluetooth OBD, bonded-device listing and one-shot location for weather.
+ *
+ * OBD is Bluetooth Classic SPP only. The adapter itself is never scanned here;
+ * the user pairs the ELM327 in Android Settings first.
+ */
+class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCallHandler {
+
+    companion object {
+        const val CHANNEL = "com.wasp.cardash_launcher/telemetry"
+        private const val REQUEST_BLUETOOTH = 41
+        private const val REQUEST_LOCATION = 42
+        private const val KIND_BLUETOOTH = 1
+        private const val KIND_LOCATION = 2
+    }
+
+    private var channel: MethodChannel? = null
+    private var obdService: ObdService? = null
+    private val mainHandler = Handler(Looper.getMainLooper())
+    private val locationExecutor = Executors.newSingleThreadExecutor()
+    private var pendingPermissionResult: MethodChannel.Result? = null
+    private var pendingPermissionKind = 0
+    @Volatile private var disposed = false
+
+    fun attach(messenger: BinaryMessenger) {
+        channel = MethodChannel(messenger, CHANNEL).also { it.setMethodCallHandler(this) }
+    }
+
+    fun dispose() {
+        disposed = true
+        obdService?.stop()
+        obdService = null
+        channel?.setMethodCallHandler(null)
+        channel = null
+        locationExecutor.shutdownNow()
+    }
+
+    override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
+        when (call.method) {
+            "listBondedDevices" -> listBondedDevices(result)
+            "isBluetoothEnabled" -> result.success(bluetoothAdapter()?.isEnabled == true)
+            "ensureBluetoothPermission" -> ensurePermission(KIND_BLUETOOTH, result)
+            "ensureLocationPermission" -> ensurePermission(KIND_LOCATION, result)
+            "getLocation" -> getLocation(result)
+            "connectObd" -> connectObd(call, result)
+            "disconnectObd" -> {
+                obdService?.stop()
+                obdService = null
+                result.success(true)
+            }
+            "getObdState" -> result.success(obdService?.snapshot() ?: emptyObdState())
+            else -> result.notImplemented()
+        }
+    }
+
+    fun onRequestPermissionsResult(requestCode: Int, grantResults: IntArray) {
+        val result = pendingPermissionResult ?: return
+        val kind = pendingPermissionKind
+        if (requestCode != REQUEST_BLUETOOTH && requestCode != REQUEST_LOCATION) return
+        pendingPermissionResult = null
+        pendingPermissionKind = 0
+        result.success(
+            if (kind == KIND_BLUETOOTH) hasBluetoothPermission() else hasLocationPermission(),
+        )
+    }
+
+    private fun emptyObdState(): Map<String, Any?> {
+        return mapOf(
+            "connected" to false,
+            "connecting" to false,
+            "speedKmh" to null,
+            "throttle" to null,
+            "maf" to null,
+            "ambient" to null,
+            "error" to null,
+            "updatedAt" to 0L,
+        )
+    }
+
+    private fun bluetoothAdapter(): BluetoothAdapter? {
+        return try {
+            activity.getSystemService(BluetoothManager::class.java)?.adapter
+        } catch (_: Throwable) {
+            null
+        }
+    }
+
+    private fun hasBluetoothPermission(): Boolean {
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
+        return activity.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) ==
+            PackageManager.PERMISSION_GRANTED
+    }
+
+    private fun hasLocationPermission(): Boolean {
+        val fine = activity.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
+            PackageManager.PERMISSION_GRANTED
+        val coarse = activity.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) ==
+            PackageManager.PERMISSION_GRANTED
+        return fine || coarse
+    }
+
+    private fun ensurePermission(kind: Int, result: MethodChannel.Result) {
+        val granted = if (kind == KIND_BLUETOOTH) {
+            hasBluetoothPermission()
+        } else {
+            hasLocationPermission()
+        }
+        if (granted) {
+            result.success(true)
+            return
+        }
+        if (pendingPermissionResult != null) {
+            result.error("permission_busy", "Another permission request is in progress.", null)
+            return
+        }
+        pendingPermissionResult = result
+        pendingPermissionKind = kind
+
+        val permissions = if (kind == KIND_BLUETOOTH) {
+            arrayOf(Manifest.permission.BLUETOOTH_CONNECT)
+        } else {
+            arrayOf(
+                Manifest.permission.ACCESS_FINE_LOCATION,
+                Manifest.permission.ACCESS_COARSE_LOCATION,
+            )
+        }
+        val code = if (kind == KIND_BLUETOOTH) REQUEST_BLUETOOTH else REQUEST_LOCATION
+        try {
+            activity.requestPermissions(permissions, code)
+        } catch (_: Throwable) {
+            pendingPermissionResult = null
+            pendingPermissionKind = 0
+            result.success(false)
+        }
+    }
+
+    private fun listBondedDevices(result: MethodChannel.Result) {
+        if (!hasBluetoothPermission()) {
+            result.error("permission", "Bluetooth permission is required.", null)
+            return
+        }
+        val adapter = bluetoothAdapter()
+        if (adapter == null) {
+            result.error("unavailable", "Bluetooth is not available on this device.", null)
+            return
+        }
+        if (!adapter.isEnabled) {
+            result.error("disabled", "Bluetooth is turned off.", null)
+            return
+        }
+        try {
+            val devices = adapter.bondedDevices
+                .map {
+                    mapOf(
+                        "name" to (it.name ?: "Unknown device"),
+                        "address" to it.address,
+                    )
+                }
+                .sortedBy { (it["name"] as? String)?.lowercase(Locale.getDefault()) ?: "" }
+            result.success(devices)
+        } catch (problem: Throwable) {
+            result.error("list_failed", problem.message ?: "Could not list Bluetooth devices.", null)
+        }
+    }
+
+    private fun connectObd(call: MethodCall, result: MethodChannel.Result) {
+        val mac = call.argument<String>("address")
+        if (mac.isNullOrBlank()) {
+            result.error("bad_arguments", "A Bluetooth address is required.", null)
+            return
+        }
+        if (!hasBluetoothPermission()) {
+            result.error("permission", "Bluetooth permission is required.", null)
+            return
+        }
+        val adapter = bluetoothAdapter()
+        if (adapter == null) {
+            result.error("unavailable", "Bluetooth is not available on this device.", null)
+            return
+        }
+        if (!adapter.isEnabled) {
+            result.error("disabled", "Bluetooth is turned off.", null)
+            return
+        }
+        try {
+            obdService?.stop()
+            val device = adapter.getRemoteDevice(mac)
+            obdService = ObdService(adapter, device).also { it.start() }
+            result.success(true)
+        } catch (problem: Throwable) {
+            result.error("connect_failed", problem.message ?: "Could not start OBD.", null)
+        }
+    }
+
+    private fun getLocation(result: MethodChannel.Result) {
+        if (!hasLocationPermission()) {
+            result.error("permission", "Location permission is required.", null)
+            return
+        }
+        val manager = try {
+            activity.getSystemService(LocationManager::class.java)
+        } catch (_: Throwable) {
+            null
+        }
+        if (manager == null) {
+            result.error("unavailable", "Location is not available on this device.", null)
+            return
+        }
+
+        val providers = listOf(
+            LocationManager.GPS_PROVIDER,
+            LocationManager.NETWORK_PROVIDER,
+        ).filter {
+            try {
+                manager.isProviderEnabled(it)
+            } catch (_: Throwable) {
+                false
+            }
+        }
+        val best = providers.mapNotNull {
+            try {
+                manager.getLastKnownLocation(it)
+            } catch (_: Throwable) {
+                null
+            }
+        }.maxByOrNull { it.time }
+
+        if (best != null) {
+            finishLocation(best, result)
+            return
+        }
+        if (providers.isEmpty()) {
+            result.error("location", "No location provider is enabled.", null)
+            return
+        }
+
+        var delivered = false
+        lateinit var listener: LocationListener
+        val timeout = Runnable {
+            if (delivered) return@Runnable
+            delivered = true
+            try {
+                manager.removeUpdates(listener)
+            } catch (_: Throwable) {
+                // Best effort.
+            }
+            result.error("location", "Could not get a location fix.", null)
+        }
+
+        listener = object : LocationListener {
+            override fun onLocationChanged(location: Location) {
+                if (delivered) return
+                delivered = true
+                mainHandler.removeCallbacks(timeout)
+                try {
+                    manager.removeUpdates(this)
+                } catch (_: Throwable) {
+                    // Best effort.
+                }
+                finishLocation(location, result)
+            }
+
+            override fun onProviderDisabled(provider: String) {}
+            override fun onProviderEnabled(provider: String) {}
+            override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
+        }
+
+        try {
+            for (provider in providers) {
+                manager.requestLocationUpdates(
+                    provider,
+                    0L,
+                    0f,
+                    listener,
+                    Looper.getMainLooper(),
+                )
+            }
+            mainHandler.postDelayed(timeout, 15_000L)
+        } catch (problem: Throwable) {
+            delivered = true
+            result.error("location", problem.message ?: "Could not start location updates.", null)
+        }
+    }
+
+    private fun finishLocation(location: Location, result: MethodChannel.Result) {
+        val latitude = location.latitude
+        val longitude = location.longitude
+        locationExecutor.execute {
+            var place = ""
+            try {
+                if (Geocoder.isPresent()) {
+                    @Suppress("DEPRECATION")
+                    val addresses = Geocoder(activity, Locale.getDefault())
+                        .getFromLocation(latitude, longitude, 1)
+                    val address = addresses?.firstOrNull()
+                    place = address?.locality
+                        ?: address?.subAdminArea
+                        ?: address?.adminArea
+                        ?: address?.countryName
+                        ?: ""
+                }
+            } catch (_: Throwable) {
+                // A missing place name is fine; weather can still use coordinates.
+            }
+            if (disposed) return@execute
+            mainHandler.post {
+                if (!disposed) {
+                    result.success(
+                        mapOf(
+                            "latitude" to latitude,
+                            "longitude" to longitude,
+                            "place" to place,
+                        ),
+                    )
+                }
+            }
+        }
+    }
+}

+ 36 - 6
docs/IMPLEMENTATION_PLAN.md

@@ -30,6 +30,11 @@ compatibility:
 - The Home-role banner is gone; a one-time first-run tip points to Settings.
 - Long-pressing a pinned app opens Rename, Change icon and Remove. Rename/icon
   changes persist; Remove needs no confirmation.
+- Optional OBD-II over a paired Bluetooth Classic ELM327: digital speed, throttle
+  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.
+- 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.
 - Landscape-only; tablet-first 1280×800; Android 10 minimum.
@@ -53,6 +58,12 @@ compatibility:
 | Home role | Settings row only; one-time first-run tip | Removes permanent dashboard chrome while keeping discovery. |
 | Pinned editing | Long-press menu: Rename / Change icon / Remove | Keeps the home page clean; removal remains immediate. |
 | Custom icon source | Default app icon plus 29 bundled monochrome line icons | Matches the stylized dashboard and follows light/dark theme colors. |
+| OBD transport | Bluetooth Classic SPP, bonded ELM327 only, read-only | No scanning, BLE, USB or Wi-Fi; one native worker serializes requests. |
+| 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. |
+| 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` while foreground | The dashboard is the car display; no wake-lock permission needed. |
 | Theme | Light / Dark / Automatic with fixed 19:00–07:00 window | No location permission or network; time pickers make the window adjustable. |
 | PIN | Four digits, salted PBKDF2 hash, cold start only | Matches the explicit instruction; no re-lock while running. |
@@ -75,9 +86,11 @@ Flutter
   SettingsScreen -> General / Security
   PinSetupScreen / LockScreen -> LockController -> PinService
 Android (Kotlin)
-  MainActivity -> keep-screen-on, volume keys
+  MainActivity -> keep-screen-on, volume keys, permission forwarding
                -> LauncherBridge (packages, icons, system flag, role)
                -> SystemBridge (battery, Wi-Fi, volume, media activity)
+               -> TelemetryBridge (Bluetooth devices, OBD, location)
+               -> ObdService (Classic SPP ELM327 worker)
 ```
 
 - `SettingsRepository` is the only persistence path for theme, night window,
@@ -88,7 +101,7 @@ Android (Kotlin)
 - Timers for volume/status are stopped when the app leaves the foreground.
 - The PIN check is awaited before `runApp`, so there is no dashboard flash on a
   locked cold start.
-- No INTERNET permission, no weather, no telemetry.
+- Network access is limited to the optional Open-Meteo weather fallback; no telemetry.
 
 ## 4. Milestones
 
@@ -106,6 +119,7 @@ Android (Kotlin)
 | M9 | Accent colors, battery toggle, fading hint, user icons, Media equalizer, install rule | Done |
 | M10 | Remove Home-role banner, one-time first-run tip, pinned Rename/icon/remove menu | Done |
 | M11 | Restore stylized fixed icons; pinned apps show own icons by default; 29-icon selector | Done |
+| M12 | Accent-tinted dark theme, OBD-II adapter/telemetry, location weather fallback, Car data Settings | Done |
 
 ## 5. Verification checklist
 
@@ -141,8 +155,13 @@ Android (Kotlin)
 - [x] Rename updates the Home label; Change icon persists a stylized icon; Remove is immediate.
 - [x] Fixed buttons stay stylized; pinned apps show their own icons by default.
 - [x] Change icon offers Default plus 29 monochrome icons and applies the chosen icon.
-- [x] Release 0.5.0 installed on the emulator and physical Android 17 tablet.
-- [x] Screenshots in `docs/screenshots/`; README/AGENTS/plan updated.
+- [x] Accent-tinted dark background changes from green to blue when Blue is chosen.
+- [x] OBD Settings device dropdown and Bluetooth permission action are present.
+- [x] OBD economy/card tests pass; native ELM327 path compiles.
+- [x] Location permission, reverse geocoding and Open-Meteo fallback verified live (Ukiah, 17°C).
+- [x] Temperature row renders below the date and follows OBD/weather priority.
+- [x] Release 0.6.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
 
@@ -168,14 +187,20 @@ Android (Kotlin)
 9. **Pinned customizations**: Rename affects only the Home label. Default
    restores the app icon; a chosen stylized icon is a bundled asset and cannot
    become unavailable.
-10. **Uninstall/reinstall** may drop the HOME role; Settings and the installer
+10. **OBD hardware is untested here.** No ELM327 adapter is available on this
+    machine; the native socket/protocol path compiles and the parser/economy UI is
+    unit-tested, but a real-car soak test is still required.
+11. **Weather depends on location, permission and network.** Any failure hides
+    the row; OBD ambient is always preferred when available.
+12. **Uninstall/reinstall** may drop the HOME role; Settings and the installer
     script can restore it.
 
 ## 7. Out of scope (for now)
 
 - Embedded third-party apps, overlay bars, split screen, kiosk/device-owner.
 - Global play/pause or now-playing metadata.
-- Weather, Bluetooth, calls, climate, reverse camera.
+- Wi-Fi/BLE/USB OBD adapters, DTC contents, RPM/coolant/voltage/MIL, fuel level/range.
+- Calls, climate, reverse camera.
 - An app drawer with folders/pages; a single searchable All Apps page is enough.
 - Play Store packaging/signing, remote git, CI.
 
@@ -205,3 +230,8 @@ Android (Kotlin)
   stylized; only pinned apps show their own icon by default. The Change icon
   grid now offers Default plus 29 bundled monochrome line icons. Verified
   0.5.0 on the emulator and tablet.
+- 2026-10-29: Fixed the dark accent background. Added optional ELM327 OBD-II
+  (speed, throttle, rolling MAF economy, ambient), location/Open-Meteo fallback,
+  Car data Settings with adapter dropdown and weather toggle, and permission
+  flows. Verified 30 tests, live weather on the emulator and 0.6.0 installation;
+  OBD hardware remains a car test.

+ 169 - 0
docs/OBD_TEMPERATURE.md

@@ -0,0 +1,169 @@
+# CarDash — OBD-II and temperature design note
+
+_Status: implemented in CarDash 0.6.0; OBD hardware still needs a real-car test.
+Last updated: 2026-10-29_
+
+## 1. Scope
+
+CarDash optionally reads live data from a Bluetooth Classic ELM327 adapter in
+the car's OBD-II port and shows it on the home page. When OBD does not provide
+an ambient temperature, an optional location-based weather fallback can fill
+the temperature row.
+
+### Implemented values
+
+| Value | PID | Notes |
+|---|---|---|
+| Vehicle speed, km/h | `0D` | Digital home readout |
+| Throttle, % | `49`, fallback `11` | Horizontal bar |
+| MAF, g/s | `10` | Rolling fuel-economy input |
+| Ambient temperature, °C | `46` | OBD temperature row |
+
+The economy figure is a **5 km rolling average** in L/100 km. It uses petrol
+constants (stoichiometric AFR 14.7, 745 g/L), ignores MAF on a closed-throttle
+coast above 10 km/h, and shows `—` until at least 0.5 km has accumulated.
+
+### Out of scope
+
+- RPM, coolant, module voltage, MIL/DTC contents, fuel level, range.
+- Mode `04`/`08` writes; CarDash is read-only.
+- Wi-Fi, USB and BLE adapters.
+- Cabin temperature.
+- Background OBD polling or background location.
+
+## 2. Decisions
+
+| # | Decision |
+|---|---|
+| D1 | Bluetooth Classic SPP only; only devices already paired in Android Settings. No scan UI. |
+| D2 | Throttle uses accelerator `49`, falling back to plate `11`. |
+| D3 | Metric L/100 km only. |
+| D4 | Small live card above the volume card; temperature row below the date. |
+| D5 | Rolling window resets on disconnect or a sample gap over 30 s. |
+| D6 | No fuel level or range. |
+| D7 | OBD ambient wins over weather whenever it is present and fresh. |
+
+## 3. Permissions
+
+| Feature | Manifest | Runtime |
+|---|---|---|
+| Bluetooth 29–30 | `BLUETOOTH`, `BLUETOOTH_ADMIN` (`maxSdkVersion="30"`) | install-time |
+| Bluetooth 31+ | `BLUETOOTH_CONNECT` | requested from the Car data card |
+| Weather | `INTERNET`, `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION` | location requested when the weather toggle is enabled |
+
+There is no `BLUETOOTH_SCAN`. The app lists bonded devices only and requests
+Bluetooth permission from the Car data card before listing them.
+
+Location is one-shot. Coordinates and place names are held only in memory for
+the current weather value; they are not persisted. The adapter address and the
+weather toggle are persisted in `SettingsRepository`.
+
+## 4. Architecture
+
+```
+Kotlin
+  TelemetryBridge  MethodChannel "com.wasp.cardash_launcher/telemetry"
+    - listBondedDevices / isBluetoothEnabled / ensureBluetoothPermission
+    - connectObd(mac) / disconnectObd / getObdState
+    - ensureLocationPermission / getLocation (last-known or single fix + Geocoder)
+  ObdService       one worker thread over BluetoothSocket/RFCOMM SPP
+    - handshake ATZ, ATE0, ATL0, ATS0, ATSP0
+    - request/response serialized, prompt-terminated
+    - latest snapshot polled by Dart
+
+Dart
+  NativeTelemetryBridge  platform boundary
+  ObdController          connection lifecycle, 1 s snapshot polling, status
+  ObdEconomy             MAF/speed integrator with 5 km window
+  WeatherController      toggle, permission, one-shot location, weather cache
+  WeatherService         keyless Open-Meteo current temperature_2m
+  TelemetryBridge models BluetoothDeviceInfo / LocationReading / ObdState
+  SettingsRepository     obd.adapterAddress, weather.locationTemperature
+```
+
+The native `ObdService` polls speed, throttle and MAF roughly once per second;
+ambient temperature is refreshed every 30 poll cycles. A snapshot carries
+`updatedAt`; the Dart side treats data older than five seconds as stale and
+hides the live card. Connection failures surface as a short Settings status and
+keep the dashboard quiet.
+
+## 5. Home UI
+
+- **Temperature row** immediately below the date:
+  - OBD: `Outside, 7°C`
+  - Weather: `Sofia, 7°C` (or `Current location, 7°C`)
+  - Red below 3°C, otherwise normal ink.
+  - OBD ambient is always preferred. If OBD is disconnected or has no ambient
+    value, the location weather fallback is used when enabled and available.
+  - If OBD has no temperature and weather is off/unavailable, no row is shown.
+- **OBD card** in the left column above the media-volume card, only while fresh
+  telemetry exists:
+
+  ```
+  ● OBD-II                         LIVE
+  44 km/h
+  Throttle                     22%
+  [========------------------]
+  Rolling economy        7.5 L/100 km
+  ```
+
+- The card and row disappear cleanly when off/no adapter/no fresh data. The
+  16-button grid keeps its own space.
+
+## 6. Settings — Car data
+
+- **OBD-II adapter**: dropdown containing `None` and paired Bluetooth Classic
+  devices. `Find`/`Allow` requests Bluetooth permission and refreshes the list.
+- Connection status: Off / Connecting / Connected / waiting for data / error.
+- **Location temperature** toggle: “Show temperature from current location if
+  no OBD-II connected.” Enabling it asks for location permission.
+- Read-only note: CarDash never writes to the ECU.
+
+Selecting a device persists its MAC address and connects immediately when the
+app is active. Selecting `None` disconnects. Polling stops when CarDash leaves
+the foreground and reconnects on resume.
+
+## 7. Weather fallback
+
+`WeatherController`:
+
+1. Requires the toggle to be enabled and location permission granted.
+2. Gets one location fix through `TelemetryBridge.getLocation`.
+3. Reverse-geocodes a display name with Android `Geocoder`.
+4. Queries Open-Meteo:
+
+   `https://api.open-meteo.com/v1/forecast?...&current=temperature_2m&timezone=auto`
+
+5. Refreshes every 15 minutes while enabled.
+
+Any failure clears the temperature and hides the row. No API key, account or
+coordinates are stored.
+
+## 8. Failure and freshness
+
+- Missing PID → null, never zero.
+- OBD data older than 5 s → stale; hide live card and ambient.
+- Ambient `-40`/`215` sentinel values are rejected.
+- Weather failure → no row.
+- Permission denial is a normal state; the Settings card shows the error and
+  offers the relevant Allow action.
+- The OBD status is visible only in Settings; the dashboard stays quiet.
+
+## 9. Testing
+
+- Dart unit tests cover the rolling economy math and the OBD card.
+- Settings persistence covers the adapter address and weather toggle.
+- The API 35 emulator verifies the location permission path, reverse geocoding,
+  Open-Meteo fetch and the temperature row (`Ukiah, 17°C` during implementation).
+- The Android bridge compiles for API 29/35.
+- **Not yet verified:** a real ELM327 socket, PID responses, clone timing and
+  reconnect behavior in a car. This is the main remaining soak test.
+
+## 10. Decision log
+
+- 2026-10-29 — Classic SPP, bonded-only, read-only subset: speed, throttle,
+  MAF economy, ambient.
+- 2026-10-29 — Added Open-Meteo/location fallback at the user's request;
+  INTERNET and location permissions accepted for this feature only.
+- 2026-10-29 — Implemented in 0.6.0 with Car data Settings, live OBD card and
+  temperature row; hardware verification deferred to a car.

BIN
docs/screenshots/home-16.png


BIN
docs/screenshots/settings-car-data.png


+ 10 - 0
lib/app.dart

@@ -1,3 +1,5 @@
+import 'dart:async';
+
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:provider/provider.dart';
@@ -6,7 +8,9 @@ import 'core/theme/app_theme.dart';
 import 'core/theme/theme_controller.dart';
 import 'features/home/home_screen.dart';
 import 'features/home/launcher_controller.dart';
+import 'features/home/obd_controller.dart';
 import 'features/home/system_controller.dart';
+import 'features/home/weather_controller.dart';
 import 'features/lock/lock_controller.dart';
 import 'features/lock/lock_screen.dart';
 
@@ -26,6 +30,8 @@ class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
       if (!mounted) return;
       _applyImmersive();
       context.read<SystemController>().start();
+      unawaited(context.read<ObdController>().start());
+      unawaited(context.read<WeatherController>().start());
     });
   }
 
@@ -44,6 +50,8 @@ class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
         _applyImmersive();
         context.read<ThemeController>().refreshNow();
         system.start();
+        unawaited(context.read<ObdController>().start());
+        unawaited(context.read<WeatherController>().start());
         context.read<LauncherController>().refresh();
       case AppLifecycleState.inactive:
         // The system volume panel and permission dialogs can make the activity
@@ -54,6 +62,8 @@ class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
       case AppLifecycleState.paused:
       case AppLifecycleState.detached:
         system.stop();
+        context.read<ObdController>().stop();
+        context.read<WeatherController>().stop();
     }
   }
 

+ 23 - 0
lib/core/theme/app_colors.dart

@@ -64,7 +64,30 @@ class AppColors extends ThemeExtension<AppColors> {
   }
 
   static AppColors darkFor(AccentChoice accent) {
+    final tint = accent.darkAccent;
     return dark.copyWith(
+      // Keep the dark surfaces neutral and tint them toward the chosen accent.
+      // Without this the fixed green base shows through even when Blue is picked.
+      canvas: Color.alphaBlend(
+        tint.withValues(alpha: 0.11),
+        const Color(0xFF090B0D),
+      ),
+      canvasDeep: Color.alphaBlend(
+        tint.withValues(alpha: 0.07),
+        const Color(0xFF06080A),
+      ),
+      surface: Color.alphaBlend(
+        tint.withValues(alpha: 0.045),
+        const Color(0xFF14171A),
+      ),
+      surfaceStrong: Color.alphaBlend(
+        tint.withValues(alpha: 0.035),
+        const Color(0xFF1A1E21),
+      ),
+      border: Color.alphaBlend(
+        tint.withValues(alpha: 0.10),
+        const Color(0xFF293033),
+      ),
       accent: accent.darkAccent,
       accentInk: accent.darkInk,
       accentSoft: accent.darkSoft,

+ 16 - 0
lib/data/models/bluetooth_device_info.dart

@@ -0,0 +1,16 @@
+/// A Bluetooth Classic device already paired with the tablet.
+class BluetoothDeviceInfo {
+  const BluetoothDeviceInfo({required this.name, required this.address});
+
+  final String name;
+  final String address;
+
+  factory BluetoothDeviceInfo.fromMap(Map<dynamic, dynamic> map) {
+    return BluetoothDeviceInfo(
+      name: (map['name'] as String?)?.trim() ?? 'Unknown device',
+      address: (map['address'] as String?) ?? '',
+    );
+  }
+
+  String get label => name.isEmpty ? address : '$name · $address';
+}

+ 22 - 0
lib/data/models/location_reading.dart

@@ -0,0 +1,22 @@
+/// A one-shot location fix plus a best-effort place label.
+class LocationReading {
+  const LocationReading({
+    required this.latitude,
+    required this.longitude,
+    required this.place,
+  });
+
+  final double latitude;
+  final double longitude;
+  final String place;
+
+  factory LocationReading.fromMap(Map<dynamic, dynamic> map) {
+    return LocationReading(
+      latitude: (map['latitude'] as num?)?.toDouble() ?? 0,
+      longitude: (map['longitude'] as num?)?.toDouble() ?? 0,
+      place: ((map['place'] as String?) ?? '').trim(),
+    );
+  }
+
+  String get displayPlace => place.isEmpty ? 'Current location' : place;
+}

+ 41 - 0
lib/data/models/obd_state.dart

@@ -0,0 +1,41 @@
+/// Latest values read from the ELM327 adapter.
+class ObdState {
+  const ObdState({
+    this.connected = false,
+    this.connecting = false,
+    this.speedKmh,
+    this.throttlePercent,
+    this.mafGramsPerSecond,
+    this.ambientCelsius,
+    this.error,
+    this.updatedAt = 0,
+  });
+
+  final bool connected;
+  final bool connecting;
+  final double? speedKmh;
+  final double? throttlePercent;
+  final double? mafGramsPerSecond;
+  final double? ambientCelsius;
+  final String? error;
+  final int updatedAt;
+
+  bool get hasVehicleData =>
+      connected && (speedKmh != null || throttlePercent != null);
+
+  bool get hasTemperature => connected && ambientCelsius != null;
+
+  factory ObdState.fromMap(Map<dynamic, dynamic> map) {
+    double? number(String key) => (map[key] as num?)?.toDouble();
+    return ObdState(
+      connected: map['connected'] == true,
+      connecting: map['connecting'] == true,
+      speedKmh: number('speedKmh'),
+      throttlePercent: number('throttle'),
+      mafGramsPerSecond: number('maf'),
+      ambientCelsius: number('ambient'),
+      error: map['error'] as String?,
+      updatedAt: (map['updatedAt'] as num?)?.toInt() ?? 0,
+    );
+  }
+}

+ 23 - 0
lib/data/repositories/settings_repository.dart

@@ -23,6 +23,8 @@ class SettingsRepository {
   static const _slotPrefix = 'slot';
   static const _pinnedKey = 'home.pinned';
   static const _homeRoleHintShownKey = 'onboarding.homeRoleHintShown';
+  static const _obdAddressKey = 'obd.adapterAddress';
+  static const _weatherTemperatureKey = 'weather.locationTemperature';
 
   ThemePreference get themePreference {
     return ThemePreference.fromStorage(_prefs.getString(_themeKey));
@@ -42,6 +44,15 @@ class SettingsRepository {
     return _prefs.getBool(_homeRoleHintShownKey) ?? false;
   }
 
+  String? get obdAdapterAddress {
+    final value = _prefs.getString(_obdAddressKey)?.trim();
+    return value == null || value.isEmpty ? null : value;
+  }
+
+  bool get weatherLocationTemperature {
+    return _prefs.getBool(_weatherTemperatureKey) ?? false;
+  }
+
   Future<void> setThemePreference(ThemePreference preference) {
     return _prefs.setString(_themeKey, preference.storageValue);
   }
@@ -58,6 +69,18 @@ class SettingsRepository {
     return _prefs.setBool(_homeRoleHintShownKey, shown);
   }
 
+  Future<void> setObdAdapterAddress(String? address) async {
+    if (address == null || address.isEmpty) {
+      await _prefs.remove(_obdAddressKey);
+    } else {
+      await _prefs.setString(_obdAddressKey, address);
+    }
+  }
+
+  Future<void> setWeatherLocationTemperature(bool enabled) {
+    return _prefs.setBool(_weatherTemperatureKey, enabled);
+  }
+
   Future<void> setNightWindow({
     required int startMinutes,
     required int endMinutes,

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

@@ -0,0 +1,141 @@
+import 'package:flutter/services.dart';
+
+import '../models/bluetooth_device_info.dart';
+import '../models/location_reading.dart';
+import '../models/obd_state.dart';
+
+class TelemetryException implements Exception {
+  TelemetryException(this.message);
+
+  final String message;
+
+  @override
+  String toString() => message;
+}
+
+/// Bluetooth OBD and location boundary. Kept separate from LauncherPlatform so
+/// package/launcher fakes do not need telemetry methods.
+abstract class TelemetryPlatform {
+  Future<List<BluetoothDeviceInfo>> listBondedDevices();
+
+  Future<bool> isBluetoothEnabled();
+
+  Future<bool> ensureBluetoothPermission();
+
+  Future<bool> ensureLocationPermission();
+
+  Future<LocationReading> getLocation();
+
+  Future<void> connectObd(String address);
+
+  Future<void> disconnectObd();
+
+  Future<ObdState> getObdState();
+}
+
+class NativeTelemetryBridge implements TelemetryPlatform {
+  static const _channel = MethodChannel('com.wasp.cardash_launcher/telemetry');
+
+  @override
+  Future<List<BluetoothDeviceInfo>> listBondedDevices() async {
+    try {
+      final raw = await _channel.invokeMethod<dynamic>('listBondedDevices');
+      if (raw is! List) return const [];
+      return raw
+          .whereType<Map>()
+          .map(BluetoothDeviceInfo.fromMap)
+          .where((device) => device.address.isNotEmpty)
+          .toList(growable: false);
+    } on PlatformException catch (error) {
+      throw TelemetryException(
+        error.message ?? 'Could not list Bluetooth devices.',
+      );
+    } on MissingPluginException {
+      throw TelemetryException('The telemetry bridge is unavailable.');
+    }
+  }
+
+  @override
+  Future<bool> isBluetoothEnabled() async {
+    try {
+      return await _channel.invokeMethod<bool>('isBluetoothEnabled') ?? false;
+    } on PlatformException {
+      return false;
+    } on MissingPluginException {
+      return false;
+    }
+  }
+
+  @override
+  Future<bool> ensureBluetoothPermission() async {
+    try {
+      return await _channel.invokeMethod<bool>('ensureBluetoothPermission') ??
+          false;
+    } on PlatformException {
+      return false;
+    } on MissingPluginException {
+      return false;
+    }
+  }
+
+  @override
+  Future<bool> ensureLocationPermission() async {
+    try {
+      return await _channel.invokeMethod<bool>('ensureLocationPermission') ??
+          false;
+    } on PlatformException {
+      return false;
+    } on MissingPluginException {
+      return false;
+    }
+  }
+
+  @override
+  Future<LocationReading> getLocation() async {
+    try {
+      final raw = await _channel.invokeMethod<dynamic>('getLocation');
+      if (raw is! Map) {
+        throw TelemetryException('Could not read the current location.');
+      }
+      return LocationReading.fromMap(raw);
+    } on PlatformException catch (error) {
+      throw TelemetryException(error.message ?? 'Could not read location.');
+    } on MissingPluginException {
+      throw TelemetryException('The telemetry bridge is unavailable.');
+    }
+  }
+
+  @override
+  Future<void> connectObd(String address) async {
+    try {
+      await _channel.invokeMethod<dynamic>('connectObd', {'address': address});
+    } on PlatformException catch (error) {
+      throw TelemetryException(error.message ?? 'Could not connect to OBD.');
+    } on MissingPluginException {
+      throw TelemetryException('The telemetry bridge is unavailable.');
+    }
+  }
+
+  @override
+  Future<void> disconnectObd() async {
+    try {
+      await _channel.invokeMethod<dynamic>('disconnectObd');
+    } on PlatformException {
+      // Nothing useful to do when already disconnected.
+    } on MissingPluginException {
+      // No-op outside Android.
+    }
+  }
+
+  @override
+  Future<ObdState> getObdState() async {
+    try {
+      final raw = await _channel.invokeMethod<dynamic>('getObdState');
+      return raw is Map ? ObdState.fromMap(raw) : const ObdState();
+    } on PlatformException {
+      return const ObdState();
+    } on MissingPluginException {
+      return const ObdState();
+    }
+  }
+}

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

@@ -0,0 +1,42 @@
+import 'dart:convert';
+import 'dart:io';
+
+/// Keyless current-temperature lookup for the dashboard fallback.
+///
+/// Open-Meteo is used deliberately: no API key, no account and a tiny JSON
+/// response. Any failure returns null so the UI simply hides the row.
+class WeatherService {
+  Future<double?> temperatureCelsius({
+    required double latitude,
+    required double longitude,
+  }) async {
+    final uri = Uri.https('api.open-meteo.com', '/v1/forecast', {
+      'latitude': latitude.toStringAsFixed(4),
+      'longitude': longitude.toStringAsFixed(4),
+      'current': 'temperature_2m',
+      'timezone': 'auto',
+    });
+    final client = HttpClient()..connectionTimeout = const Duration(seconds: 8);
+    try {
+      final request = await client.getUrl(uri);
+      request.headers.set(HttpHeaders.userAgentHeader, 'CarDash/0.6');
+      final response = await request.close().timeout(
+        const Duration(seconds: 10),
+      );
+      if (response.statusCode != HttpStatus.ok) return null;
+      final body = await response.transform(utf8.decoder).join();
+      final decoded = jsonDecode(body);
+      if (decoded is! Map) return null;
+      final current = decoded['current'];
+      if (current is! Map) return null;
+      final temperature = current['temperature_2m'];
+      if (temperature is! num) return null;
+      if (temperature < -90 || temperature > 60) return null;
+      return temperature.toDouble();
+    } catch (_) {
+      return null;
+    } finally {
+      client.close(force: true);
+    }
+  }
+}

+ 31 - 1
lib/features/home/home_screen.dart

@@ -12,9 +12,12 @@ import '../apps/all_apps_screen.dart';
 import '../apps/app_picker_sheet.dart';
 import '../settings/settings_screen.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/obd_card.dart';
 import 'widgets/shortcut_grid.dart';
 import 'widgets/status_cluster.dart';
 import 'widgets/volume_card.dart';
@@ -31,6 +34,8 @@ class HomeScreen extends StatelessWidget {
     final launcher = context.watch<LauncherController>();
     final system = context.watch<SystemController>();
     final appearance = context.watch<ThemeController>();
+    final obd = context.watch<ObdController>();
+    final weather = context.watch<WeatherController>();
     final screen = MediaQuery.sizeOf(context);
     final compact = screen.height < 700 || screen.width < 1100;
     final horizontalPadding = compact ? 30.0 : 44.0;
@@ -40,6 +45,18 @@ class HomeScreen extends StatelessWidget {
         : (screen.width * 0.37).clamp(300.0, 440.0);
     final gap = compact ? 26.0 : 40.0;
 
+    final obdTemperature = obd.ambientCelsius;
+    final useObdTemperature = obdTemperature != null;
+    final temperature = useObdTemperature
+        ? obdTemperature
+        : (weather.hasTemperature ? weather.temperatureCelsius : null);
+    final temperatureText = temperature == null
+        ? null
+        : useObdTemperature
+        ? 'Outside, ${temperature.round()}°C'
+        : '${weather.locationName}, ${temperature.round()}°C';
+    final temperatureAlert = temperature != null && temperature < 3;
+
     ShortcutSpec slotSpec(SlotType slot) {
       final assignment = launcher.assignmentFor(slot);
       final playing =
@@ -144,7 +161,11 @@ class HomeScreen extends StatelessWidget {
                     Row(
                       crossAxisAlignment: CrossAxisAlignment.start,
                       children: [
-                        ClockBlock(compact: compact),
+                        ClockBlock(
+                          compact: compact,
+                          temperatureText: temperatureText,
+                          temperatureAlert: temperatureAlert,
+                        ),
                         const Spacer(),
                         StatusCluster(
                           status: system.status,
@@ -164,6 +185,15 @@ class HomeScreen extends StatelessWidget {
                               crossAxisAlignment: CrossAxisAlignment.stretch,
                               mainAxisAlignment: MainAxisAlignment.end,
                               children: [
+                                if (obd.hasTelemetry) ...[
+                                  ObdCard(
+                                    speedKmh: obd.speedKmh,
+                                    throttlePercent: obd.throttlePercent,
+                                    economyL100: obd.economyL100,
+                                    compact: compact,
+                                  ),
+                                  SizedBox(height: compact ? 12 : 14),
+                                ],
                                 VolumeCard(
                                   volume: system.volume,
                                   compact: compact,

+ 223 - 0
lib/features/home/obd_controller.dart

@@ -0,0 +1,223 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+
+import '../../data/models/bluetooth_device_info.dart';
+import '../../data/models/obd_state.dart';
+import '../../data/repositories/settings_repository.dart';
+import '../../data/services/telemetry_bridge.dart';
+import 'obd_economy.dart';
+
+/// Connection lifecycle and rolling economy for the optional ELM327 adapter.
+///
+/// Bluetooth Classic SPP only; the adapter must already be paired in Android
+/// Settings. The native side queues the read-only PID requests.
+class ObdController extends ChangeNotifier {
+  ObdController(this._platform, this._settings, {DateTime Function()? clock})
+    : _clock = clock ?? DateTime.now;
+
+  final TelemetryPlatform _platform;
+  final SettingsRepository _settings;
+  final DateTime Function() _clock;
+  final ObdEconomy _economy = ObdEconomy();
+
+  String? _selectedAddress;
+  List<BluetoothDeviceInfo> _devices = const [];
+  bool _loadingDevices = false;
+  String? _devicesError;
+  bool _needsBluetoothPermission = false;
+
+  ObdState _state = const ObdState();
+  String? _localError;
+  Timer? _pollTimer;
+  bool _active = false;
+  bool _polling = false;
+  bool _disposed = false;
+  int? _lastEconomyTimestamp;
+
+  String? get selectedAddress => _selectedAddress;
+
+  List<BluetoothDeviceInfo> get devices => _devices;
+
+  bool get loadingDevices => _loadingDevices;
+
+  String? get devicesError => _devicesError;
+
+  bool get needsBluetoothPermission => _needsBluetoothPermission;
+
+  bool get connected => _state.connected;
+
+  bool get connecting => _state.connecting;
+
+  bool get hasConfiguredAdapter => _selectedAddress != null;
+
+  String? get error {
+    if (_localError != null) return _localError;
+    if (_state.connected) return null;
+    return _state.error;
+  }
+
+  String get statusLabel {
+    if (_loadingDevices) return 'Checking paired devices…';
+    if (_needsBluetoothPermission) return 'Bluetooth permission needed';
+    if (connecting) return 'Connecting…';
+    if (connected) {
+      return isFresh ? 'Connected' : 'Connected — waiting for data';
+    }
+    if (error != null) return 'Not connected — $error';
+    if (_selectedAddress == null) return 'Off';
+    return 'Not connected';
+  }
+
+  bool get isFresh {
+    if (!_state.connected || _state.updatedAt <= 0) return false;
+    final age = _clock().millisecondsSinceEpoch - _state.updatedAt;
+    return age >= 0 && age < 5000;
+  }
+
+  bool get hasTelemetry =>
+      isFresh && (_state.speedKmh != null || _state.throttlePercent != null);
+
+  double? get speedKmh => isFresh ? _state.speedKmh : null;
+
+  double? get throttlePercent => isFresh ? _state.throttlePercent : null;
+
+  double? get ambientCelsius =>
+      _state.connected && isFresh ? _state.ambientCelsius : null;
+
+  double? get economyL100 => isFresh ? _economy.litresPer100Km : null;
+
+  void initialize() {
+    _selectedAddress = _settings.obdAdapterAddress;
+  }
+
+  Future<void> start() async {
+    if (_active) return;
+    _active = true;
+    if (_selectedAddress != null) {
+      await _connect();
+    }
+    _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _poll());
+    _notify();
+  }
+
+  void stop() {
+    _active = false;
+    _pollTimer?.cancel();
+    _pollTimer = null;
+    _lastEconomyTimestamp = null;
+    _economy.reset();
+    _state = const ObdState();
+    unawaited(_platform.disconnectObd());
+    _notify();
+  }
+
+  Future<void> selectAdapter(String? address) async {
+    if (_selectedAddress == address) return;
+    _selectedAddress = address;
+    _localError = null;
+    _lastEconomyTimestamp = null;
+    _economy.reset();
+    _state = const ObdState();
+    await _settings.setObdAdapterAddress(address);
+    await _platform.disconnectObd();
+    if (_active && address != null) {
+      await _connect();
+    }
+    _notify();
+  }
+
+  /// Lists bonded devices. Pass [requestPermission] only when the user asked to
+  /// find adapters; the Settings card can show a dedicated allow button.
+  Future<bool> refreshDevices({bool requestPermission = false}) async {
+    if (_loadingDevices) return false;
+    _loadingDevices = true;
+    _devicesError = null;
+    _notify();
+    try {
+      if (requestPermission) {
+        final granted = await _platform.ensureBluetoothPermission();
+        if (!granted) {
+          _needsBluetoothPermission = true;
+          _devicesError = 'Bluetooth permission was denied.';
+          return false;
+        }
+      }
+      final enabled = await _platform.isBluetoothEnabled();
+      if (!enabled) {
+        _needsBluetoothPermission = false;
+        _devicesError = 'Bluetooth is turned off.';
+        return false;
+      }
+      final found = await _platform.listBondedDevices();
+      _devices = List.unmodifiable(found);
+      _needsBluetoothPermission = false;
+      return true;
+    } on TelemetryException catch (problem) {
+      _needsBluetoothPermission = problem.message.toLowerCase().contains(
+        'permission',
+      );
+      _devicesError = problem.message;
+      return false;
+    } finally {
+      _loadingDevices = false;
+      _notify();
+    }
+  }
+
+  Future<void> _connect() async {
+    final address = _selectedAddress;
+    if (address == null) return;
+    _localError = null;
+    try {
+      await _platform.connectObd(address);
+    } on TelemetryException catch (problem) {
+      _localError = problem.message;
+    } catch (_) {
+      _localError = 'Could not connect to the OBD adapter.';
+    }
+    _notify();
+  }
+
+  Future<void> _poll() async {
+    if (!_active || _polling) return;
+    _polling = true;
+    try {
+      final next = await _platform.getObdState();
+      final lostConnection = _state.connected && !next.connected;
+      if (lostConnection) {
+        _economy.reset();
+        _lastEconomyTimestamp = null;
+      }
+      _state = next;
+      if (next.connected && next.updatedAt > 0) {
+        if (_lastEconomyTimestamp != next.updatedAt) {
+          _lastEconomyTimestamp = next.updatedAt;
+          _economy.add(
+            at: _clock(),
+            speedKmh: next.speedKmh,
+            mafGps: next.mafGramsPerSecond,
+            throttlePercent: next.throttlePercent,
+          );
+        }
+      }
+    } catch (_) {
+      // Keep the last known state; the next poll may recover.
+    } finally {
+      _polling = false;
+      _notify();
+    }
+  }
+
+  void _notify() {
+    if (!_disposed) notifyListeners();
+  }
+
+  @override
+  void dispose() {
+    _disposed = true;
+    _pollTimer?.cancel();
+    unawaited(_platform.disconnectObd());
+    super.dispose();
+  }
+}

+ 99 - 0
lib/features/home/obd_economy.dart

@@ -0,0 +1,99 @@
+/// Rolling 5 km fuel economy calculated from MAF, speed and throttle.
+///
+/// Petrol constants: stoichiometric AFR 14.7 and 745 g/L. This is deliberately
+/// an estimate; the dashboard presents it as a trend, not a measurement.
+class ObdEconomy {
+  static const windowKm = 5.0;
+  static const minimumKm = 0.5;
+  static const _maxGap = Duration(seconds: 30);
+  static const _afr = 14.7;
+  static const _fuelDensityGramsPerLitre = 745.0;
+
+  final List<_Segment> _segments = [];
+  DateTime? _lastAt;
+  double? _lastSpeedKmh;
+  double? _lastMafGps;
+
+  double? get litresPer100Km {
+    if (_segments.isEmpty) return null;
+    var km = 0.0;
+    var litres = 0.0;
+    for (final segment in _segments) {
+      km += segment.km;
+      litres += segment.litres;
+    }
+    if (km < minimumKm || km <= 0) return null;
+    return (100 * litres / km).clamp(0.0, 99.9);
+  }
+
+  double get accumulatedKm {
+    return _segments.fold(0, (total, segment) => total + segment.km);
+  }
+
+  void reset() {
+    _segments.clear();
+    _lastAt = null;
+    _lastSpeedKmh = null;
+    _lastMafGps = null;
+  }
+
+  void add({
+    required DateTime at,
+    required double? speedKmh,
+    required double? mafGps,
+    required double? throttlePercent,
+  }) {
+    final previousAt = _lastAt;
+    _lastAt = at;
+    if (speedKmh == null || mafGps == null) {
+      _lastSpeedKmh = null;
+      _lastMafGps = null;
+      return;
+    }
+    if (previousAt == null ||
+        _lastSpeedKmh == null ||
+        _lastMafGps == null ||
+        at.difference(previousAt) > _maxGap ||
+        at.isBefore(previousAt)) {
+      if (previousAt != null && at.difference(previousAt) > _maxGap) {
+        reset();
+      }
+      _lastAt = at;
+      _lastSpeedKmh = speedKmh;
+      _lastMafGps = mafGps;
+      return;
+    }
+
+    final deltaSeconds = at.difference(previousAt).inMilliseconds / 1000.0;
+    if (deltaSeconds <= 0) return;
+    final fuelCut =
+        throttlePercent != null && throttlePercent < 1 && speedKmh > 10;
+    final litresPerHour = fuelCut
+        ? 0.0
+        : (mafGps / _afr) * 3600 / _fuelDensityGramsPerLitre;
+    final km = speedKmh * deltaSeconds / 3600;
+    if (km > 0) {
+      _segments.add(
+        _Segment(km: km, litres: litresPerHour * deltaSeconds / 3600),
+      );
+      _trim();
+    }
+    _lastAt = at;
+    _lastSpeedKmh = speedKmh;
+    _lastMafGps = mafGps;
+  }
+
+  void _trim() {
+    var km = accumulatedKm;
+    while (km > windowKm && _segments.isNotEmpty) {
+      km -= _segments.removeAt(0).km;
+    }
+  }
+}
+
+class _Segment {
+  const _Segment({required this.km, required this.litres});
+
+  final double km;
+  final double litres;
+}

+ 139 - 0
lib/features/home/weather_controller.dart

@@ -0,0 +1,139 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+
+import '../../data/models/location_reading.dart';
+import '../../data/repositories/settings_repository.dart';
+import '../../data/services/telemetry_bridge.dart';
+import '../../data/services/weather_service.dart';
+
+/// Location-based temperature fallback used only when OBD has no ambient value.
+class WeatherController extends ChangeNotifier {
+  WeatherController(
+    this._platform,
+    this._settings, {
+    WeatherService? weatherService,
+  }) : _weatherService = weatherService ?? WeatherService();
+
+  final TelemetryPlatform _platform;
+  final SettingsRepository _settings;
+  final WeatherService _weatherService;
+
+  bool _enabled = false;
+  bool _loading = false;
+  bool _active = false;
+  bool _disposed = false;
+  double? _temperatureCelsius;
+  String? _locationName;
+  String? _error;
+  Timer? _refreshTimer;
+
+  bool get enabled => _enabled;
+
+  bool get loading => _loading;
+
+  double? get temperatureCelsius => _temperatureCelsius;
+
+  String? get locationName => _locationName;
+
+  String? get error => _error;
+
+  bool get hasTemperature =>
+      _enabled && _temperatureCelsius != null && _locationName != null;
+
+  void initialize() {
+    _enabled = _settings.weatherLocationTemperature;
+  }
+
+  Future<void> start() async {
+    if (!_enabled || _active) return;
+    _active = true;
+    await refresh();
+    _refreshTimer?.cancel();
+    _refreshTimer = Timer.periodic(
+      const Duration(minutes: 15),
+      (_) => unawaited(refresh()),
+    );
+  }
+
+  void stop() {
+    _active = false;
+    _refreshTimer?.cancel();
+    _refreshTimer = null;
+  }
+
+  /// Enables the fallback and asks for location access at that moment.
+  Future<bool> setEnabled(bool value) async {
+    if (value == _enabled) return true;
+    if (value) {
+      final granted = await _platform.ensureLocationPermission();
+      if (!granted) {
+        _error = 'Location permission is required for weather.';
+        _notify();
+        return false;
+      }
+    }
+    _enabled = value;
+    await _settings.setWeatherLocationTemperature(value);
+    if (value) {
+      _active = true;
+      await refresh();
+      _refreshTimer?.cancel();
+      _refreshTimer = Timer.periodic(
+        const Duration(minutes: 15),
+        (_) => unawaited(refresh()),
+      );
+    } else {
+      stop();
+      _temperatureCelsius = null;
+      _locationName = null;
+      _error = null;
+    }
+    _notify();
+    return true;
+  }
+
+  Future<void> refresh() async {
+    if (!_enabled || _loading) return;
+    _loading = true;
+    _notify();
+    try {
+      final LocationReading location;
+      try {
+        location = await _platform.getLocation();
+      } on TelemetryException catch (problem) {
+        _error = problem.message;
+        _temperatureCelsius = null;
+        _locationName = null;
+        return;
+      }
+      final temperature = await _weatherService.temperatureCelsius(
+        latitude: location.latitude,
+        longitude: location.longitude,
+      );
+      if (temperature == null) {
+        _error = 'Weather is unavailable.';
+        _temperatureCelsius = null;
+        _locationName = null;
+        return;
+      }
+      _temperatureCelsius = temperature;
+      _locationName = location.displayPlace;
+      _error = null;
+    } finally {
+      _loading = false;
+      _notify();
+    }
+  }
+
+  void _notify() {
+    if (!_disposed) notifyListeners();
+  }
+
+  @override
+  void dispose() {
+    _disposed = true;
+    _refreshTimer?.cancel();
+    super.dispose();
+  }
+}

+ 25 - 1
lib/features/home/widgets/clock_block.dart

@@ -8,9 +8,16 @@ import '../../../core/theme/app_colors.dart';
 /// Large, quiet clock. It sleeps until the next minute instead of ticking
 /// every second.
 class ClockBlock extends StatefulWidget {
-  const ClockBlock({super.key, required this.compact});
+  const ClockBlock({
+    super.key,
+    required this.compact,
+    this.temperatureText,
+    this.temperatureAlert = false,
+  });
 
   final bool compact;
+  final String? temperatureText;
+  final bool temperatureAlert;
 
   @override
   State<ClockBlock> createState() => _ClockBlockState();
@@ -97,6 +104,23 @@ class _ClockBlockState extends State<ClockBlock> {
             ),
           ],
         ),
+        if (widget.temperatureText != null) ...[
+          const SizedBox(height: 7),
+          AnimatedSwitcher(
+            duration: const Duration(milliseconds: 220),
+            child: Text(
+              widget.temperatureText!,
+              key: ValueKey(widget.temperatureText),
+              maxLines: 1,
+              overflow: TextOverflow.ellipsis,
+              style: theme.textTheme.titleSmall?.copyWith(
+                color: widget.temperatureAlert ? colors.danger : colors.ink,
+                fontWeight: FontWeight.w600,
+                fontSize: widget.compact ? 13 : 14,
+              ),
+            ),
+          ),
+        ],
       ],
     );
   }

+ 153 - 0
lib/features/home/widgets/obd_card.dart

@@ -0,0 +1,153 @@
+import 'package:flutter/material.dart';
+
+import '../../../core/theme/app_colors.dart';
+import '../../../core/widgets/surface_card.dart';
+
+/// Compact live car data shown only while the ELM327 adapter is delivering
+/// fresh speed/throttle values.
+class ObdCard extends StatelessWidget {
+  const ObdCard({
+    super.key,
+    required this.speedKmh,
+    required this.throttlePercent,
+    required this.economyL100,
+    this.compact = false,
+  });
+
+  final double? speedKmh;
+  final double? throttlePercent;
+  final double? economyL100;
+  final bool compact;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final throttle = (throttlePercent ?? 0).clamp(0.0, 100.0);
+
+    return 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(
+                'OBD-II',
+                style: theme.textTheme.labelMedium?.copyWith(
+                  color: colors.inkMuted,
+                  letterSpacing: 0.8,
+                ),
+              ),
+              const Spacer(),
+              Text(
+                'LIVE',
+                style: theme.textTheme.labelSmall?.copyWith(
+                  color: colors.accent,
+                  fontWeight: FontWeight.w700,
+                  letterSpacing: 0.8,
+                ),
+              ),
+            ],
+          ),
+          SizedBox(height: compact ? 6 : 8),
+          Row(
+            crossAxisAlignment: CrossAxisAlignment.end,
+            children: [
+              Text(
+                speedKmh == null ? '—' : 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,
+                  ),
+                ),
+              ),
+            ],
+          ),
+          SizedBox(height: compact ? 7 : 9),
+          Row(
+            children: [
+              Text(
+                'Throttle',
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: colors.inkMuted,
+                ),
+              ),
+              const Spacer(),
+              Text(
+                '${throttle.round()}%',
+                style: theme.textTheme.labelMedium?.copyWith(
+                  color: colors.ink,
+                  fontFeatures: const [FontFeature.tabularFigures()],
+                ),
+              ),
+            ],
+          ),
+          const SizedBox(height: 5),
+          ClipRRect(
+            borderRadius: BorderRadius.circular(999),
+            child: TweenAnimationBuilder<double>(
+              tween: Tween(begin: 0, end: throttle / 100),
+              duration: const Duration(milliseconds: 180),
+              curve: Curves.easeOut,
+              builder: (context, value, _) {
+                return LinearProgressIndicator(
+                  value: value,
+                  minHeight: compact ? 5 : 6,
+                  backgroundColor: colors.border,
+                  valueColor: AlwaysStoppedAnimation(colors.accent),
+                );
+              },
+            ),
+          ),
+          SizedBox(height: compact ? 7 : 9),
+          Row(
+            children: [
+              Text(
+                'Rolling economy',
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: colors.inkMuted,
+                ),
+              ),
+              const Spacer(),
+              Text(
+                economyL100 == null
+                    ? '—'
+                    : '${economyL100!.toStringAsFixed(1)} L/100 km',
+                style: theme.textTheme.labelMedium?.copyWith(
+                  color: colors.ink,
+                  fontFeatures: const [FontFeature.tabularFigures()],
+                ),
+              ),
+            ],
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 256 - 0
lib/features/settings/car_data_section.dart

@@ -0,0 +1,256 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/widgets/app_icon.dart';
+import '../../data/models/bluetooth_device_info.dart';
+import '../home/obd_controller.dart';
+import '../home/weather_controller.dart';
+
+/// Settings card for the optional ELM327 adapter and location weather.
+class CarDataSection extends StatefulWidget {
+  const CarDataSection({super.key});
+
+  @override
+  State<CarDataSection> createState() => _CarDataSectionState();
+}
+
+class _CarDataSectionState extends State<CarDataSection> {
+  @override
+  void initState() {
+    super.initState();
+    WidgetsBinding.instance.addPostFrameCallback((_) {
+      if (!mounted) return;
+      unawaited(
+        context.read<ObdController>().refreshDevices(requestPermission: false),
+      );
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final obd = context.watch<ObdController>();
+    final weather = context.watch<WeatherController>();
+
+    final selected = obd.selectedAddress;
+    final options = <BluetoothDeviceInfo>[...obd.devices];
+    if (selected != null && !options.any((d) => d.address == selected)) {
+      options.add(
+        BluetoothDeviceInfo(name: 'Saved adapter', address: selected),
+      );
+    }
+    final selectedInOptions = options.any((d) => d.address == selected);
+    final dropdownValue = selectedInOptions ? selected : null;
+
+    final statusColor = obd.connected
+        ? colors.accent
+        : obd.error != null
+        ? colors.danger
+        : colors.inkFaint;
+
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        Row(
+          children: [
+            Container(
+              width: 42,
+              height: 42,
+              decoration: BoxDecoration(
+                color: colors.accentSoft,
+                borderRadius: BorderRadius.circular(14),
+              ),
+              alignment: Alignment.center,
+              child: AppIcon(
+                'assets/icons/car.svg',
+                size: 20,
+                color: colors.accent,
+              ),
+            ),
+            const SizedBox(width: 14),
+            Expanded(
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  Text('OBD-II adapter', style: theme.textTheme.titleMedium),
+                  const SizedBox(height: 2),
+                  Row(
+                    children: [
+                      Container(
+                        width: 7,
+                        height: 7,
+                        decoration: BoxDecoration(
+                          color: statusColor,
+                          shape: BoxShape.circle,
+                        ),
+                      ),
+                      const SizedBox(width: 7),
+                      Expanded(
+                        child: Text(
+                          obd.statusLabel,
+                          maxLines: 1,
+                          overflow: TextOverflow.ellipsis,
+                          style: theme.textTheme.bodySmall?.copyWith(
+                            color: colors.inkMuted,
+                          ),
+                        ),
+                      ),
+                    ],
+                  ),
+                ],
+              ),
+            ),
+            TextButton.icon(
+              onPressed: obd.loadingDevices
+                  ? null
+                  : () async {
+                      final ok = await obd.refreshDevices(
+                        requestPermission: true,
+                      );
+                      if (!ok && context.mounted) {
+                        _message(
+                          context,
+                          obd.devicesError ??
+                              'Could not read paired Bluetooth devices.',
+                        );
+                      }
+                    },
+              icon: AppIcon(
+                'assets/icons/search.svg',
+                size: 16,
+                color: colors.accent,
+              ),
+              label: Text(obd.needsBluetoothPermission ? 'Allow' : 'Find'),
+              style: TextButton.styleFrom(foregroundColor: colors.accent),
+            ),
+          ],
+        ),
+        const SizedBox(height: 12),
+        DropdownButtonFormField<String?>(
+          key: ValueKey(dropdownValue),
+          initialValue: dropdownValue,
+          isExpanded: true,
+          dropdownColor: colors.surface,
+          style: theme.textTheme.bodyLarge?.copyWith(color: colors.ink),
+          decoration: const InputDecoration(
+            labelText: 'Bluetooth device',
+            isDense: true,
+          ),
+          items: [
+            const DropdownMenuItem<String?>(value: null, child: Text('None')),
+            for (final device in options)
+              DropdownMenuItem<String?>(
+                value: device.address,
+                child: Text(
+                  device.label,
+                  maxLines: 1,
+                  overflow: TextOverflow.ellipsis,
+                ),
+              ),
+          ],
+          onChanged: (value) async {
+            await obd.selectAdapter(value);
+            if (!context.mounted) return;
+            if (obd.error != null) {
+              _message(context, obd.error!);
+            }
+          },
+        ),
+        if (obd.devicesError != null && !obd.needsBluetoothPermission) ...[
+          const SizedBox(height: 6),
+          Text(
+            obd.devicesError!,
+            style: theme.textTheme.bodySmall?.copyWith(color: colors.danger),
+          ),
+        ],
+        const SizedBox(height: 8),
+        Text(
+          'Pair the ELM327 in Android Bluetooth settings first. CarDash only '
+          'reads standard OBD-II PIDs; it never writes.',
+          style: theme.textTheme.bodySmall?.copyWith(color: colors.inkMuted),
+        ),
+        Divider(color: colors.border, height: 30),
+        Row(
+          children: [
+            Container(
+              width: 38,
+              height: 38,
+              decoration: BoxDecoration(
+                color: colors.surfaceStrong,
+                borderRadius: BorderRadius.circular(13),
+                border: Border.all(color: colors.border),
+              ),
+              alignment: Alignment.center,
+              child: AppIcon(
+                'assets/icons/cloud.svg',
+                size: 18,
+                color: colors.inkMuted,
+              ),
+            ),
+            const SizedBox(width: 14),
+            Expanded(
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  Text(
+                    'Location temperature',
+                    style: theme.textTheme.bodyLarge,
+                  ),
+                  const SizedBox(height: 2),
+                  Text(
+                    'Show temperature from current location if no OBD-II connected.',
+                    style: theme.textTheme.bodySmall?.copyWith(
+                      color: colors.inkMuted,
+                    ),
+                  ),
+                ],
+              ),
+            ),
+            const SizedBox(width: 8),
+            Switch(
+              value: weather.enabled,
+              onChanged: (value) async {
+                final ok = await weather.setEnabled(value);
+                if (!ok && context.mounted) {
+                  _message(
+                    context,
+                    weather.error ??
+                        'Location permission is required for weather.',
+                  );
+                }
+              },
+              thumbColor: WidgetStateProperty.resolveWith<Color?>(
+                (states) => states.contains(WidgetState.selected)
+                    ? colors.accentInk
+                    : colors.inkFaint,
+              ),
+              trackColor: WidgetStateProperty.resolveWith<Color?>(
+                (states) => states.contains(WidgetState.selected)
+                    ? colors.accent
+                    : colors.surfaceStrong,
+              ),
+              trackOutlineColor: WidgetStatePropertyAll(colors.border),
+            ),
+          ],
+        ),
+        if (weather.enabled && weather.error != null) ...[
+          const SizedBox(height: 4),
+          Text(
+            weather.error!,
+            style: theme.textTheme.bodySmall?.copyWith(color: colors.inkMuted),
+          ),
+        ],
+      ],
+    );
+  }
+
+  void _message(BuildContext context, String message) {
+    final messenger = ScaffoldMessenger.of(context);
+    messenger.hideCurrentSnackBar();
+    messenger.showSnackBar(SnackBar(content: Text(message)));
+  }
+}

+ 14 - 2
lib/features/settings/settings_screen.dart

@@ -13,6 +13,7 @@ import '../apps/app_picker_sheet.dart';
 import '../home/launcher_controller.dart';
 import '../lock/lock_controller.dart';
 import '../lock/pin_setup_screen.dart';
+import 'car_data_section.dart';
 
 class SettingsScreen extends StatelessWidget {
   const SettingsScreen({super.key});
@@ -70,7 +71,7 @@ class SettingsScreen extends StatelessWidget {
                             ),
                             const SizedBox(height: 2),
                             Text(
-                              'Theme, shortcuts and access',
+                              'Theme, car data and access',
                               style: theme.textTheme.bodySmall?.copyWith(
                                 color: colors.inkMuted,
                               ),
@@ -214,6 +215,17 @@ class SettingsScreen extends StatelessWidget {
                       ),
                     ),
                     const SizedBox(height: 30),
+                    _SectionTitle(
+                      title: 'Car data',
+                      colors: colors,
+                      theme: theme,
+                    ),
+                    const SizedBox(height: 12),
+                    SurfaceCard(
+                      padding: const EdgeInsets.fromLTRB(26, 22, 26, 20),
+                      child: const CarDataSection(),
+                    ),
+                    const SizedBox(height: 30),
                     _SectionTitle(
                       title: 'Security',
                       colors: colors,
@@ -306,7 +318,7 @@ class SettingsScreen extends StatelessWidget {
                     const SizedBox(height: 28),
                     Center(
                       child: Text(
-                        'CarDash 0.5.0 · Android 10+',
+                        'CarDash 0.6.0 · Android 10+',
                         style: theme.textTheme.bodySmall?.copyWith(
                           color: colors.inkFaint,
                         ),

+ 9 - 0
lib/main.dart

@@ -9,8 +9,11 @@ import 'data/repositories/settings_repository.dart';
 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/launcher_controller.dart';
+import 'features/home/obd_controller.dart';
 import 'features/home/system_controller.dart';
+import 'features/home/weather_controller.dart';
 import 'features/lock/lock_controller.dart';
 
 Future<void> main() async {
@@ -34,11 +37,15 @@ Future<void> main() async {
   final preferences = await SharedPreferences.getInstance();
   final settings = SettingsRepository(preferences);
   final platform = NativeBridge();
+  final telemetry = NativeTelemetryBridge();
 
   final themeController = ThemeController(settings);
   final launcherController = LauncherController(platform, settings)
     ..initialize();
   final systemController = SystemController(platform);
+  final obdController = ObdController(telemetry, settings)..initialize();
+  final weatherController = WeatherController(telemetry, settings)
+    ..initialize();
   final lockController = LockController(PinService(FlutterSecureStore()));
   await lockController.initialize();
 
@@ -48,6 +55,8 @@ Future<void> main() async {
         ChangeNotifierProvider.value(value: themeController),
         ChangeNotifierProvider.value(value: launcherController),
         ChangeNotifierProvider.value(value: systemController),
+        ChangeNotifierProvider.value(value: obdController),
+        ChangeNotifierProvider.value(value: weatherController),
         ChangeNotifierProvider.value(value: lockController),
       ],
       child: const CarDashApp(),

+ 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.5.0+5
+version: 0.6.0+6
 
 environment:
   sdk: ^3.13.7

+ 33 - 0
test/obd_card_test.dart

@@ -0,0 +1,33 @@
+import 'package:cardash_launcher/core/theme/app_theme.dart';
+import 'package:cardash_launcher/features/home/widgets/obd_card.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  testWidgets('OBD card renders speed, throttle and economy', (tester) async {
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: AppTheme.dark(),
+        home: const Scaffold(
+          body: Center(
+            child: SizedBox(
+              width: 380,
+              child: ObdCard(
+                speedKmh: 44,
+                throttlePercent: 22,
+                economyL100: 7.5,
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+    await tester.pump(const Duration(milliseconds: 250));
+
+    expect(tester.takeException(), isNull);
+    expect(find.text('44'), findsOneWidget);
+    expect(find.text('km/h'), findsOneWidget);
+    expect(find.text('Throttle'), findsOneWidget);
+    expect(find.text('7.5 L/100 km'), findsOneWidget);
+  });
+}

+ 64 - 0
test/obd_economy_test.dart

@@ -0,0 +1,64 @@
+import 'package:cardash_launcher/features/home/obd_economy.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  test('MAF and speed produce a rolling L/100 km value', () {
+    final economy = ObdEconomy();
+    final start = DateTime(2026, 10, 29, 12);
+    for (var i = 0; i <= 40; i++) {
+      economy.add(
+        at: start.add(Duration(seconds: i)),
+        speedKmh: 60,
+        mafGps: 12,
+        throttlePercent: 20,
+      );
+    }
+    expect(economy.accumulatedKm, closeTo(40 * 60 / 3600, 0.12));
+    expect(economy.litresPer100Km, closeTo(6.6, 0.2));
+  });
+
+  test('idle yields no economy value', () {
+    final economy = ObdEconomy();
+    final start = DateTime(2026);
+    for (var i = 0; i < 10; i++) {
+      economy.add(
+        at: start.add(Duration(seconds: i)),
+        speedKmh: 0,
+        mafGps: 4,
+        throttlePercent: 2,
+      );
+    }
+    expect(economy.litresPer100Km, isNull);
+  });
+
+  test('fuel cut while coasting reads zero consumption', () {
+    final economy = ObdEconomy();
+    final start = DateTime(2026);
+    for (var i = 0; i <= 35; i++) {
+      economy.add(
+        at: start.add(Duration(seconds: i)),
+        speedKmh: 60,
+        mafGps: 12,
+        throttlePercent: 0,
+      );
+    }
+    expect(economy.litresPer100Km, 0);
+  });
+
+  test('reset clears the rolling window', () {
+    final economy = ObdEconomy();
+    final start = DateTime(2026);
+    for (var i = 0; i <= 35; i++) {
+      economy.add(
+        at: start.add(Duration(seconds: i)),
+        speedKmh: 60,
+        mafGps: 12,
+        throttlePercent: 20,
+      );
+    }
+    expect(economy.litresPer100Km, isNotNull);
+    economy.reset();
+    expect(economy.litresPer100Km, isNull);
+    expect(economy.accumulatedKm, 0);
+  });
+}

+ 16 - 0
test/pin_service_test.dart

@@ -66,6 +66,22 @@ void main() {
       expect(repository.homeRoleHintShown, isTrue);
     });
 
+    test('OBD adapter and weather preferences persist', () async {
+      SharedPreferences.setMockInitialValues({});
+      final prefs = await SharedPreferences.getInstance();
+      final repository = SettingsRepository(prefs);
+
+      expect(repository.obdAdapterAddress, isNull);
+      expect(repository.weatherLocationTemperature, isFalse);
+      await repository.setObdAdapterAddress('00:11:22:33:44:55');
+      await repository.setWeatherLocationTemperature(true);
+      expect(repository.obdAdapterAddress, '00:11:22:33:44:55');
+      expect(repository.weatherLocationTemperature, isTrue);
+
+      await repository.setObdAdapterAddress(null);
+      expect(repository.obdAdapterAddress, isNull);
+    });
+
     test('home buttons default to enabled and persist a toggle', () async {
       SharedPreferences.setMockInitialValues({});
       final prefs = await SharedPreferences.getInstance();

+ 7 - 0
test/theme_controller_test.dart

@@ -39,6 +39,13 @@ void main() {
     expect(controller.isDark, isIn(<bool>[true, false]));
   });
 
+  test('dark surfaces are tinted by the chosen accent', () {
+    final green = AppColors.darkFor(AccentChoice.green);
+    final blue = AppColors.darkFor(AccentChoice.blue);
+    expect(blue.canvas, isNot(green.canvas));
+    expect(blue.canvas.b, greaterThan(blue.canvas.g));
+  });
+
   test('a chosen accent becomes the theme primary', () {
     final theme = AppTheme.light(accent: AccentChoice.blue);
     expect(theme.colorScheme.primary, AccentChoice.blue.lightAccent);