Parcourir la source

feat: split settings into OBD-II, speed and temperature cards

Add independent speed and temperature visibility switches, OBD/GPS and OBD/weather source switches, and limit the long-press hint to ten minutes after a fresh install. Upgrade installs migrate to an expired hint and keep their previous temperature choice. Bump to 1.0.3+15.
Krasimir Kazakov il y a 1 semaine
Parent
commit
93cc427f00

+ 8 - 6
AGENTS.md

@@ -33,13 +33,13 @@ ADB lives at `/opt/android-sdk/platform-tools/adb`; the SDK is at `/opt/android-
 
 ## 3. Architecture
 
-- `lib/core/` — theme tokens (`AppColors`, `AccentChoice`), `AppTheme`, `ThemeController`, `DisplayController` (keep-awake), `AppClock`, reusable widgets.
+- `lib/core/` — theme tokens (`AppColors`, `AccentChoice`), `AppTheme`, `ThemeController`, `DisplayController` (keep-awake), reusable widgets.
 - `lib/data/models/` — `AppEntry`, `SlotType`, `SlotAssignment`, `SystemStatus`, `VolumeInfo`, `MediaPlayback`, OBD/weather models.
 - `lib/data/services/` — `NativeBridge`, `TelemetryBridge`, `WeatherService`, `UpdateService`, `PinService`, secure-store adapter.
-- `lib/data/repositories/` — `SettingsRepository`, the SSOT for theme, night window, slot assignments, visibility flags and pinned apps.
-- `lib/features/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, `GpsSpeedController`, equalizer, night overlay.
+- `lib/data/repositories/` — `SettingsRepository`, the SSOT for theme, night window, slot assignments, visibility flags, pinned apps, onboarding time, and speed/temperature choices.
+- `lib/features/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, `GpsSpeedController`, `TelemetrySettingsController` (speed/temperature visibility and sources), equalizer, night overlay.
 - `lib/features/apps/` — All Apps screen and app-picker sheet.
-- `lib/features/settings/` — General, Media, Car data, Security and Updates pages.
+- `lib/features/settings/` — General, Media, OBD-II, Speed, Temperature, Security and Updates cards.
 - `lib/features/lock/` — PIN pad, lock screen, cold-start `LockController`.
 - `android/app/src/main/kotlin/com/wasp/cardash_launcher/` — `MainActivity`, `LauncherBridge`, `SystemBridge`, `TelemetryBridge`, `ObdService` (Classic SPP ELM327), `MediaBridge`, `CardashNotificationListener`; `SystemBridge` also dispatches media keys and applies the keep-awake flag.
 - `tool/` — emulator/install/check scripts. `docs/` — implementation plan and design notes.
@@ -90,17 +90,19 @@ notifyListeners();
 - Immersive mode is reapplied on every `resumed` lifecycle event; Android may restore system bars after dialogs or role prompts.
 - The HOME role must be granted for the dashboard to replace the tablet home screen. On the emulator use `cmd role add-role-holder`; the Settings row uses `RoleManager`.
 - There is no Home-role banner. The one-time first-run tip uses `LauncherController.homeRoleHintShown`; do not show it again.
+- The long-press hint uses `onboarding.firstLaunchAt` and `HomeHint`: it is visible only for the first ten minutes after a fresh installation. Upgraded installs are migrated to an expired window and never see it again.
 - Screen-space is deliberately finite: test the 16-button grid at 1280×800, 1280×720 and 1024×600. The grid includes All apps; Settings sits in `StatusCluster` and does not consume a slot. Avoid unbounded `ListView`s inside `Column`s.
 - `tool/emulator.sh` starts a headless AVD with the **host GPU**; SwiftShader segfaulted on this machine's API 35 image. Use `CARDASH_GPU=swiftshader_indirect` only as a fallback. Use `adb exec-out screencap -p` for screenshots; there may be multiple `adb` binaries on PATH, so use the absolute one.
 - While CarDash is foreground, `MainActivity` consumes hardware volume keys and adjusts `STREAM_MUSIC` so they match the dashboard. Do not remove that override without providing another sync path; otherwise Android controls the ring stream when no media is playing.
 - `FLAG_KEEP_SCREEN_ON` is controlled by `DisplayController` from `display.keepScreenOn` (default on) through `SystemBridge`; do not hard-code it in `MainActivity` or move it to a wake lock.
 - The Music equalizer follows `AudioManager.isMusicActive`. Now-playing title/progress use the notification-listener component and active MediaSession; never read or store notification content.
 - Night mode is a full-screen black overlay with a dim clock. Long-press only on empty background; tap anywhere to dismiss. It does not change brightness or add a wake lock.
-- GPS speed is a fallback only when no OBD adapter is connected. It starts only with already-granted fine location and GPS; never prompt from that path, and clamp stationary noise below 2 km/h to 0.
+- Speed is shown only when `TelemetrySettingsController.speedEnabled` is on. OBD-II is preferred when connected and fresh; the GPS fallback is used when OBD speed is off or unavailable. GPS starts only with already-granted fine location and GPS; never prompt from that path, and clamp stationary noise below 2 km/h to 0.
+- Temperature is shown only when `TelemetrySettingsController.temperatureEnabled` is on. OBD ambient is preferred when connected and fresh; Open-Meteo/location weather is the fallback, and the source switch only appears while an OBD-II adapter is selected.
 - Updates use a non-blocking public Gogs release check. The Update action downloads the APK to cache and opens Android’s package installer; never install silently. REQUEST_INSTALL_PACKAGES + one-time unknown-apps permission is required. Show the yellow dot on the Settings gear whenever `UpdateController.updateAvailable` is true.
 - 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.
-- When Bluetooth is off, the Car data card shows **Open Bluetooth settings** and refreshes paired devices on app resume; pairing happens in Android settings, never in CarDash.
+- When Bluetooth is off, the OBD-II card shows **Open Bluetooth settings** and refreshes paired devices on app resume; pairing happens in Android settings, never in CarDash.
 - The circular ring is song progress; its centre is play/pause and the Mute pill is separate. Playback uses Android media keys; title/progress need the user-granted notification access, otherwise the card falls back to Playing / Nothing playing.
 - Reinstalling the app can clear its HOME role; re-run `tool/install.sh` or use Settings > Home app.
 

+ 76 - 53
README.md

@@ -18,7 +18,7 @@ the normal Android launcher mechanism. No overlays, no root, no kiosk setup.
 ## Download
 
 - [Gogs release page](https://git.krasi.net/kkazakov/car-interface-android/releases) — published releases
-- [cardash-android-1.0.2.apk](releases/cardash-android-1.0.2.apk) — universal Android release
+- [cardash-android-1.0.3.apk](releases/cardash-android-1.0.3.apk) — universal Android release
 - SHA-256 and build details: [releases/README.md](releases/README.md)
 
 Gogs limits release attachments to 32 MB, so the 54 MB universal APK is linked
@@ -35,7 +35,6 @@ Also: [Home with top-right Settings](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) ·
 [Settings · Media access](docs/screenshots/settings-media.png) ·
 [Now playing](docs/screenshots/home-now-playing.png) ·
 [1280×720](docs/screenshots/home-1280x720.png) ·
@@ -63,17 +62,20 @@ live vehicle data.
 - **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.
-- **GPS speed fallback** — when no OBD adapter is connected and precise location
-  is already allowed, show speed from GPS; it reads 0 km/h when stationary.
-  CarDash never prompts for this; GPS must be enabled and location previously
-  allowed (for example through the weather toggle).
-- **Location temperature fallback** — if OBD has no ambient value, an optional
-  Settings toggle fetches the current temperature from Open-Meteo using the
-  device location. The row reads `Outside, 7°C` for OBD or `Sofia, 7°C` for
-  weather, and turns red below 3°C. No network/selection means no row.
+- **OBD-II car data** — optional Bluetooth Classic ELM327 adapter. A dedicated
+  **OBD-II** Settings card holds the adapter status, paired-device picker and
+  Bluetooth actions. The live card shows speed, a throttle bar and rolling 5 km
+  MAF economy, plus ambient temperature when the adapter provides it.
+- **Speed card with source choice** — Settings → **Speed** shows or hides the
+  speed card and, while an adapter is selected, chooses OBD-II or GPS. GPS is
+  the fallback when OBD speed is off or unavailable; it reads 0 km/h when
+  stationary and never prompts for location by itself.
+- **Temperature row with source choice** — Settings → **Temperature** shows or
+  hides the outside temperature and, while an adapter is selected, chooses
+  OBD-II or GPS + weather. Enabling temperature asks for location permission
+  for the Open-Meteo fallback; if denied, OBD temperature still works. The row
+  reads `Outside, 7°C` for OBD or `Sofia, 7°C` for weather, and turns red below
+  3°C.
 - **Now-playing media card** — the circular ring shows song progress around a
   play/pause button. The song title and artist scroll along the bottom; paused
   audio stops the scroll and holds its position. When nothing is playing it
@@ -109,8 +111,9 @@ live vehicle data.
   across the whole interface.
 - **Prevent CarDash from sleeping** — a Settings switch, on by default. Turn it off to use the system display timeout.
 - **Night mode** — long-press an empty part of the Home background to go black with a very dim centered clock; tap anywhere to return. Long-presses on buttons and cards are unaffected.
-- **A short onboarding hint** — the long-press hint fades away two minutes after
-  app start, then stays gone.
+- **A short onboarding hint** — the long-press hint is visible only during the
+  first ten minutes after a fresh installation, then stays gone. Upgraded
+  installs do not see it again.
 - **Light / Dark / Automatic** theme. Automatic uses a fixed local-time window
   (default 19:00–07:00), editable in Settings.
 - **Optional PIN** — four digits, stored as a salted PBKDF2 hash in
@@ -196,11 +199,12 @@ 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 |
-| No OBD + allowed location + GPS on | Shows GPS speed above the volume card; 0 when stopped |
-| Temperature row | OBD ambient, or location weather when OBD has none |
-| Settings | Theme, accent, media access, battery, home buttons, default-home role, PIN |
+| Settings → OBD-II | Pick a paired ELM327 or None; open Bluetooth settings |
+| Settings → Speed | Show or hide the speed card; choose OBD-II or GPS source |
+| Settings → Temperature | Show or hide the temperature row; choose OBD-II or GPS + weather |
+| Speed card | OBD-II when connected, otherwise GPS; 0 km/h when stopped |
+| Temperature row | OBD ambient, or location weather when OBD is off/unavailable |
+| Settings | Theme, accent, media access, battery, home buttons, OBD/speed/temperature, 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 |
 | Circular ring | Song progress; tap the centre to play/pause |
@@ -233,14 +237,30 @@ the app resumes.
 - Without access the ring stays empty and the card shows a generic Playing /
   Nothing playing state.
 
-**Car data**
-- OBD-II adapter dropdown: None plus paired Bluetooth Classic devices.
+**OBD-II**
+- Adapter dropdown: None plus paired Bluetooth Classic devices.
 - Connection status and a Find/Allow action for Bluetooth permission.
 - If Bluetooth is off: **Open Bluetooth settings** to turn it on, then return
   and the paired-device list refreshes automatically.
-- 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.
+- Read-only note: CarDash never writes to the ECU. Adapter settings only; speed
+  and temperature choices live in their own cards.
+
+**Speed**
+- One switch shows or hides the speed card on Home.
+- While an OBD-II adapter is selected, **Use OBD-II for speed** selects the
+  source. Off means GPS is used.
+- Without OBD (or with OBD speed unavailable), the GPS fallback is used when
+  precise location is already granted. It never prompts from this path and
+  reads 0 km/h when stationary.
+
+**Temperature**
+- One switch shows or hides the outside-temperature row below the clock.
+- While an OBD-II adapter is selected, **Use OBD-II for temperature** selects
+  the source. Off means GPS + the Open-Meteo weather provider is used.
+- Enabling temperature or choosing the weather source asks for location
+  permission and refreshes every 15 minutes. If permission is denied, OBD
+  temperature still works when available.
+- The row turns red below 3°C and hides when no fresh value exists.
 
 **Updates**
 - Non-blocking check against the public Gogs releases.
@@ -267,7 +287,7 @@ lib/
   features/
     home/      home page, shortcut grid, volume card, OBD/weather controllers
     apps/      All Apps, app-picker sheet
-    settings/  General, Car data + Security
+    settings/  General, OBD-II, Speed, Temperature + Security
     lock/      PIN pad, cold-start lock screen
 android/...    MainActivity, LauncherBridge, SystemBridge, TelemetryBridge, ObdService
 tool/          emulator, install, check scripts
@@ -294,23 +314,24 @@ flutter test
 ./tool/check.sh
 ```
 
-The 51 tests cover PIN hashing/persistence, settings persistence, theme/accent,
-dark accent tinting, battery visibility, first-run state, slot visibility,
-pinned apps, custom labels/icons, the 16-button cap at 1280×800, 1280×720 and
-1024×600, the dense shortcut grid, the equalizer animation, OBD economy, the OBD
-card, media-key playback, media metadata/progress, the marquee, the play/pause
-ring, the keep-awake preference, the night overlay, GPS speed fallback and the
-update version/availability logic and the Settings update dot. Native package
-enumeration, the HOME role, volume, app launching and PIN flows were verified on
-API 35 and API 29 emulators. Location permission, reverse geocoding and the
-Open-Meteo fallback were verified live on the API 35 emulator. Now-playing
-title, artist, progress and pause/resume were verified against a live Chrome
-MediaSession. The OBD hardware path compiles and is covered by the parser/economy
-design, but no ELM327 adapter is available on this machine; socket behavior still
-needs a car test. Release
-The 1.0.2 universal APK is built from this tree. The tablet is on 1.0.1 and
-will pick up 1.0.2 through Settings → Updates; the yellow dot appears when the
-new release is published. The emulator has verified the full in-app update path.
+The 63 tests cover PIN hashing/persistence, settings persistence, theme/accent,
+dark accent tinting, battery visibility, first-run state, the ten-minute
+onboarding hint, slot visibility, pinned apps, custom labels/icons, the
+16-button cap at 1280×800, 1280×720 and 1024×600, the dense shortcut grid, the
+equalizer animation, OBD economy, the OBD card, media-key playback, media
+metadata/progress, the marquee, the play/pause ring, the keep-awake preference,
+the night overlay, GPS speed fallback, speed/temperature source persistence,
+weather fallback, the update version/availability logic and the Settings update
+dot. Native package enumeration, the HOME role, volume, app launching and PIN
+flows were verified on API 35 and API 29 emulators. Location permission, reverse
+geocoding and the Open-Meteo fallback were verified live on the API 35 emulator.
+Now-playing title, artist, progress and pause/resume were verified against a live
+Chrome MediaSession. The OBD hardware path compiles and is covered by the
+parser/economy design, but no ELM327 adapter is available on this machine;
+socket behavior still needs a car test. Release
+The 1.0.3 universal APK is built from this tree and published; devices on 1.0.2
+will pick it up through Settings → Updates when the release is published. The
+emulator has verified the in-app update path.
 
 ## Troubleshooting the emulator
 
@@ -352,8 +373,9 @@ Android 15 AVD is left running headlessly after development; use
 - **Night mode is a display overlay only.** It does not change panel brightness
   or keep the screen awake by itself; the screen-on preference still applies.
   Long-press on cards and buttons is reserved for their own actions.
-- **Onboarding hint** is anchored to process start and disappears after two
-  minutes; it does not return until the next cold start.
+- **Onboarding hint** is anchored to the first launch after a fresh install and
+  disappears after ten minutes. It is migrated to expired for upgraded installs
+  and never returns.
 - **No embedded third-party app UI.** Navigation/Music/Radio are launched in
   their own full-screen windows.
 - **Renaming changes the Home label only.** Android does not allow a launcher to
@@ -369,17 +391,18 @@ Android 15 AVD is left running headlessly after development; use
 - **Song progress/title require notification access.** Without it, Android does
   not expose MediaSession metadata to a normal launcher; the card still controls
   playback and shows Nothing playing or Playing.
-- **GPS speed fallback** requires precise location already granted and the GPS
-  provider enabled; CarDash does not request permission from this path. OBD speed
-  wins whenever an adapter is connected, and stale/no-fix GPS values are hidden.
+- **Speed source** is selectable only while an OBD adapter is selected. GPS
+  fallback requires precise location already granted and the GPS provider
+  enabled; CarDash does not request permission from this path. Stale/no-fix GPS
+  values are hidden.
 - **OBD-II is a subset**: Bluetooth Classic SPP only, paired devices only,
   read-only. Speed, throttle, MAF economy and ambient temperature are implemented;
   RPM, coolant, voltage, MIL/DTCs, fuel level and BLE/Wi-Fi adapters are not.
-- **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.
+- **Temperature source** is selectable only while an OBD adapter is selected.
+  Weather fallback uses Open-Meteo and the device location; it requires location
+  permission and network access, and hides the row when unavailable.
+- **Location data is not stored.** Only the selected adapter address, the
+  speed/temperature choices and the weather preference 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.

+ 16 - 0
assets/icons/gauge.svg

@@ -0,0 +1,16 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-gauge"
+  xmlns="http://www.w3.org/2000/svg"
+  width="24"
+  height="24"
+  viewBox="0 0 24 24"
+  fill="none"
+  stroke="currentColor"
+  stroke-width="2"
+  stroke-linecap="round"
+  stroke-linejoin="round"
+>
+  <path d="m12 14 4-4" />
+  <path d="M3.34 19a10 10 0 1 1 17.32 0" />
+</svg>

+ 15 - 0
assets/icons/thermometer.svg

@@ -0,0 +1,15 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-thermometer"
+  xmlns="http://www.w3.org/2000/svg"
+  width="24"
+  height="24"
+  viewBox="0 0 24 24"
+  fill="none"
+  stroke="currentColor"
+  stroke-width="2"
+  stroke-linecap="round"
+  stroke-linejoin="round"
+>
+  <path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" />
+</svg>

+ 34 - 17
docs/IMPLEMENTATION_PLAN.md

@@ -1,6 +1,6 @@
 # CarDash implementation plan
 
-_Last updated: 2026-10-29_
+_Last updated: 2026-10-30_
 
 ## 1. What we are building
 
@@ -25,21 +25,23 @@ compatibility:
 - Every external app opens full-screen through a normal Android `Intent`.
 - Settings: light/dark/automatic theme, six accent colors, battery visibility,
   fixed night window, home-button switches, slot assignment, HOME-role setup,
-  four-digit PIN.
+  four-digit PIN, plus separate **OBD-II**, **Speed** and **Temperature** cards.
 - The stylized Music tile shows an animated equalizer while media audio is
   active.
 - Settings → Media grants notification access for the active MediaSession title,
   duration and position. CarDash does not read notification content.
-- The long-press hint fades away two minutes after app start.
+- The long-press hint is visible only during the first ten minutes after a fresh
+  installation, then never returns. Upgraded installs are migrated to expired.
 - 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.
-- GPS speed fallback when no OBD adapter is connected and precise location was
-  already granted; stationary shows 0 km/h, with no permission prompt.
+- Speed card with a show/hide switch and, while OBD is selected, an
+  OBD-II/GPS source switch. GPS is the fallback; stationary shows 0 km/h.
+- Temperature row with a show/hide switch and, while OBD is selected, an
+  OBD-II/weather source switch. Open-Meteo weather + location is the fallback;
+  red below 3°C.
 - Non-blocking Gogs update check with a Settings → Updates card and a release-page
   link; no automatic download or silent installation.
 - Dark surfaces are tinted by the selected accent instead of retaining a green base.
@@ -68,17 +70,19 @@ compatibility:
 | Media feedback | Stylized Music tile + animated bars while media is active | Compact visual feedback without adding a media control surface. |
 | Now-playing metadata | MediaSessionManager via a notification-listener component | The only public path to title, duration and position; read-only and scoped to media sessions. |
 | Song progress | Ring fraction from position/duration; scrolling title | Replaces the volume ring as requested; paused state holds position and scroll offset. |
-| Hint lifetime | Two minutes from process start, then fade out | Avoids permanent onboarding chrome; survives widget rebuilds via `AppClock`. |
+| Hint lifetime | Ten minutes from the first launch after a fresh install, then never | Avoids permanent onboarding chrome; upgraded installs migrate to expired. |
 | 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. |
-| GPS speed fallback | Poll GPS updates only when no OBD is connected and fine location is already granted | No permission prompt; 0 below 2 km/h to suppress stationary drift. |
+| Weather fallback | Open-Meteo + one-shot device location via the Temperature card | No API key/account; hide the row on any failure. |
+| GPS speed fallback | Poll GPS when speed source is GPS, OBD is unavailable, or the Speed card is off | No permission prompt from this path; 0 below 2 km/h to suppress stationary drift. |
+| Speed source | OBD-II preferred while connected; GPS fallback, switch in Settings | The user can force GPS even with an adapter connected. |
 | Update check | Public Gogs releases API, non-blocking startup check, Settings card | Update downloads the APK and opens Android’s installer; installation stays user-confirmed. Yellow dot on Settings gear when available. |
-| Temperature priority | OBD ambient, else location weather | OBD wins whenever a fresh value exists. |
+| Temperature priority | OBD ambient while its source switch is on, else location weather | OBD wins when fresh; weather is the fallback. |
+| Settings cards | Separate OBD-II, Speed and Temperature cards | Adapter settings stay isolated from display/source choices. |
 | Accent background | Dark canvas/surfaces/borders blend toward the accent | Fixes the green base showing through when Blue is selected. |
 | Screen | `FLAG_KEEP_SCREEN_ON` controlled by the Settings keep-awake switch | Default on; no wake-lock permission, and off restores the system display timeout. |
 | Night mode | Long-press empty background; black overlay + dim clock; tap wakes | Quiet night display without changing panel brightness or adding a wake lock. |
@@ -101,10 +105,11 @@ Flutter
     SystemController    -> battery/Wi-Fi/volume/media state + media keys
     DisplayController   -> persisted keep-awake window flag
     GpsSpeedController  -> GPS speed fallback (permission already granted)
+    TelemetrySettingsController -> speed/temperature visibility + sources
     UpdateController    -> public Gogs release check + release-page link
     HomeScreen          -> NightModeOverlay on empty-background long press
   AllAppsScreen / AppPickerSheet
-  SettingsScreen -> General / Car data / Security
+  SettingsScreen -> General / OBD-II / Speed / Temperature / Security
   PinSetupScreen / LockScreen -> LockController -> PinService
 Android (Kotlin)
   MainActivity -> keep-screen-on, volume keys, permission forwarding
@@ -116,8 +121,9 @@ Android (Kotlin)
 ```
 
 - `SettingsRepository` is the only persistence path for theme, night window,
-  slot visibility/assignments and pinned apps. `flutter_secure_storage` holds
-  the salted PIN hash.
+  slot visibility/assignments, pinned apps, onboarding time, speed/temperature
+  visibility and source choices. `flutter_secure_storage` holds the salted PIN
+  hash.
 - Package enumeration and icon rendering happen on a worker thread in Kotlin and
   are returned as PNG bytes.
 - Timers for volume/status are stopped when the app leaves the foreground.
@@ -148,6 +154,7 @@ Android (Kotlin)
 | M16 | GPS speed fallback when no OBD adapter and location is already allowed | Done |
 | M17 | Non-blocking Gogs update check; 1.0.1 version-only update-test release | Done |
 | M18 | Bluetooth-off settings action; 1.0.2 release | Done |
+| M19 | OBD-II/Speed/Temperature Settings cards; source switches; 10-minute first-install hint; 1.0.3 | Done |
 
 ## 5. Verification checklist
 
@@ -177,7 +184,7 @@ Android (Kotlin)
 - [x] Settings is next to Wi-Fi; All apps is the last grid button.
 - [x] A user-installed app (F-Droid) shows its own icon; system apps stay generic.
 - [x] Music equalizer widget animates; native media-active polling is wired.
-- [x] Home hint is gone after two minutes from process start.
+- [x] Home hint is gone ten minutes after the first launch of a fresh install.
 - [x] All Apps search icon is visibly smaller.
 - [x] No Home-role banner; the first-run tip appears once, then never again.
 - [x] Long-press pinned app shows Rename, Change icon and Remove.
@@ -188,7 +195,7 @@ Android (Kotlin)
 - [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] Temperature row renders below the date and follows the selected OBD/weather source.
 - [x] Circled ring shows song progress; 44 tests include media keys, metadata,
   progress, the marquee, the ring, keep-awake and night mode.
 - [x] Scrolling title/artist verified with a live Chrome MediaSession; pause holds
@@ -199,9 +206,15 @@ Android (Kotlin)
 - [x] 1.0.0 updater build installed on emulator/tablet; it reported up to date before 1.0.1.
 - [x] 1.0.1 universal APK built and added to `releases/`; release published for update testing.
 - [x] Emulator completed the full in-app 1.0.0 → 1.0.1 download/installer flow.
-- [x] Yellow update dot appears on the Settings gear; 51 tests pass.
+- [x] Yellow update dot appears on the Settings gear; 63 tests pass.
 - [x] Bluetooth-off Settings card exposes **Open Bluetooth settings** and refreshes on return.
 - [x] 1.0.2 universal APK built and published for in-app update.
+- [x] Settings split into separate OBD-II, Speed and Temperature cards; OBD-II holds adapter settings only.
+- [x] Speed and Temperature toggles hide/show their Home elements and persist.
+- [x] OBD/GPS source switches appear while an adapter is selected and persist.
+- [x] Onboarding hint is limited to ten minutes after a fresh install; upgraded installs never show it.
+- [x] 63 tests pass, including the onboarding window and source preferences.
+- [x] 1.0.3 universal APK built and published for in-app update.
 - [x] Tablet has the 1.0.0 installer build; unknown-app confirmation is user-controlled.
 - [x] Screenshots in `docs/screenshots/`; README/AGENTS/plan/OBD doc updated.
 
@@ -300,6 +313,10 @@ Android (Kotlin)
   download and package-installer flow.
 - 2026-10-30: Added the Bluetooth-off **Open Bluetooth settings** action and
   release/deployment runbook. Bumped to 1.0.2+14 and published it for in-app update.
+- 2026-10-30: Split Settings into OBD-II, Speed and Temperature cards, added
+  independent show/hide switches plus OBD/GPS and OBD/weather source switches,
+  and limited the long-press hint to ten minutes after a fresh install. Bumped to
+  1.0.3+15; 63 tests pass.
 - 2026-10-30: Versioned the universal Android release as 1.0.0+12 and added
   `releases/cardash-android-1.0.0.apk` with checksum and release notes.
 - 2026-10-29: Centered the now-playing title across the whole media card instead of

+ 61 - 31
docs/OBD_TEMPERATURE.md

@@ -1,14 +1,15 @@
 # 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_
+_Status: implemented in CarDash 1.0.3; OBD hardware still needs a real-car test.
+Last updated: 2026-10-30_
 
 ## 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.
+the car's OBD-II port and shows it on the home page. Settings has separate
+**OBD-II**, **Speed** and **Temperature** cards. Speed can be hidden, sourced
+from OBD-II or GPS, and temperature can be hidden, sourced from OBD-II ambient
+or GPS + the Open-Meteo weather provider.
 
 ### Implemented values
 
@@ -38,25 +39,29 @@ coast above 10 km/h, and shows `—` until at least 0.5 km has accumulated.
 | 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. |
+| D4 | Small live speed 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. |
+| D7 | OBD ambient wins over weather while the OBD temperature source is selected. |
+| D8 | Speed and temperature each have an independent show/hide switch in Settings. |
+| D9 | Source switches appear only while an OBD-II adapter is selected; GPS/weather is the fallback. |
+| D10 | Temperature starts off on fresh installs; upgrades that already used OBD or weather keep it on. |
 
 ## 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 |
+| Bluetooth 31+ | `BLUETOOTH_CONNECT` | requested from the OBD-II card |
+| Weather | `INTERNET`, `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION` | location requested when temperature/weather 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.
+Bluetooth permission from the OBD-II 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`.
+the current weather value; they are not persisted. The adapter address, speed
+and temperature visibility, source choices and weather preference are persisted
+in `SettingsRepository`.
 
 ## 4. Architecture
 
@@ -75,10 +80,12 @@ 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
+  TelemetrySettingsController
+                         persisted speed/temperature visibility and source
+  WeatherController      permission, one-shot location, 15 min weather cache
   WeatherService         keyless Open-Meteo current temperature_2m
   TelemetryBridge models BluetoothDeviceInfo / LocationReading / ObdState
-  SettingsRepository     obd.adapterAddress, weather.locationTemperature
+  SettingsRepository     adapter address, speed/temperature flags, weather
 ```
 
 The native `ObdService` polls speed, throttle and MAF roughly once per second;
@@ -89,15 +96,17 @@ 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`)
+- **Temperature row** immediately below the date, only while the Temperature
+  switch is on:
+  - OBD source: `Outside, 7°C`
+  - Weather source: `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.
+  - OBD ambient is preferred while its source switch is on. 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:
+- **Speed card** in the left column above the media-volume card, only while the
+  Speed switch is on and a fresh source value exists:
 
   ```
   ● OBD-II                         LIVE
@@ -107,17 +116,32 @@ keep the dashboard quiet.
   Rolling economy        7.5 L/100 km
   ```
 
+  When the GPS source is selected (or OBD is unavailable), the card reads
+  `GPS / FROM LOCATION` with the GPS speed instead.
 - 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
+## 6. Settings cards
 
-- **OBD-II adapter**: dropdown containing `None` and paired Bluetooth Classic
-  devices. `Find`/`Allow` requests Bluetooth permission and refreshes the list.
+**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.
+- Bluetooth-off action: **Open Bluetooth settings**.
 - Read-only note: CarDash never writes to the ECU.
+- This card contains adapter settings only.
+
+**Speed**
+- Show/hide switch for the speed card.
+- While an OBD-II adapter is selected: **Use OBD-II for speed**. Off selects GPS.
+- The source switch is hidden while no adapter is selected; GPS is then used.
+
+**Temperature**
+- Show/hide switch for the temperature row.
+- While an OBD-II adapter is selected: **Use OBD-II for temperature**. Off selects GPS +
+  Open-Meteo weather.
+- Enabling temperature or choosing the weather source asks for location
+  permission. OBD temperature still works if permission is denied.
 
 Selecting a device persists its MAC address and connects immediately when the
 app is active. Selecting `None` disconnects. Polling stops when CarDash leaves
@@ -127,7 +151,7 @@ the foreground and reconnects on resume.
 
 `WeatherController`:
 
-1. Requires the toggle to be enabled and location permission granted.
+1. Requires the Temperature switch to be on 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:
@@ -145,14 +169,16 @@ coordinates are stored.
 - 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.
+- Permission denial is a normal state; the Temperature card shows the weather
+  error while OBD temperature can still work.
 - 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.
+- Dart unit tests cover the rolling economy math, the OBD card and the
+  speed/temperature preference controller.
+- Settings persistence covers the adapter address, visibility switches, source
+  choices and weather preference.
 - 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.
@@ -167,3 +193,7 @@ coordinates are stored.
   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.
+- 2026-10-30 — Split Settings into OBD-II, Speed and Temperature cards, added
+  independent visibility toggles and OBD/GPS-weather source switches.
+- 2026-10-30 — Retired the two-minute process-start onboarding hint in favor of
+  a ten-minute first-install window that never returns after an upgrade.

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


+ 0 - 6
lib/core/app_clock.dart

@@ -1,6 +0,0 @@
-/// Wall-clock anchor for app-start UI, independent of widget rebuilds.
-class AppClock {
-  AppClock._();
-
-  static final DateTime startedAt = DateTime.now();
-}

+ 1 - 1
lib/core/app_version.dart

@@ -1,2 +1,2 @@
 /// Keep this in sync with the `version` in pubspec.yaml.
-const String appVersion = '1.0.2';
+const String appVersion = '1.0.3';

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

@@ -22,10 +22,15 @@ class SettingsRepository {
   static const _showBatteryKey = 'settings.showBattery';
   static const _slotPrefix = 'slot';
   static const _pinnedKey = 'home.pinned';
+  static const _firstLaunchAtKey = 'onboarding.firstLaunchAt';
   static const _homeRoleHintShownKey = 'onboarding.homeRoleHintShown';
   static const _obdAddressKey = 'obd.adapterAddress';
   static const _weatherTemperatureKey = 'weather.locationTemperature';
   static const _keepScreenOnKey = 'display.keepScreenOn';
+  static const _speedEnabledKey = 'carData.speedEnabled';
+  static const _speedUseObdKey = 'carData.speedUseObd';
+  static const _temperatureEnabledKey = 'carData.temperatureEnabled';
+  static const _temperatureUseObdKey = 'carData.temperatureUseObd';
 
   ThemePreference get themePreference {
     return ThemePreference.fromStorage(_prefs.getString(_themeKey));
@@ -41,10 +46,54 @@ class SettingsRepository {
 
   bool get showBattery => _prefs.getBool(_showBatteryKey) ?? true;
 
+  /// When CarDash first ran after this installation. Older installs are
+  /// migrated to the epoch so onboarding copy cannot reappear after an update.
+  DateTime? get firstLaunchAt {
+    final millis = _prefs.getInt(_firstLaunchAtKey);
+    return millis == null ? null : DateTime.fromMillisecondsSinceEpoch(millis);
+  }
+
+  /// Records the first launch once. Existing preferences mean this is an
+  /// upgraded install, so the onboarding window is treated as already over.
+  Future<DateTime> ensureFirstLaunchAt(DateTime now) async {
+    final stored = firstLaunchAt;
+    if (stored != null) return stored;
+    final first = _prefs.getKeys().isEmpty
+        ? now
+        : DateTime.fromMillisecondsSinceEpoch(0);
+    await _prefs.setInt(_firstLaunchAtKey, first.millisecondsSinceEpoch);
+    return first;
+  }
+
+  /// Freezes the one-time migration default for temperature. Existing installs
+  /// that configured OBD-II or location weather keep the feature; a fresh
+  /// install starts off so it cannot fetch weather without an explicit choice.
+  Future<void> ensureTelemetryDefaults() async {
+    if (_prefs.containsKey(_temperatureEnabledKey)) return;
+    final wasUsingCarData =
+        (_prefs.getBool(_weatherTemperatureKey) ?? false) ||
+        obdAdapterAddress != null;
+    await _prefs.setBool(_temperatureEnabledKey, wasUsingCarData);
+  }
+
   bool get homeRoleHintShown {
     return _prefs.getBool(_homeRoleHintShownKey) ?? false;
   }
 
+  /// Whether the speed card may be shown on Home.
+  bool get speedEnabled => _prefs.getBool(_speedEnabledKey) ?? true;
+
+  /// OBD-II is preferred for speed when it is connected; GPS is the fallback.
+  bool get speedUseObd => _prefs.getBool(_speedUseObdKey) ?? true;
+
+  /// Fresh installs start with temperature off. [ensureTelemetryDefaults]
+  /// preserves it for an upgrade that already had OBD or weather configured.
+  bool get temperatureEnabled =>
+      _prefs.getBool(_temperatureEnabledKey) ?? false;
+
+  /// OBD-II ambient is preferred when available; weather is the fallback.
+  bool get temperatureUseObd => _prefs.getBool(_temperatureUseObdKey) ?? true;
+
   String? get obdAdapterAddress {
     final value = _prefs.getString(_obdAddressKey)?.trim();
     return value == null || value.isEmpty ? null : value;
@@ -71,6 +120,22 @@ class SettingsRepository {
     return _prefs.setBool(_showBatteryKey, show);
   }
 
+  Future<void> setSpeedEnabled(bool enabled) {
+    return _prefs.setBool(_speedEnabledKey, enabled);
+  }
+
+  Future<void> setSpeedUseObd(bool enabled) {
+    return _prefs.setBool(_speedUseObdKey, enabled);
+  }
+
+  Future<void> setTemperatureEnabled(bool enabled) {
+    return _prefs.setBool(_temperatureEnabledKey, enabled);
+  }
+
+  Future<void> setTemperatureUseObd(bool enabled) {
+    return _prefs.setBool(_temperatureUseObdKey, enabled);
+  }
+
   Future<void> setHomeRoleHintShown(bool shown) {
     return _prefs.setBool(_homeRoleHintShownKey, shown);
   }

+ 21 - 59
lib/features/home/home_screen.dart

@@ -3,7 +3,6 @@ import 'dart:async';
 import 'package:flutter/material.dart';
 import 'package:provider/provider.dart';
 
-import '../../core/app_clock.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/theme_controller.dart';
 import '../../data/models/slot_assignment.dart';
@@ -16,10 +15,12 @@ import 'launcher_controller.dart';
 import 'obd_controller.dart';
 import 'pinned_app_actions.dart';
 import 'system_controller.dart';
+import 'telemetry_settings_controller.dart';
 import 'weather_controller.dart';
 import '../settings/update_controller.dart';
 import 'widgets/clock_block.dart';
 import 'widgets/gps_speed_card.dart';
+import 'widgets/home_hint.dart';
 import 'widgets/night_mode_overlay.dart';
 import 'widgets/obd_card.dart';
 import 'widgets/shortcut_grid.dart';
@@ -56,6 +57,7 @@ class _HomeScreenState extends State<HomeScreen> {
     final obd = context.watch<ObdController>();
     final gps = context.watch<GpsSpeedController>();
     final weather = context.watch<WeatherController>();
+    final telemetrySettings = context.watch<TelemetrySettingsController>();
     final update = context.watch<UpdateController>();
     final screen = MediaQuery.sizeOf(context);
     final compact = screen.height < 700 || screen.width < 1100;
@@ -67,8 +69,11 @@ class _HomeScreenState extends State<HomeScreen> {
     final gap = compact ? 26.0 : 40.0;
 
     final obdTemperature = obd.ambientCelsius;
-    final useObdTemperature = obdTemperature != null;
-    final temperature = useObdTemperature
+    final useObdTemperature =
+        telemetrySettings.temperatureUseObd && obdTemperature != null;
+    final temperature = !telemetrySettings.temperatureEnabled
+        ? null
+        : useObdTemperature
         ? obdTemperature
         : (weather.hasTemperature ? weather.temperatureCelsius : null);
     final temperatureText = temperature == null
@@ -78,6 +83,13 @@ class _HomeScreenState extends State<HomeScreen> {
         : '${weather.locationName}, ${temperature.round()}°C';
     final temperatureAlert = temperature != null && temperature < 3;
 
+    final showObdSpeed =
+        telemetrySettings.speedEnabled &&
+        telemetrySettings.speedUseObd &&
+        obd.hasTelemetry;
+    final showGpsSpeed =
+        telemetrySettings.speedEnabled && !showObdSpeed && gps.hasSpeed;
+
     ShortcutSpec slotSpec(SlotType slot) {
       final assignment = launcher.assignmentFor(slot);
       final playing =
@@ -208,7 +220,7 @@ class _HomeScreenState extends State<HomeScreen> {
                               crossAxisAlignment: CrossAxisAlignment.stretch,
                               mainAxisAlignment: MainAxisAlignment.end,
                               children: [
-                                if (obd.hasTelemetry) ...[
+                                if (showObdSpeed) ...[
                                   ObdCard(
                                     speedKmh: obd.speedKmh,
                                     throttlePercent: obd.throttlePercent,
@@ -216,7 +228,7 @@ class _HomeScreenState extends State<HomeScreen> {
                                     compact: compact,
                                   ),
                                   SizedBox(height: compact ? 12 : 14),
-                                ] else if (!obd.connected && gps.hasSpeed) ...[
+                                ] else if (showGpsSpeed) ...[
                                   GpsSpeedCard(
                                     speedKmh: gps.speedKmh ?? 0,
                                     compact: compact,
@@ -246,9 +258,11 @@ class _HomeScreenState extends State<HomeScreen> {
                                     compact: compact,
                                   ),
                                 ),
-                                if (!compact) ...[
+                                if (!compact &&
+                                    launcher.firstLaunchAt != null) ...[
                                   const SizedBox(height: 4),
-                                  _HomeHint(
+                                  HomeHint(
+                                    firstLaunchAt: launcher.firstLaunchAt!,
                                     text: launcher.pinnedApps.isEmpty
                                         ? 'Hold a shortcut to change the app'
                                         : 'Hold a slot to change · hold an added app for options',
@@ -376,55 +390,3 @@ class _FirstRunLauncherTipState extends State<_FirstRunLauncherTip> {
   @override
   Widget build(BuildContext context) => const SizedBox.shrink();
 }
-
-class _HomeHint extends StatefulWidget {
-  const _HomeHint({required this.text, this.style});
-
-  final String text;
-  final TextStyle? style;
-
-  @override
-  State<_HomeHint> createState() => _HomeHintState();
-}
-
-class _HomeHintState extends State<_HomeHint> {
-  Timer? _timer;
-  bool _visible = true;
-
-  @override
-  void initState() {
-    super.initState();
-    final elapsed = DateTime.now().difference(AppClock.startedAt);
-    final remaining = const Duration(minutes: 2) - elapsed;
-    if (remaining <= Duration.zero) {
-      _visible = false;
-    } else {
-      _timer = Timer(remaining, () {
-        if (mounted) setState(() => _visible = false);
-      });
-    }
-  }
-
-  @override
-  void dispose() {
-    _timer?.cancel();
-    super.dispose();
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    return AnimatedSwitcher(
-      duration: const Duration(milliseconds: 350),
-      switchInCurve: Curves.easeOut,
-      switchOutCurve: Curves.easeIn,
-      child: _visible
-          ? Text(
-              widget.text,
-              key: const ValueKey('home-hint'),
-              textAlign: TextAlign.center,
-              style: widget.style,
-            )
-          : const SizedBox.shrink(key: ValueKey('home-hint-gone')),
-    );
-  }
-}

+ 2 - 0
lib/features/home/launcher_controller.dart

@@ -54,6 +54,8 @@ class LauncherController extends ChangeNotifier {
 
   bool get homeRoleHintShown => _settings.homeRoleHintShown;
 
+  DateTime? get firstLaunchAt => _settings.firstLaunchAt;
+
   Future<void> markHomeRoleHintShown() {
     return _settings.setHomeRoleHintShown(true);
   }

+ 103 - 0
lib/features/home/telemetry_settings_controller.dart

@@ -0,0 +1,103 @@
+import 'package:flutter/foundation.dart';
+
+import '../../data/repositories/settings_repository.dart';
+import 'weather_controller.dart';
+
+/// Persisted Home visibility and source choices for speed and temperature.
+///
+/// OBD-II is preferred when it is connected and fresh; GPS and the weather
+/// provider are the fallbacks. Turning temperature off also stops the weather
+/// poller so CarDash keeps working quietly when the row is hidden.
+class TelemetrySettingsController extends ChangeNotifier {
+  TelemetrySettingsController(this._settings, this._weather);
+
+  final SettingsRepository _settings;
+  final WeatherController _weather;
+
+  bool _speedEnabled = true;
+  bool _speedUseObd = true;
+  bool _temperatureEnabled = false;
+  bool _temperatureUseObd = true;
+  bool _disposed = false;
+
+  bool get speedEnabled => _speedEnabled;
+
+  bool get speedUseObd => _speedUseObd;
+
+  bool get temperatureEnabled => _temperatureEnabled;
+
+  bool get temperatureUseObd => _temperatureUseObd;
+
+  bool get weatherEnabled => _weather.enabled;
+
+  String? get weatherError => _weather.error;
+
+  void initialize() {
+    _speedEnabled = _settings.speedEnabled;
+    _speedUseObd = _settings.speedUseObd;
+    _temperatureEnabled = _settings.temperatureEnabled;
+    _temperatureUseObd = _settings.temperatureUseObd;
+    _weather.restoreEnabled(
+      _temperatureEnabled && _settings.weatherLocationTemperature,
+    );
+  }
+
+  Future<void> setSpeedEnabled(bool value) async {
+    if (value == _speedEnabled) return;
+    _speedEnabled = value;
+    _notify();
+    await _settings.setSpeedEnabled(value);
+  }
+
+  Future<void> setSpeedUseObd(bool value) async {
+    if (value == _speedUseObd) return;
+    _speedUseObd = value;
+    _notify();
+    await _settings.setSpeedUseObd(value);
+  }
+
+  /// Returns false when the temperature switch is on but location permission
+  /// for the weather fallback was denied. OBD-II temperature can still work.
+  Future<bool> setTemperatureEnabled(bool value) async {
+    if (value == _temperatureEnabled) return true;
+    _temperatureEnabled = value;
+    _notify();
+    await _settings.setTemperatureEnabled(value);
+    if (!value) {
+      await _settings.setWeatherLocationTemperature(false);
+      await _weather.setEnabled(false);
+      return true;
+    }
+    return _enableWeather();
+  }
+
+  Future<bool> setTemperatureUseObd(bool value) async {
+    if (value == _temperatureUseObd) return true;
+    _temperatureUseObd = value;
+    _notify();
+    await _settings.setTemperatureUseObd(value);
+    if (!value && _temperatureEnabled) {
+      return _enableWeather();
+    }
+    return true;
+  }
+
+  Future<bool> _enableWeather() async {
+    final granted = await _weather.setEnabled(true);
+    if (granted) {
+      await _settings.setWeatherLocationTemperature(true);
+    }
+    _notify();
+    return granted;
+  }
+
+  void _notify() {
+    if (!_disposed) notifyListeners();
+  }
+
+  @override
+  void dispose() {
+    _disposed = true;
+    super.dispose();
+  }
+}

+ 10 - 11
lib/features/home/weather_controller.dart

@@ -3,20 +3,18 @@ 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.
+/// Location-based temperature fallback used when OBD-II is not the source.
+///
+/// Persistence and the Settings switch live in [TelemetrySettingsController];
+/// this controller only owns permission, fetching and freshness.
 class WeatherController extends ChangeNotifier {
-  WeatherController(
-    this._platform,
-    this._settings, {
-    WeatherService? weatherService,
-  }) : _weatherService = weatherService ?? WeatherService();
+  WeatherController(this._platform, {WeatherService? weatherService})
+    : _weatherService = weatherService ?? WeatherService();
 
   final TelemetryPlatform _platform;
-  final SettingsRepository _settings;
   final WeatherService _weatherService;
 
   bool _enabled = false;
@@ -41,8 +39,10 @@ class WeatherController extends ChangeNotifier {
   bool get hasTemperature =>
       _enabled && _temperatureCelsius != null && _locationName != null;
 
-  void initialize() {
-    _enabled = _settings.weatherLocationTemperature;
+  /// Restores the persisted weather preference at cold start without prompting
+  /// for permission. [start] fetches only when location is already granted.
+  void restoreEnabled(bool enabled) {
+    _enabled = enabled;
   }
 
   Future<void> start() async {
@@ -74,7 +74,6 @@ class WeatherController extends ChangeNotifier {
       }
     }
     _enabled = value;
-    await _settings.setWeatherLocationTemperature(value);
     if (value) {
       _active = true;
       await refresh();

+ 67 - 0
lib/features/home/widgets/home_hint.dart

@@ -0,0 +1,67 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+
+/// The one-time long-press hint. It is anchored to the first launch after
+/// installation and never returns once the ten-minute onboarding window ends.
+class HomeHint extends StatefulWidget {
+  const HomeHint({
+    super.key,
+    required this.text,
+    required this.firstLaunchAt,
+    this.style,
+    this.lifetime = const Duration(minutes: 10),
+    this.clock,
+  });
+
+  final String text;
+  final DateTime firstLaunchAt;
+  final TextStyle? style;
+  final Duration lifetime;
+  final DateTime Function()? clock;
+
+  @override
+  State<HomeHint> createState() => _HomeHintState();
+}
+
+class _HomeHintState extends State<HomeHint> {
+  Timer? _timer;
+  bool _visible = true;
+
+  @override
+  void initState() {
+    super.initState();
+    final now = widget.clock?.call() ?? DateTime.now();
+    final remaining = widget.firstLaunchAt.add(widget.lifetime).difference(now);
+    if (remaining <= Duration.zero) {
+      _visible = false;
+    } else {
+      _timer = Timer(remaining, () {
+        if (mounted) setState(() => _visible = false);
+      });
+    }
+  }
+
+  @override
+  void dispose() {
+    _timer?.cancel();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return AnimatedSwitcher(
+      duration: const Duration(milliseconds: 350),
+      switchInCurve: Curves.easeOut,
+      switchOutCurve: Curves.easeIn,
+      child: _visible
+          ? Text(
+              widget.text,
+              key: const ValueKey('home-hint'),
+              textAlign: TextAlign.center,
+              style: widget.style,
+            )
+          : const SizedBox.shrink(key: ValueKey('home-hint-gone')),
+    );
+  }
+}

+ 5 - 78
lib/features/settings/car_data_section.dart → lib/features/settings/obd_settings_card.dart

@@ -7,17 +7,16 @@ 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});
+/// Settings card for the optional Bluetooth Classic ELM327 adapter only.
+class ObdSettingsCard extends StatefulWidget {
+  const ObdSettingsCard({super.key});
 
   @override
-  State<CarDataSection> createState() => _CarDataSectionState();
+  State<ObdSettingsCard> createState() => _ObdSettingsCardState();
 }
 
-class _CarDataSectionState extends State<CarDataSection>
+class _ObdSettingsCardState extends State<ObdSettingsCard>
     with WidgetsBindingObserver {
   @override
   void initState() {
@@ -51,7 +50,6 @@ class _CarDataSectionState extends State<CarDataSection>
     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];
@@ -212,77 +210,6 @@ class _CarDataSectionState extends State<CarDataSection>
           '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),
-          ),
-        ],
       ],
     );
   }

+ 27 - 41
lib/features/settings/settings_screen.dart

@@ -17,7 +17,10 @@ import '../home/system_controller.dart';
 import 'update_controller.dart';
 import '../lock/lock_controller.dart';
 import '../lock/pin_setup_screen.dart';
-import 'car_data_section.dart';
+import 'obd_settings_card.dart';
+import 'settings_widgets.dart';
+import 'speed_settings_card.dart';
+import 'temperature_settings_card.dart';
 
 class SettingsScreen extends StatelessWidget {
   const SettingsScreen({super.key});
@@ -235,14 +238,32 @@ class SettingsScreen extends StatelessWidget {
                     ),
                     const SizedBox(height: 30),
                     _SectionTitle(
-                      title: 'Car data',
+                      title: 'OBD-II',
                       colors: colors,
                       theme: theme,
                     ),
                     const SizedBox(height: 12),
                     SurfaceCard(
                       padding: const EdgeInsets.fromLTRB(26, 22, 26, 20),
-                      child: const CarDataSection(),
+                      child: const ObdSettingsCard(),
+                    ),
+                    const SizedBox(height: 30),
+                    _SectionTitle(title: 'Speed', colors: colors, theme: theme),
+                    const SizedBox(height: 12),
+                    SurfaceCard(
+                      padding: const EdgeInsets.fromLTRB(26, 22, 26, 20),
+                      child: const SpeedSettingsCard(),
+                    ),
+                    const SizedBox(height: 30),
+                    _SectionTitle(
+                      title: 'Temperature',
+                      colors: colors,
+                      theme: theme,
+                    ),
+                    const SizedBox(height: 12),
+                    SurfaceCard(
+                      padding: const EdgeInsets.fromLTRB(26, 22, 26, 20),
+                      child: const TemperatureSettingsCard(),
                     ),
                     const SizedBox(height: 30),
                     _SectionTitle(
@@ -559,41 +580,6 @@ class _AccentSelector extends StatelessWidget {
   }
 }
 
-class _SettingsSwitch extends StatelessWidget {
-  const _SettingsSwitch({
-    required this.value,
-    required this.onChanged,
-    required this.semanticsLabel,
-  });
-
-  final bool value;
-  final ValueChanged<bool> onChanged;
-  final String semanticsLabel;
-
-  @override
-  Widget build(BuildContext context) {
-    final colors = context.colors;
-    return Semantics(
-      label: semanticsLabel,
-      child: Switch(
-        value: value,
-        onChanged: onChanged,
-        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),
-      ),
-    );
-  }
-}
-
 class _KeepScreenOnRow extends StatelessWidget {
   const _KeepScreenOnRow({required this.value, required this.onChanged});
 
@@ -642,7 +628,7 @@ class _KeepScreenOnRow extends StatelessWidget {
           ),
         ),
         const SizedBox(width: 8),
-        _SettingsSwitch(
+        SettingsSwitch(
           value: value,
           semanticsLabel: value
               ? 'Allow the display to sleep'
@@ -699,7 +685,7 @@ class _BatteryToggleRow extends StatelessWidget {
           ),
         ),
         const SizedBox(width: 8),
-        _SettingsSwitch(
+        SettingsSwitch(
           value: value,
           semanticsLabel: value
               ? 'Hide battery and charging status'
@@ -1022,7 +1008,7 @@ class _ShortcutToggleRow extends StatelessWidget {
           ),
         ),
         const SizedBox(width: 8),
-        _SettingsSwitch(
+        SettingsSwitch(
           value: enabled,
           semanticsLabel: enabled
               ? 'Hide ${slot.label} on Home'

+ 40 - 0
lib/features/settings/settings_widgets.dart

@@ -0,0 +1,40 @@
+import 'package:flutter/material.dart';
+
+import '../../core/theme/app_colors.dart';
+
+/// Shared Settings switch styling so each card matches the rest of the page.
+class SettingsSwitch extends StatelessWidget {
+  const SettingsSwitch({
+    super.key,
+    required this.value,
+    required this.onChanged,
+    required this.semanticsLabel,
+  });
+
+  final bool value;
+  final ValueChanged<bool> onChanged;
+  final String semanticsLabel;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    return Semantics(
+      label: semanticsLabel,
+      child: Switch(
+        value: value,
+        onChanged: onChanged,
+        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),
+      ),
+    );
+  }
+}

+ 102 - 0
lib/features/settings/speed_settings_card.dart

@@ -0,0 +1,102 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/widgets/app_icon.dart';
+import '../home/obd_controller.dart';
+import '../home/telemetry_settings_controller.dart';
+import 'settings_widgets.dart';
+
+/// Settings card for showing speed on Home and choosing its source.
+class SpeedSettingsCard extends StatelessWidget {
+  const SpeedSettingsCard({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final telemetry = context.watch<TelemetrySettingsController>();
+    final obd = context.watch<ObdController>();
+
+    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/gauge.svg',
+                size: 20,
+                color: colors.accent,
+              ),
+            ),
+            const SizedBox(width: 14),
+            Expanded(
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  Text('Show speed', style: theme.textTheme.titleMedium),
+                  const SizedBox(height: 2),
+                  Text(
+                    'Show the speed card on the home screen.',
+                    style: theme.textTheme.bodySmall?.copyWith(
+                      color: colors.inkMuted,
+                    ),
+                  ),
+                ],
+              ),
+            ),
+            const SizedBox(width: 8),
+            SettingsSwitch(
+              value: telemetry.speedEnabled,
+              semanticsLabel: telemetry.speedEnabled
+                  ? 'Hide the speed card'
+                  : 'Show the speed card',
+              onChanged: telemetry.setSpeedEnabled,
+            ),
+          ],
+        ),
+        if (obd.hasConfiguredAdapter) ...[
+          Divider(color: colors.border, height: 30),
+          Row(
+            children: [
+              Expanded(
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    Text(
+                      'Use OBD-II for speed',
+                      style: theme.textTheme.bodyLarge,
+                    ),
+                    const SizedBox(height: 2),
+                    Text(
+                      'GPS is used when this is off.',
+                      style: theme.textTheme.bodySmall?.copyWith(
+                        color: colors.inkMuted,
+                      ),
+                    ),
+                  ],
+                ),
+              ),
+              const SizedBox(width: 8),
+              SettingsSwitch(
+                value: telemetry.speedUseObd,
+                semanticsLabel: telemetry.speedUseObd
+                    ? 'Use GPS for speed'
+                    : 'Use OBD-II for speed',
+                onChanged: telemetry.setSpeedUseObd,
+              ),
+            ],
+          ),
+        ],
+      ],
+    );
+  }
+}

+ 135 - 0
lib/features/settings/temperature_settings_card.dart

@@ -0,0 +1,135 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/widgets/app_icon.dart';
+import '../home/obd_controller.dart';
+import '../home/telemetry_settings_controller.dart';
+import 'settings_widgets.dart';
+
+/// Settings card for the outside-temperature row and its data source.
+class TemperatureSettingsCard extends StatelessWidget {
+  const TemperatureSettingsCard({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final telemetry = context.watch<TelemetrySettingsController>();
+    final obd = context.watch<ObdController>();
+
+    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/thermometer.svg',
+                size: 20,
+                color: colors.accent,
+              ),
+            ),
+            const SizedBox(width: 14),
+            Expanded(
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  Text('Show temperature', style: theme.textTheme.titleMedium),
+                  const SizedBox(height: 2),
+                  Text(
+                    'Show the outside temperature below the clock.',
+                    style: theme.textTheme.bodySmall?.copyWith(
+                      color: colors.inkMuted,
+                    ),
+                  ),
+                ],
+              ),
+            ),
+            const SizedBox(width: 8),
+            SettingsSwitch(
+              value: telemetry.temperatureEnabled,
+              semanticsLabel: telemetry.temperatureEnabled
+                  ? 'Hide the outside temperature'
+                  : 'Show the outside temperature',
+              onChanged: (value) async {
+                final ok = await telemetry.setTemperatureEnabled(value);
+                if (!ok && context.mounted) {
+                  _message(
+                    context,
+                    telemetry.weatherError ??
+                        'Location permission is required for weather.',
+                  );
+                }
+              },
+            ),
+          ],
+        ),
+        if (obd.hasConfiguredAdapter && telemetry.temperatureEnabled) ...[
+          Divider(color: colors.border, height: 30),
+          Row(
+            children: [
+              Expanded(
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    Text(
+                      'Use OBD-II for temperature',
+                      style: theme.textTheme.bodyLarge,
+                    ),
+                    const SizedBox(height: 2),
+                    Text(
+                      'GPS and the weather provider are used when this is off.',
+                      style: theme.textTheme.bodySmall?.copyWith(
+                        color: colors.inkMuted,
+                      ),
+                    ),
+                  ],
+                ),
+              ),
+              const SizedBox(width: 8),
+              SettingsSwitch(
+                value: telemetry.temperatureUseObd,
+                semanticsLabel: telemetry.temperatureUseObd
+                    ? 'Use GPS and weather for temperature'
+                    : 'Use OBD-II for temperature',
+                onChanged: (value) async {
+                  final ok = await telemetry.setTemperatureUseObd(value);
+                  if (!ok && context.mounted) {
+                    _message(
+                      context,
+                      telemetry.weatherError ??
+                          'Location permission is required for weather.',
+                    );
+                  }
+                },
+              ),
+            ],
+          ),
+        ],
+        if (telemetry.temperatureEnabled &&
+            telemetry.weatherError != null &&
+            (!telemetry.weatherEnabled || !telemetry.temperatureUseObd)) ...[
+          const SizedBox(height: 6),
+          Text(
+            telemetry.weatherError!,
+            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)));
+  }
+}

+ 9 - 2
lib/main.dart

@@ -15,6 +15,7 @@ import 'features/home/gps_speed_controller.dart';
 import 'features/home/launcher_controller.dart';
 import 'features/home/obd_controller.dart';
 import 'features/home/system_controller.dart';
+import 'features/home/telemetry_settings_controller.dart';
 import 'features/home/weather_controller.dart';
 import 'features/settings/update_controller.dart';
 import 'features/lock/lock_controller.dart';
@@ -39,6 +40,8 @@ Future<void> main() async {
 
   final preferences = await SharedPreferences.getInstance();
   final settings = SettingsRepository(preferences);
+  await settings.ensureFirstLaunchAt(DateTime.now());
+  await settings.ensureTelemetryDefaults();
   final platform = NativeBridge();
   final telemetry = NativeTelemetryBridge();
 
@@ -51,8 +54,11 @@ Future<void> main() async {
   final obdController = ObdController(telemetry, settings)..initialize();
   final gpsSpeedController = GpsSpeedController(telemetry);
   final updateController = UpdateController(platform);
-  final weatherController = WeatherController(telemetry, settings)
-    ..initialize();
+  final weatherController = WeatherController(telemetry);
+  final telemetrySettingsController = TelemetrySettingsController(
+    settings,
+    weatherController,
+  )..initialize();
   final lockController = LockController(PinService(FlutterSecureStore()));
   await lockController.initialize();
 
@@ -66,6 +72,7 @@ Future<void> main() async {
         ChangeNotifierProvider.value(value: obdController),
         ChangeNotifierProvider.value(value: gpsSpeedController),
         ChangeNotifierProvider.value(value: weatherController),
+        ChangeNotifierProvider.value(value: telemetrySettingsController),
         ChangeNotifierProvider.value(value: updateController),
         ChangeNotifierProvider.value(value: lockController),
       ],

+ 1 - 1
pubspec.yaml

@@ -1,7 +1,7 @@
 name: cardash_launcher
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 publish_to: "none"
-version: 1.0.2+14
+version: 1.0.3+15
 
 environment:
   sdk: ^3.13.7

+ 11 - 0
releases/README.md

@@ -1,5 +1,16 @@
 # CarDash releases
 
+## 1.0.3 — Speed and temperature cards
+
+- **File:** `cardash-android-1.0.3.apk`
+- **Version:** 1.0.3 (versionCode 15)
+- **ABIs:** arm64-v8a, armeabi-v7a, x86_64
+- **SHA-256:** `431d241a6aa94acb183d6ae4daa6e8f899e475b2c0a997769e31a53cec111c36`
+- **Changes:** splits Settings into **OBD-II**, **Speed** and **Temperature**
+  cards; adds independent show/hide switches plus OBD-II/GPS and OBD-II/weather
+  source switches; limits the long-press hint to the first ten minutes after a
+  fresh installation and never shows it again after an upgrade.
+
 ## 1.0.2 — Bluetooth settings action
 
 - **File:** `cardash-android-1.0.2.apk`

BIN
releases/cardash-android-1.0.3.apk


+ 1 - 0
releases/cardash-android-1.0.3.apk.sha256

@@ -0,0 +1 @@
+431d241a6aa94acb183d6ae4daa6e8f899e475b2c0a997769e31a53cec111c36  cardash-android-1.0.3.apk

+ 67 - 0
test/home_hint_test.dart

@@ -0,0 +1,67 @@
+import 'package:cardash_launcher/core/theme/app_theme.dart';
+import 'package:cardash_launcher/features/home/widgets/home_hint.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+Widget _host(HomeHint hint) {
+  return MaterialApp(
+    theme: AppTheme.light(),
+    home: Scaffold(body: Center(child: hint)),
+  );
+}
+
+void main() {
+  testWidgets('hint fades out ten minutes after first launch', (tester) async {
+    final firstLaunch = DateTime(2026, 1, 1, 12);
+
+    await tester.pumpWidget(
+      _host(
+        HomeHint(
+          text: 'Hold a shortcut to change the app',
+          firstLaunchAt: firstLaunch,
+          clock: () => firstLaunch.add(const Duration(minutes: 1)),
+        ),
+      ),
+    );
+
+    expect(find.text('Hold a shortcut to change the app'), findsOneWidget);
+
+    await tester.pump(const Duration(minutes: 10));
+    await tester.pump(const Duration(milliseconds: 400));
+
+    expect(find.text('Hold a shortcut to change the app'), findsNothing);
+  });
+
+  testWidgets('hint never appears after the first-launch window', (
+    tester,
+  ) async {
+    final firstLaunch = DateTime(2026, 1, 1, 12);
+
+    await tester.pumpWidget(
+      _host(
+        HomeHint(
+          text: 'Hold a shortcut to change the app',
+          firstLaunchAt: firstLaunch,
+          clock: () => firstLaunch.add(const Duration(minutes: 11)),
+        ),
+      ),
+    );
+
+    expect(find.text('Hold a shortcut to change the app'), findsNothing);
+  });
+
+  testWidgets('an upgraded installation has no onboarding hint', (
+    tester,
+  ) async {
+    await tester.pumpWidget(
+      _host(
+        HomeHint(
+          text: 'Hold a shortcut to change the app',
+          firstLaunchAt: DateTime.fromMillisecondsSinceEpoch(0),
+        ),
+      ),
+    );
+
+    expect(find.text('Hold a shortcut to change the app'), findsNothing);
+  });
+}

+ 78 - 0
test/pin_service_test.dart

@@ -66,6 +66,84 @@ void main() {
       expect(repository.homeRoleHintShown, isTrue);
     });
 
+    test('records the first launch once for a new installation', () async {
+      SharedPreferences.setMockInitialValues({});
+      final prefs = await SharedPreferences.getInstance();
+      final repository = SettingsRepository(prefs);
+      final now = DateTime(2026, 1, 1, 12);
+
+      expect(repository.firstLaunchAt, isNull);
+      expect(await repository.ensureFirstLaunchAt(now), now);
+      expect(repository.firstLaunchAt, now);
+      expect(await repository.ensureFirstLaunchAt(DateTime(2026, 1, 2)), now);
+    });
+
+    test(
+      'an upgraded installation never reopens the onboarding window',
+      () async {
+        SharedPreferences.setMockInitialValues({'settings.theme': 'dark'});
+        final prefs = await SharedPreferences.getInstance();
+        final repository = SettingsRepository(prefs);
+
+        final first = await repository.ensureFirstLaunchAt(
+          DateTime(2026, 1, 1),
+        );
+        expect(first.millisecondsSinceEpoch, 0);
+      },
+    );
+
+    test('speed and temperature preferences persist', () async {
+      SharedPreferences.setMockInitialValues({});
+      final prefs = await SharedPreferences.getInstance();
+      final repository = SettingsRepository(prefs);
+
+      expect(repository.speedEnabled, isTrue);
+      expect(repository.speedUseObd, isTrue);
+      expect(repository.temperatureEnabled, isFalse);
+      expect(repository.temperatureUseObd, isTrue);
+
+      await repository.ensureTelemetryDefaults();
+      expect(repository.temperatureEnabled, isFalse);
+      await repository.setTemperatureEnabled(true);
+      expect(repository.temperatureEnabled, isTrue);
+
+      await repository.setSpeedEnabled(false);
+      await repository.setSpeedUseObd(false);
+      await repository.setTemperatureEnabled(false);
+      await repository.setTemperatureUseObd(false);
+
+      expect(repository.speedEnabled, isFalse);
+      expect(repository.speedUseObd, isFalse);
+      expect(repository.temperatureEnabled, isFalse);
+      expect(repository.temperatureUseObd, isFalse);
+    });
+
+    test('temperature migrates on for an existing OBD install', () async {
+      SharedPreferences.setMockInitialValues({
+        'obd.adapterAddress': '00:11:22:33:44:55',
+      });
+      final prefs = await SharedPreferences.getInstance();
+      final repository = SettingsRepository(prefs);
+
+      await repository.ensureTelemetryDefaults();
+      expect(repository.temperatureEnabled, isTrue);
+
+      await repository.setTemperatureEnabled(false);
+      await repository.ensureTelemetryDefaults();
+      expect(repository.temperatureEnabled, isFalse);
+    });
+
+    test('temperature migrates on for an existing weather install', () async {
+      SharedPreferences.setMockInitialValues({
+        'weather.locationTemperature': true,
+      });
+      final prefs = await SharedPreferences.getInstance();
+      final repository = SettingsRepository(prefs);
+
+      await repository.ensureTelemetryDefaults();
+      expect(repository.temperatureEnabled, isTrue);
+    });
+
     test('OBD adapter and weather preferences persist', () async {
       SharedPreferences.setMockInitialValues({});
       final prefs = await SharedPreferences.getInstance();

+ 191 - 0
test/settings_screen_test.dart

@@ -0,0 +1,191 @@
+import 'package:cardash_launcher/core/display_controller.dart';
+import 'package:cardash_launcher/core/theme/app_theme.dart';
+import 'package:cardash_launcher/core/theme/theme_controller.dart';
+import 'package:cardash_launcher/core/theme/theme_preference.dart';
+import 'package:cardash_launcher/data/models/app_entry.dart';
+import 'package:cardash_launcher/data/models/bluetooth_device_info.dart';
+import 'package:cardash_launcher/data/models/gps_speed.dart';
+import 'package:cardash_launcher/data/models/location_reading.dart';
+import 'package:cardash_launcher/data/models/media_playback.dart';
+import 'package:cardash_launcher/data/models/obd_state.dart';
+import 'package:cardash_launcher/data/models/system_status.dart';
+import 'package:cardash_launcher/data/models/update_info.dart';
+import 'package:cardash_launcher/data/repositories/settings_repository.dart';
+import 'package:cardash_launcher/data/services/native_bridge.dart';
+import 'package:cardash_launcher/data/services/pin_service.dart';
+import 'package:cardash_launcher/data/services/secure_store.dart';
+import 'package:cardash_launcher/data/services/telemetry_bridge.dart';
+import 'package:cardash_launcher/features/home/launcher_controller.dart';
+import 'package:cardash_launcher/features/home/obd_controller.dart';
+import 'package:cardash_launcher/features/home/system_controller.dart';
+import 'package:cardash_launcher/features/home/telemetry_settings_controller.dart';
+import 'package:cardash_launcher/features/home/weather_controller.dart';
+import 'package:cardash_launcher/features/lock/lock_controller.dart';
+import 'package:cardash_launcher/features/settings/settings_screen.dart';
+import 'package:cardash_launcher/features/settings/update_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:provider/provider.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class _LauncherPlatform implements LauncherPlatform {
+  @override
+  Future<List<AppEntry>> listApps() async => const [];
+
+  @override
+  Future<void> launchApp({
+    required String packageName,
+    required String activityName,
+  }) async {}
+
+  @override
+  Future<bool> isDefaultHome() async => true;
+
+  @override
+  Future<bool> requestDefaultHome() async => true;
+
+  @override
+  Future<SystemStatus> getStatus() async => const SystemStatus();
+
+  @override
+  Future<VolumeInfo> getVolume() async => const VolumeInfo();
+
+  @override
+  Future<void> setVolume(int index) async {}
+
+  @override
+  Future<bool> isMusicPlaying() async => false;
+
+  @override
+  Future<bool> sendMediaKey(String action) async => true;
+
+  @override
+  Future<MediaPlayback> getMediaState() async => const MediaPlayback();
+
+  @override
+  Future<bool> openNotificationAccessSettings() async => true;
+
+  @override
+  Future<void> setKeepScreenOn(bool enabled) async {}
+
+  @override
+  Future<bool> openUrl(String url) async => true;
+
+  @override
+  Future<UpdateInstallResult> downloadAndInstallUpdate(String url) async {
+    return const UpdateInstallResult(started: false, permissionRequired: false);
+  }
+}
+
+class _TelemetryPlatform implements TelemetryPlatform {
+  @override
+  Future<List<BluetoothDeviceInfo>> listBondedDevices() async => const [];
+
+  @override
+  Future<bool> isBluetoothEnabled() async => true;
+
+  @override
+  Future<bool> ensureBluetoothPermission() async => true;
+
+  @override
+  Future<bool> openBluetoothSettings() async => true;
+
+  @override
+  Future<bool> ensureLocationPermission() async => true;
+
+  @override
+  Future<LocationReading> getLocation() async =>
+      const LocationReading(latitude: 0, longitude: 0, place: 'Home');
+
+  @override
+  Future<void> connectObd(String address) async {}
+
+  @override
+  Future<void> disconnectObd() async {}
+
+  @override
+  Future<ObdState> getObdState() async => const ObdState();
+
+  @override
+  Future<bool> startGpsSpeed() async => false;
+
+  @override
+  Future<void> stopGpsSpeed() async {}
+
+  @override
+  Future<GpsSpeed> getGpsSpeed() async => const GpsSpeed();
+}
+
+class _SecureStore implements SecureStore {
+  @override
+  Future<String?> read(String key) async => null;
+
+  @override
+  Future<void> write(String key, String value) async {}
+
+  @override
+  Future<void> delete(String key) async {}
+}
+
+void main() {
+  TestWidgetsFlutterBinding.ensureInitialized();
+
+  testWidgets('settings shows the OBD-II, Speed and Temperature cards', (
+    tester,
+  ) async {
+    await tester.binding.setSurfaceSize(const Size(1280, 800));
+    addTearDown(() => tester.binding.setSurfaceSize(null));
+
+    SharedPreferences.setMockInitialValues({});
+    final prefs = await SharedPreferences.getInstance();
+    final settings = SettingsRepository(prefs);
+    await settings.setThemePreference(ThemePreference.light);
+    final platform = _LauncherPlatform();
+    final telemetry = _TelemetryPlatform();
+    final theme = ThemeController(settings);
+    addTearDown(theme.dispose);
+    final display = DisplayController(platform, settings);
+    await display.initialize();
+    final launcher = LauncherController(platform, settings);
+    final system = SystemController(platform);
+    final obd = ObdController(telemetry, settings)..initialize();
+    final weather = WeatherController(telemetry);
+    final telemetrySettings = TelemetrySettingsController(settings, weather)
+      ..initialize();
+    final update = UpdateController(platform);
+    final lock = LockController(PinService(_SecureStore(), iterations: 10));
+    await lock.initialize();
+
+    await tester.pumpWidget(
+      MultiProvider(
+        providers: [
+          ChangeNotifierProvider.value(value: theme),
+          ChangeNotifierProvider.value(value: display),
+          ChangeNotifierProvider.value(value: launcher),
+          ChangeNotifierProvider.value(value: system),
+          ChangeNotifierProvider.value(value: obd),
+          ChangeNotifierProvider.value(value: weather),
+          ChangeNotifierProvider.value(value: telemetrySettings),
+          ChangeNotifierProvider.value(value: update),
+          ChangeNotifierProvider.value(value: lock),
+        ],
+        child: MaterialApp(
+          theme: AppTheme.light(),
+          home: const SettingsScreen(),
+        ),
+      ),
+    );
+    await tester.pumpAndSettle();
+
+    expect(find.text('OBD-II'), findsOneWidget);
+    expect(find.text('Speed'), findsOneWidget);
+    expect(find.text('Temperature'), findsOneWidget);
+    expect(find.text('Show speed'), findsOneWidget);
+    expect(find.text('Show temperature'), findsOneWidget);
+    expect(tester.takeException(), isNull);
+
+    obd.dispose();
+    weather.dispose();
+    telemetrySettings.dispose();
+  });
+}

+ 148 - 0
test/telemetry_settings_controller_test.dart

@@ -0,0 +1,148 @@
+import 'package:cardash_launcher/data/models/bluetooth_device_info.dart';
+import 'package:cardash_launcher/data/models/gps_speed.dart';
+import 'package:cardash_launcher/data/models/location_reading.dart';
+import 'package:cardash_launcher/data/models/obd_state.dart';
+import 'package:cardash_launcher/data/repositories/settings_repository.dart';
+import 'package:cardash_launcher/data/services/telemetry_bridge.dart';
+import 'package:cardash_launcher/data/services/weather_service.dart';
+import 'package:cardash_launcher/features/home/telemetry_settings_controller.dart';
+import 'package:cardash_launcher/features/home/weather_controller.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class _TelemetryPlatform implements TelemetryPlatform {
+  _TelemetryPlatform({this.locationGranted = true});
+
+  final bool locationGranted;
+
+  @override
+  Future<bool> ensureLocationPermission() async => locationGranted;
+
+  @override
+  Future<LocationReading> getLocation() async =>
+      const LocationReading(latitude: 1, longitude: 2, place: 'Sofia');
+
+  @override
+  Future<List<BluetoothDeviceInfo>> listBondedDevices() async => const [];
+
+  @override
+  Future<bool> isBluetoothEnabled() async => true;
+
+  @override
+  Future<bool> ensureBluetoothPermission() async => true;
+
+  @override
+  Future<bool> openBluetoothSettings() async => true;
+
+  @override
+  Future<void> connectObd(String address) async {}
+
+  @override
+  Future<void> disconnectObd() async {}
+
+  @override
+  Future<ObdState> getObdState() async => const ObdState();
+
+  @override
+  Future<bool> startGpsSpeed() async => false;
+
+  @override
+  Future<void> stopGpsSpeed() async {}
+
+  @override
+  Future<GpsSpeed> getGpsSpeed() async => const GpsSpeed();
+}
+
+class _WeatherService extends WeatherService {
+  @override
+  Future<double?> temperatureCelsius({
+    required double latitude,
+    required double longitude,
+  }) async => 12;
+}
+
+void main() {
+  TestWidgetsFlutterBinding.ensureInitialized();
+
+  test('persists source choices and starts weather when needed', () async {
+    SharedPreferences.setMockInitialValues({});
+    final prefs = await SharedPreferences.getInstance();
+    final settings = SettingsRepository(prefs);
+    await settings.setTemperatureEnabled(true);
+    final weather = WeatherController(
+      _TelemetryPlatform(),
+      weatherService: _WeatherService(),
+    );
+    final controller = TelemetrySettingsController(settings, weather)
+      ..initialize();
+    addTearDown(controller.dispose);
+    addTearDown(weather.dispose);
+
+    expect(controller.speedEnabled, isTrue);
+    expect(controller.speedUseObd, isTrue);
+    expect(controller.temperatureEnabled, isTrue);
+    expect(controller.temperatureUseObd, isTrue);
+
+    await controller.setSpeedEnabled(false);
+    await controller.setSpeedUseObd(false);
+    await controller.setTemperatureUseObd(false);
+
+    expect(settings.speedEnabled, isFalse);
+    expect(settings.speedUseObd, isFalse);
+    expect(settings.temperatureUseObd, isFalse);
+    expect(weather.enabled, isTrue);
+    expect(weather.hasTemperature, isTrue);
+    expect(settings.weatherLocationTemperature, isTrue);
+  });
+
+  test('turning temperature off stops and clears the weather poller', () async {
+    SharedPreferences.setMockInitialValues({});
+    final prefs = await SharedPreferences.getInstance();
+    final settings = SettingsRepository(prefs);
+    await settings.setTemperatureEnabled(true);
+    final weather = WeatherController(
+      _TelemetryPlatform(),
+      weatherService: _WeatherService(),
+    );
+    final controller = TelemetrySettingsController(settings, weather)
+      ..initialize();
+    addTearDown(controller.dispose);
+    addTearDown(weather.dispose);
+
+    await controller.setTemperatureUseObd(false);
+    expect(weather.hasTemperature, isTrue);
+
+    await controller.setTemperatureEnabled(false);
+
+    expect(controller.temperatureEnabled, isFalse);
+    expect(settings.temperatureEnabled, isFalse);
+    expect(weather.enabled, isFalse);
+    expect(weather.hasTemperature, isFalse);
+    expect(settings.weatherLocationTemperature, isFalse);
+  });
+
+  test(
+    'denied location leaves the switch on but reports weather failure',
+    () async {
+      SharedPreferences.setMockInitialValues({});
+      final prefs = await SharedPreferences.getInstance();
+      final settings = SettingsRepository(prefs);
+      await settings.setTemperatureEnabled(true);
+      final weather = WeatherController(
+        _TelemetryPlatform(locationGranted: false),
+        weatherService: _WeatherService(),
+      );
+      final controller = TelemetrySettingsController(settings, weather)
+        ..initialize();
+      addTearDown(controller.dispose);
+      addTearDown(weather.dispose);
+
+      final granted = await controller.setTemperatureUseObd(false);
+
+      expect(granted, isFalse);
+      expect(controller.temperatureUseObd, isFalse);
+      expect(weather.enabled, isFalse);
+      expect(controller.weatherError, isNotNull);
+    },
+  );
+}