소스 검색

feat: add keep-awake setting and long-press night mode

Add a default-on Prevent CarDash from sleeping switch backed by the Android window flag, and a black long-press night overlay with a very dim clock. Long-press only empty Home background; cards/buttons keep their actions and tapping wakes. Verify 16 buttons at 1280x720, update tests, docs, screenshots and bump to 0.9.0.
Krasimir Kazakov 1 주 전
부모
커밋
fe5518b849

+ 7 - 6
AGENTS.md

@@ -2,7 +2,7 @@
 
 ## 1. Project Overview
 
-CarDash is a Flutter/Dart Android home-screen launcher for a car tablet. It has one configurable home page (clock, battery/Wi-Fi, media volume, up to 16 home buttons), launches other apps full-screen, keeps the screen awake while foreground, and offers light/dark/automatic themes plus an optional four-digit PIN requested only on cold start.
+CarDash is a Flutter/Dart Android home-screen launcher for a car tablet. It has one configurable home page (clock, battery/Wi-Fi, media volume, up to 16 home buttons), launches other apps full-screen, keeps the screen awake by default (configurable), offers a long-press night screen, light/dark/automatic themes, and an optional four-digit PIN requested only on cold start.
 
 Stack: Flutter 3.47.8 / Dart 3.13.7, Android minSdk 29 (Android 10), compile/targetSdk 36, Kotlin bridges, AGP 9.1.0, Gradle 9.3.1, JDK 21. Package `com.wasp.cardash_launcher`. Landscape-only, tablet-first (1280×800).
 
@@ -33,15 +33,15 @@ 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` (theme/accent/battery), `AppClock`, reusable widgets.
+- `lib/core/` — theme tokens (`AppColors`, `AccentChoice`), `AppTheme`, `ThemeController`, `DisplayController` (keep-awake), `AppClock`, reusable widgets.
 - `lib/data/models/` — `AppEntry`, `SlotType`, `SlotAssignment`, `SystemStatus`, `VolumeInfo`, `MediaPlayback`, OBD/weather models.
 - `lib/data/services/` — `NativeBridge`, `TelemetryBridge`, `WeatherService`, `PinService`, secure-store adapter.
 - `lib/data/repositories/` — `SettingsRepository`, the SSOT for theme, night window, slot assignments, visibility flags and pinned apps.
-- `lib/features/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, equalizer.
+- `lib/features/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, equalizer, night overlay.
 - `lib/features/apps/` — All Apps screen and app-picker sheet.
 - `lib/features/settings/` — General, Media, Car data and Security pages.
 - `lib/features/lock/` — PIN pad, lock screen, cold-start `LockController`.
-- `android/app/src/main/kotlin/com/wasp/cardash_launcher/` — `MainActivity`, `LauncherBridge`, `SystemBridge`, `TelemetryBridge`, `ObdService` (Classic SPP ELM327), `MediaBridge`, `CardashNotificationListener`; `SystemBridge` also dispatches media keys.
+- `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.
 
 Rules:
@@ -90,11 +90,12 @@ 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.
-- Screen-space is deliberately finite: test the 16-button grid at 1280×800 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.
+- 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` in `MainActivity` is intentional. It applies while CarDash is the foreground window; do not move it to a permission-based wake lock.
+- `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.
 - ADB installs: always try at most twice per connected device. If both attempts fail, stop silently rather than looping or switching install methods.
 - OBD is Bluetooth Classic SPP, bonded-only and read-only, and still needs a real-car test; weather uses Open-Meteo and one-shot location and stores no coordinates.
 - The circular ring is song progress; its centre is play/pause and the Mute pill is separate. Playback uses Android media keys; title/progress need the user-granted notification access, otherwise the card falls back to Playing / Nothing playing.

+ 27 - 15
README.md

@@ -4,14 +4,14 @@ A calm, configurable Android car dashboard launcher, built with Flutter.
 
 CarDash replaces the tablet home screen with one landscape page: a large clock,
 battery and Wi-Fi status, a now-playing media card, and up to 16 buttons —
-**Navigation**, **Music**, **Radio**, **Settings**, **All apps**, plus any apps
-you pin from All Apps. The first three can be shown, hidden and assigned to
-installed apps. External apps open full-screen using the normal Android launcher
-mechanism. No overlays, no root, no kiosk setup.
+**Navigation**, **Music**, **Radio**, **All apps**, plus any apps you pin from
+All Apps. The first three can be shown, hidden and assigned to installed apps;
+Settings opens from the gear next to Wi-Fi. External apps open full-screen using
+the normal Android launcher mechanism. No overlays, no root, no kiosk setup.
 
 <p align="center">
-  <em>Verified on Android 15 and Android 10 tablet emulators at 1280×800 and
-  1024×600. Runs on a physical Android 17 HyperOS tablet. OBD-II UI/parser is
+  <em>Verified on Android 15 and Android 10 tablet emulators at 1280×800,
+  1280×720 and 1024×600. Runs on a physical Android 17 HyperOS tablet. OBD-II UI/parser is
   implemented; the ELM327 socket path still needs a real-car test. Landscape only.</em>
 </p>
 
@@ -29,6 +29,8 @@ Also: [Home with top-right Settings](docs/screenshots/home-16.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) ·
+[Night mode](docs/screenshots/home-night-mode.png) ·
 [Blue accent](docs/screenshots/settings-blue-accent.png) ·
 [user-app icon on Home](docs/screenshots/home-user-icon.png) ·
 [pinned-app menu](docs/screenshots/home-app-menu.png) ·
@@ -77,7 +79,8 @@ Also: [Home with top-right Settings](docs/screenshots/home-16.png) ·
   normal launcher; see the limitation below.
 - **Six accent colors** — Green, Blue, Amber, Rose, Violet or Teal, applied
   across the whole interface.
-- **Screen stays awake** while CarDash is in the foreground.
+- **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.
 - **Light / Dark / Automatic** theme. Automatic uses a fixed local-time window
@@ -159,6 +162,9 @@ first run shows a one-time tip pointing to this setting.
 | Tap an unassigned shortcut | Open the app picker |
 | Settings → Accent | Pick Green, Blue, Amber, Rose, Violet or Teal |
 | Settings → Battery & charging | Show or hide battery level and charging bolt |
+| Settings → Prevent CarDash from sleeping | Keep the display on, or use the system timeout |
+| Long-press an empty part of Home | Enter black night mode with a dim clock |
+| Tap the night screen | Return to the dashboard |
 | 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 |
@@ -185,6 +191,7 @@ the app resumes.
 - Light / Dark / Auto theme.
 - Accent: Green, Blue, Amber, Rose, Violet or Teal.
 - Battery & charging: one switch hides both the level and charging bolt.
+- Prevent CarDash from sleeping: keep the display on; on by default.
 - Auto night window (default dark from 19:00, light from 07:00).
 - Home buttons: show/hide Navigation, Music, Radio; live count out of 16.
 - Assign the app for each slot; All apps is fixed and last.
@@ -249,11 +256,12 @@ flutter test
 ./tool/check.sh
 ```
 
-The 41 tests cover PIN hashing/persistence, settings persistence, theme/accent,
+The 44 tests cover PIN hashing/persistence, settings persistence, theme/accent,
 dark accent tinting, battery visibility, first-run state, slot visibility,
-pinned apps, custom labels/icons, the 16-button cap, the dense shortcut grid,
-the equalizer animation, OBD economy, the OBD card, media-key playback, media
-metadata/progress, the marquee and the play/pause ring. Native package
+pinned apps, custom labels/icons, the 16-button cap at 1280×800, 1280×720 and
+1024×600, the dense shortcut grid, the equalizer animation, OBD economy, the OBD
+card, media-key playback, media metadata/progress, the marquee, the play/pause
+ring, the keep-awake preference and the night overlay. Native package
 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
@@ -261,7 +269,7 @@ title, artist, progress and pause/resume were verified against a live Chrome
 MediaSession. The OBD hardware path compiles and is covered by the parser/economy
 design, but no ELM327 adapter is available on this machine; socket behavior still
 needs a car test. Release
-0.8.0 is installed on the emulator and the physical Android 17 HyperOS tablet.
+0.9.0 is installed on the emulator and the physical Android 17 HyperOS tablet.
 
 ## Troubleshooting the emulator
 
@@ -283,7 +291,7 @@ Android 15 AVD is left running headlessly after development; use
 - **minSdk 29 (Android 10)**; compile/targetSdk 36. Nothing in the design
   requires a newer Android release.
 - **Landscape only** (`sensorLandscape`). The home page is designed for
-  1280×800 and remains usable at 1024×600.
+  1280×800 and verified at 1280×720 and 1024×600; 16 buttons fit at all three.
 - **16-button cap** includes All apps; Settings lives in the top-right status
   area and does not consume a slot. Only apps pinned from All Apps show an
   original app icon by default; the predefined slots, All apps and the Settings
@@ -297,8 +305,12 @@ Android 15 AVD is left running headlessly after development; use
 - **Notification access is used only for media metadata.** CarDash does not read
   notification text or store track history. Revoke it in Android settings at any
   time; the ring and title then fall back to an empty/generic state.
-- **Screen-on** applies while CarDash is the foreground window. Android can
-  still sleep while another app is in front.
+- **Screen-on** is controlled by Settings → Prevent CarDash from sleeping
+  (default on). It applies only while CarDash is the foreground window; with the
+  switch off, the normal Android display timeout applies.
+- **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.
 - **No embedded third-party app UI.** Navigation/Music/Radio are launched in

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

@@ -3,7 +3,6 @@ package com.wasp.cardash_launcher
 import android.media.AudioManager
 import android.os.Bundle
 import android.view.KeyEvent
-import android.view.WindowManager
 import io.flutter.embedding.android.FlutterActivity
 import io.flutter.embedding.engine.FlutterEngine
 
@@ -13,14 +12,6 @@ class MainActivity : FlutterActivity() {
     private var telemetryBridge: TelemetryBridge? = null
     private var mediaBridge: MediaBridge? = null
 
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        // The dashboard is the car's screen. Keep it awake while this window is
-        // visible; Android clears the flag automatically when the activity is
-        // no longer in front.
-        window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
-    }
-
     override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
         super.configureFlutterEngine(flutterEngine)
 

+ 24 - 0
android/app/src/main/kotlin/com/wasp/cardash_launcher/SystemBridge.kt

@@ -8,6 +8,7 @@ import android.net.ConnectivityManager
 import android.net.NetworkCapabilities
 import android.os.BatteryManager
 import android.view.KeyEvent
+import android.view.WindowManager
 import io.flutter.plugin.common.BinaryMessenger
 import io.flutter.plugin.common.MethodCall
 import io.flutter.plugin.common.MethodChannel
@@ -35,6 +36,15 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
             "getStatus" -> result.success(readStatus())
             "getVolume" -> result.success(readVolume())
             "isMusicPlaying" -> result.success(isMusicPlaying())
+            "setKeepScreenOn" -> {
+                val enabled = call.argument<Boolean>("enabled")
+                if (enabled == null) {
+                    result.error("bad_arguments", "An enabled flag is required.", null)
+                    return
+                }
+                setKeepScreenOn(enabled)
+                result.success(true)
+            }
             "mediaKey" -> {
                 val action = call.argument<String>("action")
                 if (action == null) {
@@ -102,6 +112,20 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
         }
     }
 
+    private fun setKeepScreenOn(enabled: Boolean) {
+        activity.runOnUiThread {
+            try {
+                if (enabled) {
+                    activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+                } else {
+                    activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+                }
+            } catch (_: Throwable) {
+                // Window flags are best-effort.
+            }
+        }
+    }
+
     private fun sendMediaKey(action: String): Boolean {
         val keyCode = when (action) {
             "play" -> KeyEvent.KEYCODE_MEDIA_PLAY

+ 18 - 7
docs/IMPLEMENTATION_PLAN.md

@@ -41,7 +41,9 @@ compatibility:
 - Dark surfaces are tinted by the selected accent instead of retaining a green base.
 - PIN is requested **only on a cold start**, never on resume or screen-off.
 - The screen stays awake while CarDash is the foreground window.
-- Landscape-only; tablet-first 1280×800; Android 10 minimum.
+- Landscape-only; tablet-first 1280×800, also verified at 1280×720; Android 10 minimum.
+- Settings keeps the display awake by default; the user can switch to the system timeout.
+- Long-press on empty Home enters a black night overlay with a very dim clock; tap wakes.
 - Package: `com.wasp.cardash_launcher`. Display name: **CarDash**.
 
 ## 2. Decisions and why
@@ -72,7 +74,8 @@ compatibility:
 | Weather fallback | Open-Meteo + one-shot device location, Settings toggle | No API key/account; hide the row on any failure. |
 | Temperature priority | OBD ambient, else location weather | OBD wins whenever a fresh value exists. |
 | Accent background | Dark canvas/surfaces/borders blend toward the accent | Fixes the green base showing through when Blue is selected. |
-| Screen | `FLAG_KEEP_SCREEN_ON` while foreground | The dashboard is the car display; no wake-lock permission needed. |
+| 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. |
 | Theme | Light / Dark / Automatic with fixed 19:00–07:00 window | No location permission or network; time pickers make the window adjustable. |
 | PIN | Four digits, salted PBKDF2 hash, cold start only | Matches the explicit instruction; no re-lock while running. |
 | Min Android | API 29 (Android 10) | Requested minimum; no in-scope API requires newer. |
@@ -90,6 +93,8 @@ Flutter
     ClockBlock / StatusCluster / VolumeCard / ShortcutGrid
     LauncherController  -> apps, slots, pinned apps, 16-button cap, default-home state
     SystemController    -> battery/Wi-Fi/volume/media state + media keys
+    DisplayController   -> persisted keep-awake window flag
+    HomeScreen          -> NightModeOverlay on empty-background long press
   AllAppsScreen / AppPickerSheet
   SettingsScreen -> General / Car data / Security
   PinSetupScreen / LockScreen -> LockController -> PinService
@@ -131,6 +136,7 @@ Android (Kotlin)
 | M12 | Accent-tinted dark theme, OBD-II adapter/telemetry, location weather fallback, Car data Settings | Done |
 | M13 | Play/pause volume ring, Settings gear in status area, 16-button cap without Settings | Done |
 | M14 | Song-progress ring, scrolling now-playing title, notification-access Settings card | Done |
+| M15 | Keep-awake Settings switch (default on), night mode, 1280×720 layout | Done |
 
 ## 5. Verification checklist
 
@@ -152,8 +158,9 @@ Android (Kotlin)
 - [x] Navigation/Music/Radio switches hide/show buttons, persist, and preserve assignments.
 - [x] All Apps long-press pins apps; the 16-button cap rejects a 17th button.
 - [x] Long-press on an added app removes it; it can be re-added from All Apps.
-- [x] 16 buttons fit at 1280×800 and 1024×600; the grid uses 2–6 columns.
-- [x] `FLAG_KEEP_SCREEN_ON` is present on the dashboard window.
+- [x] 16 buttons fit at 1280×800, 1280×720 and 1024×600; the grid uses 2–6 columns.
+- [x] `FLAG_KEEP_SCREEN_ON` follows the keep-awake switch: present by default, cleared when off and restored when on.
+- [x] Long-press on empty Home enters night mode; card/button long-presses are unaffected; tapping wakes.
 - [x] Six accent choices change the Material theme immediately and persist.
 - [x] Battery switch hides both the battery level and charging bolt.
 - [x] Settings is next to Wi-Fi; All apps is the last grid button.
@@ -171,13 +178,13 @@ Android (Kotlin)
 - [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] Circled ring shows song progress; 41 tests include media keys, metadata,
-  progress, the marquee and the ring.
+- [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
   both the ring and scroll position, resume continues.
 - [x] Settings → Media grants notification access and shows Granted.
 - [x] Settings gear is next to Wi-Fi; the grid ends with All apps.
-- [x] Release 0.8.0 installed on the emulator and physical Android 17 tablet.
+- [x] Release 0.9.0 installed on the emulator and physical Android 17 tablet.
 - [x] Screenshots in `docs/screenshots/`; README/AGENTS/plan/OBD doc updated.
 
 ## 6. Known risks and mitigations
@@ -262,3 +269,7 @@ Android (Kotlin)
   notification-access grant; no notification content is read. Verified metadata,
   progress, scrolling/pause/resume with a live Chrome media session, 41 tests and
   0.8.0 on emulator/tablet.
+- 2026-10-29: Added a default-on keep-awake switch, a black long-press night mode
+  with a dim clock, and verified the 16-button layout at 1280×720. Verified the
+  native window flag toggles off/on, night mode/card long-press isolation, 44 tests
+  and 0.9.0 on emulator/tablet.

BIN
docs/screenshots/home-1280x720.png


BIN
docs/screenshots/home-night-mode.png


+ 2 - 0
lib/app.dart

@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
 import 'package:provider/provider.dart';
 
 import 'core/theme/app_theme.dart';
+import 'core/display_controller.dart';
 import 'core/theme/theme_controller.dart';
 import 'features/home/home_screen.dart';
 import 'features/home/launcher_controller.dart';
@@ -50,6 +51,7 @@ class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
         _applyImmersive();
         context.read<ThemeController>().refreshNow();
         system.start();
+        unawaited(context.read<DisplayController>().apply());
         unawaited(context.read<ObdController>().start());
         unawaited(context.read<WeatherController>().start());
         context.read<LauncherController>().refresh();

+ 50 - 0
lib/core/display_controller.dart

@@ -0,0 +1,50 @@
+import 'package:flutter/foundation.dart';
+
+import '../data/repositories/settings_repository.dart';
+import '../data/services/native_bridge.dart';
+
+/// Applies the persisted "Prevent CarDash from sleeping" preference to the
+/// Android window. The flag only applies while this window is visible.
+class DisplayController extends ChangeNotifier {
+  DisplayController(this._platform, this._settings)
+    : _keepScreenOn = _settings.keepScreenOn;
+
+  final LauncherPlatform _platform;
+  final SettingsRepository _settings;
+
+  bool _keepScreenOn;
+  bool _disposed = false;
+
+  bool get keepScreenOn => _keepScreenOn;
+
+  Future<void> initialize() => _apply();
+
+  Future<void> setKeepScreenOn(bool enabled) async {
+    if (enabled == _keepScreenOn) return;
+    _keepScreenOn = enabled;
+    await _settings.setKeepScreenOn(enabled);
+    await _apply();
+    _notify();
+  }
+
+  /// Reapplies after resume, when Android may have recreated the window.
+  Future<void> apply() => _apply();
+
+  Future<void> _apply() async {
+    try {
+      await _platform.setKeepScreenOn(_keepScreenOn);
+    } catch (_) {
+      // Display flags are best-effort; settings remain persisted.
+    }
+  }
+
+  void _notify() {
+    if (!_disposed) notifyListeners();
+  }
+
+  @override
+  void dispose() {
+    _disposed = true;
+    super.dispose();
+  }
+}

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

@@ -25,6 +25,7 @@ class SettingsRepository {
   static const _homeRoleHintShownKey = 'onboarding.homeRoleHintShown';
   static const _obdAddressKey = 'obd.adapterAddress';
   static const _weatherTemperatureKey = 'weather.locationTemperature';
+  static const _keepScreenOnKey = 'display.keepScreenOn';
 
   ThemePreference get themePreference {
     return ThemePreference.fromStorage(_prefs.getString(_themeKey));
@@ -53,6 +54,11 @@ class SettingsRepository {
     return _prefs.getBool(_weatherTemperatureKey) ?? false;
   }
 
+  /// The dashboard keeps the display awake by default.
+  bool get keepScreenOn {
+    return _prefs.getBool(_keepScreenOnKey) ?? true;
+  }
+
   Future<void> setThemePreference(ThemePreference preference) {
     return _prefs.setString(_themeKey, preference.storageValue);
   }
@@ -81,6 +87,10 @@ class SettingsRepository {
     return _prefs.setBool(_weatherTemperatureKey, enabled);
   }
 
+  Future<void> setKeepScreenOn(bool enabled) {
+    return _prefs.setBool(_keepScreenOnKey, enabled);
+  }
+
   Future<void> setNightWindow({
     required int startMinutes,
     required int endMinutes,

+ 15 - 0
lib/data/services/native_bridge.dart

@@ -41,6 +41,8 @@ abstract class LauncherPlatform {
   Future<MediaPlayback> getMediaState();
 
   Future<bool> openNotificationAccessSettings();
+
+  Future<void> setKeepScreenOn(bool enabled);
 }
 
 class NativeBridge implements LauncherPlatform {
@@ -191,6 +193,19 @@ class NativeBridge implements LauncherPlatform {
     }
   }
 
+  @override
+  Future<void> setKeepScreenOn(bool enabled) async {
+    try {
+      await _systemChannel.invokeMethod<dynamic>('setKeepScreenOn', {
+        'enabled': enabled,
+      });
+    } on PlatformException {
+      // Older builds simply keep the window behaviour unchanged.
+    } on MissingPluginException {
+      // Nothing to apply outside Android.
+    }
+  }
+
   @override
   Future<void> setVolume(int index) async {
     try {

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

@@ -17,6 +17,7 @@ import 'pinned_app_actions.dart';
 import 'system_controller.dart';
 import 'weather_controller.dart';
 import 'widgets/clock_block.dart';
+import 'widgets/night_mode_overlay.dart';
 import 'widgets/obd_card.dart';
 import 'widgets/shortcut_grid.dart';
 import 'widgets/status_cluster.dart';
@@ -24,9 +25,24 @@ import 'widgets/volume_card.dart';
 
 /// The only home page. No app bar, no dock, no pages: information, the main
 /// volume control, and up to 16 configurable shortcuts.
-class HomeScreen extends StatelessWidget {
+class HomeScreen extends StatefulWidget {
   const HomeScreen({super.key});
 
+  @override
+  State<HomeScreen> createState() => _HomeScreenState();
+}
+
+class _HomeScreenState extends State<HomeScreen> {
+  bool _nightMode = false;
+
+  void _enterNightMode() {
+    setState(() => _nightMode = true);
+  }
+
+  void _leaveNightMode() {
+    setState(() => _nightMode = false);
+  }
+
   @override
   Widget build(BuildContext context) {
     final colors = context.colors;
@@ -116,6 +132,13 @@ class HomeScreen extends StatelessWidget {
                 ),
               ),
             ),
+            if (!_nightMode)
+              Positioned.fill(
+                child: GestureDetector(
+                  behavior: HitTestBehavior.translucent,
+                  onLongPress: _enterNightMode,
+                ),
+              ),
             const _FirstRunLauncherTip(),
             Positioned(
               top: -190,
@@ -232,6 +255,10 @@ class HomeScreen extends StatelessWidget {
                 ),
               ),
             ),
+            if (_nightMode)
+              Positioned.fill(
+                child: NightModeOverlay(onDismiss: _leaveNightMode),
+              ),
           ],
         ),
       ),

+ 81 - 0
lib/features/home/widgets/night_mode_overlay.dart

@@ -0,0 +1,81 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+
+/// Black night screen with a very dim clock. Tapping anywhere returns.
+class NightModeOverlay extends StatefulWidget {
+  const NightModeOverlay({super.key, required this.onDismiss});
+
+  final VoidCallback onDismiss;
+
+  @override
+  State<NightModeOverlay> createState() => _NightModeOverlayState();
+}
+
+class _NightModeOverlayState extends State<NightModeOverlay> {
+  late DateTime _now = DateTime.now();
+  Timer? _timer;
+
+  @override
+  void initState() {
+    super.initState();
+    _scheduleTick();
+  }
+
+  void _scheduleTick() {
+    _timer?.cancel();
+    final now = DateTime.now();
+    final next = DateTime(
+      now.year,
+      now.month,
+      now.day,
+      now.hour,
+      now.minute,
+    ).add(const Duration(minutes: 1));
+    _timer = Timer(next.difference(now) + const Duration(milliseconds: 80), () {
+      if (!mounted) return;
+      setState(() => _now = DateTime.now());
+      _scheduleTick();
+    });
+  }
+
+  @override
+  void dispose() {
+    _timer?.cancel();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final locale = Localizations.localeOf(context).toLanguageTag();
+    final use24Hour = MediaQuery.alwaysUse24HourFormatOf(context);
+    final time = (use24Hour ? DateFormat.Hm(locale) : DateFormat.jm(locale))
+        .format(_now);
+
+    return Semantics(
+      button: true,
+      label: 'Exit night mode',
+      child: GestureDetector(
+        behavior: HitTestBehavior.opaque,
+        onTap: widget.onDismiss,
+        child: ColoredBox(
+          color: Colors.black,
+          child: Center(
+            child: Text(
+              time,
+              maxLines: 1,
+              style: TextStyle(
+                color: Colors.white.withValues(alpha: 0.12),
+                fontSize: 84,
+                fontWeight: FontWeight.w300,
+                height: 1,
+                fontFeatures: const [FontFeature.tabularFigures()],
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}

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

@@ -1,6 +1,7 @@
 import 'package:flutter/material.dart';
 import 'package:provider/provider.dart';
 
+import '../../core/display_controller.dart';
 import '../../core/theme/accent_choice.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/theme_controller.dart';
@@ -24,6 +25,7 @@ class SettingsScreen extends StatelessWidget {
     final colors = context.colors;
     final theme = Theme.of(context);
     final themeController = context.watch<ThemeController>();
+    final display = context.watch<DisplayController>();
     final launcher = context.watch<LauncherController>();
     final system = context.watch<SystemController>();
     final lock = context.watch<LockController>();
@@ -180,6 +182,11 @@ class SettingsScreen extends StatelessWidget {
                               themeController.setShowBattery(value);
                             },
                           ),
+                          Divider(color: colors.border, height: 24),
+                          _KeepScreenOnRow(
+                            value: display.keepScreenOn,
+                            onChanged: display.setKeepScreenOn,
+                          ),
                           Divider(color: colors.border, height: 30),
                           Row(
                             children: [
@@ -327,7 +334,7 @@ class SettingsScreen extends StatelessWidget {
                     const SizedBox(height: 28),
                     Center(
                       child: Text(
-                        'CarDash 0.8.0 · Android 10+',
+                        'CarDash 0.9.0 · Android 10+',
                         style: theme.textTheme.bodySmall?.copyWith(
                           color: colors.inkFaint,
                         ),
@@ -573,6 +580,66 @@ class _SettingsSwitch extends StatelessWidget {
   }
 }
 
+class _KeepScreenOnRow extends StatelessWidget {
+  const _KeepScreenOnRow({required this.value, required this.onChanged});
+
+  final bool value;
+  final ValueChanged<bool> onChanged;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+
+    return 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/clock.svg',
+            size: 18,
+            color: colors.inkMuted,
+          ),
+        ),
+        const SizedBox(width: 14),
+        Expanded(
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Text(
+                'Prevent CarDash from sleeping',
+                style: theme.textTheme.bodyLarge,
+              ),
+              const SizedBox(height: 2),
+              Text(
+                'Keep the display on while CarDash is in front. Turn off to use the system screen timeout.',
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: colors.inkMuted,
+                ),
+              ),
+            ],
+          ),
+        ),
+        const SizedBox(width: 8),
+        _SettingsSwitch(
+          value: value,
+          semanticsLabel: value
+              ? 'Allow the display to sleep'
+              : 'Prevent CarDash from sleeping',
+          onChanged: onChanged,
+        ),
+      ],
+    );
+  }
+}
+
 class _BatteryToggleRow extends StatelessWidget {
   const _BatteryToggleRow({required this.value, required this.onChanged});
 

+ 4 - 0
lib/main.dart

@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
 import 'package:shared_preferences/shared_preferences.dart';
 
 import 'app.dart';
+import 'core/display_controller.dart';
 import 'core/theme/theme_controller.dart';
 import 'data/repositories/settings_repository.dart';
 import 'data/services/native_bridge.dart';
@@ -40,6 +41,8 @@ Future<void> main() async {
   final telemetry = NativeTelemetryBridge();
 
   final themeController = ThemeController(settings);
+  final displayController = DisplayController(platform, settings);
+  await displayController.initialize();
   final launcherController = LauncherController(platform, settings)
     ..initialize();
   final systemController = SystemController(platform);
@@ -53,6 +56,7 @@ Future<void> main() async {
     MultiProvider(
       providers: [
         ChangeNotifierProvider.value(value: themeController),
+        ChangeNotifierProvider.value(value: displayController),
         ChangeNotifierProvider.value(value: launcherController),
         ChangeNotifierProvider.value(value: systemController),
         ChangeNotifierProvider.value(value: obdController),

+ 1 - 1
pubspec.yaml

@@ -1,7 +1,7 @@
 name: cardash_launcher
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 publish_to: "none"
-version: 0.8.0+8
+version: 0.9.0+9
 
 environment:
   sdk: ^3.13.7

+ 77 - 0
test/display_controller_test.dart

@@ -0,0 +1,77 @@
+import 'package:cardash_launcher/core/display_controller.dart';
+import 'package:cardash_launcher/data/models/app_entry.dart';
+import 'package:cardash_launcher/data/models/media_playback.dart';
+import 'package:cardash_launcher/data/models/system_status.dart';
+import 'package:cardash_launcher/data/repositories/settings_repository.dart';
+import 'package:cardash_launcher/data/services/native_bridge.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class _DisplayPlatform implements LauncherPlatform {
+  final List<bool> calls = [];
+
+  @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 {
+    calls.add(enabled);
+  }
+}
+
+void main() {
+  test('keeps the screen on by default and persists a change', () async {
+    SharedPreferences.setMockInitialValues({});
+    final prefs = await SharedPreferences.getInstance();
+    final settings = SettingsRepository(prefs);
+    final platform = _DisplayPlatform();
+    final controller = DisplayController(platform, settings);
+    addTearDown(controller.dispose);
+
+    expect(controller.keepScreenOn, isTrue);
+    await controller.initialize();
+    expect(platform.calls.last, isTrue);
+
+    await controller.setKeepScreenOn(false);
+    expect(controller.keepScreenOn, isFalse);
+    expect(platform.calls.last, isFalse);
+    expect(SettingsRepository(prefs).keepScreenOn, isFalse);
+    expect(
+      DisplayController(platform, SettingsRepository(prefs)).keepScreenOn,
+      isFalse,
+    );
+  });
+}

+ 3 - 0
test/launcher_controller_test.dart

@@ -48,6 +48,9 @@ class FakeLauncherPlatform implements LauncherPlatform {
 
   @override
   Future<bool> openNotificationAccessSettings() async => true;
+
+  @override
+  Future<void> setKeepScreenOn(bool enabled) async {}
 }
 
 List<AppEntry> _apps(int count) {

+ 15 - 0
test/night_mode_overlay_test.dart

@@ -0,0 +1,15 @@
+import 'package:cardash_launcher/features/home/widgets/night_mode_overlay.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  testWidgets('night mode clock dismisses on a tap', (tester) async {
+    var dismissed = false;
+    await tester.pumpWidget(
+      MaterialApp(home: NightModeOverlay(onDismiss: () => dismissed = true)),
+    );
+    expect(find.byType(Text), findsOneWidget);
+    await tester.tap(find.byType(NightModeOverlay));
+    expect(dismissed, isTrue);
+  });
+}

+ 3 - 0
test/pin_service_test.dart

@@ -72,11 +72,14 @@ void main() {
       final repository = SettingsRepository(prefs);
 
       expect(repository.obdAdapterAddress, isNull);
+      expect(repository.keepScreenOn, isTrue);
       expect(repository.weatherLocationTemperature, isFalse);
       await repository.setObdAdapterAddress('00:11:22:33:44:55');
       await repository.setWeatherLocationTemperature(true);
+      await repository.setKeepScreenOn(false);
       expect(repository.obdAdapterAddress, '00:11:22:33:44:55');
       expect(repository.weatherLocationTemperature, isTrue);
+      expect(repository.keepScreenOn, isFalse);
 
       await repository.setObdAdapterAddress(null);
       expect(repository.obdAdapterAddress, isNull);

+ 10 - 0
test/shortcut_grid_test.dart

@@ -54,6 +54,16 @@ void main() {
     expect(first.size, greaterThan(64));
   });
 
+  testWidgets('16 buttons fit at the 1280x720 layout', (tester) async {
+    await _pumpGrid(tester, size: const Size(755, 500), compact: false);
+
+    expect(tester.takeException(), isNull);
+    expect(find.byType(ShortcutTile), findsNWidgets(16));
+    final first = tester.widget<ShortcutTile>(find.byType(ShortcutTile).first);
+    expect(first.size, lessThanOrEqualTo(196));
+    expect(first.size, greaterThan(64));
+  });
+
   testWidgets('16 buttons fit at the 1024x600 compact layout', (tester) async {
     await _pumpGrid(tester, size: const Size(559, 476), compact: true);
 

+ 3 - 0
test/system_controller_test.dart

@@ -50,6 +50,9 @@ class _MediaPlatform implements LauncherPlatform {
 
   @override
   Future<bool> openNotificationAccessSettings() async => true;
+
+  @override
+  Future<void> setKeepScreenOn(bool enabled) async {}
 }
 
 void main() {