Pārlūkot izejas kodu

feat: build the single-screen CarDash launcher

Krasimir Kazakov 1 nedēļu atpakaļ
revīzija
7c3ef2090d
100 mainītis faili ar 7427 papildinājumiem un 0 dzēšanām
  1. 48 0
      .gitignore
  2. 30 0
      .metadata
  3. 94 0
      AGENTS.md
  4. 198 0
      README.md
  5. 33 0
      analysis_options.yaml
  6. 14 0
      android/.gitignore
  7. 41 0
      android/app/build.gradle.kts
  8. 7 0
      android/app/src/debug/AndroidManifest.xml
  9. 61 0
      android/app/src/main/AndroidManifest.xml
  10. 209 0
      android/app/src/main/kotlin/com/wasp/cardash_launcher/LauncherBridge.kt
  11. 28 0
      android/app/src/main/kotlin/com/wasp/cardash_launcher/MainActivity.kt
  12. 97 0
      android/app/src/main/kotlin/com/wasp/cardash_launcher/SystemBridge.kt
  13. 4 0
      android/app/src/main/res/drawable-v21/launch_background.xml
  14. 15 0
      android/app/src/main/res/drawable/ic_launcher_foreground.xml
  15. 4 0
      android/app/src/main/res/drawable/launch_background.xml
  16. 5 0
      android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
  17. BIN
      android/app/src/main/res/mipmap-hdpi/ic_launcher.png
  18. BIN
      android/app/src/main/res/mipmap-mdpi/ic_launcher.png
  19. BIN
      android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
  20. BIN
      android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
  21. BIN
      android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
  22. 12 0
      android/app/src/main/res/values-night/styles.xml
  23. 11 0
      android/app/src/main/res/values-v31/styles.xml
  24. 5 0
      android/app/src/main/res/values/colors.xml
  25. 12 0
      android/app/src/main/res/values/styles.xml
  26. 7 0
      android/app/src/profile/AndroidManifest.xml
  27. 24 0
      android/build.gradle.kts
  28. 6 0
      android/gradle.properties
  29. 5 0
      android/gradle/wrapper/gradle-wrapper.properties
  30. 26 0
      android/settings.gradle.kts
  31. BIN
      assets/fonts/Manrope-wght.ttf
  32. 93 0
      assets/fonts/OFL-Manrope.txt
  33. 16 0
      assets/icons/arrow-left.svg
  34. 18 0
      assets/icons/battery-charging.svg
  35. 19 0
      assets/icons/battery-full.svg
  36. 15 0
      assets/icons/check.svg
  37. 15 0
      assets/icons/chevron-right.svg
  38. 17 0
      assets/icons/delete.svg
  39. 19 0
      assets/icons/grid-3x3.svg
  40. 17 0
      assets/icons/info.svg
  41. 18 0
      assets/icons/layout-grid.svg
  42. 16 0
      assets/icons/lock.svg
  43. 15 0
      assets/icons/moon.svg
  44. 17 0
      assets/icons/music.svg
  45. 15 0
      assets/icons/navigation.svg
  46. 16 0
      assets/icons/plus.svg
  47. 19 0
      assets/icons/radio.svg
  48. 16 0
      assets/icons/search.svg
  49. 16 0
      assets/icons/settings.svg
  50. 23 0
      assets/icons/sliders-horizontal.svg
  51. 16 0
      assets/icons/smartphone.svg
  52. 23 0
      assets/icons/sun.svg
  53. 17 0
      assets/icons/volume-2.svg
  54. 17 0
      assets/icons/volume-x.svg
  55. 21 0
      assets/icons/wifi-off.svg
  56. 18 0
      assets/icons/wifi.svg
  57. 16 0
      assets/icons/x.svg
  58. 128 0
      docs/IMPLEMENTATION_PLAN.md
  59. 98 0
      lib/app.dart
  60. 143 0
      lib/core/theme/app_colors.dart
  61. 177 0
      lib/core/theme/app_theme.dart
  62. 140 0
      lib/core/theme/theme_controller.dart
  63. 17 0
      lib/core/theme/theme_preference.dart
  64. 32 0
      lib/core/widgets/app_icon.dart
  65. 44 0
      lib/core/widgets/app_icon_image.dart
  66. 63 0
      lib/core/widgets/pressable.dart
  67. 62 0
      lib/core/widgets/surface_card.dart
  68. 49 0
      lib/data/models/app_entry.dart
  69. 42 0
      lib/data/models/slot_assignment.dart
  70. 20 0
      lib/data/models/slot_type.dart
  71. 39 0
      lib/data/models/system_status.dart
  72. 68 0
      lib/data/repositories/settings_repository.dart
  73. 143 0
      lib/data/services/native_bridge.dart
  74. 89 0
      lib/data/services/pin_service.dart
  75. 27 0
      lib/data/services/secure_store.dart
  76. 250 0
      lib/features/apps/all_apps_screen.dart
  77. 312 0
      lib/features/apps/app_picker_sheet.dart
  78. 299 0
      lib/features/home/home_screen.dart
  79. 182 0
      lib/features/home/launcher_controller.dart
  80. 125 0
      lib/features/home/system_controller.dart
  81. 103 0
      lib/features/home/widgets/clock_block.dart
  82. 83 0
      lib/features/home/widgets/shortcut_grid.dart
  83. 132 0
      lib/features/home/widgets/shortcut_tile.dart
  84. 154 0
      lib/features/home/widgets/status_cluster.dart
  85. 316 0
      lib/features/home/widgets/volume_card.dart
  86. 204 0
      lib/features/lock/lock_controller.dart
  87. 182 0
      lib/features/lock/lock_screen.dart
  88. 220 0
      lib/features/lock/pin_pad.dart
  89. 338 0
      lib/features/lock/pin_setup_screen.dart
  90. 611 0
      lib/features/settings/settings_screen.dart
  91. 56 0
      lib/main.dart
  92. 634 0
      pubspec.lock
  93. 33 0
      pubspec.yaml
  94. 81 0
      test/pin_service_test.dart
  95. 48 0
      test/theme_controller_test.dart
  96. 32 0
      test/widget_test.dart
  97. 15 0
      tool/check.sh
  98. 55 0
      tool/emulator.sh
  99. 46 0
      tool/install.sh
  100. 11 0
      tool/screenshot.sh

+ 48 - 0
.gitignore

@@ -0,0 +1,48 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+/coverage/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
+
+# Widget Preview related
+.widget_preview/

+ 30 - 0
.metadata

@@ -0,0 +1,30 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+  revision: "737ba8f885dc774b53b442759999a312c9461e4e"
+  channel: "[user-branch]"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+  platforms:
+    - platform: root
+      create_revision: 737ba8f885dc774b53b442759999a312c9461e4e
+      base_revision: 737ba8f885dc774b53b442759999a312c9461e4e
+    - platform: android
+      create_revision: 737ba8f885dc774b53b442759999a312c9461e4e
+      base_revision: 737ba8f885dc774b53b442759999a312c9461e4e
+
+  # User provided section
+
+  # List of Local paths (relative to this file) that should be
+  # ignored by the migrate tool.
+  #
+  # Files that are not part of the templates will be ignored by default.
+  unmanaged_files:
+    - 'lib/main.dart'
+    - 'ios/Runner.xcodeproj/project.pbxproj'

+ 94 - 0
AGENTS.md

@@ -0,0 +1,94 @@
+# CarDash — Agent Guide
+
+## 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, five shortcuts), launches other apps full-screen, and offers light/dark/automatic themes plus 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).
+
+You are the implementation agent: keep the app buildable, tested, documented, and committed. Always call this project **CarDash**.
+
+## 2. Commands
+
+Run from the repository root.
+
+```bash
+flutter pub get
+flutter analyze
+flutter test
+flutter test test/pin_service_test.dart
+
+export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
+flutter build apk --debug
+flutter build apk --release
+
+./tool/check.sh                  # analyze + tests
+./tool/emulator.sh 35            # boot the Android 15 tablet (headless)
+./tool/emulator.sh 29            # boot the Android 10 tablet
+./tool/install.sh debug          # build with JDK 21, install, set HOME role
+./tool/install.sh release
+```
+
+ADB lives at `/opt/android-sdk/platform-tools/adb`; the SDK is at `/opt/android-sdk`. AVDs: `cardash_tablet_api35`, `cardash_tablet_api29` (1280×800, landscape).
+
+## 3. Architecture
+
+- `lib/core/` — theme tokens (`AppColors`, `AppTheme`), `ThemeController`, reusable widgets.
+- `lib/data/models/` — `AppEntry`, `SlotType`, `SlotAssignment`, `SystemStatus`, `VolumeInfo`.
+- `lib/data/services/` — `NativeBridge` (platform channels), `PinService`, secure-store adapter.
+- `lib/data/repositories/` — `SettingsRepository`, the SSOT for theme, night window and slot assignments.
+- `lib/features/home/` — home screen, `LauncherController` (apps + shortcuts + HOME role), `SystemController` (volume/battery/Wi-Fi polling).
+- `lib/features/apps/` — All Apps screen and app-picker sheet.
+- `lib/features/settings/` — General and Security pages.
+- `lib/features/lock/` — PIN pad, lock screen, cold-start `LockController`.
+- `android/app/src/main/kotlin/com/wasp/cardash_launcher/` — `MainActivity`, `LauncherBridge` (package manager, role), `SystemBridge` (battery, Wi-Fi, volume).
+- `tool/` — emulator/install/check scripts. `docs/` — implementation plan and design notes.
+
+Rules:
+- Persisted configuration goes through `SettingsRepository`; never add ad-hoc SharedPreferences keys in widgets.
+- Controllers are `ChangeNotifier`s consumed with `provider`; no business logic in widgets.
+- Android behavior lives behind `LauncherPlatform` so controllers stay testable.
+- External apps are launched with an Intent; they are **not** embedded in the launcher.
+- The PIN is app-local and checked only when the process starts.
+
+Persistence pattern:
+
+```dart
+_slots[slot] = app.toAssignment();
+await _settings.setSlot(slot, app.toAssignment());
+notifyListeners();
+```
+
+## 4. Conventions
+
+- Use `context.colors` / `AppTheme` tokens, not hard-coded colors in widgets.
+- All UI copy is sentence case (except the CarDash name). Keep copy short and functional.
+- New shortcut slots: extend `SlotType`, add a picker entry, a home `ShortcutSpec`, and repository coverage.
+- New native methods: add them to `LauncherPlatform`, `NativeBridge`, the relevant Kotlin bridge, and the test fake if used.
+- Tests: fake `LauncherPlatform`/`SecureStore`; `SharedPreferences.setMockInitialValues({})`; widget tests wrap in `MaterialApp(theme: AppTheme.light())`. Test files live in `test/` and end with `_test.dart`.
+- Run `dart format lib test` before committing. Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`).
+- Update this file, `README.md`, and `docs/IMPLEMENTATION_PLAN.md` in the same commit as any behavior change.
+- Keep the UI fast: no large blur layers, no continuously running decorative animation, prefer implicit animations under ~250 ms.
+
+## 5. Boundaries
+
+- NEVER embed a third-party app's UI, add an overlay/`SYSTEM_ALERT_WINDOW`, kiosk, root, or split-screen feature. The agreed product launches apps full-screen.
+- NEVER add a terms-and-conditions screen, blocking first-run modal, or mandatory onboarding.
+- NEVER change the application ID, HOME intent filter, landscape lock, or minSdk without human confirmation.
+- NEVER re-lock on resume, screen-off, or route changes; no "Lock now" action. PIN is requested only on cold start.
+- NEVER store, log, or display a plaintext PIN.
+- NEVER add the INTERNET permission or weather without human confirmation.
+- NEVER commit `android/local.properties`, keystores, build outputs, APKs, or secrets.
+- REQUIRE human confirmation before changing signing configuration, package visibility permissions, or the repository remote.
+
+## 6. Gotchas
+
+- The machine default Java is 27; Gradle/AGP for this project is validated with **JDK 21**. Always export `JAVA_HOME=/usr/lib/jvm/java-21-openjdk` for builds.
+- Only x86_64 system images run here; the pre-installed arm64 image cannot boot. The download script/images are already in `/opt/android-sdk`.
+- `QUERY_ALL_PACKAGES` is required to list every app. It is acceptable for a launcher but is not a Play-Store-distributable choice as-is; document it.
+- The Flutter template uses AGP 9.1 built-in Kotlin support. Do not add the Kotlin Gradle plugin unless the build explicitly requires it.
+- 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 in-app banner and Settings row use `RoleManager`.
+- Screen-space is deliberately finite: test at 1280×800 and 1024×600. Avoid unbounded `ListView`s inside `Column`s.
+- `tool/emulator.sh` starts a headless AVD; use `adb exec-out screencap -p` for screenshots. There may be multiple `adb` binaries on PATH; use the absolute one.
+- Reinstalling the app can clear its HOME role; re-run `tool/install.sh` or use Settings > Home app.

+ 198 - 0
README.md

@@ -0,0 +1,198 @@
+# CarDash
+
+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, the main media-volume control, and five shortcuts —
+**Navigation**, **Music**, **Radio**, **Settings** and **All apps**. The first
+three are configurable; a long press picks a different installed app. External
+apps open full-screen using the normal Android launcher mechanism. No overlays,
+no root, no kiosk setup.
+
+<p align="center">
+  <em>Early preview — implementation and UI screenshots are in progress.</em>
+</p>
+
+## Features
+
+- **One home screen** — no pages, no top bar, no bottom dock; information and
+  controls live on the home page.
+- **Large clock and date** with the device's 12/24-hour preference.
+- **Battery, charging and Wi-Fi** status.
+- **Media volume** — ring, slider, step and mute controls; hardware volume keys
+  stay in sync. This is the phone's media stream, not a private volume.
+- **Configurable shortcuts** for Navigation, Music and Radio. Generic icons on
+  the dashboard; real app icons in the picker and All Apps.
+- **All apps** — searchable grid of every launchable app, launched full-screen.
+- **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
+  Keystore-backed storage. It is requested **only when the launcher process
+  cold-starts**, never when returning from an app.
+- **Standard Android home role** — no special overlays or permissions.
+- **Android 10+**, landscape-only, tablet-first (verified at 1280×800).
+- No network permission, no accounts, no telemetry, no T&C screen.
+
+## Requirements
+
+- Flutter 3.47.8 / Dart 3.13.7 (or a compatible newer stable release)
+- Android SDK 36, build-tools 36
+- **JDK 21** for Gradle (the machine default is JDK 27, which is not validated)
+- Android 10 (API 29) or newer device/emulator
+- A landscape tablet or an emulator; the app locks itself to landscape
+
+```bash
+export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
+export ANDROID_HOME=/opt/android-sdk
+export PATH="$ANDROID_HOME/platform-tools:$PATH"
+```
+
+## Quick start
+
+```bash
+flutter pub get
+./tool/check.sh
+
+# Boot the prepared 10.1" 1280×800 Android 15 tablet (headless)
+./tool/emulator.sh 35
+
+# Build, install, and make CarDash the HOME app
+./tool/install.sh debug
+```
+
+If the emulator does not yet exist on another machine, create it with:
+
+```bash
+sdkmanager "system-images;android-35;google_apis;x86_64"
+avdmanager create avd -n cardash_tablet_api35 \
+  -k "system-images;android-35;google_apis;x86_64" \
+  -d "10.1in WXGA (Tablet)"
+```
+
+The repository also has an Android 10 AVD (`cardash_tablet_api29`) for
+minimum-version checks.
+
+### Manual install
+
+```bash
+flutter build apk --debug
+adb install -r build/app/outputs/flutter-apk/app-debug.apk
+adb shell cmd role add-role-holder android.app.role.HOME com.wasp.cardash_launcher
+adb shell am start -a android.intent.action.MAIN -c android.intent.category.HOME
+```
+
+On a real tablet, use **Settings → General → Home app → Set as default** and
+accept the system prompt. The in-app "Make CarDash your home app" card does the
+same thing. Android will not let an app force itself into the home role silently.
+
+## Using CarDash
+
+| Action | Result |
+|---|---|
+| Tap Navigation / Music / Radio | Open the assigned app full-screen |
+| Long-press Navigation / Music / Radio | Pick a different installed app |
+| Tap an unassigned shortcut | Open the app picker |
+| Settings | Theme, home shortcuts, default-home role, PIN |
+| All apps | Search every launchable app; tap to open |
+| Volume ring / mute | Toggle media mute |
+| Slider or − / + | Set media volume |
+| Hardware volume keys | Update the on-screen control automatically |
+
+The app hides the Android status and navigation bars while CarDash is in the
+foreground. Swipe from an edge to reveal them temporarily; they hide again when
+the app resumes.
+
+## Settings
+
+**General**
+- Light / Dark / Auto theme.
+- Auto night window (default dark from 19:00, light from 07:00).
+- The three home shortcuts.
+- Home role status and shortcut to the system prompt.
+
+**Security**
+- Set, change or remove a four-digit PIN.
+- The PIN is requested only on a cold start. It does not re-lock when switching
+  apps, after screen-off, or after returning from Navigation. This is the
+  agreed behavior, not an oversight.
+- The PIN protects the launcher UI. It is **not** the Android lock screen; set
+  a device screen lock separately for device-level security.
+- There is no PIN recovery. Resetting app data removes it.
+
+## Architecture
+
+```
+lib/
+  core/        design tokens, theme controller, icons, shared widgets
+  data/        models, platform services, SettingsRepository (single source of truth)
+  features/
+    home/      home page, shortcut grid, volume card, controllers
+    apps/      All Apps, app-picker sheet
+    settings/  General + Security
+    lock/      PIN pad, cold-start lock screen
+android/...    MainActivity, LauncherBridge, SystemBridge (Kotlin)
+tool/          emulator, install, check scripts
+docs/          implementation plan, design notes
+```
+
+- Flutter owns all UI. Kotlin only exposes package enumeration/launching, the
+  HOME role, battery/Wi-Fi and media volume over two `MethodChannel`s.
+- `SettingsRepository` is the only persistence path. Controllers are
+  `ChangeNotifier`s injected with `provider`.
+- External apps are separate activities; Android has no public API to embed
+  them inside the launcher. This app deliberately accepts full-screen launching
+  for maximum compatibility.
+
+## Testing
+
+```bash
+flutter analyze
+flutter test
+./tool/check.sh
+```
+
+Current tests cover PIN hashing/persistence, settings persistence, theme
+resolution and the shortcut tile widget. Native package enumeration and the
+HOME role are verified on the emulator.
+
+## Compatibility and limitations
+
+- **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.
+- **No embedded third-party app UI.** Navigation/Music/Radio are launched in
+  their own full-screen windows.
+- **No global play/pause control.** Volume is on the home screen; playback
+  buttons live in the media app itself.
+- **No weather, Bluetooth, calls or climate** in this version.
+- **`QUERY_ALL_PACKAGES`** is used to list all launchable apps. It is reasonable
+  for a launcher but requires a Play policy declaration if the app is ever
+  distributed; this is a local/sideload build for now.
+- **No internet permission.** The app does not fetch maps, weather or updates.
+- The launcher cannot replace Android's lock screen. The PIN does not protect a
+  running session, by design.
+
+## Design
+
+The visual language is inspired by the [TrailCurrent Godwit](https://trailcurrent.com/godwit.html)
+head unit (MIT): warm neutral surfaces, a single deep-green instrument accent,
+large numerals and quiet line icons. It is not a copy of that UI.
+
+- Typeface: **Manrope** (SIL Open Font License), bundled offline.
+- Icons: **Lucide** (ISC), bundled as SVGs.
+- Palette and tokens: `lib/core/theme/app_colors.dart`.
+
+## Roadmap
+
+- Optional compact now-playing control beside the volume card.
+- Configurable home layout or additional shortcut slots.
+- Screenshot set in `docs/screenshots/`.
+- Release signing configuration.
+- Real-device soak test on an Android 10 car tablet.
+
+## License
+
+This repository is currently local-only; a project license has not been chosen.
+Third-party asset licenses: Manrope (OFL) and Lucide (ISC). The design is
+inspired by TrailCurrent Godwit (MIT).

+ 33 - 0
analysis_options.yaml

@@ -0,0 +1,33 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+analyzer:
+  exclude:
+    - build/**
+    - android/**
+
+linter:
+  # The lint rules applied to this project can be customized in the
+  # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+  # included above or to enable additional rules. A list of all available lints
+  # and their documentation is published at https://dart.dev/lints.
+  #
+  # Instead of disabling a lint rule for the entire project in the
+  # section below, it can also be suppressed for a single line of code
+  # or a specific dart file by using the `// ignore: name_of_lint` and
+  # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+  # producing the lint.
+  rules:
+    # avoid_print: false  # Uncomment to disable the `avoid_print` rule
+    # prefer_single_quotes: true  # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options

+ 14 - 0
android/.gitignore

@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks

+ 41 - 0
android/app/build.gradle.kts

@@ -0,0 +1,41 @@
+plugins {
+    id("com.android.application")
+    // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+    id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+    namespace = "com.wasp.cardash_launcher"
+    compileSdk = 36
+    ndkVersion = flutter.ndkVersion
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+
+    defaultConfig {
+        applicationId = "com.wasp.cardash_launcher"
+        minSdk = 29
+        targetSdk = 36
+        versionCode = flutter.versionCode
+        versionName = flutter.versionName
+    }
+
+    buildTypes {
+        release {
+            // Local-only build for now. Replace with a real signing config before distribution.
+            signingConfig = signingConfigs.getByName("debug")
+        }
+    }
+}
+
+kotlin {
+    compilerOptions {
+        jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
+    }
+}
+
+flutter {
+    source = "../.."
+}

+ 7 - 0
android/app/src/debug/AndroidManifest.xml

@@ -0,0 +1,7 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <!-- The INTERNET permission is required for development. Specifically,
+         the Flutter tool needs it to communicate with the running application
+         to allow setting breakpoints, to provide hot reload, etc.
+    -->
+    <uses-permission android:name="android.permission.INTERNET"/>
+</manifest>

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

@@ -0,0 +1,61 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <!-- A home-screen launcher needs to see every launchable app. -->
+    <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
+
+    <application
+        android:label="CarDash"
+        android:name="${applicationName}"
+        android:icon="@mipmap/ic_launcher"
+        android:roundIcon="@mipmap/ic_launcher"
+        android:allowBackup="false"
+        android:supportsRtl="true">
+
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:launchMode="singleTask"
+            android:taskAffinity=""
+            android:screenOrientation="sensorLandscape"
+            android:excludeFromRecents="true"
+            android:theme="@style/LaunchTheme"
+            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
+            android:hardwareAccelerated="true"
+            android:windowSoftInputMode="adjustResize">
+
+            <meta-data
+                android:name="io.flutter.embedding.android.NormalTheme"
+                android:resource="@style/NormalTheme" />
+
+            <!-- Skia is more stable than Impeller on software-rendered and older
+                 car tablets; the UI is intentionally lightweight. -->
+            <meta-data
+                android:name="io.flutter.embedding.android.EnableImpeller"
+                android:value="false" />
+
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.HOME" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+
+        <meta-data
+            android:name="flutterEmbedding"
+            android:value="2" />
+    </application>
+
+    <queries>
+        <intent>
+            <action android:name="android.intent.action.MAIN" />
+            <category android:name="android.intent.category.LAUNCHER" />
+        </intent>
+        <intent>
+            <action android:name="android.intent.action.PROCESS_TEXT" />
+            <data android:mimeType="text/plain" />
+        </intent>
+    </queries>
+</manifest>

+ 209 - 0
android/app/src/main/kotlin/com/wasp/cardash_launcher/LauncherBridge.kt

@@ -0,0 +1,209 @@
+package com.wasp.cardash_launcher
+
+import android.app.Activity
+import android.app.role.RoleManager
+import android.content.ComponentName
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.drawable.Drawable
+import android.os.Handler
+import android.os.Looper
+import android.provider.Settings
+import io.flutter.plugin.common.BinaryMessenger
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+import java.io.ByteArrayOutputStream
+import java.util.concurrent.Executors
+
+/**
+ * Exposes the package manager and the launcher role to Flutter.
+ *
+ * Icon and label lookups are done on a worker thread; results are posted back
+ * to the platform thread because MethodChannel.Result is not thread-safe.
+ */
+class LauncherBridge(private val activity: Activity) : MethodChannel.MethodCallHandler {
+
+    companion object {
+        const val CHANNEL = "com.wasp.cardash_launcher/apps"
+        private const val ICON_SIZE_PX = 96
+    }
+
+    private var channel: MethodChannel? = null
+    private val executor = Executors.newSingleThreadExecutor()
+    private val mainHandler = Handler(Looper.getMainLooper())
+    @Volatile private var disposed = false
+
+    fun attach(messenger: BinaryMessenger) {
+        channel = MethodChannel(messenger, CHANNEL).also { it.setMethodCallHandler(this) }
+    }
+
+    fun dispose() {
+        disposed = true
+        channel?.setMethodCallHandler(null)
+        channel = null
+        executor.shutdownNow()
+    }
+
+    override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
+        when (call.method) {
+            "listApps" -> loadApps(result)
+            "launchApp" -> launchApp(call, result)
+            "isDefaultHome" -> result.success(isDefaultHome())
+            "requestDefaultHome" -> result.success(requestDefaultHome())
+            else -> result.notImplemented()
+        }
+    }
+
+    private fun loadApps(result: MethodChannel.Result) {
+        executor.execute {
+            val apps = try {
+                queryApps()
+            } catch (error: Throwable) {
+                post { result.error("list_failed", error.message ?: "Could not list apps.", null) }
+                return@execute
+            }
+            post { result.success(apps) }
+        }
+    }
+
+    private fun queryApps(): List<Map<String, Any?>> {
+        val pm = activity.packageManager
+        @Suppress("DEPRECATION")
+        val resolved = pm.queryIntentActivities(
+            Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER),
+            0,
+        )
+
+        val seenPackages = HashSet<String>()
+        val apps = ArrayList<Map<String, Any?>>(resolved.size)
+        for (info in resolved) {
+            val packageName = info.activityInfo?.packageName ?: continue
+            val activityName = info.activityInfo?.name ?: continue
+            if (packageName == activity.packageName) continue
+            if (!seenPackages.add(packageName)) continue
+
+            val label = try {
+                info.loadLabel(pm)?.toString()?.trim().orEmpty()
+            } catch (_: Throwable) {
+                ""
+            }.ifBlank { packageName }
+
+            val iconBytes = try {
+                drawableToPng(info.loadIcon(pm))
+            } catch (_: Throwable) {
+                null
+            }
+
+            apps.add(
+                mapOf(
+                    "package" to packageName,
+                    "activity" to activityName,
+                    "label" to label,
+                    "icon" to iconBytes,
+                ),
+            )
+        }
+        apps.sortBy { (it["label"] as? String)?.lowercase() ?: "" }
+        return apps
+    }
+
+    private fun drawableToPng(drawable: Drawable): ByteArray {
+        val bitmap = Bitmap.createBitmap(ICON_SIZE_PX, ICON_SIZE_PX, Bitmap.Config.ARGB_8888)
+        return try {
+            val canvas = Canvas(bitmap)
+            drawable.setBounds(0, 0, ICON_SIZE_PX, ICON_SIZE_PX)
+            drawable.draw(canvas)
+            ByteArrayOutputStream().use { stream ->
+                bitmap.compress(Bitmap.CompressFormat.PNG, 92, stream)
+                stream.toByteArray()
+            }
+        } finally {
+            bitmap.recycle()
+        }
+    }
+
+    private fun launchApp(call: MethodCall, result: MethodChannel.Result) {
+        val packageName = call.argument<String>("package")
+        val activityName = call.argument<String>("activity")
+        if (packageName.isNullOrBlank() || activityName.isNullOrBlank()) {
+            result.error("bad_arguments", "A package and activity are required.", null)
+            return
+        }
+
+        try {
+            val component = ComponentName(packageName, activityName)
+            val intent = Intent(Intent.ACTION_MAIN).apply {
+                addCategory(Intent.CATEGORY_LAUNCHER)
+                this.component = component
+                addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
+            }
+            activity.startActivity(intent)
+            result.success(true)
+        } catch (_: Throwable) {
+            // Some apps change their launcher activity after an update. Fall back
+            // to the package's current launch intent before reporting a failure.
+            val fallback = try {
+                activity.packageManager.getLaunchIntentForPackage(packageName)
+            } catch (_: Throwable) {
+                null
+            }
+            if (fallback != null) {
+                try {
+                    fallback.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+                    activity.startActivity(fallback)
+                    result.success(true)
+                    return
+                } catch (_: Throwable) {
+                    // Fall through to the error below.
+                }
+            }
+            result.error("launch_failed", "Could not open $packageName.", null)
+        }
+    }
+
+    private fun isDefaultHome(): Boolean {
+        return try {
+            val roleManager = activity.getSystemService(RoleManager::class.java)
+            if (roleManager != null && roleManager.isRoleAvailable(RoleManager.ROLE_HOME)) {
+                roleManager.isRoleHeld(RoleManager.ROLE_HOME)
+            } else {
+                val homeIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
+                val resolved = activity.packageManager.resolveActivity(
+                    homeIntent,
+                    PackageManager.MATCH_DEFAULT_ONLY,
+                )
+                resolved?.activityInfo?.packageName == activity.packageName
+            }
+        } catch (_: Throwable) {
+            false
+        }
+    }
+
+    private fun requestDefaultHome(): Boolean {
+        return try {
+            val roleManager = activity.getSystemService(RoleManager::class.java)
+            if (roleManager != null && roleManager.isRoleAvailable(RoleManager.ROLE_HOME)) {
+                if (roleManager.isRoleHeld(RoleManager.ROLE_HOME)) {
+                    false
+                } else {
+                    activity.startActivityForResult(
+                        roleManager.createRequestRoleIntent(RoleManager.ROLE_HOME),
+                        9,
+                    )
+                    true
+                }
+            } else {
+                activity.startActivity(Intent(Settings.ACTION_HOME_SETTINGS))
+                true
+            }
+        } catch (_: Throwable) {
+            false
+        }
+    }
+
+    private fun post(block: () -> Unit) {
+        if (!disposed) mainHandler.post(block)
+    }
+}

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

@@ -0,0 +1,28 @@
+package com.wasp.cardash_launcher
+
+import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.engine.FlutterEngine
+
+class MainActivity : FlutterActivity() {
+    private var launcherBridge: LauncherBridge? = null
+    private var systemBridge: SystemBridge? = null
+
+    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+        super.configureFlutterEngine(flutterEngine)
+
+        launcherBridge = LauncherBridge(this).also {
+            it.attach(flutterEngine.dartExecutor.binaryMessenger)
+        }
+        systemBridge = SystemBridge(this).also {
+            it.attach(flutterEngine.dartExecutor.binaryMessenger)
+        }
+    }
+
+    override fun onDestroy() {
+        launcherBridge?.dispose()
+        systemBridge?.dispose()
+        launcherBridge = null
+        systemBridge = null
+        super.onDestroy()
+    }
+}

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

@@ -0,0 +1,97 @@
+package com.wasp.cardash_launcher
+
+import android.app.Activity
+import android.content.Intent
+import android.content.IntentFilter
+import android.media.AudioManager
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.os.BatteryManager
+import io.flutter.plugin.common.BinaryMessenger
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+
+/** Battery, Wi-Fi and media-volume bridge. */
+class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHandler {
+
+    companion object {
+        const val CHANNEL = "com.wasp.cardash_launcher/system"
+    }
+
+    private var channel: MethodChannel? = null
+
+    fun attach(messenger: BinaryMessenger) {
+        channel = MethodChannel(messenger, CHANNEL).also { it.setMethodCallHandler(this) }
+    }
+
+    fun dispose() {
+        channel?.setMethodCallHandler(null)
+        channel = null
+    }
+
+    override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
+        when (call.method) {
+            "getStatus" -> result.success(readStatus())
+            "getVolume" -> result.success(readVolume())
+            "setVolume" -> {
+                val index = call.argument<Int>("index")
+                if (index == null) {
+                    result.error("bad_arguments", "A volume index is required.", null)
+                    return
+                }
+                result.success(setVolume(index))
+            }
+            else -> result.notImplemented()
+        }
+    }
+
+    private fun readStatus(): Map<String, Any?> {
+        val batteryIntent = activity.registerReceiver(
+            null,
+            IntentFilter(Intent.ACTION_BATTERY_CHANGED),
+        )
+        val level = batteryIntent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
+        val scale = batteryIntent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
+        val percentage = if (level >= 0 && scale > 0) (level * 100f / scale).toInt() else 0
+        val status = batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
+        val charging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
+            status == BatteryManager.BATTERY_STATUS_FULL
+
+        val wifi = try {
+            val connectivity = activity.getSystemService(ConnectivityManager::class.java)
+            val network = connectivity?.activeNetwork
+            val capabilities = network?.let { connectivity.getNetworkCapabilities(it) }
+            capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
+        } catch (_: Throwable) {
+            false
+        }
+
+        return mapOf(
+            "batteryLevel" to percentage,
+            "charging" to charging,
+            "wifi" to wifi,
+        )
+    }
+
+    private fun audioManager(): AudioManager =
+        activity.getSystemService(AudioManager::class.java)
+
+    private fun readVolume(): Map<String, Any?> {
+        val audio = audioManager()
+        return mapOf(
+            "index" to audio.getStreamVolume(AudioManager.STREAM_MUSIC),
+            "max" to audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC),
+        )
+    }
+
+    private fun setVolume(index: Int): Boolean {
+        return try {
+            val audio = audioManager()
+            val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
+            audio.setStreamVolume(AudioManager.STREAM_MUSIC, index.coerceIn(0, max), 0)
+            true
+        } catch (_: Throwable) {
+            false
+        }
+    }
+}

+ 4 - 0
android/app/src/main/res/drawable-v21/launch_background.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:drawable="@color/cardash_launch_bg" />
+</layer-list>

+ 15 - 0
android/app/src/main/res/drawable/ic_launcher_foreground.xml

@@ -0,0 +1,15 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <path android:fillColor="#F4F1E8" android:pathData="M36,36 m-4.2,0 a4.2,4.2 0 1,0 8.4,0 a4.2,4.2 0 1,0 -8.4,0" />
+    <path android:fillColor="#F4F1E8" android:pathData="M54,36 m-4.2,0 a4.2,4.2 0 1,0 8.4,0 a4.2,4.2 0 1,0 -8.4,0" />
+    <path android:fillColor="#F4F1E8" android:pathData="M72,36 m-4.2,0 a4.2,4.2 0 1,0 8.4,0 a4.2,4.2 0 1,0 -8.4,0" />
+    <path android:fillColor="#F4F1E8" android:pathData="M36,54 m-4.2,0 a4.2,4.2 0 1,0 8.4,0 a4.2,4.2 0 1,0 -8.4,0" />
+    <path android:fillColor="#F4F1E8" android:pathData="M54,54 m-4.2,0 a4.2,4.2 0 1,0 8.4,0 a4.2,4.2 0 1,0 -8.4,0" />
+    <path android:fillColor="#F4F1E8" android:pathData="M72,54 m-4.2,0 a4.2,4.2 0 1,0 8.4,0 a4.2,4.2 0 1,0 -8.4,0" />
+    <path android:fillColor="#F4F1E8" android:pathData="M36,72 m-4.2,0 a4.2,4.2 0 1,0 8.4,0 a4.2,4.2 0 1,0 -8.4,0" />
+    <path android:fillColor="#F4F1E8" android:pathData="M54,72 m-4.2,0 a4.2,4.2 0 1,0 8.4,0 a4.2,4.2 0 1,0 -8.4,0" />
+    <path android:fillColor="#F4F1E8" android:pathData="M72,72 m-4.2,0 a4.2,4.2 0 1,0 8.4,0 a4.2,4.2 0 1,0 -8.4,0" />
+</vector>

+ 4 - 0
android/app/src/main/res/drawable/launch_background.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:drawable="@color/cardash_launch_bg" />
+</layer-list>

+ 5 - 0
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_launcher_background" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>

BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png


BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png


BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png


BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png


BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png


+ 12 - 0
android/app/src/main/res/values-night/styles.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
+        <item name="android:windowBackground">@drawable/launch_background</item>
+        <item name="android:fontFamily">sans</item>
+        <item name="android:windowActionModeOverlay">true</item>
+    </style>
+    <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
+        <item name="android:windowBackground">@color/cardash_launch_bg</item>
+        <item name="android:windowActionModeOverlay">true</item>
+    </style>
+</resources>

+ 11 - 0
android/app/src/main/res/values-v31/styles.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
+        <item name="android:windowSplashScreenBackground">@color/cardash_launch_bg</item>
+        <item name="android:windowSplashScreenAnimatedIcon">@mipmap/ic_launcher</item>
+        <item name="android:windowBackground">@drawable/launch_background</item>
+    </style>
+    <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
+        <item name="android:windowBackground">@color/cardash_launch_bg</item>
+    </style>
+</resources>

+ 5 - 0
android/app/src/main/res/values/colors.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="cardash_launch_bg">#0B0F0C</color>
+    <color name="ic_launcher_background">#3E7A32</color>
+</resources>

+ 12 - 0
android/app/src/main/res/values/styles.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
+        <item name="android:windowBackground">@drawable/launch_background</item>
+        <item name="android:fontFamily">sans</item>
+        <item name="android:windowActionModeOverlay">true</item>
+    </style>
+    <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
+        <item name="android:windowBackground">@color/cardash_launch_bg</item>
+        <item name="android:windowActionModeOverlay">true</item>
+    </style>
+</resources>

+ 7 - 0
android/app/src/profile/AndroidManifest.xml

@@ -0,0 +1,7 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <!-- The INTERNET permission is required for development. Specifically,
+         the Flutter tool needs it to communicate with the running application
+         to allow setting breakpoints, to provide hot reload, etc.
+    -->
+    <uses-permission android:name="android.permission.INTERNET"/>
+</manifest>

+ 24 - 0
android/build.gradle.kts

@@ -0,0 +1,24 @@
+allprojects {
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+val newBuildDir: Directory =
+    rootProject.layout.buildDirectory
+        .dir("../../build")
+        .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+    val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+    project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+    project.evaluationDependsOn(":app")
+}
+
+tasks.register<Delete>("clean") {
+    delete(rootProject.layout.buildDirectory)
+}

+ 6 - 0
android/gradle.properties

@@ -0,0 +1,6 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
+# This newDsl flag was added by the Flutter template
+android.newDsl=false
+# This builtInKotlin flag was added by the Flutter template
+android.builtInKotlin=false

+ 5 - 0
android/gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip

+ 26 - 0
android/settings.gradle.kts

@@ -0,0 +1,26 @@
+pluginManagement {
+    val flutterSdkPath =
+        run {
+            val properties = java.util.Properties()
+            file("local.properties").inputStream().use { properties.load(it) }
+            val flutterSdkPath = properties.getProperty("flutter.sdk")
+            require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+            flutterSdkPath
+        }
+
+    includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+    repositories {
+        google()
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+
+plugins {
+    id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+    id("com.android.application") version "9.1.0" apply false
+    id("org.jetbrains.kotlin.android") version "2.4.0" apply false
+}
+
+include(":app")

BIN
assets/fonts/Manrope-wght.ttf


+ 93 - 0
assets/fonts/OFL-Manrope.txt

@@ -0,0 +1,93 @@
+Copyright 2018 The Manrope Project Authors (https://github.com/googlefonts/manrope)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded, 
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.

+ 16 - 0
assets/icons/arrow-left.svg

@@ -0,0 +1,16 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-arrow-left"
+  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 19-7-7 7-7" />
+  <path d="M19 12H5" />
+</svg>

+ 18 - 0
assets/icons/battery-charging.svg

@@ -0,0 +1,18 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-battery-charging"
+  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="m11 7-3 5h4l-3 5" />
+  <path d="M14.856 6H16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.935" />
+  <path d="M22 14v-4" />
+  <path d="M5.14 18H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.936" />
+</svg>

+ 19 - 0
assets/icons/battery-full.svg

@@ -0,0 +1,19 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-battery-full"
+  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="M10 10v4" />
+  <path d="M14 10v4" />
+  <path d="M22 14v-4" />
+  <path d="M6 10v4" />
+  <rect x="2" y="6" width="16" height="12" rx="2" />
+</svg>

+ 15 - 0
assets/icons/check.svg

@@ -0,0 +1,15 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-check"
+  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="M20 6 9 17l-5-5" />
+</svg>

+ 15 - 0
assets/icons/chevron-right.svg

@@ -0,0 +1,15 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-chevron-right"
+  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="m9 18 6-6-6-6" />
+</svg>

+ 17 - 0
assets/icons/delete.svg

@@ -0,0 +1,17 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-delete"
+  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="M10 5a2 2 0 0 0-1.344.519l-6.328 5.74a1 1 0 0 0 0 1.481l6.328 5.741A2 2 0 0 0 10 19h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z" />
+  <path d="m12 9 6 6" />
+  <path d="m18 9-6 6" />
+</svg>

+ 19 - 0
assets/icons/grid-3x3.svg

@@ -0,0 +1,19 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-grid-3x3"
+  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"
+>
+  <rect width="18" height="18" x="3" y="3" rx="2" />
+  <path d="M3 9h18" />
+  <path d="M3 15h18" />
+  <path d="M9 3v18" />
+  <path d="M15 3v18" />
+</svg>

+ 17 - 0
assets/icons/info.svg

@@ -0,0 +1,17 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-info"
+  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"
+>
+  <circle cx="12" cy="12" r="10" />
+  <path d="M12 16v-4" />
+  <path d="M12 8h.01" />
+</svg>

+ 18 - 0
assets/icons/layout-grid.svg

@@ -0,0 +1,18 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-layout-grid"
+  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"
+>
+  <rect width="7" height="7" x="3" y="3" rx="1" />
+  <rect width="7" height="7" x="14" y="3" rx="1" />
+  <rect width="7" height="7" x="14" y="14" rx="1" />
+  <rect width="7" height="7" x="3" y="14" rx="1" />
+</svg>

+ 16 - 0
assets/icons/lock.svg

@@ -0,0 +1,16 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-lock"
+  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"
+>
+  <rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
+  <path d="M7 11V7a5 5 0 0 1 10 0v4" />
+</svg>

+ 15 - 0
assets/icons/moon.svg

@@ -0,0 +1,15 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-moon"
+  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="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" />
+</svg>

+ 17 - 0
assets/icons/music.svg

@@ -0,0 +1,17 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-music"
+  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="M21 16V3a1 1 0 0 0-1.28-.96l-10 2.917a1 1 0 0 0-.72.96V19" />
+  <circle cx="18" cy="16" r="3" />
+  <circle cx="6" cy="19" r="3" />
+</svg>

+ 15 - 0
assets/icons/navigation.svg

@@ -0,0 +1,15 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-navigation"
+  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"
+>
+  <polygon points="3 11 22 2 13 21 11 13 3 11" />
+</svg>

+ 16 - 0
assets/icons/plus.svg

@@ -0,0 +1,16 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-plus"
+  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="M5 12h14" />
+  <path d="M12 5v14" />
+</svg>

+ 19 - 0
assets/icons/radio.svg

@@ -0,0 +1,19 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-radio"
+  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="M16.247 7.761a6 6 0 0 1 0 8.478" />
+  <path d="M19.075 4.933a10 10 0 0 1 0 14.134" />
+  <path d="M4.925 19.067a10 10 0 0 1 0-14.134" />
+  <path d="M7.753 16.239a6 6 0 0 1 0-8.478" />
+  <circle cx="12" cy="12" r="2" />
+</svg>

+ 16 - 0
assets/icons/search.svg

@@ -0,0 +1,16 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-search"
+  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="m21 21-4.34-4.34" />
+  <circle cx="11" cy="11" r="8" />
+</svg>

+ 16 - 0
assets/icons/settings.svg

@@ -0,0 +1,16 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-settings"
+  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="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
+  <circle cx="12" cy="12" r="3" />
+</svg>

+ 23 - 0
assets/icons/sliders-horizontal.svg

@@ -0,0 +1,23 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-sliders-horizontal"
+  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="M10 5H3" />
+  <path d="M12 19H3" />
+  <path d="M14 3v4" />
+  <path d="M16 17v4" />
+  <path d="M21 12h-9" />
+  <path d="M21 19h-5" />
+  <path d="M21 5h-7" />
+  <path d="M8 10v4" />
+  <path d="M8 12H3" />
+</svg>

+ 16 - 0
assets/icons/smartphone.svg

@@ -0,0 +1,16 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-smartphone"
+  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"
+>
+  <rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
+  <path d="M12 18h.01" />
+</svg>

+ 23 - 0
assets/icons/sun.svg

@@ -0,0 +1,23 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-sun"
+  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"
+>
+  <circle cx="12" cy="12" r="4" />
+  <path d="M12 2v2" />
+  <path d="M12 20v2" />
+  <path d="m4.93 4.93 1.41 1.41" />
+  <path d="m17.66 17.66 1.41 1.41" />
+  <path d="M2 12h2" />
+  <path d="M20 12h2" />
+  <path d="m6.34 17.66-1.41 1.41" />
+  <path d="m19.07 4.93-1.41 1.41" />
+</svg>

+ 17 - 0
assets/icons/volume-2.svg

@@ -0,0 +1,17 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-volume-2"
+  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="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
+  <path d="M16 9a5 5 0 0 1 0 6" />
+  <path d="M19.364 18.364a9 9 0 0 0 0-12.728" />
+</svg>

+ 17 - 0
assets/icons/volume-x.svg

@@ -0,0 +1,17 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-volume-x"
+  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="M11 4.702a.7.7 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.7.7 0 0 0 11 19.298z" />
+  <path d="m16.5 14.5 5-5" />
+  <path d="m16.5 9.5 5 5" />
+</svg>

+ 21 - 0
assets/icons/wifi-off.svg

@@ -0,0 +1,21 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-wifi-off"
+  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 20h.01" />
+  <path d="M8.5 16.429a5 5 0 0 1 7 0" />
+  <path d="M5 12.859a10 10 0 0 1 5.17-2.69" />
+  <path d="M19 12.859a10 10 0 0 0-2.007-1.523" />
+  <path d="M2 8.82a15 15 0 0 1 4.177-2.643" />
+  <path d="M22 8.82a15 15 0 0 0-11.288-3.764" />
+  <path d="m2 2 20 20" />
+</svg>

+ 18 - 0
assets/icons/wifi.svg

@@ -0,0 +1,18 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-wifi"
+  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 20h.01" />
+  <path d="M2 8.82a15 15 0 0 1 20 0" />
+  <path d="M5 12.859a10 10 0 0 1 14 0" />
+  <path d="M8.5 16.429a5 5 0 0 1 7 0" />
+</svg>

+ 16 - 0
assets/icons/x.svg

@@ -0,0 +1,16 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-x"
+  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="M18 6 6 18" />
+  <path d="m6 6 12 12" />
+</svg>

+ 128 - 0
docs/IMPLEMENTATION_PLAN.md

@@ -0,0 +1,128 @@
+# CarDash implementation plan
+
+_Last updated: 2026-10-29_
+
+## 1. What we are building
+
+CarDash is a Flutter/Dart Android **home-screen launcher** for a landscape car
+tablet. It deliberately trades the reference head-unit chrome for maximum
+compatibility:
+
+- One home page. No top bar, no bottom dock, no pages.
+- Clock/date, battery/charging and Wi-Fi on the page.
+- A prominent media-volume card (ring, slider, −/+, mute).
+- Five shortcuts: Navigation, Music, Radio, Settings, All apps.
+- Navigation/Music/Radio are **app slots**. They can be assigned, changed and
+  cleared. Dashboard icons stay generic.
+- Every external app opens full-screen through a normal Android `Intent`.
+- Settings: light/dark/automatic theme, fixed night window, slot assignment,
+  HOME-role setup, four-digit PIN.
+- PIN is requested **only on a cold start**, never on resume or screen-off.
+- Landscape-only; tablet-first 1280×800; Android 10 minimum.
+- Package: `com.wasp.cardash_launcher`. Display name: **CarDash**.
+
+## 2. Decisions and why
+
+| Decision | Choice | Rationale |
+|---|---|---|
+| Central app content | Launcher-native; apps open full-screen | Android cannot embed another app; overlay chrome was rejected as fragile and app-obstructing. |
+| UI chrome | None (no bars) | Removes the mismatch between launcher chrome and full-screen apps; cleaner, more compatible. |
+| Home layout | Single page, info + volume + five shortcuts | Matches the refined brief: one configurable home screen. |
+| App slots | Navigation, Music, Radio | Kept from the original brief; Settings and All apps are fixed. |
+| Icons | Generic for dashboard; real icons in pickers/All Apps | Keeps the car interface consistent while making app identification possible. |
+| 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. |
+| Target/compile | 36 | Current Flutter toolchain defaults; runs on API 29. |
+| Orientation | `sensorLandscape` + Dart orientation lock | Car/tablet form factor; portrait is intentionally unsupported. |
+| AVD | 10.1" WXGA tablet, 1280×800, 160 dpi, API 35 and API 29 | Matches a dashboard tablet and the reference resolution class. |
+| HOME role | Standard `RoleManager` request + Settings row | A launcher must be chosen as the system home app; never silently forced. |
+| Overlay/kiosk/root | Out of scope | Rejected for compatibility. |
+
+## 3. Architecture
+
+```
+Flutter
+  HomeScreen
+    ClockBlock / StatusCluster / VolumeCard / ShortcutGrid
+    LauncherController  -> apps, slots, default-home state
+    SystemController    -> battery/Wi-Fi/volume polling
+  AllAppsScreen / AppPickerSheet
+  SettingsScreen -> General / Security
+  PinSetupScreen / LockScreen -> LockController -> PinService
+Android (Kotlin)
+  MainActivity -> LauncherBridge (apps, launch, role)
+              -> SystemBridge (battery, Wi-Fi, volume)
+```
+
+- `SettingsRepository` is the only persistence path for theme, night window and
+  slots. `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.
+- The PIN check is awaited before `runApp`, so there is no dashboard flash on a
+  locked cold start.
+- No INTERNET permission, no weather, no telemetry.
+
+## 4. Milestones
+
+| # | Milestone | State |
+|---|---|---|
+| M0 | Local git repo, Flutter scaffold, launcher config, package ID, icons, docs | Done |
+| M1 | x86_64 API 35/API 29 images, 1280×800 landscape tablet AVDs, emulator/install scripts | Done |
+| M2 | Theme system, single home layout, clock/status, volume card, shortcut grid | Done (initial visual pass) |
+| M3 | Kotlin bridges: app list/icons/launch, HOME role, volume, battery/Wi-Fi | Done |
+| M4 | App picker, All Apps, slot persistence, long-press flows, launch errors | Done |
+| M5 | Settings, theme auto window, PIN setup/change/remove, cold-start lock | Done |
+| M6 | Interaction testing, light/dark screenshots, layout polish, API 29 verification | In progress |
+| M7 | Release build, final docs, screenshots, last commits | Pending |
+
+## 5. Verification checklist
+
+- [x] `flutter analyze` clean.
+- [x] Unit/widget tests pass (PIN, repository, theme, shortcut tile).
+- [x] Debug APK builds with JDK 21.
+- [x] Installs on API 35 1280×800 tablet.
+- [x] HOME role granted; Home opens CarDash.
+- [x] Immersive full-screen; no Android bars.
+- [x] Real app list and icons in All Apps.
+- [ ] App picker assign / long-press change / clear, with dock-independent persistence.
+- [ ] Launch Navigation/Music/Radio full-screen and return Home.
+- [ ] Volume slider, mute, hardware-key synchronization.
+- [ ] Light theme, dark theme and automatic switch.
+- [ ] PIN set → cold start shows lock; wrong PIN/cooldown; remove PIN.
+- [ ] Verify 1024×600 compact layout (`adb shell wm size 1024x600`).
+- [ ] Boot and verify on Android 10 (API 29) AVD.
+- [ ] Release APK on both AVDs.
+- [ ] Screenshots in `docs/screenshots/`; README/AGENTS/plan updated.
+
+## 6. Known risks and mitigations
+
+1. **Media-volume polling** is every 900 ms while foreground. It is cheap and
+   handles hardware keys, but a future native `ContentObserver` would remove
+   the polling. No global play/pause is in scope.
+2. **`QUERY_ALL_PACKAGES`** is required for a launcher app list. It is documented
+   and acceptable for sideloading; a Play distribution needs policy work.
+3. **PIN security scope**: cold-start only by design; it does not replace the
+   Android keyguard or protect against adb/data extraction. README explains it.
+4. **Emulator stability**: software-rendered headless emulator can be killed by
+   the host; scripts use managed background boot and `swiftshader_indirect`.
+5. **Flutter 3.47 / AGP 9.1 / JDK 21**: works on this machine; Java 27 is not the
+   validated toolchain.
+6. **Uninstall/reinstall** may drop the HOME role; Settings and the installer
+   script can restore it.
+
+## 7. Out of scope (for now)
+
+- Embedded third-party apps, overlay bars, split screen, kiosk/device-owner.
+- Global play/pause or now-playing metadata.
+- Weather, Bluetooth, calls, climate, reverse camera.
+- An app drawer with folders/pages; a single searchable All Apps page is enough.
+- Play Store packaging/signing, remote git, CI.
+
+## 8. Change log
+
+- 2026-10-29: Pivoted from an overlay/head-unit chrome design to a
+  launcher-native single home page, no top/bottom bars, full-screen external
+  apps. Implemented core UI, Kotlin bridges, settings, PIN and documentation.
+  Verified initial home/All Apps/immersive behavior on the API 35 AVD.

+ 98 - 0
lib/app.dart

@@ -0,0 +1,98 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:provider/provider.dart';
+
+import 'core/theme/app_theme.dart';
+import 'core/theme/theme_controller.dart';
+import 'features/home/home_screen.dart';
+import 'features/home/launcher_controller.dart';
+import 'features/home/system_controller.dart';
+import 'features/lock/lock_controller.dart';
+import 'features/lock/lock_screen.dart';
+
+class CarDashApp extends StatefulWidget {
+  const CarDashApp({super.key});
+
+  @override
+  State<CarDashApp> createState() => _CarDashAppState();
+}
+
+class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
+  late final ThemeData _lightTheme = AppTheme.light();
+  late final ThemeData _darkTheme = AppTheme.dark();
+
+  @override
+  void initState() {
+    super.initState();
+    WidgetsBinding.instance.addObserver(this);
+    WidgetsBinding.instance.addPostFrameCallback((_) {
+      if (!mounted) return;
+      _applyImmersive();
+      context.read<SystemController>().start();
+    });
+  }
+
+  @override
+  void dispose() {
+    WidgetsBinding.instance.removeObserver(this);
+    super.dispose();
+  }
+
+  @override
+  void didChangeAppLifecycleState(AppLifecycleState state) {
+    if (!mounted) return;
+    final system = context.read<SystemController>();
+    switch (state) {
+      case AppLifecycleState.resumed:
+        _applyImmersive();
+        context.read<ThemeController>().refreshNow();
+        system.start();
+        context.read<LauncherController>().refresh();
+      case AppLifecycleState.inactive:
+      case AppLifecycleState.hidden:
+      case AppLifecycleState.paused:
+      case AppLifecycleState.detached:
+        system.stop();
+    }
+  }
+
+  Future<void> _applyImmersive() async {
+    await SystemChrome.setPreferredOrientations(const [
+      DeviceOrientation.landscapeLeft,
+      DeviceOrientation.landscapeRight,
+    ]);
+    await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final themeController = context.watch<ThemeController>();
+
+    return MaterialApp(
+      debugShowCheckedModeBanner: false,
+      title: 'CarDash',
+      theme: _lightTheme,
+      darkTheme: _darkTheme,
+      themeMode: themeController.themeMode,
+      themeAnimationDuration: const Duration(milliseconds: 260),
+      themeAnimationCurve: Curves.easeOutCubic,
+      builder: (context, child) {
+        final media = MediaQuery.of(context);
+        return MediaQuery(
+          data: media.copyWith(
+            textScaler: media.textScaler.clamp(
+              minScaleFactor: 0.9,
+              maxScaleFactor: 1.15,
+            ),
+          ),
+          child: child ?? const SizedBox.shrink(),
+        );
+      },
+      home: Consumer<LockController>(
+        builder: (context, lock, _) {
+          return lock.isLocked ? const LockScreen() : const HomeScreen();
+        },
+      ),
+    );
+  }
+}

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

@@ -0,0 +1,143 @@
+import 'package:flutter/material.dart';
+
+/// Design tokens for CarDash.
+///
+/// The palette is deliberately warm-neutral with a single deep green accent;
+/// the same token set is used by the light and dark themes so the whole app
+/// can cross-fade between them.
+@immutable
+class AppColors extends ThemeExtension<AppColors> {
+  const AppColors({
+    required this.canvas,
+    required this.canvasDeep,
+    required this.surface,
+    required this.surfaceStrong,
+    required this.ink,
+    required this.inkMuted,
+    required this.inkFaint,
+    required this.accent,
+    required this.accentInk,
+    required this.accentSoft,
+    required this.border,
+    required this.danger,
+    required this.shadow,
+  });
+
+  final Color canvas;
+  final Color canvasDeep;
+  final Color surface;
+  final Color surfaceStrong;
+  final Color ink;
+  final Color inkMuted;
+  final Color inkFaint;
+  final Color accent;
+  final Color accentInk;
+  final Color accentSoft;
+  final Color border;
+  final Color danger;
+  final Color shadow;
+
+  static const light = AppColors(
+    canvas: Color(0xFFF2F1EC),
+    canvasDeep: Color(0xFFE8E8E0),
+    surface: Color(0xFFFBFBF8),
+    surfaceStrong: Color(0xFFFFFFFF),
+    ink: Color(0xFF131711),
+    inkMuted: Color(0xFF646A60),
+    inkFaint: Color(0xFF8B9188),
+    accent: Color(0xFF3E7A32),
+    accentInk: Color(0xFFFFFFFF),
+    accentSoft: Color(0xFFDCEAD4),
+    border: Color(0xFFDDDCD4),
+    danger: Color(0xFFA74736),
+    shadow: Color(0xFF141A12),
+  );
+
+  static const dark = AppColors(
+    canvas: Color(0xFF0B0F0C),
+    canvasDeep: Color(0xFF070A08),
+    surface: Color(0xFF151A15),
+    surfaceStrong: Color(0xFF1B211A),
+    ink: Color(0xFFF1F3EC),
+    inkMuted: Color(0xFF9AA294),
+    inkFaint: Color(0xFF6F776C),
+    accent: Color(0xFF8BCB77),
+    accentInk: Color(0xFF0A1208),
+    accentSoft: Color(0xFF22321B),
+    border: Color(0xFF293026),
+    danger: Color(0xFFE08A76),
+    shadow: Color(0xFF000000),
+  );
+
+  @override
+  AppColors copyWith({
+    Color? canvas,
+    Color? canvasDeep,
+    Color? surface,
+    Color? surfaceStrong,
+    Color? ink,
+    Color? inkMuted,
+    Color? inkFaint,
+    Color? accent,
+    Color? accentInk,
+    Color? accentSoft,
+    Color? border,
+    Color? danger,
+    Color? shadow,
+  }) {
+    return AppColors(
+      canvas: canvas ?? this.canvas,
+      canvasDeep: canvasDeep ?? this.canvasDeep,
+      surface: surface ?? this.surface,
+      surfaceStrong: surfaceStrong ?? this.surfaceStrong,
+      ink: ink ?? this.ink,
+      inkMuted: inkMuted ?? this.inkMuted,
+      inkFaint: inkFaint ?? this.inkFaint,
+      accent: accent ?? this.accent,
+      accentInk: accentInk ?? this.accentInk,
+      accentSoft: accentSoft ?? this.accentSoft,
+      border: border ?? this.border,
+      danger: danger ?? this.danger,
+      shadow: shadow ?? this.shadow,
+    );
+  }
+
+  @override
+  AppColors lerp(covariant AppColors? other, double t) {
+    if (other == null) return this;
+    return AppColors(
+      canvas: Color.lerp(canvas, other.canvas, t)!,
+      canvasDeep: Color.lerp(canvasDeep, other.canvasDeep, t)!,
+      surface: Color.lerp(surface, other.surface, t)!,
+      surfaceStrong: Color.lerp(surfaceStrong, other.surfaceStrong, t)!,
+      ink: Color.lerp(ink, other.ink, t)!,
+      inkMuted: Color.lerp(inkMuted, other.inkMuted, t)!,
+      inkFaint: Color.lerp(inkFaint, other.inkFaint, t)!,
+      accent: Color.lerp(accent, other.accent, t)!,
+      accentInk: Color.lerp(accentInk, other.accentInk, t)!,
+      accentSoft: Color.lerp(accentSoft, other.accentSoft, t)!,
+      border: Color.lerp(border, other.border, t)!,
+      danger: Color.lerp(danger, other.danger, t)!,
+      shadow: Color.lerp(shadow, other.shadow, t)!,
+    );
+  }
+}
+
+extension AppColorsContext on BuildContext {
+  AppColors get colors => Theme.of(this).extension<AppColors>()!;
+}
+
+/// A light accent wash used behind the home screen.
+BoxDecoration homeBackground(AppColors colors, Brightness brightness) {
+  final isDark = brightness == Brightness.dark;
+  return BoxDecoration(
+    gradient: LinearGradient(
+      begin: Alignment.topLeft,
+      end: Alignment.bottomRight,
+      colors: isDark
+          ? [colors.canvas, colors.canvasDeep, colors.canvas]
+          : [colors.canvas, const Color(0xFFF7F5EF), colors.canvasDeep],
+      stops: const [0.0, 0.55, 1.0],
+    ),
+  );
+}

+ 177 - 0
lib/core/theme/app_theme.dart

@@ -0,0 +1,177 @@
+import 'package:flutter/material.dart';
+
+import 'app_colors.dart';
+
+/// Material 3 theme built from the CarDash token set.
+class AppTheme {
+  const AppTheme._();
+
+  static ThemeData light() => _build(AppColors.light, Brightness.light);
+
+  static ThemeData dark() => _build(AppColors.dark, Brightness.dark);
+
+  static ThemeData _build(AppColors colors, Brightness brightness) {
+    final scheme =
+        ColorScheme.fromSeed(
+          seedColor: colors.accent,
+          brightness: brightness,
+        ).copyWith(
+          primary: colors.accent,
+          onPrimary: colors.accentInk,
+          primaryContainer: colors.accentSoft,
+          onPrimaryContainer: colors.ink,
+          secondary: colors.accent,
+          onSecondary: colors.accentInk,
+          secondaryContainer: colors.accentSoft,
+          onSecondaryContainer: colors.ink,
+          surface: colors.surface,
+          onSurface: colors.ink,
+          surfaceContainerHighest: colors.surfaceStrong,
+          outline: colors.border,
+          outlineVariant: colors.border,
+          error: colors.danger,
+          onError: Colors.white,
+        );
+
+    final textTheme = _textTheme(colors);
+
+    return ThemeData(
+      useMaterial3: true,
+      brightness: brightness,
+      colorScheme: scheme,
+      scaffoldBackgroundColor: colors.canvas,
+      canvasColor: colors.canvas,
+      fontFamily: 'Manrope',
+      textTheme: textTheme,
+      iconTheme: IconThemeData(color: colors.ink, size: 22),
+      dividerTheme: DividerThemeData(color: colors.border, thickness: 1),
+      sliderTheme: SliderThemeData(
+        activeTrackColor: colors.accent,
+        inactiveTrackColor: colors.border,
+        thumbColor: colors.accent,
+        overlayColor: colors.accent.withValues(alpha: 0.14),
+        trackHeight: 6,
+        thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
+        overlayShape: const RoundSliderOverlayShape(overlayRadius: 22),
+        trackShape: const RoundedRectSliderTrackShape(),
+        tickMarkShape: SliderTickMarkShape.noTickMark,
+        disabledActiveTrackColor: colors.border,
+        disabledInactiveTrackColor: colors.border,
+        disabledThumbColor: colors.inkFaint,
+      ),
+      inputDecorationTheme: InputDecorationTheme(
+        filled: true,
+        fillColor: colors.surfaceStrong,
+        hintStyle: textTheme.bodyLarge?.copyWith(color: colors.inkFaint),
+        contentPadding: const EdgeInsets.symmetric(
+          horizontal: 20,
+          vertical: 16,
+        ),
+        border: OutlineInputBorder(
+          borderRadius: BorderRadius.circular(18),
+          borderSide: BorderSide.none,
+        ),
+        enabledBorder: OutlineInputBorder(
+          borderRadius: BorderRadius.circular(18),
+          borderSide: BorderSide(color: colors.border),
+        ),
+        focusedBorder: OutlineInputBorder(
+          borderRadius: BorderRadius.circular(18),
+          borderSide: BorderSide(color: colors.accent, width: 1.4),
+        ),
+      ),
+      snackBarTheme: SnackBarThemeData(
+        backgroundColor: colors.ink,
+        contentTextStyle: textTheme.bodyMedium?.copyWith(color: colors.canvas),
+        behavior: SnackBarBehavior.floating,
+        elevation: 0,
+        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
+      ),
+      bottomSheetTheme: const BottomSheetThemeData(
+        backgroundColor: Colors.transparent,
+        surfaceTintColor: Colors.transparent,
+        modalBarrierColor: Color(0x99000000),
+      ),
+      dialogTheme: DialogThemeData(
+        backgroundColor: colors.surface,
+        surfaceTintColor: Colors.transparent,
+        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(26)),
+      ),
+      pageTransitionsTheme: const PageTransitionsTheme(
+        builders: {
+          TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
+          TargetPlatform.linux: FadeUpwardsPageTransitionsBuilder(),
+        },
+      ),
+      extensions: [colors],
+    );
+  }
+
+  static TextTheme _textTheme(AppColors colors) {
+    TextStyle style(
+      double size,
+      FontWeight weight,
+      int variation, {
+      double? height,
+      double? letterSpacing,
+    }) {
+      return TextStyle(
+        fontFamily: 'Manrope',
+        fontSize: size,
+        fontWeight: weight,
+        height: height,
+        letterSpacing: letterSpacing,
+        color: colors.ink,
+        fontVariations: [FontVariation('wght', variation.toDouble())],
+      );
+    }
+
+    return TextTheme(
+      displayLarge: style(
+        68,
+        FontWeight.w600,
+        640,
+        height: 0.96,
+        letterSpacing: -2.6,
+      ),
+      displayMedium: style(
+        52,
+        FontWeight.w600,
+        620,
+        height: 1.0,
+        letterSpacing: -1.8,
+      ),
+      displaySmall: style(
+        42,
+        FontWeight.w600,
+        620,
+        height: 1.05,
+        letterSpacing: -1.2,
+      ),
+      headlineLarge: style(
+        34,
+        FontWeight.w600,
+        620,
+        height: 1.1,
+        letterSpacing: -0.6,
+      ),
+      headlineMedium: style(
+        28,
+        FontWeight.w600,
+        610,
+        height: 1.15,
+        letterSpacing: -0.4,
+      ),
+      headlineSmall: style(24, FontWeight.w600, 600, height: 1.2),
+      titleLarge: style(20, FontWeight.w600, 600, height: 1.25),
+      titleMedium: style(16, FontWeight.w600, 600, height: 1.3),
+      titleSmall: style(14, FontWeight.w600, 600, height: 1.3),
+      bodyLarge: style(16, FontWeight.w400, 440, height: 1.45),
+      bodyMedium: style(14, FontWeight.w400, 440, height: 1.45),
+      bodySmall: style(12, FontWeight.w400, 430, height: 1.4),
+      labelLarge: style(14, FontWeight.w600, 600, height: 1.2),
+      labelMedium: style(12, FontWeight.w500, 530, height: 1.25),
+      labelSmall: style(11, FontWeight.w500, 520, height: 1.25),
+    ).apply(bodyColor: colors.ink, displayColor: colors.ink);
+  }
+}

+ 140 - 0
lib/core/theme/theme_controller.dart

@@ -0,0 +1,140 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+
+import '../../data/repositories/settings_repository.dart';
+import 'theme_preference.dart';
+
+/// Resolves the light/dark choice, including the fixed time-of-day schedule.
+///
+/// Automatic mode is dark from [nightStartMinutes] until [nightEndMinutes],
+/// wrapping around midnight (19:00 -> 07:00 by default). A timer wakes exactly
+/// at the next boundary so the app can switch without polling.
+class ThemeController extends ChangeNotifier {
+  ThemeController(this._settings) {
+    _preference = _settings.themePreference;
+    _nightStartMinutes = _settings.nightStartMinutes;
+    _nightEndMinutes = _settings.nightEndMinutes;
+    _resolvedDark = _computeDark(DateTime.now());
+    _scheduleBoundary();
+  }
+
+  final SettingsRepository _settings;
+
+  ThemePreference _preference = ThemePreference.automatic;
+  int _nightStartMinutes = 19 * 60;
+  int _nightEndMinutes = 7 * 60;
+  bool _resolvedDark = false;
+  Timer? _boundaryTimer;
+  bool _disposed = false;
+
+  ThemePreference get preference => _preference;
+  int get nightStartMinutes => _nightStartMinutes;
+  int get nightEndMinutes => _nightEndMinutes;
+  bool get isDark => _resolvedDark;
+  ThemeMode get themeMode => _resolvedDark ? ThemeMode.dark : ThemeMode.light;
+
+  Future<void> setPreference(ThemePreference preference) async {
+    if (_preference == preference) return;
+    _preference = preference;
+    _resolvedDark = _computeDark(DateTime.now());
+    await _settings.setThemePreference(preference);
+    _scheduleBoundary();
+    _notify();
+  }
+
+  Future<void> setNightWindow({
+    required int startMinutes,
+    required int endMinutes,
+  }) async {
+    _nightStartMinutes = startMinutes.clamp(0, 1439);
+    _nightEndMinutes = endMinutes.clamp(0, 1439);
+    await _settings.setNightWindow(
+      startMinutes: _nightStartMinutes,
+      endMinutes: _nightEndMinutes,
+    );
+    _resolvedDark = _computeDark(DateTime.now());
+    _scheduleBoundary();
+    _notify();
+  }
+
+  /// Re-evaluates the schedule after the app resumes or a settings page closes.
+  void refreshNow() {
+    if (_preference != ThemePreference.automatic) return;
+    final next = _computeDark(DateTime.now());
+    if (next != _resolvedDark) {
+      _resolvedDark = next;
+      _notify();
+    }
+    _scheduleBoundary();
+  }
+
+  bool _computeDark(DateTime now) {
+    switch (_preference) {
+      case ThemePreference.light:
+        return false;
+      case ThemePreference.dark:
+        return true;
+      case ThemePreference.automatic:
+        final minutes = now.hour * 60 + now.minute;
+        if (_nightStartMinutes == _nightEndMinutes) return false;
+        if (_nightStartMinutes < _nightEndMinutes) {
+          return minutes >= _nightStartMinutes && minutes < _nightEndMinutes;
+        }
+        return minutes >= _nightStartMinutes || minutes < _nightEndMinutes;
+    }
+  }
+
+  void _scheduleBoundary() {
+    _boundaryTimer?.cancel();
+    _boundaryTimer = null;
+    if (_preference != ThemePreference.automatic) return;
+
+    final now = DateTime.now();
+    final next = _nextBoundary(now);
+    if (next == null) return;
+    _boundaryTimer = Timer(
+      next.difference(now) + const Duration(seconds: 1),
+      () {
+        _resolvedDark = _computeDark(DateTime.now());
+        _notify();
+        _scheduleBoundary();
+      },
+    );
+  }
+
+  DateTime? _nextBoundary(DateTime now) {
+    DateTime at(int minutes) {
+      return DateTime(
+        now.year,
+        now.month,
+        now.day,
+        minutes ~/ 60,
+        minutes % 60,
+      );
+    }
+
+    final candidates = <DateTime>[];
+    for (final minutes in {_nightStartMinutes, _nightEndMinutes}) {
+      var candidate = at(minutes);
+      if (!candidate.isAfter(now)) {
+        candidate = candidate.add(const Duration(days: 1));
+      }
+      candidates.add(candidate);
+    }
+    if (candidates.isEmpty) return null;
+    candidates.sort();
+    return candidates.first;
+  }
+
+  void _notify() {
+    if (!_disposed) notifyListeners();
+  }
+
+  @override
+  void dispose() {
+    _disposed = true;
+    _boundaryTimer?.cancel();
+    super.dispose();
+  }
+}

+ 17 - 0
lib/core/theme/theme_preference.dart

@@ -0,0 +1,17 @@
+/// The three theme choices exposed in Settings.
+enum ThemePreference {
+  light('light'),
+  dark('dark'),
+  automatic('automatic');
+
+  const ThemePreference(this.storageValue);
+
+  final String storageValue;
+
+  static ThemePreference fromStorage(String? value) {
+    return ThemePreference.values.firstWhere(
+      (preference) => preference.storageValue == value,
+      orElse: () => ThemePreference.automatic,
+    );
+  }
+}

+ 32 - 0
lib/core/widgets/app_icon.dart

@@ -0,0 +1,32 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+
+/// The generic line-icon set used across the launcher.
+class AppIcon extends StatelessWidget {
+  const AppIcon(
+    this.asset, {
+    super.key,
+    this.size = 24,
+    this.color,
+    this.semanticLabel,
+  });
+
+  final String asset;
+  final double size;
+  final Color? color;
+  final String? semanticLabel;
+
+  @override
+  Widget build(BuildContext context) {
+    return SvgPicture.asset(
+      asset,
+      width: size,
+      height: size,
+      colorFilter: ColorFilter.mode(
+        color ?? Theme.of(context).colorScheme.onSurface,
+        BlendMode.srcIn,
+      ),
+      semanticsLabel: semanticLabel,
+    );
+  }
+}

+ 44 - 0
lib/core/widgets/app_icon_image.dart

@@ -0,0 +1,44 @@
+import 'dart:typed_data';
+
+import 'package:flutter/material.dart';
+
+import '../theme/app_colors.dart';
+
+/// Renders a real installed-app icon where identification matters (the app
+/// picker and All Apps). Dashboard shortcut icons stay generic.
+class AppIconImage extends StatelessWidget {
+  const AppIconImage({super.key, this.bytes, this.size = 40});
+
+  final Uint8List? bytes;
+  final double size;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final fallback = Container(
+      width: size,
+      height: size,
+      decoration: BoxDecoration(
+        color: colors.accentSoft,
+        borderRadius: BorderRadius.circular(size * 0.26),
+      ),
+      alignment: Alignment.center,
+      child: Icon(Icons.apps_rounded, size: size * 0.56, color: colors.accent),
+    );
+
+    if (bytes == null || bytes!.isEmpty) return fallback;
+
+    return ClipRRect(
+      borderRadius: BorderRadius.circular(size * 0.24),
+      child: Image.memory(
+        bytes!,
+        width: size,
+        height: size,
+        fit: BoxFit.cover,
+        filterQuality: FilterQuality.medium,
+        gaplessPlayback: true,
+        errorBuilder: (_, _, _) => fallback,
+      ),
+    );
+  }
+}

+ 63 - 0
lib/core/widgets/pressable.dart

@@ -0,0 +1,63 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+/// A lightweight press-scale wrapper used instead of InkWell for the large
+/// dashboard tiles. It keeps touch feedback cheap on older devices.
+class Pressable extends StatefulWidget {
+  const Pressable({
+    super.key,
+    required this.child,
+    this.onTap,
+    this.onLongPress,
+    this.semanticLabel,
+    this.pressedScale = 0.965,
+    this.borderRadius = BorderRadius.zero,
+  });
+
+  final Widget child;
+  final VoidCallback? onTap;
+  final VoidCallback? onLongPress;
+  final String? semanticLabel;
+  final double pressedScale;
+  final BorderRadius borderRadius;
+
+  @override
+  State<Pressable> createState() => _PressableState();
+}
+
+class _PressableState extends State<Pressable> {
+  bool _pressed = false;
+
+  void _setPressed(bool pressed) {
+    if (!mounted || _pressed == pressed) return;
+    setState(() => _pressed = pressed);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final canInteract = widget.onTap != null || widget.onLongPress != null;
+    return Semantics(
+      button: canInteract,
+      label: widget.semanticLabel,
+      child: GestureDetector(
+        behavior: HitTestBehavior.opaque,
+        onTapDown: canInteract ? (_) => _setPressed(true) : null,
+        onTapUp: canInteract ? (_) => _setPressed(false) : null,
+        onTapCancel: canInteract ? () => _setPressed(false) : null,
+        onTap: widget.onTap,
+        onLongPress: widget.onLongPress == null
+            ? null
+            : () {
+                HapticFeedback.mediumImpact();
+                widget.onLongPress!();
+              },
+        child: AnimatedScale(
+          scale: _pressed ? widget.pressedScale : 1,
+          duration: const Duration(milliseconds: 110),
+          curve: Curves.easeOutCubic,
+          child: widget.child,
+        ),
+      ),
+    );
+  }
+}

+ 62 - 0
lib/core/widgets/surface_card.dart

@@ -0,0 +1,62 @@
+import 'package:flutter/material.dart';
+
+import '../theme/app_colors.dart';
+
+/// The soft paper-like card used for the volume control, shortcuts and
+/// settings sections.
+class SurfaceCard extends StatelessWidget {
+  const SurfaceCard({
+    super.key,
+    required this.child,
+    this.padding = const EdgeInsets.all(24),
+    this.radius = 28,
+    this.color,
+    this.borderColor,
+    this.showShadow = true,
+    this.onTap,
+  });
+
+  final Widget child;
+  final EdgeInsetsGeometry padding;
+  final double radius;
+  final Color? color;
+  final Color? borderColor;
+  final bool showShadow;
+  final VoidCallback? onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final isDark = Theme.of(context).brightness == Brightness.dark;
+    final decoration = BoxDecoration(
+      color: color ?? colors.surface,
+      borderRadius: BorderRadius.circular(radius),
+      border: Border.all(color: borderColor ?? colors.border),
+      boxShadow: showShadow
+          ? [
+              BoxShadow(
+                color: colors.shadow.withValues(alpha: isDark ? 0.24 : 0.045),
+                blurRadius: isDark ? 28 : 30,
+                offset: const Offset(0, 12),
+              ),
+            ]
+          : null,
+    );
+
+    final content = Container(
+      padding: padding,
+      decoration: decoration,
+      child: child,
+    );
+
+    if (onTap == null) return content;
+    return Material(
+      color: Colors.transparent,
+      child: InkWell(
+        onTap: onTap,
+        borderRadius: BorderRadius.circular(radius),
+        child: content,
+      ),
+    );
+  }
+}

+ 49 - 0
lib/data/models/app_entry.dart

@@ -0,0 +1,49 @@
+import 'dart:typed_data';
+
+import 'slot_assignment.dart';
+
+/// A launchable activity returned by the platform.
+class AppEntry {
+  const AppEntry({
+    required this.packageName,
+    required this.activityName,
+    required this.label,
+    this.icon,
+  });
+
+  final String packageName;
+  final String activityName;
+  final String label;
+  final Uint8List? icon;
+
+  String get componentId => '$packageName/$activityName';
+
+  factory AppEntry.fromMap(Map<dynamic, dynamic> map) {
+    final rawIcon = map['icon'];
+    final icon = rawIcon is Uint8List
+        ? rawIcon
+        : rawIcon is List<int>
+        ? Uint8List.fromList(rawIcon)
+        : rawIcon is List
+        ? Uint8List.fromList(rawIcon.whereType<int>().toList())
+        : null;
+
+    return AppEntry(
+      packageName: (map['package'] as String?) ?? '',
+      activityName: (map['activity'] as String?) ?? '',
+      label: ((map['label'] as String?) ?? '').trim(),
+      icon: icon,
+    );
+  }
+
+  SlotAssignment toAssignment() => SlotAssignment(
+    packageName: packageName,
+    activityName: activityName,
+    label: label,
+  );
+
+  bool matches(SlotAssignment assignment) {
+    return assignment.packageName == packageName &&
+        assignment.activityName == activityName;
+  }
+}

+ 42 - 0
lib/data/models/slot_assignment.dart

@@ -0,0 +1,42 @@
+import 'package:flutter/foundation.dart';
+
+/// A persisted app choice for one shortcut slot.
+@immutable
+class SlotAssignment {
+  const SlotAssignment({
+    required this.packageName,
+    required this.activityName,
+    required this.label,
+  });
+
+  final String packageName;
+  final String activityName;
+  final String label;
+
+  factory SlotAssignment.fromMap(Map<dynamic, dynamic> map) {
+    return SlotAssignment(
+      packageName: (map['package'] as String?) ?? '',
+      activityName: (map['activity'] as String?) ?? '',
+      label: (map['label'] as String?) ?? '',
+    );
+  }
+
+  Map<String, Object?> toMap() => {
+    'package': packageName,
+    'activity': activityName,
+    'label': label,
+  };
+
+  @override
+  bool operator ==(Object other) {
+    return other is SlotAssignment &&
+        other.packageName == packageName &&
+        other.activityName == activityName;
+  }
+
+  @override
+  int get hashCode => Object.hash(packageName, activityName);
+
+  @override
+  String toString() => label.isEmpty ? packageName : label;
+}

+ 20 - 0
lib/data/models/slot_type.dart

@@ -0,0 +1,20 @@
+/// The three configurable shortcut slots on the home screen.
+enum SlotType {
+  navigation(
+    key: 'navigation',
+    label: 'Navigation',
+    iconAsset: 'assets/icons/navigation.svg',
+  ),
+  music(key: 'music', label: 'Music', iconAsset: 'assets/icons/music.svg'),
+  radio(key: 'radio', label: 'Radio', iconAsset: 'assets/icons/radio.svg');
+
+  const SlotType({
+    required this.key,
+    required this.label,
+    required this.iconAsset,
+  });
+
+  final String key;
+  final String label;
+  final String iconAsset;
+}

+ 39 - 0
lib/data/models/system_status.dart

@@ -0,0 +1,39 @@
+/// Battery and connectivity snapshot shown on the home screen.
+class SystemStatus {
+  const SystemStatus({
+    this.batteryLevel = 0,
+    this.charging = false,
+    this.wifi = false,
+  });
+
+  final int batteryLevel;
+  final bool charging;
+  final bool wifi;
+
+  factory SystemStatus.fromMap(Map<dynamic, dynamic> map) {
+    return SystemStatus(
+      batteryLevel: (map['batteryLevel'] as num?)?.toInt() ?? 0,
+      charging: map['charging'] == true,
+      wifi: map['wifi'] == true,
+    );
+  }
+}
+
+/// Current media-stream volume and its maximum.
+class VolumeInfo {
+  const VolumeInfo({this.index = 0, this.max = 15});
+
+  final int index;
+  final int max;
+
+  factory VolumeInfo.fromMap(Map<dynamic, dynamic> map) {
+    return VolumeInfo(
+      index: (map['index'] as num?)?.toInt() ?? 0,
+      max: (map['max'] as num?)?.toInt() ?? 15,
+    );
+  }
+
+  double get fraction => max <= 0 ? 0 : (index / max).clamp(0.0, 1.0);
+
+  int get percent => (fraction * 100).round();
+}

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

@@ -0,0 +1,68 @@
+import 'package:shared_preferences/shared_preferences.dart';
+
+import '../../core/theme/theme_preference.dart';
+import '../models/slot_assignment.dart';
+import '../models/slot_type.dart';
+
+/// Persists launcher configuration. This is the single source of truth for
+/// theme choice, the automatic night window, and the three shortcut slots.
+class SettingsRepository {
+  SettingsRepository(this._prefs);
+
+  final SharedPreferences _prefs;
+
+  static const _themeKey = 'settings.theme';
+  static const _nightStartKey = 'settings.nightStartMinutes';
+  static const _nightEndKey = 'settings.nightEndMinutes';
+  static const _slotPrefix = 'slot';
+
+  ThemePreference get themePreference {
+    return ThemePreference.fromStorage(_prefs.getString(_themeKey));
+  }
+
+  int get nightStartMinutes => _prefs.getInt(_nightStartKey) ?? 19 * 60;
+
+  int get nightEndMinutes => _prefs.getInt(_nightEndKey) ?? 7 * 60;
+
+  Future<void> setThemePreference(ThemePreference preference) {
+    return _prefs.setString(_themeKey, preference.storageValue);
+  }
+
+  Future<void> setNightWindow({
+    required int startMinutes,
+    required int endMinutes,
+  }) async {
+    await _prefs.setInt(_nightStartKey, startMinutes);
+    await _prefs.setInt(_nightEndKey, endMinutes);
+  }
+
+  SlotAssignment? slot(SlotType slot) {
+    final packageName = _prefs.getString('$_slotPrefix.${slot.key}.package');
+    final activityName = _prefs.getString('$_slotPrefix.${slot.key}.activity');
+    final label = _prefs.getString('$_slotPrefix.${slot.key}.label');
+    if (packageName == null || activityName == null) return null;
+    return SlotAssignment(
+      packageName: packageName,
+      activityName: activityName,
+      label: label ?? packageName,
+    );
+  }
+
+  Future<void> setSlot(SlotType slot, SlotAssignment assignment) async {
+    await _prefs.setString(
+      '$_slotPrefix.${slot.key}.package',
+      assignment.packageName,
+    );
+    await _prefs.setString(
+      '$_slotPrefix.${slot.key}.activity',
+      assignment.activityName,
+    );
+    await _prefs.setString('$_slotPrefix.${slot.key}.label', assignment.label);
+  }
+
+  Future<void> clearSlot(SlotType slot) async {
+    await _prefs.remove('$_slotPrefix.${slot.key}.package');
+    await _prefs.remove('$_slotPrefix.${slot.key}.activity');
+    await _prefs.remove('$_slotPrefix.${slot.key}.label');
+  }
+}

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

@@ -0,0 +1,143 @@
+import 'package:flutter/services.dart';
+
+import '../models/app_entry.dart';
+import '../models/system_status.dart';
+
+class LauncherPlatformException implements Exception {
+  LauncherPlatformException(this.message);
+
+  final String message;
+
+  @override
+  String toString() => message;
+}
+
+/// Thin, testable boundary around Android's package manager, volume and
+/// connectivity APIs.
+abstract class LauncherPlatform {
+  Future<List<AppEntry>> listApps();
+
+  Future<void> launchApp({
+    required String packageName,
+    required String activityName,
+  });
+
+  Future<bool> isDefaultHome();
+
+  Future<bool> requestDefaultHome();
+
+  Future<SystemStatus> getStatus();
+
+  Future<VolumeInfo> getVolume();
+
+  Future<void> setVolume(int index);
+}
+
+class NativeBridge implements LauncherPlatform {
+  static const _appsChannel = MethodChannel('com.wasp.cardash_launcher/apps');
+  static const _systemChannel = MethodChannel(
+    'com.wasp.cardash_launcher/system',
+  );
+
+  @override
+  Future<List<AppEntry>> listApps() async {
+    try {
+      final raw = await _appsChannel.invokeMethod<dynamic>('listApps');
+      if (raw is Map && raw['error'] != null) {
+        throw LauncherPlatformException(raw['error'].toString());
+      }
+      if (raw is! List) return const [];
+      return raw
+          .whereType<Map>()
+          .map(AppEntry.fromMap)
+          .where(
+            (app) => app.packageName.isNotEmpty && app.activityName.isNotEmpty,
+          )
+          .toList(growable: false);
+    } on PlatformException catch (error) {
+      throw LauncherPlatformException(
+        error.message ?? 'Could not load installed apps.',
+      );
+    } on MissingPluginException {
+      throw LauncherPlatformException('The Android app bridge is unavailable.');
+    }
+  }
+
+  @override
+  Future<void> launchApp({
+    required String packageName,
+    required String activityName,
+  }) async {
+    try {
+      await _appsChannel.invokeMethod<dynamic>('launchApp', {
+        'package': packageName,
+        'activity': activityName,
+      });
+    } on PlatformException catch (error) {
+      throw LauncherPlatformException(
+        error.message ?? 'Could not open the app.',
+      );
+    } on MissingPluginException {
+      throw LauncherPlatformException('The Android app bridge is unavailable.');
+    }
+  }
+
+  @override
+  Future<bool> isDefaultHome() async {
+    try {
+      return await _appsChannel.invokeMethod<bool>('isDefaultHome') ?? false;
+    } on PlatformException {
+      return false;
+    } on MissingPluginException {
+      return false;
+    }
+  }
+
+  @override
+  Future<bool> requestDefaultHome() async {
+    try {
+      return await _appsChannel.invokeMethod<bool>('requestDefaultHome') ??
+          false;
+    } on PlatformException {
+      return false;
+    } on MissingPluginException {
+      return false;
+    }
+  }
+
+  @override
+  Future<SystemStatus> getStatus() async {
+    try {
+      final raw = await _systemChannel.invokeMethod<dynamic>('getStatus');
+      return raw is Map ? SystemStatus.fromMap(raw) : const SystemStatus();
+    } on PlatformException {
+      return const SystemStatus();
+    } on MissingPluginException {
+      return const SystemStatus();
+    }
+  }
+
+  @override
+  Future<VolumeInfo> getVolume() async {
+    try {
+      final raw = await _systemChannel.invokeMethod<dynamic>('getVolume');
+      return raw is Map ? VolumeInfo.fromMap(raw) : const VolumeInfo();
+    } on PlatformException {
+      return const VolumeInfo();
+    } on MissingPluginException {
+      return const VolumeInfo();
+    }
+  }
+
+  @override
+  Future<void> setVolume(int index) async {
+    try {
+      await _systemChannel.invokeMethod<dynamic>('setVolume', {'index': index});
+    } on PlatformException {
+      // Volume can be unavailable on some emulators; the UI keeps its local
+      // value and will reconcile on the next poll.
+    } on MissingPluginException {
+      // Nothing to synchronise when running outside Android.
+    }
+  }
+}

+ 89 - 0
lib/data/services/pin_service.dart

@@ -0,0 +1,89 @@
+import 'dart:convert';
+import 'dart:math';
+import 'dart:typed_data';
+
+import 'package:crypto/crypto.dart';
+
+import 'secure_store.dart';
+
+/// Stores a salted PBKDF2-HMAC-SHA256 hash of the four-digit PIN in
+/// Keystore-backed secure storage. The PIN itself is never persisted.
+class PinService {
+  PinService(this._store, {this.iterations = 12000});
+
+  final SecureStore _store;
+  final int iterations;
+
+  static const _saltKey = 'cardash.pin.salt';
+  static const _hashKey = 'cardash.pin.hash';
+  static const _iterationsKey = 'cardash.pin.iterations';
+
+  static final RegExp _validPin = RegExp(r'^\d{4}$');
+
+  Future<bool> isPinSet() async {
+    final hash = await _store.read(_hashKey);
+    return hash != null && hash.isNotEmpty;
+  }
+
+  Future<void> setPin(String pin) async {
+    if (!_validPin.hasMatch(pin)) {
+      throw ArgumentError('The PIN must be exactly four digits.');
+    }
+    final salt = _randomBytes(16);
+    final derived = _derive(pin, salt, iterations);
+    await _store.write(_saltKey, base64Encode(salt));
+    await _store.write(_hashKey, base64Encode(derived));
+    await _store.write(_iterationsKey, '$iterations');
+  }
+
+  Future<bool> verify(String pin) async {
+    if (!_validPin.hasMatch(pin)) return false;
+    final saltRaw = await _store.read(_saltKey);
+    final hashRaw = await _store.read(_hashKey);
+    final iterationsRaw = await _store.read(_iterationsKey);
+    if (saltRaw == null || hashRaw == null) return false;
+
+    final salt = base64Decode(saltRaw);
+    final expected = base64Decode(hashRaw);
+    final rounds = int.tryParse(iterationsRaw ?? '') ?? iterations;
+    final actual = _derive(pin, salt, rounds);
+    return _constantTimeEquals(expected, actual);
+  }
+
+  Future<void> clearPin() async {
+    await _store.delete(_saltKey);
+    await _store.delete(_hashKey);
+    await _store.delete(_iterationsKey);
+  }
+
+  Uint8List _derive(String pin, List<int> salt, int rounds) {
+    final hmac = Hmac(sha256, utf8.encode(pin));
+    var block = Uint8List.fromList([...salt, 0, 0, 0, 1]);
+    var u = Uint8List.fromList(hmac.convert(block).bytes);
+    final result = Uint8List.fromList(u);
+
+    for (var round = 1; round < rounds; round++) {
+      u = Uint8List.fromList(hmac.convert(u).bytes);
+      for (var i = 0; i < result.length; i++) {
+        result[i] ^= u[i];
+      }
+    }
+    return result;
+  }
+
+  Uint8List _randomBytes(int length) {
+    final random = Random.secure();
+    return Uint8List.fromList(
+      List<int>.generate(length, (_) => random.nextInt(256)),
+    );
+  }
+
+  bool _constantTimeEquals(List<int> a, List<int> b) {
+    if (a.length != b.length) return false;
+    var difference = 0;
+    for (var i = 0; i < a.length; i++) {
+      difference |= a[i] ^ b[i];
+    }
+    return difference == 0;
+  }
+}

+ 27 - 0
lib/data/services/secure_store.dart

@@ -0,0 +1,27 @@
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+
+/// Small abstraction so the PIN hashing logic can be tested without Android.
+abstract class SecureStore {
+  Future<String?> read(String key);
+
+  Future<void> write(String key, String value);
+
+  Future<void> delete(String key);
+}
+
+class FlutterSecureStore implements SecureStore {
+  FlutterSecureStore([FlutterSecureStorage? storage])
+    : _storage = storage ?? const FlutterSecureStorage();
+
+  final FlutterSecureStorage _storage;
+
+  @override
+  Future<String?> read(String key) => _storage.read(key: key);
+
+  @override
+  Future<void> write(String key, String value) =>
+      _storage.write(key: key, value: value);
+
+  @override
+  Future<void> delete(String key) => _storage.delete(key: key);
+}

+ 250 - 0
lib/features/apps/all_apps_screen.dart

@@ -0,0 +1,250 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/widgets/app_icon.dart';
+import '../../core/widgets/app_icon_image.dart';
+import '../../core/widgets/pressable.dart';
+import '../../data/models/app_entry.dart';
+import '../home/launcher_controller.dart';
+
+/// Every launchable app, with real icons and a search field. The header is
+/// page content; there is no persistent app bar.
+class AllAppsScreen extends StatefulWidget {
+  const AllAppsScreen({super.key});
+
+  @override
+  State<AllAppsScreen> createState() => _AllAppsScreenState();
+}
+
+class _AllAppsScreenState extends State<AllAppsScreen> {
+  final _search = TextEditingController();
+  String _query = '';
+
+  @override
+  void initState() {
+    super.initState();
+    WidgetsBinding.instance.addPostFrameCallback((_) {
+      if (mounted) context.read<LauncherController>().loadApps(force: true);
+    });
+  }
+
+  @override
+  void dispose() {
+    _search.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final launcher = context.watch<LauncherController>();
+    final query = _query.trim().toLowerCase();
+    final apps = launcher.apps
+        .where(
+          (app) => query.isEmpty || app.label.toLowerCase().contains(query),
+        )
+        .toList(growable: false);
+
+    return Scaffold(
+      backgroundColor: colors.canvas,
+      body: Container(
+        decoration: homeBackground(colors, Theme.of(context).brightness),
+        child: SafeArea(
+          child: Padding(
+            padding: const EdgeInsets.fromLTRB(36, 24, 36, 16),
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Row(
+                  children: [
+                    _BackButton(onTap: () => Navigator.of(context).pop()),
+                    const SizedBox(width: 18),
+                    Expanded(
+                      child: Column(
+                        crossAxisAlignment: CrossAxisAlignment.start,
+                        children: [
+                          Text(
+                            'All apps',
+                            style: theme.textTheme.headlineLarge,
+                          ),
+                          const SizedBox(height: 2),
+                          Text(
+                            '${apps.length} installed ${apps.length == 1 ? 'app' : 'apps'}',
+                            style: theme.textTheme.bodySmall?.copyWith(
+                              color: colors.inkMuted,
+                            ),
+                          ),
+                        ],
+                      ),
+                    ),
+                    SizedBox(
+                      width: 300,
+                      child: TextField(
+                        controller: _search,
+                        onChanged: (value) => setState(() => _query = value),
+                        style: theme.textTheme.bodyMedium,
+                        decoration: InputDecoration(
+                          hintText: 'Search apps',
+                          isDense: true,
+                          prefixIcon: Padding(
+                            padding: const EdgeInsets.only(left: 2),
+                            child: AppIcon(
+                              'assets/icons/search.svg',
+                              size: 18,
+                              color: colors.inkFaint,
+                            ),
+                          ),
+                        ),
+                      ),
+                    ),
+                  ],
+                ),
+                const SizedBox(height: 24),
+                Expanded(child: _buildBody(context, launcher, apps)),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  Widget _buildBody(
+    BuildContext context,
+    LauncherController launcher,
+    List<AppEntry> apps,
+  ) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+
+    if (apps.isEmpty && launcher.isLoadingApps) {
+      return Center(
+        child: SizedBox(
+          width: 30,
+          height: 30,
+          child: CircularProgressIndicator(
+            strokeWidth: 2.4,
+            color: colors.accent,
+          ),
+        ),
+      );
+    }
+
+    if (apps.isEmpty && launcher.appsError != null) {
+      return Center(
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            Text(
+              'Could not read installed apps.',
+              style: theme.textTheme.bodyLarge,
+            ),
+            const SizedBox(height: 12),
+            TextButton(
+              onPressed: () => launcher.loadApps(force: true),
+              child: const Text('Try again'),
+            ),
+          ],
+        ),
+      );
+    }
+
+    if (apps.isEmpty) {
+      return Center(
+        child: Text(
+          'No apps match “${_query.trim()}”.',
+          style: theme.textTheme.bodyMedium?.copyWith(color: colors.inkMuted),
+        ),
+      );
+    }
+
+    return GridView.builder(
+      padding: const EdgeInsets.only(bottom: 20),
+      gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
+        maxCrossAxisExtent: 162,
+        mainAxisExtent: 150,
+        crossAxisSpacing: 12,
+        mainAxisSpacing: 12,
+      ),
+      itemCount: apps.length,
+      itemBuilder: (context, index) {
+        final app = apps[index];
+        return Pressable(
+          semanticLabel: '${app.label}. Open app.',
+          onTap: () => _launch(app),
+          child: Container(
+            decoration: BoxDecoration(
+              color: colors.surface,
+              borderRadius: BorderRadius.circular(24),
+              border: Border.all(color: colors.border),
+            ),
+            padding: const EdgeInsets.fromLTRB(12, 16, 12, 12),
+            child: Column(
+              mainAxisAlignment: MainAxisAlignment.center,
+              children: [
+                AppIconImage(bytes: app.icon, size: 46),
+                const SizedBox(height: 10),
+                Text(
+                  app.label,
+                  maxLines: 2,
+                  overflow: TextOverflow.ellipsis,
+                  textAlign: TextAlign.center,
+                  style: theme.textTheme.bodySmall?.copyWith(
+                    color: colors.ink,
+                    fontWeight: FontWeight.w500,
+                  ),
+                ),
+              ],
+            ),
+          ),
+        );
+      },
+    );
+  }
+
+  Future<void> _launch(AppEntry app) async {
+    final launcher = context.read<LauncherController>();
+    final error = await launcher.launchAppEntry(app);
+    if (!mounted) return;
+    if (error != null) {
+      ScaffoldMessenger.of(context)
+          .showSnackBar(SnackBar(content: Text(error)));
+      return;
+    }
+    // Pressing Home should land on the dashboard, not on All apps.
+    Navigator.of(context).popUntil((route) => route.isFirst);
+  }
+}
+
+class _BackButton extends StatelessWidget {
+  const _BackButton({required this.onTap});
+
+  final VoidCallback onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    return Pressable(
+      onTap: onTap,
+      semanticLabel: 'Back',
+      pressedScale: 0.92,
+      child: Container(
+        width: 46,
+        height: 46,
+        decoration: BoxDecoration(
+          color: colors.surface,
+          shape: BoxShape.circle,
+          border: Border.all(color: colors.border),
+        ),
+        alignment: Alignment.center,
+        child: AppIcon(
+          'assets/icons/arrow-left.svg',
+          size: 20,
+          color: colors.ink,
+        ),
+      ),
+    );
+  }
+}

+ 312 - 0
lib/features/apps/app_picker_sheet.dart

@@ -0,0 +1,312 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/widgets/app_icon.dart';
+import '../../core/widgets/app_icon_image.dart';
+import '../../core/widgets/pressable.dart';
+import '../../data/models/app_entry.dart';
+import '../../data/models/slot_assignment.dart';
+import '../../data/models/slot_type.dart';
+import '../home/launcher_controller.dart';
+
+Future<void> showAppPicker(BuildContext context, SlotType slot) {
+  return showModalBottomSheet<void>(
+    context: context,
+    isScrollControlled: true,
+    useSafeArea: false,
+    backgroundColor: Colors.transparent,
+    barrierColor: const Color(0x8A000000),
+    builder: (_) => AppPickerSheet(slot: slot),
+  );
+}
+
+class AppPickerSheet extends StatefulWidget {
+  const AppPickerSheet({super.key, required this.slot});
+
+  final SlotType slot;
+
+  @override
+  State<AppPickerSheet> createState() => _AppPickerSheetState();
+}
+
+class _AppPickerSheetState extends State<AppPickerSheet> {
+  final _search = TextEditingController();
+  String _query = '';
+  @override
+  void initState() {
+    super.initState();
+    final controller = context.read<LauncherController>();
+    if (!controller.isLoadingApps) {
+      controller.loadApps(force: true);
+    }
+  }
+
+  @override
+  void dispose() {
+    _search.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final launcher = context.watch<LauncherController>();
+    final current = launcher.assignmentFor(widget.slot);
+    final query = _query.trim().toLowerCase();
+    final apps = launcher.apps
+        .where(
+          (app) => query.isEmpty || app.label.toLowerCase().contains(query),
+        )
+        .toList(growable: false);
+
+    return Padding(
+      padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
+      child: Container(
+        height: MediaQuery.sizeOf(context).height * 0.86,
+        decoration: BoxDecoration(
+          color: colors.surface,
+          borderRadius: const BorderRadius.vertical(top: Radius.circular(32)),
+        ),
+        child: Column(
+          children: [
+            const SizedBox(height: 12),
+            Container(
+              width: 44,
+              height: 4,
+              decoration: BoxDecoration(
+                color: colors.inkFaint.withValues(alpha: 0.4),
+                borderRadius: BorderRadius.circular(99),
+              ),
+            ),
+            Padding(
+              padding: const EdgeInsets.fromLTRB(28, 20, 18, 12),
+              child: Row(
+                children: [
+                  Expanded(
+                    child: Column(
+                      crossAxisAlignment: CrossAxisAlignment.start,
+                      children: [
+                        Text(
+                          'Choose ${widget.slot.label.toLowerCase()} app',
+                          style: theme.textTheme.headlineSmall,
+                        ),
+                        const SizedBox(height: 3),
+                        Text(
+                          current == null
+                              ? 'Pick any installed app'
+                              : 'Currently ${current.label}',
+                          style: theme.textTheme.bodySmall?.copyWith(
+                            color: colors.inkMuted,
+                          ),
+                        ),
+                      ],
+                    ),
+                  ),
+                  if (current != null)
+                    TextButton(
+                      onPressed: () async {
+                        await launcher.clearSlot(widget.slot);
+                        if (context.mounted) Navigator.of(context).pop();
+                      },
+                      child: const Text('Clear'),
+                    ),
+                  _CircleButton(
+                    iconAsset: 'assets/icons/x.svg',
+                    semanticLabel: 'Close',
+                    onTap: () => Navigator.of(context).pop(),
+                  ),
+                ],
+              ),
+            ),
+            Padding(
+              padding: const EdgeInsets.symmetric(horizontal: 24),
+              child: TextField(
+                controller: _search,
+                onChanged: (value) => setState(() => _query = value),
+                textInputAction: TextInputAction.search,
+                style: theme.textTheme.bodyLarge,
+                decoration: InputDecoration(
+                  hintText: 'Search apps',
+                  prefixIcon: Padding(
+                    padding: const EdgeInsets.only(left: 4),
+                    child: AppIcon(
+                      'assets/icons/search.svg',
+                      size: 20,
+                      color: colors.inkFaint,
+                    ),
+                  ),
+                  suffixIcon: _query.isEmpty
+                      ? null
+                      : IconButton(
+                          onPressed: () {
+                            _search.clear();
+                            setState(() => _query = '');
+                          },
+                          icon: Icon(
+                            Icons.close_rounded,
+                            size: 20,
+                            color: colors.inkFaint,
+                          ),
+                        ),
+                ),
+              ),
+            ),
+            const SizedBox(height: 12),
+            Expanded(child: _buildBody(context, launcher, apps, current)),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Widget _buildBody(
+    BuildContext context,
+    LauncherController launcher,
+    List<AppEntry> apps,
+    SlotAssignment? current,
+  ) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+
+    if (apps.isEmpty && launcher.isLoadingApps) {
+      return Center(
+        child: SizedBox(
+          width: 28,
+          height: 28,
+          child: CircularProgressIndicator(
+            strokeWidth: 2.4,
+            color: colors.accent,
+          ),
+        ),
+      );
+    }
+
+    if (apps.isEmpty && launcher.appsError != null) {
+      return Center(
+        child: Padding(
+          padding: const EdgeInsets.all(32),
+          child: Column(
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              Text(
+                'Could not read installed apps.',
+                style: theme.textTheme.bodyLarge,
+                textAlign: TextAlign.center,
+              ),
+              const SizedBox(height: 12),
+              TextButton(
+                onPressed: () => launcher.loadApps(force: true),
+                child: const Text('Try again'),
+              ),
+            ],
+          ),
+        ),
+      );
+    }
+
+    if (apps.isEmpty) {
+      return Center(
+        child: Text(
+          'No apps match “${_query.trim()}”.',
+          style: theme.textTheme.bodyMedium?.copyWith(color: colors.inkMuted),
+        ),
+      );
+    }
+
+    return ListView.builder(
+      padding: const EdgeInsets.fromLTRB(16, 0, 16, 28),
+      itemCount: apps.length,
+      itemBuilder: (context, index) {
+        final app = apps[index];
+        final selected =
+            current != null &&
+            current.packageName == app.packageName &&
+            current.activityName == app.activityName;
+        return Padding(
+          padding: const EdgeInsets.symmetric(vertical: 2),
+          child: Material(
+            color: Colors.transparent,
+            child: InkWell(
+              borderRadius: BorderRadius.circular(20),
+              onTap: () async {
+                await launcher.assign(widget.slot, app);
+                if (context.mounted) Navigator.of(context).pop();
+              },
+              child: Padding(
+                padding: const EdgeInsets.symmetric(
+                  horizontal: 12,
+                  vertical: 11,
+                ),
+                child: Row(
+                  children: [
+                    AppIconImage(bytes: app.icon, size: 42),
+                    const SizedBox(width: 16),
+                    Expanded(
+                      child: Text(
+                        app.label,
+                        maxLines: 1,
+                        overflow: TextOverflow.ellipsis,
+                        style: theme.textTheme.bodyLarge?.copyWith(
+                          fontWeight: FontWeight.w500,
+                        ),
+                      ),
+                    ),
+                    if (selected)
+                      Container(
+                        width: 26,
+                        height: 26,
+                        decoration: BoxDecoration(
+                          color: colors.accent,
+                          shape: BoxShape.circle,
+                        ),
+                        child: AppIcon(
+                          'assets/icons/check.svg',
+                          size: 15,
+                          color: colors.accentInk,
+                        ),
+                      ),
+                  ],
+                ),
+              ),
+            ),
+          ),
+        );
+      },
+    );
+  }
+}
+
+class _CircleButton extends StatelessWidget {
+  const _CircleButton({
+    required this.iconAsset,
+    required this.semanticLabel,
+    required this.onTap,
+  });
+
+  final String iconAsset;
+  final String semanticLabel;
+  final VoidCallback onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    return Pressable(
+      onTap: onTap,
+      semanticLabel: semanticLabel,
+      pressedScale: 0.92,
+      child: Container(
+        width: 40,
+        height: 40,
+        decoration: BoxDecoration(
+          color: colors.surfaceStrong,
+          shape: BoxShape.circle,
+          border: Border.all(color: colors.border),
+        ),
+        alignment: Alignment.center,
+        child: AppIcon(iconAsset, size: 17, color: colors.inkMuted),
+      ),
+    );
+  }
+}

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

@@ -0,0 +1,299 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/widgets/app_icon.dart';
+import '../../core/widgets/surface_card.dart';
+import '../../data/models/slot_type.dart';
+import '../apps/all_apps_screen.dart';
+import '../apps/app_picker_sheet.dart';
+import '../settings/settings_screen.dart';
+import 'launcher_controller.dart';
+import 'system_controller.dart';
+import 'widgets/clock_block.dart';
+import 'widgets/shortcut_grid.dart';
+import 'widgets/status_cluster.dart';
+import 'widgets/volume_card.dart';
+
+/// The only home page. No app bar, no dock, no pages: information, the main
+/// volume control, and five configurable shortcuts.
+class HomeScreen extends StatelessWidget {
+  const HomeScreen({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final launcher = context.watch<LauncherController>();
+    final system = context.watch<SystemController>();
+    final screen = MediaQuery.sizeOf(context);
+    final compact = screen.height < 700 || screen.width < 1100;
+    final horizontalPadding = compact ? 30.0 : 44.0;
+    final leftWidth = (screen.width * 0.37).clamp(300.0, 440.0);
+    final gap = compact ? 26.0 : 40.0;
+    final showHomeBanner = launcher.defaultHomeKnown && !launcher.isDefaultHome;
+
+    final navigation = launcher.assignmentFor(SlotType.navigation);
+    final music = launcher.assignmentFor(SlotType.music);
+    final radio = launcher.assignmentFor(SlotType.radio);
+
+    final shortcuts = <ShortcutSpec>[
+      ShortcutSpec(
+        title: 'Navigation',
+        subtitle: navigation?.label ?? 'Select app',
+        iconAsset: SlotType.navigation.iconAsset,
+        assigned: navigation != null,
+        onTap: () => _openSlot(context, SlotType.navigation),
+        onLongPress: () => showAppPicker(context, SlotType.navigation),
+      ),
+      ShortcutSpec(
+        title: 'Music',
+        subtitle: music?.label ?? 'Select app',
+        iconAsset: SlotType.music.iconAsset,
+        assigned: music != null,
+        onTap: () => _openSlot(context, SlotType.music),
+        onLongPress: () => showAppPicker(context, SlotType.music),
+      ),
+      ShortcutSpec(
+        title: 'Radio',
+        subtitle: radio?.label ?? 'Select app',
+        iconAsset: SlotType.radio.iconAsset,
+        assigned: radio != null,
+        onTap: () => _openSlot(context, SlotType.radio),
+        onLongPress: () => showAppPicker(context, SlotType.radio),
+      ),
+      ShortcutSpec(
+        title: 'Settings',
+        subtitle: 'Theme & PIN',
+        iconAsset: 'assets/icons/settings.svg',
+        assigned: true,
+        onTap: () => _openSettings(context),
+      ),
+      ShortcutSpec(
+        title: 'All apps',
+        subtitle: 'Installed apps',
+        iconAsset: 'assets/icons/layout-grid.svg',
+        assigned: true,
+        onTap: () => _openAllApps(context),
+      ),
+    ];
+
+    return PopScope(
+      canPop: false,
+      child: Scaffold(
+        backgroundColor: colors.canvas,
+        body: Stack(
+          children: [
+            Positioned.fill(
+              child: DecoratedBox(
+                decoration: homeBackground(
+                  colors,
+                  Theme.of(context).brightness,
+                ),
+              ),
+            ),
+            Positioned(
+              top: -190,
+              right: -120,
+              child: IgnorePointer(
+                child: Container(
+                  width: 560,
+                  height: 560,
+                  decoration: BoxDecoration(
+                    shape: BoxShape.circle,
+                    gradient: RadialGradient(
+                      colors: [
+                        colors.accent.withValues(
+                          alpha: Theme.of(context).brightness == Brightness.dark
+                              ? 0.16
+                              : 0.10,
+                        ),
+                        colors.accent.withValues(alpha: 0),
+                      ],
+                    ),
+                  ),
+                ),
+              ),
+            ),
+            SafeArea(
+              minimum: EdgeInsets.fromLTRB(
+                horizontalPadding,
+                compact ? 18 : 24,
+                horizontalPadding,
+                compact ? 14 : 18,
+              ),
+              child: SizedBox.expand(
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.stretch,
+                  children: [
+                    Row(
+                      crossAxisAlignment: CrossAxisAlignment.start,
+                      children: [
+                        ClockBlock(compact: compact),
+                        const Spacer(),
+                        StatusCluster(status: system.status, compact: compact),
+                      ],
+                    ),
+                    SizedBox(height: compact ? 16 : 24),
+                    Expanded(
+                      child: Row(
+                        crossAxisAlignment: CrossAxisAlignment.stretch,
+                        children: [
+                          SizedBox(
+                            width: leftWidth,
+                            child: Column(
+                              crossAxisAlignment: CrossAxisAlignment.stretch,
+                              mainAxisAlignment: MainAxisAlignment.end,
+                              children: [
+                                if (showHomeBanner) ...[
+                                  _HomeRoleBanner(
+                                    launcher: launcher,
+                                    onSetUp: () => _requestHome(context),
+                                  ),
+                                  const SizedBox(height: 14),
+                                ],
+                                VolumeCard(
+                                  volume: system.volume,
+                                  compact: compact,
+                                  onSetVolume: system.setVolume,
+                                  onToggleMute: system.toggleMute,
+                                ),
+                              ],
+                            ),
+                          ),
+                          SizedBox(width: gap),
+                          Expanded(
+                            child: Column(
+                              children: [
+                                Expanded(
+                                  child: ShortcutGrid(
+                                    shortcuts: shortcuts,
+                                    compact: compact,
+                                  ),
+                                ),
+                                if (!compact) ...[
+                                  const SizedBox(height: 4),
+                                  Text(
+                                    'Hold a shortcut to change the app',
+                                    style: theme.textTheme.bodySmall?.copyWith(
+                                      color: colors.inkFaint,
+                                    ),
+                                  ),
+                                ],
+                              ],
+                            ),
+                          ),
+                        ],
+                      ),
+                    ),
+                  ],
+                ),
+              ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Future<void> _openSlot(BuildContext context, SlotType slot) async {
+    final launcher = context.read<LauncherController>();
+    if (launcher.assignmentFor(slot) == null) {
+      await showAppPicker(context, slot);
+      return;
+    }
+    final error = await launcher.launchSlot(slot);
+    if (error != null && context.mounted) {
+      _showMessage(context, error);
+    }
+  }
+
+  void _openSettings(BuildContext context) {
+    Navigator.of(context)
+        .push(MaterialPageRoute<void>(builder: (_) => const SettingsScreen()));
+  }
+
+  void _openAllApps(BuildContext context) {
+    Navigator.of(context)
+        .push(MaterialPageRoute<void>(builder: (_) => const AllAppsScreen()));
+  }
+
+  Future<void> _requestHome(BuildContext context) async {
+    final launcher = context.read<LauncherController>();
+    final launched = await launcher.requestDefaultHome();
+    if (!launched && context.mounted) {
+      _showMessage(context, 'Could not open the home app settings.');
+    }
+  }
+
+  void _showMessage(BuildContext context, String message) {
+    ScaffoldMessenger.of(context)
+        .showSnackBar(SnackBar(content: Text(message)));
+  }
+}
+
+class _HomeRoleBanner extends StatelessWidget {
+  const _HomeRoleBanner({required this.launcher, required this.onSetUp});
+
+  final LauncherController launcher;
+  final VoidCallback onSetUp;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+
+    return SurfaceCard(
+      padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
+      radius: 24,
+      color: colors.accentSoft.withValues(alpha: 0.62),
+      borderColor: colors.accent.withValues(alpha: 0.18),
+      showShadow: false,
+      child: Row(
+        children: [
+          Container(
+            width: 38,
+            height: 38,
+            decoration: BoxDecoration(
+              color: colors.accent.withValues(alpha: 0.14),
+              borderRadius: BorderRadius.circular(13),
+            ),
+            alignment: Alignment.center,
+            child: AppIcon(
+              'assets/icons/smartphone.svg',
+              size: 18,
+              color: colors.accent,
+            ),
+          ),
+          const SizedBox(width: 13),
+          Expanded(
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Text(
+                  'Make CarDash your home app',
+                  style: theme.textTheme.titleSmall,
+                ),
+                const SizedBox(height: 2),
+                Text(
+                  'Open it with the Home button.',
+                  style: theme.textTheme.bodySmall?.copyWith(
+                    color: colors.inkMuted,
+                  ),
+                ),
+              ],
+            ),
+          ),
+          TextButton(
+            onPressed: onSetUp,
+            style: TextButton.styleFrom(
+              foregroundColor: colors.accent,
+              textStyle: theme.textTheme.labelLarge,
+            ),
+            child: const Text('Set up'),
+          ),
+        ],
+      ),
+    );
+  }
+}

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

@@ -0,0 +1,182 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+
+import '../../data/models/app_entry.dart';
+import '../../data/models/slot_assignment.dart';
+import '../../data/models/slot_type.dart';
+import '../../data/repositories/settings_repository.dart';
+import '../../data/services/native_bridge.dart';
+
+/// Owns installed apps, shortcut assignments and the default-home state.
+class LauncherController extends ChangeNotifier {
+  LauncherController(this._platform, this._settings);
+
+  final LauncherPlatform _platform;
+  final SettingsRepository _settings;
+
+  final Map<SlotType, SlotAssignment> _slots = {};
+  List<AppEntry> _apps = const [];
+  bool _appsLoaded = false;
+  bool _loadingApps = false;
+  Object? _appsError;
+  DateTime? _lastAppsLoad;
+  bool _defaultHome = false;
+  bool _defaultHomeKnown = false;
+  Timer? _stateTimer;
+  bool _disposed = false;
+
+  List<AppEntry> get apps => _apps;
+  bool get isLoadingApps => _loadingApps;
+  bool get appsLoaded => _appsLoaded;
+  Object? get appsError => _appsError;
+  bool get isDefaultHome => _defaultHome;
+  bool get defaultHomeKnown => _defaultHomeKnown;
+
+  SlotAssignment? assignmentFor(SlotType slot) => _slots[slot];
+
+  /// Loads persisted choices synchronously, then refreshes platform state in
+  /// the background so the first frame is not blocked by package enumeration.
+  void initialize() {
+    for (final slot in SlotType.values) {
+      final assignment = _settings.slot(slot);
+      if (assignment != null) _slots[slot] = assignment;
+    }
+    _notify();
+
+    unawaited(refreshDefaultHome());
+    unawaited(loadApps());
+  }
+
+  Future<void> loadApps({bool force = false}) async {
+    if (_loadingApps) return;
+    if (_appsLoaded && !force) return;
+
+    _loadingApps = true;
+    _appsError = null;
+    _notify();
+
+    try {
+      final loaded = await _platform.listApps();
+      _apps = List<AppEntry>.unmodifiable(loaded);
+      _appsLoaded = true;
+      _lastAppsLoad = DateTime.now();
+      await _validateAssignments();
+    } catch (error) {
+      _appsError = error;
+    } finally {
+      _loadingApps = false;
+      _notify();
+    }
+  }
+
+  /// Called when the launcher returns to the foreground. The package list is
+  /// refreshed at most once a minute unless [forceApps] is set.
+  Future<void> refresh({bool forceApps = false}) async {
+    await refreshDefaultHome();
+    final last = _lastAppsLoad;
+    if (forceApps ||
+        last == null ||
+        DateTime.now().difference(last).inSeconds > 60) {
+      await loadApps(force: true);
+    }
+  }
+
+  Future<void> _validateAssignments() async {
+    final installed = _apps
+        .map((app) => '${app.packageName}/${app.activityName}')
+        .toSet();
+    final missing = <SlotType>[];
+    _slots.forEach((slot, assignment) {
+      if (!installed.contains(
+        '${assignment.packageName}/${assignment.activityName}',
+      )) {
+        missing.add(slot);
+      }
+    });
+    for (final slot in missing) {
+      _slots.remove(slot);
+      await _settings.clearSlot(slot);
+    }
+  }
+
+  Future<void> assign(SlotType slot, AppEntry app) async {
+    final assignment = app.toAssignment();
+    _slots[slot] = assignment;
+    await _settings.setSlot(slot, assignment);
+    _notify();
+  }
+
+  Future<void> clearSlot(SlotType slot) async {
+    _slots.remove(slot);
+    await _settings.clearSlot(slot);
+    _notify();
+  }
+
+  Future<String?> launchSlot(SlotType slot) async {
+    final assignment = _slots[slot];
+    if (assignment == null) return null;
+    return launchAssignment(assignment);
+  }
+
+  Future<String?> launchAssignment(SlotAssignment assignment) async {
+    try {
+      await _platform.launchApp(
+        packageName: assignment.packageName,
+        activityName: assignment.activityName,
+      );
+      return null;
+    } on LauncherPlatformException catch (error) {
+      return error.message;
+    } catch (_) {
+      return 'Could not open ${assignment.label.isEmpty ? 'this app' : assignment.label}.';
+    }
+  }
+
+  Future<String?> launchAppEntry(AppEntry app) async {
+    try {
+      await _platform.launchApp(
+        packageName: app.packageName,
+        activityName: app.activityName,
+      );
+      return null;
+    } on LauncherPlatformException catch (error) {
+      return error.message;
+    } catch (_) {
+      return 'Could not open ${app.label}.';
+    }
+  }
+
+  Future<void> refreshDefaultHome() async {
+    try {
+      _defaultHome = await _platform.isDefaultHome();
+    } catch (_) {
+      _defaultHome = false;
+    }
+    _defaultHomeKnown = true;
+    _notify();
+  }
+
+  Future<bool> requestDefaultHome() async {
+    final launched = await _platform.requestDefaultHome();
+    if (launched) {
+      // The system dialog will pause us; refresh again after it closes.
+      _stateTimer?.cancel();
+      _stateTimer = Timer(const Duration(seconds: 2), () {
+        unawaited(refreshDefaultHome());
+      });
+    }
+    return launched;
+  }
+
+  void _notify() {
+    if (!_disposed) notifyListeners();
+  }
+
+  @override
+  void dispose() {
+    _disposed = true;
+    _stateTimer?.cancel();
+    super.dispose();
+  }
+}

+ 125 - 0
lib/features/home/system_controller.dart

@@ -0,0 +1,125 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+
+import '../../data/models/system_status.dart';
+import '../../data/services/native_bridge.dart';
+
+/// Polls the system for battery, Wi-Fi and media volume.
+///
+/// Polling keeps the app free of long-lived broadcast receivers and works
+/// identically on Android 10 and 15. Timers are stopped while the launcher is
+/// in the background.
+class SystemController extends ChangeNotifier {
+  SystemController(this._platform);
+
+  final LauncherPlatform _platform;
+
+  SystemStatus _status = const SystemStatus();
+  VolumeInfo _volume = const VolumeInfo();
+  Timer? _volumeTimer;
+  Timer? _statusTimer;
+  bool _active = false;
+  bool _volumeBusy = false;
+  bool _statusBusy = false;
+  bool _disposed = false;
+  int _lastNonZeroVolume = 0;
+
+  SystemStatus get status => _status;
+  VolumeInfo get volume => _volume;
+  int get volumePercent => _volume.percent;
+  bool get isMuted => _volume.index <= 0;
+
+  Future<void> start() async {
+    if (_active) return;
+    _active = true;
+    await refreshAll();
+    _volumeTimer = Timer.periodic(
+      const Duration(milliseconds: 900),
+      (_) => _pollVolume(),
+    );
+    _statusTimer = Timer.periodic(
+      const Duration(seconds: 20),
+      (_) => _pollStatus(),
+    );
+  }
+
+  void stop() {
+    _active = false;
+    _volumeTimer?.cancel();
+    _volumeTimer = null;
+    _statusTimer?.cancel();
+    _statusTimer = null;
+  }
+
+  Future<void> refreshAll() async {
+    await Future.wait([_pollVolume(), _pollStatus()]);
+  }
+
+  Future<void> _pollVolume() async {
+    if (_volumeBusy || !_active) return;
+    _volumeBusy = true;
+    try {
+      final next = await _platform.getVolume();
+      if (next.index != _volume.index || next.max != _volume.max) {
+        _volume = next;
+        if (next.index > 0) _lastNonZeroVolume = next.index;
+        _notify();
+      }
+    } finally {
+      _volumeBusy = false;
+    }
+  }
+
+  Future<void> _pollStatus() async {
+    if (_statusBusy) return;
+    _statusBusy = true;
+    try {
+      final next = await _platform.getStatus();
+      if (next.batteryLevel != _status.batteryLevel ||
+          next.charging != _status.charging ||
+          next.wifi != _status.wifi) {
+        _status = next;
+        _notify();
+      }
+    } finally {
+      _statusBusy = false;
+    }
+  }
+
+  Future<void> setVolume(int index) async {
+    final max = _volume.max <= 0 ? 15 : _volume.max;
+    final clamped = index.clamp(0, max);
+    _volume = VolumeInfo(index: clamped, max: max);
+    if (clamped > 0) _lastNonZeroVolume = clamped;
+    _notify();
+    await _platform.setVolume(clamped);
+  }
+
+  Future<void> adjustVolume(int delta) => setVolume(_volume.index + delta);
+
+  Future<void> toggleMute() async {
+    if (_volume.index > 0) {
+      _lastNonZeroVolume = _volume.index;
+      await setVolume(0);
+    } else {
+      final restore = _lastNonZeroVolume > 0
+          ? _lastNonZeroVolume
+          : (_volume.max <= 0
+                ? 1
+                : (_volume.max / 2).round().clamp(1, _volume.max));
+      await setVolume(restore);
+    }
+  }
+
+  void _notify() {
+    if (!_disposed) notifyListeners();
+  }
+
+  @override
+  void dispose() {
+    _disposed = true;
+    stop();
+    super.dispose();
+  }
+}

+ 103 - 0
lib/features/home/widgets/clock_block.dart

@@ -0,0 +1,103 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+
+import '../../../core/theme/app_colors.dart';
+
+/// Large, quiet clock. It sleeps until the next minute instead of ticking
+/// every second.
+class ClockBlock extends StatefulWidget {
+  const ClockBlock({super.key, required this.compact});
+
+  final bool compact;
+
+  @override
+  State<ClockBlock> createState() => _ClockBlockState();
+}
+
+class _ClockBlockState extends State<ClockBlock> {
+  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 theme = Theme.of(context);
+    final colors = context.colors;
+    final locale = Localizations.localeOf(context).toLanguageTag();
+    final use24Hour = MediaQuery.alwaysUse24HourFormatOf(context);
+    final time = (use24Hour ? DateFormat.Hm(locale) : DateFormat.jm(locale))
+        .format(_now);
+    final date = DateFormat('EEEE, d MMMM', locale).format(_now);
+
+    return Column(
+      mainAxisSize: MainAxisSize.min,
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        Text(
+          time,
+          maxLines: 1,
+          style: theme.textTheme.displayLarge?.copyWith(
+            fontSize: widget.compact ? 56 : 72,
+            fontFeatures: const [FontFeature.tabularFigures()],
+            height: 0.95,
+          ),
+        ),
+        const SizedBox(height: 10),
+        Row(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            Container(
+              width: 6,
+              height: 6,
+              decoration: BoxDecoration(
+                color: colors.accent,
+                shape: BoxShape.circle,
+              ),
+            ),
+            const SizedBox(width: 10),
+            Text(
+              date,
+              maxLines: 1,
+              overflow: TextOverflow.ellipsis,
+              style: theme.textTheme.titleMedium?.copyWith(
+                color: colors.inkMuted,
+                fontWeight: FontWeight.w500,
+                fontSize: widget.compact ? 14 : 16,
+              ),
+            ),
+          ],
+        ),
+      ],
+    );
+  }
+}

+ 83 - 0
lib/features/home/widgets/shortcut_grid.dart

@@ -0,0 +1,83 @@
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+
+import 'shortcut_tile.dart';
+
+class ShortcutSpec {
+  const ShortcutSpec({
+    required this.title,
+    required this.subtitle,
+    required this.iconAsset,
+    required this.assigned,
+    required this.onTap,
+    this.onLongPress,
+  });
+
+  final String title;
+  final String? subtitle;
+  final String iconAsset;
+  final bool assigned;
+  final VoidCallback onTap;
+  final VoidCallback? onLongPress;
+}
+
+/// Arranges the five home shortcuts in a centered 3+2 block on the car
+/// display, falling back to two columns on narrow windows.
+class ShortcutGrid extends StatelessWidget {
+  const ShortcutGrid({
+    super.key,
+    required this.shortcuts,
+    required this.compact,
+  });
+
+  final List<ShortcutSpec> shortcuts;
+  final bool compact;
+
+  @override
+  Widget build(BuildContext context) {
+    return LayoutBuilder(
+      builder: (context, constraints) {
+        if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) {
+          return const SizedBox.shrink();
+        }
+
+        final columns = constraints.maxWidth >= 520 ? 3 : 2;
+        final horizontalGap = compact ? 18.0 : 24.0;
+        final rowGap = compact ? 18.0 : 26.0;
+        final labelAllowance = compact ? 45.0 : 50.0;
+        final rows = (shortcuts.length / columns).ceil();
+
+        final widthLimited =
+            (constraints.maxWidth - horizontalGap * (columns - 1)) / columns;
+        final heightLimited =
+            (constraints.maxHeight - rowGap * (rows - 1)) / rows -
+            labelAllowance;
+        final size = math
+            .min(math.min(widthLimited, heightLimited), compact ? 168.0 : 188.0)
+            .clamp(112.0, 196.0);
+
+        return Center(
+          child: Wrap(
+            alignment: WrapAlignment.center,
+            crossAxisAlignment: WrapCrossAlignment.start,
+            spacing: horizontalGap,
+            runSpacing: rowGap,
+            children: [
+              for (final shortcut in shortcuts)
+                ShortcutTile(
+                  title: shortcut.title,
+                  subtitle: shortcut.subtitle,
+                  iconAsset: shortcut.iconAsset,
+                  size: size,
+                  assigned: shortcut.assigned,
+                  onTap: shortcut.onTap,
+                  onLongPress: shortcut.onLongPress,
+                ),
+            ],
+          ),
+        );
+      },
+    );
+  }
+}

+ 132 - 0
lib/features/home/widgets/shortcut_tile.dart

@@ -0,0 +1,132 @@
+import 'package:flutter/material.dart';
+
+import '../../../core/theme/app_colors.dart';
+import '../../../core/widgets/app_icon.dart';
+import '../../../core/widgets/pressable.dart';
+
+/// One large, generic shortcut on the home screen.
+class ShortcutTile extends StatelessWidget {
+  const ShortcutTile({
+    super.key,
+    required this.title,
+    required this.subtitle,
+    required this.iconAsset,
+    required this.size,
+    required this.assigned,
+    required this.onTap,
+    this.onLongPress,
+  });
+
+  final String title;
+  final String? subtitle;
+  final String iconAsset;
+  final double size;
+  final bool assigned;
+  final VoidCallback onTap;
+  final VoidCallback? onLongPress;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final placeholder = !assigned;
+    final iconSize = (size * 0.27).clamp(30.0, 46.0);
+    final semantics = StringBuffer(title);
+    if (subtitle != null && subtitle!.isNotEmpty)
+      semantics.write(', $subtitle');
+    semantics.write('. Tap to open.');
+    if (onLongPress != null) semantics.write(' Long press to change.');
+
+    return Pressable(
+      onTap: onTap,
+      onLongPress: onLongPress,
+      semanticLabel: semantics.toString(),
+      child: Column(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Container(
+            width: size,
+            height: size,
+            decoration: BoxDecoration(
+              color: colors.surface,
+              borderRadius: BorderRadius.circular(size * 0.17),
+              border: Border.all(
+                color: placeholder
+                    ? colors.accent.withValues(alpha: 0.16)
+                    : colors.border,
+              ),
+              boxShadow: [
+                BoxShadow(
+                  color: colors.shadow.withValues(
+                    alpha: Theme.of(context).brightness == Brightness.dark
+                        ? 0.2
+                        : 0.04,
+                  ),
+                  blurRadius: 26,
+                  offset: const Offset(0, 10),
+                ),
+              ],
+            ),
+            child: Stack(
+              children: [
+                Center(
+                  child: AppIcon(
+                    iconAsset,
+                    size: iconSize,
+                    color: placeholder ? colors.inkMuted : colors.ink,
+                  ),
+                ),
+                if (placeholder)
+                  Positioned(
+                    top: size * 0.09,
+                    right: size * 0.09,
+                    child: Container(
+                      width: 26,
+                      height: 26,
+                      decoration: BoxDecoration(
+                        color: colors.accent,
+                        shape: BoxShape.circle,
+                      ),
+                      alignment: Alignment.center,
+                      child: AppIcon(
+                        'assets/icons/plus.svg',
+                        size: 14,
+                        color: colors.accentInk,
+                      ),
+                    ),
+                  ),
+              ],
+            ),
+          ),
+          const SizedBox(height: 12),
+          Text(
+            title,
+            maxLines: 1,
+            overflow: TextOverflow.ellipsis,
+            textAlign: TextAlign.center,
+            style: theme.textTheme.titleMedium?.copyWith(
+              fontSize: size >= 160 ? 16 : 14,
+            ),
+          ),
+          if (subtitle != null) ...[
+            const SizedBox(height: 2),
+            SizedBox(
+              height: 17,
+              child: Text(
+                subtitle!,
+                maxLines: 1,
+                overflow: TextOverflow.ellipsis,
+                textAlign: TextAlign.center,
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: placeholder ? colors.accent : colors.inkMuted,
+                  fontWeight: placeholder ? FontWeight.w600 : FontWeight.w400,
+                  fontSize: size >= 160 ? 12 : 11,
+                ),
+              ),
+            ),
+          ],
+        ],
+      ),
+    );
+  }
+}

+ 154 - 0
lib/features/home/widgets/status_cluster.dart

@@ -0,0 +1,154 @@
+import 'package:flutter/material.dart';
+
+import '../../../core/theme/app_colors.dart';
+import '../../../core/widgets/app_icon.dart';
+import '../../../core/widgets/surface_card.dart';
+import '../../../data/models/system_status.dart';
+
+/// Quiet battery / Wi-Fi readout. It is information, not a toolbar.
+class StatusCluster extends StatelessWidget {
+  const StatusCluster({super.key, required this.status, this.compact = false});
+
+  final SystemStatus status;
+  final bool compact;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final batteryColor = status.batteryLevel <= 15 && !status.charging
+        ? colors.danger
+        : (status.charging ? colors.accent : colors.ink);
+
+    return SurfaceCard(
+      padding: EdgeInsets.symmetric(
+        horizontal: compact ? 16 : 20,
+        vertical: compact ? 10 : 12,
+      ),
+      radius: 999,
+      color: colors.surface.withValues(alpha: 0.82),
+      showShadow: false,
+      child: Row(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Semantics(
+            label: status.wifi ? 'Wi-Fi connected' : 'Wi-Fi disconnected',
+            child: AppIcon(
+              status.wifi
+                  ? 'assets/icons/wifi.svg'
+                  : 'assets/icons/wifi-off.svg',
+              size: compact ? 18 : 20,
+              color: status.wifi ? colors.ink : colors.inkFaint,
+            ),
+          ),
+          SizedBox(width: compact ? 14 : 18),
+          _BatteryGlyph(
+            level: status.batteryLevel,
+            charging: status.charging,
+            color: batteryColor,
+          ),
+          const SizedBox(width: 8),
+          Text(
+            '${status.batteryLevel}%',
+            style: theme.textTheme.labelLarge?.copyWith(
+              color: colors.ink,
+              fontSize: compact ? 13 : 14,
+            ),
+          ),
+          if (status.charging) ...[
+            const SizedBox(width: 6),
+            Icon(
+              Icons.bolt_rounded,
+              size: compact ? 16 : 18,
+              color: colors.accent,
+            ),
+          ],
+        ],
+      ),
+    );
+  }
+}
+
+class _BatteryGlyph extends StatelessWidget {
+  const _BatteryGlyph({
+    required this.level,
+    required this.charging,
+    required this.color,
+  });
+
+  final int level;
+  final bool charging;
+  final Color color;
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      width: 28,
+      height: 15,
+      child: CustomPaint(
+        painter: _BatteryPainter(
+          level: level,
+          color: color,
+          charging: charging,
+        ),
+      ),
+    );
+  }
+}
+
+class _BatteryPainter extends CustomPainter {
+  _BatteryPainter({
+    required this.level,
+    required this.color,
+    required this.charging,
+  });
+
+  final int level;
+  final Color color;
+  final bool charging;
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    final body = Rect.fromLTWH(0, 1, size.width - 4, size.height - 2);
+    final radius = Radius.circular(body.height / 2);
+    final outline = Paint()
+      ..color = color.withValues(alpha: 0.52)
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 1.3;
+    final cap = Paint()..color = color.withValues(alpha: 0.52);
+
+    canvas.drawRRect(RRect.fromRectAndRadius(body, radius), outline);
+    canvas.drawRRect(
+      RRect.fromRectAndRadius(
+        Rect.fromLTWH(
+          size.width - 3.5,
+          size.height * 0.34,
+          2.5,
+          size.height * 0.32,
+        ),
+        const Radius.circular(1),
+      ),
+      cap,
+    );
+
+    final inset = body.deflate(2.4);
+    final fillWidth = inset.width * (level.clamp(0, 100) / 100);
+    if (fillWidth > 0) {
+      final fill = Paint()..color = color;
+      canvas.drawRRect(
+        RRect.fromRectAndRadius(
+          Rect.fromLTWH(inset.left, inset.top, fillWidth, inset.height),
+          const Radius.circular(2.4),
+        ),
+        fill,
+      );
+    }
+  }
+
+  @override
+  bool shouldRepaint(covariant _BatteryPainter oldDelegate) {
+    return oldDelegate.level != level ||
+        oldDelegate.color != color ||
+        oldDelegate.charging != charging;
+  }
+}

+ 316 - 0
lib/features/home/widgets/volume_card.dart

@@ -0,0 +1,316 @@
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+
+import '../../../core/theme/app_colors.dart';
+import '../../../core/widgets/app_icon.dart';
+import '../../../core/widgets/pressable.dart';
+import '../../../core/widgets/surface_card.dart';
+import '../../../data/models/system_status.dart';
+
+/// The primary volume control: a ring, a slider, and step/mute actions.
+class VolumeCard extends StatefulWidget {
+  const VolumeCard({
+    super.key,
+    required this.volume,
+    required this.onSetVolume,
+    required this.onToggleMute,
+    this.compact = false,
+  });
+
+  final VolumeInfo volume;
+  final ValueChanged<int> onSetVolume;
+  final VoidCallback onToggleMute;
+  final bool compact;
+
+  @override
+  State<VolumeCard> createState() => _VolumeCardState();
+}
+
+class _VolumeCardState extends State<VolumeCard> {
+  double? _dragValue;
+
+  int get _max => widget.volume.max <= 0 ? 15 : widget.volume.max;
+  int get _value => widget.volume.index.clamp(0, _max);
+  bool get _muted => _value == 0;
+  int get _percent => ((_value / _max) * 100).round();
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final compact = widget.compact;
+    final displayValue = (_dragValue ?? _value.toDouble())
+        .clamp(0, _max.toDouble())
+        .toDouble();
+
+    return SurfaceCard(
+      padding: EdgeInsets.all(compact ? 20 : 26),
+      radius: 30,
+      child: Row(
+        crossAxisAlignment: CrossAxisAlignment.center,
+        children: [
+          Pressable(
+            onTap: widget.onToggleMute,
+            semanticLabel: _muted ? 'Unmute media' : 'Mute media',
+            pressedScale: 0.94,
+            child: _VolumeRing(
+              fraction: displayValue / _max,
+              muted: _muted,
+              size: compact ? 78 : 92,
+            ),
+          ),
+          SizedBox(width: compact ? 18 : 24),
+          Expanded(
+            child: Column(
+              mainAxisSize: MainAxisSize.min,
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Row(
+                  children: [
+                    Text(
+                      'Media volume',
+                      style: theme.textTheme.labelLarge?.copyWith(
+                        color: colors.inkMuted,
+                        fontSize: compact ? 12 : 13,
+                      ),
+                    ),
+                    const Spacer(),
+                    Text(
+                      '$_percent%',
+                      style: theme.textTheme.headlineMedium?.copyWith(
+                        fontSize: compact ? 24 : 28,
+                        fontFeatures: const [FontFeature.tabularFigures()],
+                      ),
+                    ),
+                  ],
+                ),
+                const SizedBox(height: 2),
+                SliderTheme(
+                  data: SliderTheme.of(context).copyWith(
+                    trackHeight: compact ? 5 : 6,
+                    thumbShape: RoundSliderThumbShape(
+                      enabledThumbRadius: compact ? 9 : 10,
+                    ),
+                  ),
+                  child: Slider(
+                    value: displayValue,
+                    min: 0,
+                    max: _max.toDouble(),
+                    divisions: _max <= 30 ? _max : null,
+                    onChanged: (next) {
+                      setState(() => _dragValue = next);
+                      widget.onSetVolume(next.round());
+                    },
+                    onChangeEnd: (_) => setState(() => _dragValue = null),
+                    semanticFormatterCallback: (value) =>
+                        '${(value / _max * 100).round()} percent',
+                  ),
+                ),
+                SizedBox(height: compact ? 4 : 8),
+                Row(
+                  children: [
+                    _StepButton(
+                      icon: Icons.remove_rounded,
+                      semanticLabel: 'Volume down',
+                      onTap: () => widget.onSetVolume(_value - 1),
+                      compact: compact,
+                    ),
+                    SizedBox(width: compact ? 8 : 10),
+                    Expanded(
+                      child: Pressable(
+                        onTap: widget.onToggleMute,
+                        semanticLabel: _muted ? 'Unmute' : 'Mute',
+                        pressedScale: 0.97,
+                        child: Container(
+                          height: compact ? 36 : 40,
+                          decoration: BoxDecoration(
+                            color: _muted
+                                ? colors.accentSoft
+                                : colors.surfaceStrong,
+                            borderRadius: BorderRadius.circular(999),
+                            border: Border.all(
+                              color: _muted
+                                  ? colors.accent.withValues(alpha: 0.25)
+                                  : colors.border,
+                            ),
+                          ),
+                          child: Row(
+                            mainAxisAlignment: MainAxisAlignment.center,
+                            children: [
+                              AppIcon(
+                                _muted
+                                    ? 'assets/icons/volume-x.svg'
+                                    : 'assets/icons/volume-2.svg',
+                                size: compact ? 16 : 18,
+                                color: _muted ? colors.accent : colors.inkMuted,
+                              ),
+                              const SizedBox(width: 8),
+                              Text(
+                                _muted ? 'Unmute' : 'Mute',
+                                style: theme.textTheme.labelMedium?.copyWith(
+                                  color: _muted
+                                      ? colors.accent
+                                      : colors.inkMuted,
+                                ),
+                              ),
+                            ],
+                          ),
+                        ),
+                      ),
+                    ),
+                    SizedBox(width: compact ? 8 : 10),
+                    _StepButton(
+                      icon: Icons.add_rounded,
+                      semanticLabel: 'Volume up',
+                      onTap: () => widget.onSetVolume(_value + 1),
+                      compact: compact,
+                    ),
+                  ],
+                ),
+              ],
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class _StepButton extends StatelessWidget {
+  const _StepButton({
+    required this.icon,
+    required this.semanticLabel,
+    required this.onTap,
+    required this.compact,
+  });
+
+  final IconData icon;
+  final String semanticLabel;
+  final VoidCallback onTap;
+  final bool compact;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    return Pressable(
+      onTap: onTap,
+      semanticLabel: semanticLabel,
+      pressedScale: 0.92,
+      child: Container(
+        width: compact ? 36 : 40,
+        height: compact ? 36 : 40,
+        decoration: BoxDecoration(
+          color: colors.surfaceStrong,
+          shape: BoxShape.circle,
+          border: Border.all(color: colors.border),
+        ),
+        child: Icon(icon, size: compact ? 18 : 20, color: colors.inkMuted),
+      ),
+    );
+  }
+}
+
+class _VolumeRing extends StatelessWidget {
+  const _VolumeRing({
+    required this.fraction,
+    required this.muted,
+    required this.size,
+  });
+
+  final double fraction;
+  final bool muted;
+  final double size;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    return TweenAnimationBuilder<double>(
+      tween: Tween(end: fraction.clamp(0.0, 1.0)),
+      duration: const Duration(milliseconds: 220),
+      curve: Curves.easeOutCubic,
+      builder: (context, value, _) {
+        return SizedBox(
+          width: size,
+          height: size,
+          child: Stack(
+            alignment: Alignment.center,
+            children: [
+              CustomPaint(
+                size: Size.square(size),
+                painter: _RingPainter(
+                  value: value,
+                  trackColor: colors.border,
+                  activeColor: colors.accent,
+                ),
+              ),
+              Container(
+                width: size * 0.62,
+                height: size * 0.62,
+                decoration: BoxDecoration(
+                  color: colors.accentSoft.withValues(alpha: muted ? 0.55 : 1),
+                  shape: BoxShape.circle,
+                ),
+                alignment: Alignment.center,
+                child: AppIcon(
+                  muted
+                      ? 'assets/icons/volume-x.svg'
+                      : 'assets/icons/volume-2.svg',
+                  size: size * 0.26,
+                  color: muted ? colors.inkMuted : colors.accent,
+                ),
+              ),
+            ],
+          ),
+        );
+      },
+    );
+  }
+}
+
+class _RingPainter extends CustomPainter {
+  _RingPainter({
+    required this.value,
+    required this.trackColor,
+    required this.activeColor,
+  });
+
+  final double value;
+  final Color trackColor;
+  final Color activeColor;
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    final stroke = size.width * 0.055;
+    final rect = Offset.zero & size;
+    final center = rect.center;
+    final radius = (size.width - stroke) / 2;
+    final track = Paint()
+      ..color = trackColor
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = stroke
+      ..strokeCap = StrokeCap.round;
+    final active = Paint()
+      ..color = activeColor
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = stroke
+      ..strokeCap = StrokeCap.round;
+
+    canvas.drawCircle(center, radius, track);
+    if (value <= 0) return;
+    canvas.drawArc(
+      Rect.fromCircle(center: center, radius: radius),
+      -math.pi / 2,
+      2 * math.pi * value,
+      false,
+      active,
+    );
+  }
+
+  @override
+  bool shouldRepaint(covariant _RingPainter oldDelegate) {
+    return oldDelegate.value != value ||
+        oldDelegate.trackColor != trackColor ||
+        oldDelegate.activeColor != activeColor;
+  }
+}

+ 204 - 0
lib/features/lock/lock_controller.dart

@@ -0,0 +1,204 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+
+import '../../data/services/pin_service.dart';
+
+/// Enforces the app PIN.
+///
+/// Deliberately simple: the PIN is checked only while the launcher process is
+/// starting. There is no re-lock on resume or screen-off. Setting or changing
+/// the PIN locks once immediately so the new code is confirmed in use.
+class LockController extends ChangeNotifier {
+  LockController(this._pinService);
+
+  final PinService _pinService;
+
+  bool _initialized = false;
+  bool _pinSet = false;
+  bool _locked = false;
+  String? _errorMessage;
+  int _failedAttempts = 0;
+  DateTime? _cooldownUntil;
+  Timer? _cooldownTimer;
+  bool _disposed = false;
+
+  bool get initialized => _initialized;
+  bool get pinSet => _pinSet;
+  bool get isLocked => _locked;
+  String? get errorMessage => _errorMessage;
+
+  bool get isCoolingDown {
+    final until = _cooldownUntil;
+    return until != null && until.isAfter(DateTime.now());
+  }
+
+  int get cooldownSecondsRemaining {
+    final until = _cooldownUntil;
+    if (until == null) return 0;
+    final remaining = until.difference(DateTime.now()).inSeconds;
+    return remaining < 0 ? 0 : remaining;
+  }
+
+  Future<void> initialize() async {
+    try {
+      _pinSet = await _pinService.isPinSet();
+      _locked = _pinSet;
+    } catch (_) {
+      // If secure storage cannot be read, do not fabricate a PIN. The security
+      // section exposes the error so the issue is visible rather than silent.
+      _pinSet = false;
+      _locked = false;
+      _errorMessage = 'Secure storage is unavailable on this device.';
+    }
+    _initialized = true;
+    _notify();
+  }
+
+  Future<bool> unlock(String pin) async {
+    if (!_pinSet) {
+      _locked = false;
+      _notify();
+      return true;
+    }
+    if (isCoolingDown) {
+      _errorMessage =
+          'Too many attempts. Try again in ${cooldownSecondsRemaining}s.';
+      _notify();
+      return false;
+    }
+
+    _errorMessage = null;
+    bool valid;
+    try {
+      valid = await _pinService.verify(pin);
+    } catch (_) {
+      _errorMessage = 'Could not verify the PIN. Try again.';
+      _notify();
+      return false;
+    }
+
+    if (valid) {
+      _locked = false;
+      _failedAttempts = 0;
+      _cooldownUntil = null;
+      _errorMessage = null;
+      _cooldownTimer?.cancel();
+      _notify();
+      return true;
+    }
+
+    _recordFailure();
+    return false;
+  }
+
+  Future<String?> setPin(String pin) async {
+    try {
+      await _pinService.setPin(pin);
+    } catch (_) {
+      return 'Could not save the PIN.';
+    }
+    _pinSet = true;
+    // The PIN is only requested on the next cold start by design. Setting or
+    // changing it does not lock the current session.
+    _failedAttempts = 0;
+    _cooldownUntil = null;
+    _cooldownTimer?.cancel();
+    _errorMessage = null;
+    _notify();
+    return null;
+  }
+
+  Future<String?> changePin({
+    required String currentPin,
+    required String newPin,
+  }) async {
+    final verified = await _verifyCurrent(currentPin);
+    if (!verified) {
+      return _errorMessage ?? 'Current PIN is incorrect.';
+    }
+    return setPin(newPin);
+  }
+
+  Future<String?> removePin(String currentPin) async {
+    final verified = await _verifyCurrent(currentPin);
+    if (!verified) {
+      return _errorMessage ?? 'Current PIN is incorrect.';
+    }
+    try {
+      await _pinService.clearPin();
+    } catch (_) {
+      return 'Could not remove the PIN.';
+    }
+    _pinSet = false;
+    _locked = false;
+    _errorMessage = null;
+    _failedAttempts = 0;
+    _cooldownUntil = null;
+    _cooldownTimer?.cancel();
+    _notify();
+    return null;
+  }
+
+  /// Verifies the current PIN without changing the lock state. Used by the
+  /// change/remove flows in Settings.
+  Future<bool> verifyPin(String pin) => _verifyCurrent(pin);
+
+  Future<bool> _verifyCurrent(String pin) async {
+    if (isCoolingDown) {
+      _errorMessage =
+          'Too many attempts. Try again in ${cooldownSecondsRemaining}s.';
+      _notify();
+      return false;
+    }
+    try {
+      final valid = await _pinService.verify(pin);
+      if (!valid) {
+        _recordFailure();
+        return false;
+      }
+      _failedAttempts = 0;
+      _errorMessage = null;
+      _notify();
+      return true;
+    } catch (_) {
+      _errorMessage = 'Could not verify the PIN.';
+      _notify();
+      return false;
+    }
+  }
+
+  void _recordFailure() {
+    _failedAttempts++;
+    if (_failedAttempts >= 5) {
+      _failedAttempts = 0;
+      _cooldownUntil = DateTime.now().add(const Duration(seconds: 30));
+      _cooldownTimer?.cancel();
+      _cooldownTimer = Timer(const Duration(seconds: 30), () {
+        _errorMessage = null;
+        _notify();
+      });
+      _errorMessage = 'Too many attempts. Try again in 30s.';
+    } else {
+      _errorMessage = 'Incorrect PIN. Try again.';
+    }
+    _notify();
+  }
+
+  void clearError() {
+    if (_errorMessage == null) return;
+    _errorMessage = null;
+    _notify();
+  }
+
+  void _notify() {
+    if (!_disposed) notifyListeners();
+  }
+
+  @override
+  void dispose() {
+    _disposed = true;
+    _cooldownTimer?.cancel();
+    super.dispose();
+  }
+}

+ 182 - 0
lib/features/lock/lock_screen.dart

@@ -0,0 +1,182 @@
+import 'dart:async';
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../core/theme/app_colors.dart';
+import 'lock_controller.dart';
+import 'pin_pad.dart';
+
+/// Full-screen gate shown only when the launcher is cold-started with a PIN
+/// configured. It never re-locks a running session.
+class LockScreen extends StatefulWidget {
+  const LockScreen({super.key});
+
+  @override
+  State<LockScreen> createState() => _LockScreenState();
+}
+
+class _LockScreenState extends State<LockScreen>
+    with SingleTickerProviderStateMixin {
+  String _pin = '';
+  bool _busy = false;
+  Timer? _tick;
+  late final AnimationController _shake = AnimationController(
+    vsync: this,
+    duration: const Duration(milliseconds: 380),
+  );
+
+  @override
+  void initState() {
+    super.initState();
+    _tick = Timer.periodic(const Duration(seconds: 1), (_) {
+      if (mounted) setState(() {});
+    });
+  }
+
+  @override
+  void dispose() {
+    _tick?.cancel();
+    _shake.dispose();
+    super.dispose();
+  }
+
+  void _onDigit(String digit) {
+    if (_busy || _pin.length >= 4) return;
+    final lock = context.read<LockController>();
+    if (lock.isCoolingDown) return;
+    setState(() => _pin += digit);
+    if (_pin.length == 4) {
+      setState(() => _busy = true);
+      Future<void>.delayed(const Duration(milliseconds: 110), _submit);
+    }
+  }
+
+  void _onBackspace() {
+    if (_busy || _pin.isEmpty) return;
+    setState(() => _pin = _pin.substring(0, _pin.length - 1));
+  }
+
+  Future<void> _submit() async {
+    if (!mounted) return;
+    final lock = context.read<LockController>();
+    final success = await lock.unlock(_pin);
+    if (!mounted) return;
+    if (success) {
+      return; // The root widget removes this screen.
+    }
+    setState(() {
+      _pin = '';
+      _busy = false;
+    });
+    _shake.forward(from: 0);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final lock = context.watch<LockController>();
+    final error = lock.isCoolingDown
+        ? 'Too many attempts. Try again in ${lock.cooldownSecondsRemaining}s.'
+        : lock.errorMessage;
+
+    return PopScope(
+      canPop: false,
+      child: Scaffold(
+        backgroundColor: colors.canvas,
+        body: Container(
+          decoration: BoxDecoration(
+            gradient: LinearGradient(
+              begin: Alignment.topLeft,
+              end: Alignment.bottomRight,
+              colors: Theme.of(context).brightness == Brightness.dark
+                  ? [colors.canvas, colors.canvasDeep]
+                  : [colors.canvas, const Color(0xFFF7F5EF)],
+            ),
+          ),
+          child: SafeArea(
+            child: Center(
+              child: SingleChildScrollView(
+                padding: const EdgeInsets.symmetric(
+                  horizontal: 32,
+                  vertical: 28,
+                ),
+                child: ConstrainedBox(
+                  constraints: const BoxConstraints(maxWidth: 420),
+                  child: Column(
+                    mainAxisSize: MainAxisSize.min,
+                    children: [
+                      const BrandMark(size: 7, gap: 6),
+                      const SizedBox(height: 16),
+                      Text(
+                        'CarDash',
+                        style: theme.textTheme.titleLarge?.copyWith(
+                          letterSpacing: 0.2,
+                          color: colors.inkMuted,
+                        ),
+                      ),
+                      const SizedBox(height: 34),
+                      Text('Enter PIN', style: theme.textTheme.headlineLarge),
+                      const SizedBox(height: 8),
+                      Text(
+                        'CarDash is locked.',
+                        style: theme.textTheme.bodyMedium?.copyWith(
+                          color: colors.inkMuted,
+                        ),
+                      ),
+                      const SizedBox(height: 30),
+                      AnimatedBuilder(
+                        animation: _shake,
+                        builder: (context, child) {
+                          final progress = _shake.value;
+                          final offset =
+                              math.sin(progress * math.pi * 6) *
+                              9 *
+                              (1 - progress);
+                          return Transform.translate(
+                            offset: Offset(offset, 0),
+                            child: child,
+                          );
+                        },
+                        child: PinDots(
+                          length: 4,
+                          filled: _pin.length,
+                          error: error != null,
+                        ),
+                      ),
+                      const SizedBox(height: 14),
+                      SizedBox(
+                        height: 42,
+                        child: AnimatedSwitcher(
+                          duration: const Duration(milliseconds: 160),
+                          child: Text(
+                            error ?? 'Enter your four-digit PIN',
+                            key: ValueKey(error ?? 'hint'),
+                            textAlign: TextAlign.center,
+                            style: theme.textTheme.bodySmall?.copyWith(
+                              color: error == null
+                                  ? colors.inkFaint
+                                  : colors.danger,
+                            ),
+                          ),
+                        ),
+                      ),
+                      const SizedBox(height: 12),
+                      PinKeypad(
+                        enabled: !_busy && !lock.isCoolingDown,
+                        onDigit: _onDigit,
+                        onBackspace: _onBackspace,
+                      ),
+                    ],
+                  ),
+                ),
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 220 - 0
lib/features/lock/pin_pad.dart

@@ -0,0 +1,220 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+import '../../../core/theme/app_colors.dart';
+import '../../../core/widgets/pressable.dart';
+
+/// Four small status dots for the PIN entry.
+class PinDots extends StatelessWidget {
+  const PinDots({
+    super.key,
+    required this.length,
+    required this.filled,
+    this.error = false,
+  });
+
+  final int length;
+  final int filled;
+  final bool error;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    return Row(
+      mainAxisSize: MainAxisSize.min,
+      children: [
+        for (var index = 0; index < length; index++)
+          AnimatedContainer(
+            duration: const Duration(milliseconds: 140),
+            curve: Curves.easeOutCubic,
+            width: 16,
+            height: 16,
+            margin: const EdgeInsets.symmetric(horizontal: 9),
+            decoration: BoxDecoration(
+              shape: BoxShape.circle,
+              color: index < filled
+                  ? (error ? colors.danger : colors.accent)
+                  : Colors.transparent,
+              border: Border.all(
+                color: index < filled
+                    ? (error ? colors.danger : colors.accent)
+                    : colors.inkFaint.withValues(alpha: 0.5),
+                width: 1.4,
+              ),
+            ),
+          ),
+      ],
+    );
+  }
+}
+
+/// The numeric keypad shared by the lock and PIN setup screens.
+class PinKeypad extends StatelessWidget {
+  const PinKeypad({
+    super.key,
+    required this.onDigit,
+    required this.onBackspace,
+    this.enabled = true,
+  });
+
+  final ValueChanged<String> onDigit;
+  final VoidCallback onBackspace;
+  final bool enabled;
+
+  @override
+  Widget build(BuildContext context) {
+    const rows = [
+      ['1', '2', '3'],
+      ['4', '5', '6'],
+      ['7', '8', '9'],
+    ];
+
+    return AnimatedOpacity(
+      duration: const Duration(milliseconds: 140),
+      opacity: enabled ? 1 : 0.42,
+      child: IgnorePointer(
+        ignoring: !enabled,
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            for (final row in rows) ...[
+              Row(
+                mainAxisAlignment: MainAxisAlignment.center,
+                children: [
+                  for (final digit in row) ...[
+                    _DigitButton(
+                      label: digit,
+                      onTap: () {
+                        HapticFeedback.selectionClick();
+                        onDigit(digit);
+                      },
+                    ),
+                    if (digit != row.last) const SizedBox(width: 16),
+                  ],
+                ],
+              ),
+              const SizedBox(height: 14),
+            ],
+            Row(
+              mainAxisAlignment: MainAxisAlignment.center,
+              children: [
+                const SizedBox(width: 76, height: 68),
+                const SizedBox(width: 16),
+                _DigitButton(
+                  label: '0',
+                  onTap: () {
+                    HapticFeedback.selectionClick();
+                    onDigit('0');
+                  },
+                ),
+                const SizedBox(width: 16),
+                _BackspaceButton(
+                  onTap: () {
+                    HapticFeedback.selectionClick();
+                    onBackspace();
+                  },
+                ),
+              ],
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+class _DigitButton extends StatelessWidget {
+  const _DigitButton({required this.label, required this.onTap});
+
+  final String label;
+  final VoidCallback onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    return Pressable(
+      onTap: onTap,
+      semanticLabel: 'Digit $label',
+      pressedScale: 0.93,
+      child: Container(
+        width: 76,
+        height: 68,
+        alignment: Alignment.center,
+        decoration: BoxDecoration(
+          color: colors.surface,
+          borderRadius: BorderRadius.circular(22),
+          border: Border.all(color: colors.border),
+        ),
+        child: Text(
+          label,
+          style: Theme.of(context).textTheme.headlineMedium
+              ?.copyWith(fontWeight: FontWeight.w500, fontSize: 26),
+        ),
+      ),
+    );
+  }
+}
+
+class _BackspaceButton extends StatelessWidget {
+  const _BackspaceButton({required this.onTap});
+
+  final VoidCallback onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    return Pressable(
+      onTap: onTap,
+      semanticLabel: 'Delete digit',
+      pressedScale: 0.93,
+      child: Container(
+        width: 76,
+        height: 68,
+        alignment: Alignment.center,
+        decoration: BoxDecoration(
+          color: Colors.transparent,
+          borderRadius: BorderRadius.circular(22),
+          border: Border.all(color: colors.border.withValues(alpha: 0.7)),
+        ),
+        child: Icon(Icons.backspace_outlined, size: 23, color: colors.inkMuted),
+      ),
+    );
+  }
+}
+
+/// The three-by-three dot mark used on the lock screen.
+class BrandMark extends StatelessWidget {
+  const BrandMark({super.key, this.size = 8, this.gap = 7});
+
+  final double size;
+  final double gap;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    return Column(
+      mainAxisSize: MainAxisSize.min,
+      children: [
+        for (var row = 0; row < 3; row++) ...[
+          Row(
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              for (var column = 0; column < 3; column++) ...[
+                Container(
+                  width: size,
+                  height: size,
+                  decoration: BoxDecoration(
+                    color: colors.accent,
+                    shape: BoxShape.circle,
+                  ),
+                ),
+                if (column < 2) SizedBox(width: gap),
+              ],
+            ],
+          ),
+          if (row < 2) SizedBox(height: gap),
+        ],
+      ],
+    );
+  }
+}

+ 338 - 0
lib/features/lock/pin_setup_screen.dart

@@ -0,0 +1,338 @@
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/widgets/app_icon.dart';
+import '../../core/widgets/pressable.dart';
+import 'lock_controller.dart';
+import 'pin_pad.dart';
+
+enum PinSetupMode { set, change, remove }
+
+/// Set, change or remove the four-digit PIN. The PIN is only used when the
+/// launcher is cold-started; this screen never locks the current session.
+class PinSetupScreen extends StatefulWidget {
+  const PinSetupScreen({super.key, required this.mode});
+
+  final PinSetupMode mode;
+
+  @override
+  State<PinSetupScreen> createState() => _PinSetupScreenState();
+}
+
+class _PinSetupScreenState extends State<PinSetupScreen>
+    with SingleTickerProviderStateMixin {
+  String _pin = '';
+  int _step = 0;
+  String? _firstPin;
+  String? _currentPin;
+  bool _busy = false;
+  String? _error;
+
+  late final AnimationController _shake = AnimationController(
+    vsync: this,
+    duration: const Duration(milliseconds: 380),
+  );
+
+  @override
+  void dispose() {
+    _shake.dispose();
+    super.dispose();
+  }
+
+  int get _stepCount {
+    switch (widget.mode) {
+      case PinSetupMode.set:
+        return 2;
+      case PinSetupMode.change:
+        return 3;
+      case PinSetupMode.remove:
+        return 1;
+    }
+  }
+
+  String get _title {
+    switch (widget.mode) {
+      case PinSetupMode.set:
+        return _step == 0 ? 'Choose a PIN' : 'Confirm your PIN';
+      case PinSetupMode.change:
+        return switch (_step) {
+          0 => 'Enter current PIN',
+          1 => 'Choose a new PIN',
+          _ => 'Confirm your new PIN',
+        };
+      case PinSetupMode.remove:
+        return 'Enter current PIN';
+    }
+  }
+
+  String get _description {
+    switch (widget.mode) {
+      case PinSetupMode.set:
+        return _step == 0
+            ? 'Four digits. You will be asked for this the next time CarDash starts.'
+            : 'Enter the same four digits again.';
+      case PinSetupMode.change:
+        return switch (_step) {
+          0 => 'Confirm your current PIN before choosing a new one.',
+          1 => 'Four digits. The new PIN is used the next time CarDash starts.',
+          _ => 'Enter the new four digits again.',
+        };
+      case PinSetupMode.remove:
+        return 'Confirm your current PIN. PIN protection will be turned off.';
+    }
+  }
+
+  void _onDigit(String digit) {
+    if (_busy || _pin.length >= 4) return;
+    final lock = context.read<LockController>();
+    if (lock.isCoolingDown) return;
+    setState(() {
+      _error = null;
+      _pin += digit;
+    });
+    if (_pin.length == 4) {
+      setState(() => _busy = true);
+      Future<void>.delayed(const Duration(milliseconds: 110), _advance);
+    }
+  }
+
+  void _onBackspace() {
+    if (_busy || _pin.isEmpty) return;
+    setState(() => _pin = _pin.substring(0, _pin.length - 1));
+  }
+
+  void _fail(String message) {
+    setState(() {
+      _pin = '';
+      _busy = false;
+      _error = message;
+    });
+    _shake.forward(from: 0);
+  }
+
+  Future<void> _advance() async {
+    if (!mounted) return;
+    final lock = context.read<LockController>();
+
+    switch (widget.mode) {
+      case PinSetupMode.set:
+        if (_step == 0) {
+          setState(() {
+            _firstPin = _pin;
+            _pin = '';
+            _step = 1;
+            _busy = false;
+            _error = null;
+          });
+          return;
+        }
+        if (_pin != _firstPin) {
+          _fail('PINs do not match.');
+          return;
+        }
+        final error = await lock.setPin(_firstPin!);
+        if (!mounted) return;
+        if (error != null) {
+          _fail(error);
+          return;
+        }
+        Navigator.of(context).pop(true);
+
+      case PinSetupMode.change:
+        if (_step == 0) {
+          final valid = await lock.verifyPin(_pin);
+          if (!mounted) return;
+          if (!valid) {
+            _fail(lock.errorMessage ?? 'Current PIN is incorrect.');
+            return;
+          }
+          setState(() {
+            _currentPin = _pin;
+            _pin = '';
+            _step = 1;
+            _busy = false;
+            _error = null;
+          });
+          return;
+        }
+        if (_step == 1) {
+          setState(() {
+            _firstPin = _pin;
+            _pin = '';
+            _step = 2;
+            _busy = false;
+            _error = null;
+          });
+          return;
+        }
+        if (_pin != _firstPin) {
+          _fail('PINs do not match.');
+          return;
+        }
+        final error = await lock.changePin(
+          currentPin: _currentPin!,
+          newPin: _firstPin!,
+        );
+        if (!mounted) return;
+        if (error != null) {
+          _fail(error);
+          return;
+        }
+        Navigator.of(context).pop(true);
+
+      case PinSetupMode.remove:
+        final error = await lock.removePin(_pin);
+        if (!mounted) return;
+        if (error != null) {
+          _fail(error);
+          return;
+        }
+        Navigator.of(context).pop(true);
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final lock = context.watch<LockController>();
+    final error = lock.isCoolingDown
+        ? 'Too many attempts. Try again in ${lock.cooldownSecondsRemaining}s.'
+        : _error;
+
+    return Scaffold(
+      backgroundColor: colors.canvas,
+      body: Container(
+        decoration: BoxDecoration(
+          gradient: LinearGradient(
+            begin: Alignment.topLeft,
+            end: Alignment.bottomRight,
+            colors: Theme.of(context).brightness == Brightness.dark
+                ? [colors.canvas, colors.canvasDeep]
+                : [colors.canvas, const Color(0xFFF7F5EF)],
+          ),
+        ),
+        child: SafeArea(
+          child: Padding(
+            padding: const EdgeInsets.fromLTRB(32, 22, 32, 22),
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Row(
+                  children: [
+                    Pressable(
+                      onTap: () => Navigator.of(context).pop(),
+                      semanticLabel: 'Back',
+                      pressedScale: 0.92,
+                      child: Container(
+                        width: 46,
+                        height: 46,
+                        decoration: BoxDecoration(
+                          color: colors.surface,
+                          shape: BoxShape.circle,
+                          border: Border.all(color: colors.border),
+                        ),
+                        alignment: Alignment.center,
+                        child: AppIcon(
+                          'assets/icons/arrow-left.svg',
+                          size: 20,
+                          color: colors.ink,
+                        ),
+                      ),
+                    ),
+                    const SizedBox(width: 16),
+                    Text(
+                      'PIN',
+                      style: theme.textTheme.titleLarge?.copyWith(
+                        color: colors.inkMuted,
+                      ),
+                    ),
+                    const Spacer(),
+                    if (_stepCount > 1)
+                      Text(
+                        '${_step + 1} of $_stepCount',
+                        style: theme.textTheme.labelMedium?.copyWith(
+                          color: colors.inkFaint,
+                        ),
+                      ),
+                  ],
+                ),
+                Expanded(
+                  child: Center(
+                    child: SingleChildScrollView(
+                      child: ConstrainedBox(
+                        constraints: const BoxConstraints(maxWidth: 420),
+                        child: Column(
+                          mainAxisSize: MainAxisSize.min,
+                          children: [
+                            const BrandMark(size: 7, gap: 6),
+                            const SizedBox(height: 26),
+                            Text(
+                              _title,
+                              textAlign: TextAlign.center,
+                              style: theme.textTheme.headlineLarge,
+                            ),
+                            const SizedBox(height: 8),
+                            Text(
+                              _description,
+                              textAlign: TextAlign.center,
+                              style: theme.textTheme.bodyMedium?.copyWith(
+                                color: colors.inkMuted,
+                              ),
+                            ),
+                            const SizedBox(height: 28),
+                            AnimatedBuilder(
+                              animation: _shake,
+                              builder: (context, child) {
+                                final progress = _shake.value;
+                                final offset =
+                                    math.sin(progress * math.pi * 6) *
+                                    9 *
+                                    (1 - progress);
+                                return Transform.translate(
+                                  offset: Offset(offset, 0),
+                                  child: child,
+                                );
+                              },
+                              child: PinDots(
+                                length: 4,
+                                filled: _pin.length,
+                                error: error != null,
+                              ),
+                            ),
+                            const SizedBox(height: 14),
+                            SizedBox(
+                              height: 42,
+                              child: Text(
+                                error ?? 'Four digits',
+                                textAlign: TextAlign.center,
+                                style: theme.textTheme.bodySmall?.copyWith(
+                                  color: error == null
+                                      ? colors.inkFaint
+                                      : colors.danger,
+                                ),
+                              ),
+                            ),
+                            const SizedBox(height: 6),
+                            PinKeypad(
+                              enabled: !_busy && !lock.isCoolingDown,
+                              onDigit: _onDigit,
+                              onBackspace: _onBackspace,
+                            ),
+                          ],
+                        ),
+                      ),
+                    ),
+                  ),
+                ),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 611 - 0
lib/features/settings/settings_screen.dart

@@ -0,0 +1,611 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/theme/theme_controller.dart';
+import '../../core/theme/theme_preference.dart';
+import '../../core/widgets/app_icon.dart';
+import '../../core/widgets/pressable.dart';
+import '../../core/widgets/surface_card.dart';
+import '../../data/models/slot_type.dart';
+import '../apps/app_picker_sheet.dart';
+import '../home/launcher_controller.dart';
+import '../lock/lock_controller.dart';
+import '../lock/pin_setup_screen.dart';
+
+class SettingsScreen extends StatelessWidget {
+  const SettingsScreen({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final themeController = context.watch<ThemeController>();
+    final launcher = context.watch<LauncherController>();
+    final lock = context.watch<LockController>();
+
+    return Scaffold(
+      backgroundColor: colors.canvas,
+      body: Container(
+        decoration: homeBackground(colors, Theme.of(context).brightness),
+        child: SafeArea(
+          child: SingleChildScrollView(
+            padding: const EdgeInsets.fromLTRB(32, 22, 32, 32),
+            child: Center(
+              child: ConstrainedBox(
+                constraints: const BoxConstraints(maxWidth: 980),
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    Row(
+                      children: [
+                        Pressable(
+                          onTap: () => Navigator.of(context).pop(),
+                          semanticLabel: 'Back',
+                          pressedScale: 0.92,
+                          child: Container(
+                            width: 46,
+                            height: 46,
+                            decoration: BoxDecoration(
+                              color: colors.surface,
+                              shape: BoxShape.circle,
+                              border: Border.all(color: colors.border),
+                            ),
+                            alignment: Alignment.center,
+                            child: AppIcon(
+                              'assets/icons/arrow-left.svg',
+                              size: 20,
+                              color: colors.ink,
+                            ),
+                          ),
+                        ),
+                        const SizedBox(width: 18),
+                        Column(
+                          crossAxisAlignment: CrossAxisAlignment.start,
+                          children: [
+                            Text(
+                              'Settings',
+                              style: theme.textTheme.headlineLarge,
+                            ),
+                            const SizedBox(height: 2),
+                            Text(
+                              'Theme, shortcuts and access',
+                              style: theme.textTheme.bodySmall?.copyWith(
+                                color: colors.inkMuted,
+                              ),
+                            ),
+                          ],
+                        ),
+                      ],
+                    ),
+                    const SizedBox(height: 32),
+                    _SectionTitle(
+                      title: 'General',
+                      colors: colors,
+                      theme: theme,
+                    ),
+                    const SizedBox(height: 12),
+                    SurfaceCard(
+                      padding: const EdgeInsets.fromLTRB(26, 22, 26, 18),
+                      child: Column(
+                        crossAxisAlignment: CrossAxisAlignment.start,
+                        children: [
+                          Row(
+                            children: [
+                              Text('Theme', style: theme.textTheme.titleMedium),
+                              const Spacer(),
+                              _ThemeSelector(
+                                selected: themeController.preference,
+                                onSelected: themeController.setPreference,
+                              ),
+                            ],
+                          ),
+                          const SizedBox(height: 8),
+                          Text(
+                            themeController.preference ==
+                                    ThemePreference.automatic
+                                ? 'Switches automatically at the times below.'
+                                : themeController.preference ==
+                                      ThemePreference.dark
+                                ? 'Always dark.'
+                                : 'Always light.',
+                            style: theme.textTheme.bodySmall?.copyWith(
+                              color: colors.inkMuted,
+                            ),
+                          ),
+                          if (themeController.preference ==
+                              ThemePreference.automatic) ...[
+                            const SizedBox(height: 8),
+                            Divider(color: colors.border, height: 24),
+                            _ActionRow(
+                              iconAsset: 'assets/icons/moon.svg',
+                              title: 'Dark from',
+                              value: _formatMinutes(
+                                context,
+                                themeController.nightStartMinutes,
+                              ),
+                              onTap: () => _pickTime(
+                                context,
+                                themeController,
+                                start: true,
+                              ),
+                            ),
+                            const SizedBox(height: 2),
+                            _ActionRow(
+                              iconAsset: 'assets/icons/sun.svg',
+                              title: 'Light from',
+                              value: _formatMinutes(
+                                context,
+                                themeController.nightEndMinutes,
+                              ),
+                              onTap: () => _pickTime(
+                                context,
+                                themeController,
+                                start: false,
+                              ),
+                            ),
+                          ],
+                          Divider(color: colors.border, height: 30),
+                          Text(
+                            'Home shortcuts',
+                            style: theme.textTheme.titleMedium,
+                          ),
+                          const SizedBox(height: 4),
+                          for (final slot in SlotType.values)
+                            _ActionRow(
+                              iconAsset: slot.iconAsset,
+                              title: slot.label,
+                              subtitle:
+                                  launcher.assignmentFor(slot)?.label ??
+                                  'Select app',
+                              highlightSubtitle:
+                                  launcher.assignmentFor(slot) == null,
+                              trailing: const _Chevron(),
+                              onTap: () => showAppPicker(context, slot),
+                            ),
+                          Divider(color: colors.border, height: 30),
+                          _HomeRoleRow(launcher: launcher),
+                        ],
+                      ),
+                    ),
+                    const SizedBox(height: 30),
+                    _SectionTitle(
+                      title: 'Security',
+                      colors: colors,
+                      theme: theme,
+                    ),
+                    const SizedBox(height: 12),
+                    SurfaceCard(
+                      padding: const EdgeInsets.fromLTRB(26, 22, 26, 22),
+                      child: 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/lock.svg',
+                                  size: 20,
+                                  color: colors.accent,
+                                ),
+                              ),
+                              const SizedBox(width: 14),
+                              Expanded(
+                                child: Column(
+                                  crossAxisAlignment: CrossAxisAlignment.start,
+                                  children: [
+                                    Text(
+                                      'PIN protection',
+                                      style: theme.textTheme.titleMedium,
+                                    ),
+                                    const SizedBox(height: 2),
+                                    Text(
+                                      lock.pinSet ? 'On' : 'Off',
+                                      style: theme.textTheme.bodySmall
+                                          ?.copyWith(
+                                            color: lock.pinSet
+                                                ? colors.accent
+                                                : colors.inkMuted,
+                                            fontWeight: FontWeight.w600,
+                                          ),
+                                    ),
+                                  ],
+                                ),
+                              ),
+                              if (!lock.pinSet)
+                                _SmallButton(
+                                  label: 'Set PIN',
+                                  onTap: () =>
+                                      _openPinSetup(context, PinSetupMode.set),
+                                ),
+                            ],
+                          ),
+                          if (lock.pinSet) ...[
+                            const SizedBox(height: 10),
+                            Divider(color: colors.border, height: 24),
+                            _ActionRow(
+                              iconAsset: 'assets/icons/lock.svg',
+                              title: 'Change PIN',
+                              trailing: const _Chevron(),
+                              onTap: () =>
+                                  _openPinSetup(context, PinSetupMode.change),
+                            ),
+                            _ActionRow(
+                              iconAsset: 'assets/icons/x.svg',
+                              title: 'Remove PIN',
+                              trailing: const _Chevron(),
+                              onTap: () =>
+                                  _openPinSetup(context, PinSetupMode.remove),
+                            ),
+                          ],
+                          const SizedBox(height: 16),
+                          Text(
+                            'The PIN is requested only when CarDash starts. It does not lock '
+                            'again when you switch apps or turn the screen off. It protects the '
+                            'launcher, not the device itself.',
+                            style: theme.textTheme.bodySmall?.copyWith(
+                              color: colors.inkMuted,
+                              height: 1.5,
+                            ),
+                          ),
+                        ],
+                      ),
+                    ),
+                    const SizedBox(height: 28),
+                    Center(
+                      child: Text(
+                        'CarDash 0.1.0 · Android 10+',
+                        style: theme.textTheme.bodySmall?.copyWith(
+                          color: colors.inkFaint,
+                        ),
+                      ),
+                    ),
+                  ],
+                ),
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  Future<void> _openPinSetup(BuildContext context, PinSetupMode mode) async {
+    final result = await Navigator.of(
+      context,
+    ).push<bool>(MaterialPageRoute(builder: (_) => PinSetupScreen(mode: mode)));
+    if (result != true || !context.mounted) return;
+    final message = switch (mode) {
+      PinSetupMode.set =>
+        'PIN set. It will be requested the next time CarDash starts.',
+      PinSetupMode.change => 'PIN changed.',
+      PinSetupMode.remove => 'PIN removed.',
+    };
+    ScaffoldMessenger.of(context)
+        .showSnackBar(SnackBar(content: Text(message)));
+  }
+
+  Future<void> _pickTime(
+    BuildContext context,
+    ThemeController controller, {
+    required bool start,
+  }) async {
+    final minutes = start
+        ? controller.nightStartMinutes
+        : controller.nightEndMinutes;
+    final picked = await showTimePicker(
+      context: context,
+      initialTime: TimeOfDay(hour: minutes ~/ 60, minute: minutes % 60),
+      helpText: start ? 'Dark from' : 'Light from',
+      builder: (context, child) {
+        final theme = Theme.of(context);
+        return Theme(
+          data: theme.copyWith(
+            timePickerTheme: TimePickerThemeData(
+              backgroundColor: theme.colorScheme.surface,
+              dialBackgroundColor: theme.colorScheme.surfaceContainerHighest,
+              shape: RoundedRectangleBorder(
+                borderRadius: BorderRadius.circular(28),
+              ),
+            ),
+          ),
+          child: child ?? const SizedBox.shrink(),
+        );
+      },
+    );
+    if (picked == null || !context.mounted) return;
+    final newValue = picked.hour * 60 + picked.minute;
+    await controller.setNightWindow(
+      startMinutes: start ? newValue : controller.nightStartMinutes,
+      endMinutes: start ? controller.nightEndMinutes : newValue,
+    );
+  }
+
+  String _formatMinutes(BuildContext context, int minutes) {
+    return TimeOfDay(hour: minutes ~/ 60, minute: minutes % 60).format(context);
+  }
+}
+
+class _SectionTitle extends StatelessWidget {
+  const _SectionTitle({
+    required this.title,
+    required this.colors,
+    required this.theme,
+  });
+
+  final String title;
+  final AppColors colors;
+  final ThemeData theme;
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+      padding: const EdgeInsets.only(left: 4),
+      child: Text(
+        title,
+        style: theme.textTheme.titleLarge?.copyWith(color: colors.inkMuted),
+      ),
+    );
+  }
+}
+
+class _ThemeSelector extends StatelessWidget {
+  const _ThemeSelector({required this.selected, required this.onSelected});
+
+  final ThemePreference selected;
+  final ValueChanged<ThemePreference> onSelected;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    return Container(
+      padding: const EdgeInsets.all(4),
+      decoration: BoxDecoration(
+        color: colors.surfaceStrong,
+        borderRadius: BorderRadius.circular(999),
+        border: Border.all(color: colors.border),
+      ),
+      child: Row(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          for (final preference in ThemePreference.values)
+            Pressable(
+              onTap: () => onSelected(preference),
+              semanticLabel: 'Theme ${preference.storageValue}',
+              pressedScale: 0.95,
+              child: AnimatedContainer(
+                duration: const Duration(milliseconds: 160),
+                curve: Curves.easeOutCubic,
+                padding: const EdgeInsets.symmetric(
+                  horizontal: 18,
+                  vertical: 9,
+                ),
+                decoration: BoxDecoration(
+                  color: selected == preference
+                      ? colors.accent
+                      : Colors.transparent,
+                  borderRadius: BorderRadius.circular(999),
+                ),
+                child: Text(
+                  switch (preference) {
+                    ThemePreference.light => 'Light',
+                    ThemePreference.dark => 'Dark',
+                    ThemePreference.automatic => 'Auto',
+                  },
+                  style: Theme.of(context).textTheme.labelMedium?.copyWith(
+                    color: selected == preference
+                        ? colors.accentInk
+                        : colors.inkMuted,
+                    fontWeight: FontWeight.w600,
+                  ),
+                ),
+              ),
+            ),
+        ],
+      ),
+    );
+  }
+}
+
+class _ActionRow extends StatelessWidget {
+  const _ActionRow({
+    required this.iconAsset,
+    required this.title,
+    this.subtitle,
+    this.value,
+    this.trailing,
+    this.onTap,
+    this.highlightSubtitle = false,
+  });
+
+  final String iconAsset;
+  final String title;
+  final String? subtitle;
+  final String? value;
+  final Widget? trailing;
+  final VoidCallback? onTap;
+  final bool highlightSubtitle;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final content = Padding(
+      padding: const EdgeInsets.symmetric(vertical: 7),
+      child: 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(iconAsset, size: 18, color: colors.inkMuted),
+          ),
+          const SizedBox(width: 14),
+          Expanded(
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Text(title, style: theme.textTheme.bodyLarge),
+                if (subtitle != null) ...[
+                  const SizedBox(height: 2),
+                  Text(
+                    subtitle!,
+                    maxLines: 1,
+                    overflow: TextOverflow.ellipsis,
+                    style: theme.textTheme.bodySmall?.copyWith(
+                      color: highlightSubtitle
+                          ? colors.accent
+                          : colors.inkMuted,
+                      fontWeight: highlightSubtitle
+                          ? FontWeight.w600
+                          : FontWeight.w400,
+                    ),
+                  ),
+                ],
+              ],
+            ),
+          ),
+          if (value != null)
+            Text(
+              value!,
+              style: theme.textTheme.labelLarge?.copyWith(
+                color: colors.inkMuted,
+              ),
+            ),
+          if (trailing != null) ...[const SizedBox(width: 8), trailing!],
+        ],
+      ),
+    );
+
+    if (onTap == null) return content;
+    return Material(
+      color: Colors.transparent,
+      child: InkWell(
+        borderRadius: BorderRadius.circular(14),
+        onTap: onTap,
+        child: content,
+      ),
+    );
+  }
+}
+
+class _Chevron extends StatelessWidget {
+  const _Chevron();
+
+  @override
+  Widget build(BuildContext context) {
+    return AppIcon(
+      'assets/icons/chevron-right.svg',
+      size: 18,
+      color: context.colors.inkFaint,
+    );
+  }
+}
+
+class _SmallButton extends StatelessWidget {
+  const _SmallButton({required this.label, required this.onTap});
+
+  final String label;
+  final VoidCallback onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    return Pressable(
+      onTap: onTap,
+      semanticLabel: label,
+      pressedScale: 0.95,
+      child: Container(
+        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
+        decoration: BoxDecoration(
+          color: colors.accent,
+          borderRadius: BorderRadius.circular(999),
+        ),
+        child: Text(
+          label,
+          style: Theme.of(context).textTheme.labelMedium
+              ?.copyWith(color: colors.accentInk, fontWeight: FontWeight.w600),
+        ),
+      ),
+    );
+  }
+}
+
+class _HomeRoleRow extends StatelessWidget {
+  const _HomeRoleRow({required this.launcher});
+
+  final LauncherController launcher;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final isDefault = launcher.isDefaultHome;
+    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/smartphone.svg',
+            size: 18,
+            color: colors.inkMuted,
+          ),
+        ),
+        const SizedBox(width: 14),
+        Expanded(
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Text('Home app', style: theme.textTheme.bodyLarge),
+              const SizedBox(height: 2),
+              Text(
+                launcher.defaultHomeKnown
+                    ? (isDefault ? 'CarDash is your home app' : 'Not set')
+                    : 'Checking…',
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: isDefault ? colors.accent : colors.inkMuted,
+                  fontWeight: isDefault ? FontWeight.w600 : FontWeight.w400,
+                ),
+              ),
+            ],
+          ),
+        ),
+        if (launcher.defaultHomeKnown && !isDefault)
+          _SmallButton(
+            label: 'Set as default',
+            onTap: () async {
+              final launched = await launcher.requestDefaultHome();
+              if (!launched && context.mounted) {
+                ScaffoldMessenger.of(context).showSnackBar(
+                  const SnackBar(
+                    content: Text('Could not open the home app settings.'),
+                  ),
+                );
+              }
+            },
+          )
+        else if (isDefault)
+          Icon(Icons.check_circle_rounded, color: colors.accent, size: 22),
+      ],
+    );
+  }
+}

+ 56 - 0
lib/main.dart

@@ -0,0 +1,56 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:provider/provider.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import 'app.dart';
+import 'core/theme/theme_controller.dart';
+import 'data/repositories/settings_repository.dart';
+import 'data/services/native_bridge.dart';
+import 'data/services/pin_service.dart';
+import 'data/services/secure_store.dart';
+import 'features/home/launcher_controller.dart';
+import 'features/home/system_controller.dart';
+import 'features/lock/lock_controller.dart';
+
+Future<void> main() async {
+  WidgetsFlutterBinding.ensureInitialized();
+
+  await SystemChrome.setPreferredOrientations(const [
+    DeviceOrientation.landscapeLeft,
+    DeviceOrientation.landscapeRight,
+  ]);
+  await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
+  SystemChrome.setSystemUIOverlayStyle(
+    const SystemUiOverlayStyle(
+      statusBarColor: Colors.transparent,
+      systemNavigationBarColor: Colors.transparent,
+      statusBarIconBrightness: Brightness.light,
+      systemNavigationBarIconBrightness: Brightness.light,
+      systemNavigationBarDividerColor: Colors.transparent,
+    ),
+  );
+
+  final preferences = await SharedPreferences.getInstance();
+  final settings = SettingsRepository(preferences);
+  final platform = NativeBridge();
+
+  final themeController = ThemeController(settings);
+  final launcherController = LauncherController(platform, settings)
+    ..initialize();
+  final systemController = SystemController(platform);
+  final lockController = LockController(PinService(FlutterSecureStore()));
+  await lockController.initialize();
+
+  runApp(
+    MultiProvider(
+      providers: [
+        ChangeNotifierProvider.value(value: themeController),
+        ChangeNotifierProvider.value(value: launcherController),
+        ChangeNotifierProvider.value(value: systemController),
+        ChangeNotifierProvider.value(value: lockController),
+      ],
+      child: const CarDashApp(),
+    ),
+  );
+}

+ 634 - 0
pubspec.lock

@@ -0,0 +1,634 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  args:
+    dependency: transitive
+    description:
+      name: args
+      sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.7.0"
+  async:
+    dependency: transitive
+    description:
+      name: async
+      sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.13.1"
+  boolean_selector:
+    dependency: transitive
+    description:
+      name: boolean_selector
+      sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.2"
+  characters:
+    dependency: transitive
+    description:
+      name: characters
+      sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.4.1"
+  clock:
+    dependency: transitive
+    description:
+      name: clock
+      sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.3"
+  code_assets:
+    dependency: transitive
+    description:
+      name: code_assets
+      sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.1"
+  collection:
+    dependency: transitive
+    description:
+      name: collection
+      sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.19.1"
+  crypto:
+    dependency: "direct main"
+    description:
+      name: crypto
+      sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.8"
+  fake_async:
+    dependency: transitive
+    description:
+      name: fake_async
+      sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.3"
+  ffi:
+    dependency: transitive
+    description:
+      name: ffi
+      sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.0"
+  ffi_leak_tracker:
+    dependency: transitive
+    description:
+      name: ffi_leak_tracker
+      sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.1.2"
+  file:
+    dependency: transitive
+    description:
+      name: file
+      sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
+      url: "https://pub.dev"
+    source: hosted
+    version: "7.0.1"
+  flutter:
+    dependency: "direct main"
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  flutter_lints:
+    dependency: "direct dev"
+    description:
+      name: flutter_lints
+      sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.0"
+  flutter_secure_storage:
+    dependency: "direct main"
+    description:
+      name: flutter_secure_storage
+      sha256: d4e1fb6b2cb524868929e78dc0282fa000554b22060fb53789dc481c9fc95bb8
+      url: "https://pub.dev"
+    source: hosted
+    version: "11.2.0"
+  flutter_secure_storage_darwin:
+    dependency: transitive
+    description:
+      name: flutter_secure_storage_darwin
+      sha256: a031ceac9b070e62ef183cd7f7d1a8798ebde402d5049f0fe6eb3a51bd797a21
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.4.3"
+  flutter_secure_storage_linux:
+    dependency: transitive
+    description:
+      name: flutter_secure_storage_linux
+      sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.3"
+  flutter_secure_storage_platform_interface:
+    dependency: transitive
+    description:
+      name: flutter_secure_storage_platform_interface
+      sha256: "64951127f001f546891c86f414972bf49ef2c4c8a4c92c65f5790cc0c9100045"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.1"
+  flutter_secure_storage_web:
+    dependency: transitive
+    description:
+      name: flutter_secure_storage_web
+      sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.1"
+  flutter_secure_storage_windows:
+    dependency: transitive
+    description:
+      name: flutter_secure_storage_windows
+      sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.2.2"
+  flutter_svg:
+    dependency: "direct main"
+    description:
+      name: flutter_svg
+      sha256: "100c19dcc4ff60083e95a44a6f44f3a1bb887d353c5d86f6a9b688aa3cb46236"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.2"
+  flutter_test:
+    dependency: "direct dev"
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  flutter_web_plugins:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  hooks:
+    dependency: transitive
+    description:
+      name: hooks
+      sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.0.2"
+  http:
+    dependency: transitive
+    description:
+      name: http
+      sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.6.0"
+  http_parser:
+    dependency: transitive
+    description:
+      name: http_parser
+      sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.1.2"
+  intl:
+    dependency: "direct main"
+    description:
+      name: intl
+      sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.20.3"
+  jni:
+    dependency: transitive
+    description:
+      name: jni
+      sha256: d8df6dab6847855512fe44dd9032b0bbe34dda7bf7597e41d30750879779813f
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.1"
+  jni_flutter:
+    dependency: transitive
+    description:
+      name: jni_flutter
+      sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.4+1"
+  jni_util:
+    dependency: transitive
+    description:
+      name: jni_util
+      sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.0"
+  leak_tracker:
+    dependency: transitive
+    description:
+      name: leak_tracker
+      sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
+      url: "https://pub.dev"
+    source: hosted
+    version: "11.0.2"
+  leak_tracker_flutter_testing:
+    dependency: transitive
+    description:
+      name: leak_tracker_flutter_testing
+      sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.10"
+  leak_tracker_testing:
+    dependency: transitive
+    description:
+      name: leak_tracker_testing
+      sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.2"
+  lints:
+    dependency: transitive
+    description:
+      name: lints
+      sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.1.0"
+  logging:
+    dependency: transitive
+    description:
+      name: logging
+      sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.0"
+  matcher:
+    dependency: transitive
+    description:
+      name: matcher
+      sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.12.20"
+  material_color_utilities:
+    dependency: transitive
+    description:
+      name: material_color_utilities
+      sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.13.0"
+  meta:
+    dependency: transitive
+    description:
+      name: meta
+      sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.18.3"
+  nested:
+    dependency: transitive
+    description:
+      name: nested
+      sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.0"
+  objective_c:
+    dependency: transitive
+    description:
+      name: objective_c
+      sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
+      url: "https://pub.dev"
+    source: hosted
+    version: "9.5.0"
+  package_config:
+    dependency: transitive
+    description:
+      name: package_config
+      sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.0"
+  path:
+    dependency: transitive
+    description:
+      name: path
+      sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.9.1"
+  path_parsing:
+    dependency: transitive
+    description:
+      name: path_parsing
+      sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.0"
+  path_provider:
+    dependency: transitive
+    description:
+      name: path_provider
+      sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.6"
+  path_provider_android:
+    dependency: transitive
+    description:
+      name: path_provider_android
+      sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.1"
+  path_provider_foundation:
+    dependency: transitive
+    description:
+      name: path_provider_foundation
+      sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.6.0"
+  path_provider_linux:
+    dependency: transitive
+    description:
+      name: path_provider_linux
+      sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.2"
+  path_provider_platform_interface:
+    dependency: transitive
+    description:
+      name: path_provider_platform_interface
+      sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.3"
+  path_provider_windows:
+    dependency: transitive
+    description:
+      name: path_provider_windows
+      sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.0"
+  petitparser:
+    dependency: transitive
+    description:
+      name: petitparser
+      sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
+      url: "https://pub.dev"
+    source: hosted
+    version: "7.0.2"
+  platform:
+    dependency: transitive
+    description:
+      name: platform
+      sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.2.0"
+  plugin_platform_interface:
+    dependency: transitive
+    description:
+      name: plugin_platform_interface
+      sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.8"
+  provider:
+    dependency: "direct main"
+    description:
+      name: provider
+      sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.1.5+1"
+  pub_semver:
+    dependency: transitive
+    description:
+      name: pub_semver
+      sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.2.1"
+  record_use:
+    dependency: transitive
+    description:
+      name: record_use
+      sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.6.0"
+  shared_preferences:
+    dependency: "direct main"
+    description:
+      name: shared_preferences
+      sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.5.6"
+  shared_preferences_android:
+    dependency: transitive
+    description:
+      name: shared_preferences_android
+      sha256: "33b38b07d9ac7d16aff0426c61418752fd3e7a8c7dc8be786c875d6ce673b002"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.5.4"
+  shared_preferences_foundation:
+    dependency: transitive
+    description:
+      name: shared_preferences_foundation
+      sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.5.7"
+  shared_preferences_linux:
+    dependency: transitive
+    description:
+      name: shared_preferences_linux
+      sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.4.1"
+  shared_preferences_platform_interface:
+    dependency: transitive
+    description:
+      name: shared_preferences_platform_interface
+      sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.4.2"
+  shared_preferences_web:
+    dependency: transitive
+    description:
+      name: shared_preferences_web
+      sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.4.3"
+  shared_preferences_windows:
+    dependency: transitive
+    description:
+      name: shared_preferences_windows
+      sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.4.1"
+  sky_engine:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  source_span:
+    dependency: transitive
+    description:
+      name: source_span
+      sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.10.2"
+  stack_trace:
+    dependency: transitive
+    description:
+      name: stack_trace
+      sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.12.2"
+  stream_channel:
+    dependency: transitive
+    description:
+      name: stream_channel
+      sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.4"
+  string_scanner:
+    dependency: transitive
+    description:
+      name: string_scanner
+      sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.4.1"
+  term_glyph:
+    dependency: transitive
+    description:
+      name: term_glyph
+      sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.2"
+  test_api:
+    dependency: transitive
+    description:
+      name: test_api
+      sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.7.12"
+  typed_data:
+    dependency: transitive
+    description:
+      name: typed_data
+      sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.4.0"
+  vector_graphics:
+    dependency: transitive
+    description:
+      name: vector_graphics
+      sha256: "0964703d02677ac8636b4aef95de0b7e816224ff0082908c2c950529cb9a2f5e"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.4"
+  vector_graphics_codec:
+    dependency: transitive
+    description:
+      name: vector_graphics_codec
+      sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.13"
+  vector_graphics_compiler:
+    dependency: transitive
+    description:
+      name: vector_graphics_compiler
+      sha256: "4c9d2e7121eea8b4f160e6e435900a0ae7214cfc97c2d6cf84c25532b789b50d"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.4"
+  vector_math:
+    dependency: transitive
+    description:
+      name: vector_math
+      sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.4.0"
+  vm_service:
+    dependency: transitive
+    description:
+      name: vm_service
+      sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78"
+      url: "https://pub.dev"
+    source: hosted
+    version: "15.4.0"
+  web:
+    dependency: transitive
+    description:
+      name: web
+      sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.2"
+  win32:
+    dependency: transitive
+    description:
+      name: win32
+      sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.4.0"
+  xdg_directories:
+    dependency: transitive
+    description:
+      name: xdg_directories
+      sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.0"
+  xml:
+    dependency: transitive
+    description:
+      name: xml
+      sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
+      url: "https://pub.dev"
+    source: hosted
+    version: "7.0.1"
+  yaml:
+    dependency: transitive
+    description:
+      name: yaml
+      sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.1.4"
+sdks:
+  dart: ">=3.13.7 <4.0.0"
+  flutter: ">=3.44.0"

+ 33 - 0
pubspec.yaml

@@ -0,0 +1,33 @@
+name: cardash_launcher
+description: "CarDash — a calm, configurable Android car dashboard launcher."
+publish_to: "none"
+version: 0.1.0+1
+
+environment:
+  sdk: ^3.13.7
+
+dependencies:
+  flutter:
+    sdk: flutter
+  provider: ^6.1.5+1
+  shared_preferences: ^2.5.6
+  flutter_secure_storage: ^11.2.0
+  flutter_svg: ^2.3.2
+  intl: ^0.20.3
+  crypto: ^3.0.8
+
+dev_dependencies:
+  flutter_test:
+    sdk: flutter
+  flutter_lints: ^6.0.0
+
+flutter:
+  uses-material-design: true
+
+  assets:
+    - assets/icons/
+
+  fonts:
+    - family: Manrope
+      fonts:
+        - asset: assets/fonts/Manrope-wght.ttf

+ 81 - 0
test/pin_service_test.dart

@@ -0,0 +1,81 @@
+import 'package:cardash_launcher/data/models/slot_assignment.dart';
+import 'package:cardash_launcher/data/models/slot_type.dart';
+import 'package:cardash_launcher/data/repositories/settings_repository.dart';
+import 'package:cardash_launcher/data/services/pin_service.dart';
+import 'package:cardash_launcher/data/services/secure_store.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class MemorySecureStore implements SecureStore {
+  final Map<String, String> values = {};
+
+  @override
+  Future<String?> read(String key) async => values[key];
+
+  @override
+  Future<void> write(String key, String value) async => values[key] = value;
+
+  @override
+  Future<void> delete(String key) async => values.remove(key);
+}
+
+void main() {
+  TestWidgetsFlutterBinding.ensureInitialized();
+
+  group('settings repository', () {
+    test('persists and clears a shortcut assignment', () async {
+      SharedPreferences.setMockInitialValues({});
+      final prefs = await SharedPreferences.getInstance();
+      final repository = SettingsRepository(prefs);
+      const assignment = SlotAssignment(
+        packageName: 'com.example.waze',
+        activityName: 'com.example.waze.Main',
+        label: 'Waze',
+      );
+
+      expect(repository.slot(SlotType.navigation), isNull);
+      await repository.setSlot(SlotType.navigation, assignment);
+      final restored = repository.slot(SlotType.navigation);
+      expect(restored, isNotNull);
+      expect(restored!.packageName, 'com.example.waze');
+      expect(restored.label, 'Waze');
+
+      await repository.clearSlot(SlotType.navigation);
+      expect(repository.slot(SlotType.navigation), isNull);
+    });
+
+    test('stores a custom night window', () async {
+      SharedPreferences.setMockInitialValues({});
+      final prefs = await SharedPreferences.getInstance();
+      final repository = SettingsRepository(prefs);
+      await repository.setNightWindow(
+        startMinutes: 20 * 60,
+        endMinutes: 6 * 60,
+      );
+      expect(repository.nightStartMinutes, 20 * 60);
+      expect(repository.nightEndMinutes, 6 * 60);
+    });
+  });
+
+  group('pin service', () {
+    test('sets, verifies and clears a four-digit PIN', () async {
+      final store = MemorySecureStore();
+      final service = PinService(store, iterations: 50);
+
+      await service.setPin('1234');
+      expect(await service.isPinSet(), isTrue);
+      expect(await service.verify('1234'), isTrue);
+      expect(await service.verify('9999'), isFalse);
+      expect(store.values['cardash.pin.hash'], isNot(contains('1234')));
+
+      await service.clearPin();
+      expect(await service.isPinSet(), isFalse);
+    });
+
+    test('rejects PINs that are not four digits', () {
+      final service = PinService(MemorySecureStore(), iterations: 10);
+      expect(() => service.setPin('12'), throwsArgumentError);
+      expect(() => service.setPin('abcd'), throwsArgumentError);
+    });
+  });
+}

+ 48 - 0
test/theme_controller_test.dart

@@ -0,0 +1,48 @@
+import 'package:cardash_launcher/core/theme/theme_controller.dart';
+import 'package:cardash_launcher/core/theme/theme_preference.dart';
+import 'package:cardash_launcher/data/repositories/settings_repository.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+void main() {
+  TestWidgetsFlutterBinding.ensureInitialized();
+
+  setUp(() {
+    SharedPreferences.setMockInitialValues({});
+  });
+
+  test('defaults are automatic with a 19:00-07:00 night window', () async {
+    final prefs = await SharedPreferences.getInstance();
+    final repository = SettingsRepository(prefs);
+    expect(repository.themePreference, ThemePreference.automatic);
+    expect(repository.nightStartMinutes, 19 * 60);
+    expect(repository.nightEndMinutes, 7 * 60);
+  });
+
+  test('explicit light and dark choices resolve immediately', () async {
+    final prefs = await SharedPreferences.getInstance();
+    final controller = ThemeController(SettingsRepository(prefs));
+    addTearDown(controller.dispose);
+
+    await controller.setPreference(ThemePreference.dark);
+    expect(controller.isDark, isTrue);
+
+    await controller.setPreference(ThemePreference.light);
+    expect(controller.isDark, isFalse);
+
+    await controller.setPreference(ThemePreference.automatic);
+    expect(controller.isDark, isIn(<bool>[true, false]));
+  });
+
+  test('theme choices survive a new repository instance', () async {
+    final prefs = await SharedPreferences.getInstance();
+    final controller = ThemeController(SettingsRepository(prefs));
+    await controller.setPreference(ThemePreference.dark);
+    controller.dispose();
+
+    final reloaded = ThemeController(SettingsRepository(prefs));
+    addTearDown(reloaded.dispose);
+    expect(reloaded.preference, ThemePreference.dark);
+    expect(reloaded.isDark, isTrue);
+  });
+}

+ 32 - 0
test/widget_test.dart

@@ -0,0 +1,32 @@
+import 'package:cardash_launcher/core/theme/app_theme.dart';
+import 'package:cardash_launcher/features/home/widgets/shortcut_tile.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  testWidgets('an unassigned shortcut invites the user to pick an app', (
+    tester,
+  ) async {
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: AppTheme.light(),
+        home: Scaffold(
+          body: Center(
+            child: ShortcutTile(
+              title: 'Navigation',
+              subtitle: 'Select app',
+              iconAsset: 'assets/icons/navigation.svg',
+              size: 150,
+              assigned: false,
+              onTap: () {},
+            ),
+          ),
+        ),
+      ),
+    );
+    await tester.pump();
+
+    expect(find.text('Navigation'), findsOneWidget);
+    expect(find.text('Select app'), findsOneWidget);
+  });
+}

+ 15 - 0
tool/check.sh

@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+echo "==> flutter analyze"
+flutter analyze
+
+echo "==> dart format check"
+dart format --output=none --set-exit-if-changed lib test
+
+echo "==> flutter test"
+flutter test
+
+echo "==> all checks passed"

+ 55 - 0
tool/emulator.sh

@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SDK="${ANDROID_HOME:-/opt/android-sdk}"
+ADB="$SDK/platform-tools/adb"
+EMULATOR="$SDK/emulator/emulator"
+API="${1:-35}"
+
+if [[ "$API" == "stop" ]]; then
+  if "$ADB" devices | grep -q "emulator-"; then
+    "$ADB" emu kill >/dev/null 2>&1 || true
+    echo "Emulator stopped."
+  else
+    echo "No emulator is running."
+  fi
+  exit 0
+fi
+
+if [[ "$API" != "35" && "$API" != "29" ]]; then
+  echo "Usage: $0 [35|29|stop] [window]" >&2
+  exit 2
+fi
+
+AVD="cardash_tablet_api$API"
+if ! "$ADB" devices | grep -q "emulator-"; then
+  args=(
+    -avd "$AVD"
+    -no-audio
+    -no-boot-anim
+    -gpu swiftshader_indirect
+    -no-snapshot
+    -no-metrics
+    -camera-back none
+    -camera-front none
+  )
+  if [[ "${2:-}" != "window" ]]; then
+    args+=(-no-window)
+  fi
+  echo "Booting $AVD (1280x800, landscape)…"
+  nohup "$EMULATOR" "${args[@]}" > "/tmp/cardash-emulator-api$API.log" 2>&1 &
+else
+  echo "An emulator is already running; reusing it."
+fi
+
+"$ADB" wait-for-device
+for _ in $(seq 1 90); do
+  if [[ "$("$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; then
+    echo "Boot completed: $("$ADB" shell wm size | tr -d '\r') @ $("$ADB" shell wm density | tr -d '\r')"
+    exit 0
+  fi
+  sleep 2
+done
+
+echo "Emulator did not report boot_completed. See /tmp/cardash-emulator-api$API.log" >&2
+exit 1

+ 46 - 0
tool/install.sh

@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+MODE="${1:-debug}"
+if [[ "$MODE" != "debug" && "$MODE" != "release" ]]; then
+  echo "Usage: $0 [debug|release]" >&2
+  exit 2
+fi
+
+export JAVA_HOME="${JAVA_HOME:-/usr/lib/jvm/java-21-openjdk}"
+if [[ ! -x "$JAVA_HOME/bin/java" ]]; then
+  echo "JDK 21 not found at $JAVA_HOME" >&2
+  exit 1
+fi
+
+SDK="${ANDROID_HOME:-/opt/android-sdk}"
+ADB="$SDK/platform-tools/adb"
+PACKAGE="com.wasp.cardash_launcher"
+
+echo "==> Building $MODE APK"
+flutter build apk "--$MODE"
+APK="build/app/outputs/flutter-apk/app-$MODE.apk"
+if [[ ! -f "$APK" ]]; then
+  echo "APK not found at $APK" >&2
+  exit 1
+fi
+
+if ! "$ADB" devices | grep -q "device$"; then
+  echo "No Android device/emulator connected. Start one with ./tool/emulator.sh 35" >&2
+  exit 1
+fi
+
+echo "==> Installing $APK"
+"$ADB" install -r "$APK"
+
+echo "==> Ensuring CarDash is the HOME app"
+if ! "$ADB" shell cmd role add-role-holder android.app.role.HOME "$PACKAGE" >/dev/null 2>&1; then
+  echo "Role manager command was unavailable; open Settings > General > Home app in CarDash."
+fi
+
+echo "==> Starting CarDash"
+"$ADB" shell am start -a android.intent.action.MAIN -c android.intent.category.HOME >/dev/null
+
+echo "Installed and started $PACKAGE"

+ 11 - 0
tool/screenshot.sh

@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+NAME="${1:-home}"
+SDK="${ANDROID_HOME:-/opt/android-sdk}"
+ADB="$SDK/platform-tools/adb"
+mkdir -p docs/screenshots
+"$ADB" exec-out screencap -p > "docs/screenshots/$NAME.png"
+echo "Saved docs/screenshots/$NAME.png"