소스 검색

feat: add play-pause control and move settings to status bar

Replace the circled mute action with a play/pause ring backed by Android media keys. Resume paused audio, or open the app assigned to the Music slot when nothing responds. Keep the Mute pill and volume controls unchanged. Move Settings next to Wi-Fi in the status cluster, remove its grid tile, and exclude it from the 16-button cap. Update tests, docs, screenshots and bump to 0.7.0.
Krasimir Kazakov 1 주 전
부모
커밋
ba9a841c35

+ 3 - 2
AGENTS.md

@@ -41,7 +41,7 @@ ADB lives at `/opt/android-sdk/platform-tools/adb`; the SDK is at `/opt/android-
 - `lib/features/apps/` — All Apps screen and app-picker sheet.
 - `lib/features/settings/` — General, Car data and Security pages.
 - `lib/features/lock/` — PIN pad, lock screen, cold-start `LockController`.
-- `android/app/src/main/kotlin/com/wasp/cardash_launcher/` — `MainActivity`, `LauncherBridge`, `SystemBridge`, `TelemetryBridge`, `ObdService` (Classic SPP ELM327).
+- `android/app/src/main/kotlin/com/wasp/cardash_launcher/` — `MainActivity`, `LauncherBridge`, `SystemBridge`, `TelemetryBridge`, `ObdService` (Classic SPP ELM327); `SystemBridge` also dispatches media keys.
 - `tool/` — emulator/install/check scripts. `docs/` — implementation plan and design notes.
 
 Rules:
@@ -90,11 +90,12 @@ notifyListeners();
 - Immersive mode is reapplied on every `resumed` lifecycle event; Android may restore system bars after dialogs or role prompts.
 - The HOME role must be granted for the dashboard to replace the tablet home screen. On the emulator use `cmd role add-role-holder`; the Settings row uses `RoleManager`.
 - There is no Home-role banner. The one-time first-run tip uses `LauncherController.homeRoleHintShown`; do not show it again.
-- Screen-space is deliberately finite: test the 16-button grid at 1280×800 and 1024×600. Avoid unbounded `ListView`s inside `Column`s.
+- Screen-space is deliberately finite: test the 16-button grid at 1280×800 and 1024×600. The grid includes All apps; Settings sits in `StatusCluster` and does not consume a slot. Avoid unbounded `ListView`s inside `Column`s.
 - `tool/emulator.sh` starts a headless AVD with the **host GPU**; SwiftShader segfaulted on this machine's API 35 image. Use `CARDASH_GPU=swiftshader_indirect` only as a fallback. Use `adb exec-out screencap -p` for screenshots; there may be multiple `adb` binaries on PATH, so use the absolute one.
 - While CarDash is foreground, `MainActivity` consumes hardware volume keys and adjusts `STREAM_MUSIC` so they match the dashboard. Do not remove that override without providing another sync path; otherwise Android controls the ring stream when no media is playing.
 - `FLAG_KEEP_SCREEN_ON` in `MainActivity` is intentional. It applies while CarDash is the foreground window; do not move it to a permission-based wake lock.
 - The Music equalizer follows `AudioManager.isMusicActive`, not the playing package. Exact attribution needs notification access and is out of scope unless requested.
 - ADB installs: always try at most twice per connected device. If both attempts fail, stop silently rather than looping or switching install methods.
 - OBD is Bluetooth Classic SPP, bonded-only and read-only, and still needs a real-car test; weather uses Open-Meteo and one-shot location and stores no coordinates.
+- The circled volume ring is play/pause, not mute; the Mute pill stays. Playback uses Android media keys (no paused-state API), then opens the assigned Music app if nothing responds.
 - Reinstalling the app can clear its HOME role; re-run `tool/install.sh` or use Settings > Home app.

+ 25 - 20
README.md

@@ -21,8 +21,8 @@ mechanism. No overlays, no root, no kiosk setup.
 |---|---|
 | ![CarDash light home](docs/screenshots/home-light.png) | ![CarDash dark home](docs/screenshots/home-dark.png) |
 
-Also: [16-button home](docs/screenshots/home-16.png) ·
-[16 buttons at 1024×600](docs/screenshots/home-16-1024.png) ·
+Also: [Home with top-right Settings](docs/screenshots/home-16.png) ·
+[Home at 1024×600](docs/screenshots/home-16-1024.png) ·
 [All apps](docs/screenshots/all-apps.png) ·
 [app picker](docs/screenshots/app-picker.png) ·
 [Settings · accent & buttons](docs/screenshots/settings-buttons.png) ·
@@ -48,16 +48,16 @@ Also: [16-button home](docs/screenshots/home-16.png) ·
   Settings toggle fetches the current temperature from Open-Meteo using the
   device location. The row reads `Outside, 7°C` for OBD or `Sofia, 7°C` for
   weather, and turns red below 3°C. No network/selection means no row.
-- **Media volume** — ring, slider, step and mute controls. While CarDash is in
-  front, the hardware volume keys are routed to the same media stream, so the
-  dashboard and the keys always agree. This is the phone's media stream, not a
-  private volume.
+- **Media volume and playback** — the circled ring is now a play/pause button;
+  slider, step and mute controls remain. If audio is paused it resumes, and if
+  nothing is playing it opens the assigned Music app. While CarDash is in front,
+  the hardware volume keys are routed to the same media stream.
 - **Configurable home buttons** — switches in Settings show or hide Navigation,
-  Music and Radio. Settings and All apps are always the last two buttons. The
-  fixed buttons and predefined slots always use the stylized line icons.
+  Music and Radio. **All apps** is always the last button; **Settings** now sits
+  next to Wi-Fi in the top-right status area and is no longer a grid tile.
 - **Pin any app from All Apps** — long-press an app to add it to Home. The home
-  page holds up to **16 buttons including Settings and All apps**, and the grid
-  shrinks as it fills.
+  page holds up to **16 buttons including All apps**, and the grid shrinks as it
+  fills.
 - **Edit a pinned app** — long-press it on Home for Rename, Change icon and
   Remove. Pinned apps show their own icon by default. Rename changes only the
   Home label; Change icon offers **Default** plus 29 monochrome line icons
@@ -180,7 +180,8 @@ the app resumes.
 - Battery & charging: one switch hides both the level and charging bolt.
 - Auto night window (default dark from 19:00, light from 07:00).
 - Home buttons: show/hide Navigation, Music, Radio; live count out of 16.
-- Assign the app for each slot; Settings and All apps are fixed and last.
+- Assign the app for each slot; All apps is fixed and last.
+- Settings is always available from the gear next to Wi-Fi.
 - Home role status and shortcut to the system prompt.
 
 **Car data**
@@ -235,16 +236,17 @@ flutter test
 ./tool/check.sh
 ```
 
-The 30 tests cover PIN hashing/persistence, settings persistence, theme/accent,
+The 33 tests cover PIN hashing/persistence, settings persistence, theme/accent,
 dark accent tinting, battery visibility, first-run state, slot visibility,
 pinned apps, custom labels/icons, the 16-button cap, the dense shortcut grid,
-the equalizer animation, OBD economy and the OBD card. Native package
+the equalizer animation, OBD economy, the OBD card, media-key playback and the
+play/pause ring. Native package
 enumeration, the HOME role, volume, app launching and PIN flows were verified on
 API 35 and API 29 emulators. Location permission, reverse geocoding and the
 Open-Meteo fallback were verified live on the API 35 emulator. The OBD hardware
 path compiles and is covered by the parser/economy design, but no ELM327 adapter
 is available on this machine; socket behavior still needs a car test. Release
-0.6.0 is installed on the emulator and the physical Android 17 HyperOS tablet.
+0.7.0 is installed on the emulator. The physical Android 17 tablet was offline during the 0.7.0 install, so it remains on the last verified 0.6.0 build.
 
 ## Troubleshooting the emulator
 
@@ -267,10 +269,11 @@ Android 15 AVD is left running headlessly after development; use
   requires a newer Android release.
 - **Landscape only** (`sensorLandscape`). The home page is designed for
   1280×800 and remains usable at 1024×600.
-- **16-button cap** includes Settings and All apps. Only apps pinned from All
-  Apps show an original app icon by default; the predefined slot buttons,
-  Settings and All apps stay stylized. A pinned icon can be replaced with the
-  Default app icon or the 29 stylized choices.
+- **16-button cap** includes All apps; Settings lives in the top-right status
+  area and does not consume a slot. Only apps pinned from All Apps show an
+  original app icon by default; the predefined slots, All apps and the Settings
+  gear stay stylized. A pinned icon can be replaced with the Default app icon or
+  the 29 stylized choices.
 - **Accent-tinted dark theme** — dark canvas, surfaces and borders blend toward
   the selected accent, so Blue no longer shows a green base.
 - **Media equalizer** reacts to system media activity, not a per-app media
@@ -287,8 +290,10 @@ Android 15 AVD is left running headlessly after development; use
   rename another app's installed label.
 - **Custom icons** are bundled monochrome SVGs, so they follow the light/dark
   theme. Default restores the pinned app's own icon.
-- **No global play/pause control.** Volume is on the home screen; playback
-  buttons live in the media app itself.
+- **Play/pause uses Android media keys.** It resumes a paused session when one
+  responds; if nothing is playing, it opens the app assigned to the Music slot
+  (or asks you to choose one). Android does not expose a public paused-state API
+  to a normal launcher, so the ring shows Play whenever audio is inactive.
 - **OBD-II is a subset**: Bluetooth Classic SPP only, paired devices only,
   read-only. Speed, throttle, MAF economy and ambient temperature are implemented;
   RPM, coolant, voltage, MIL/DTCs, fuel level and BLE/Wi-Fi adapters are not.

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

@@ -7,6 +7,7 @@ import android.media.AudioManager
 import android.net.ConnectivityManager
 import android.net.NetworkCapabilities
 import android.os.BatteryManager
+import android.view.KeyEvent
 import io.flutter.plugin.common.BinaryMessenger
 import io.flutter.plugin.common.MethodCall
 import io.flutter.plugin.common.MethodChannel
@@ -34,6 +35,14 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
             "getStatus" -> result.success(readStatus())
             "getVolume" -> result.success(readVolume())
             "isMusicPlaying" -> result.success(isMusicPlaying())
+            "mediaKey" -> {
+                val action = call.argument<String>("action")
+                if (action == null) {
+                    result.error("bad_arguments", "A media key action is required.", null)
+                    return
+                }
+                result.success(sendMediaKey(action))
+            }
             "setVolume" -> {
                 val index = call.argument<Int>("index")
                 if (index == null) {
@@ -93,6 +102,23 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
         }
     }
 
+    private fun sendMediaKey(action: String): Boolean {
+        val keyCode = when (action) {
+            "play" -> KeyEvent.KEYCODE_MEDIA_PLAY
+            "pause" -> KeyEvent.KEYCODE_MEDIA_PAUSE
+            "playPause" -> KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
+            else -> return false
+        }
+        return try {
+            val audio = audioManager()
+            audio.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode))
+            audio.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode))
+            true
+        } catch (_: Throwable) {
+            false
+        }
+    }
+
     private fun setVolume(index: Int): Boolean {
         return try {
             val audio = audioManager()

+ 23 - 13
docs/IMPLEMENTATION_PLAN.md

@@ -10,13 +10,14 @@ 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).
-- Up to **16 home buttons**: Navigation, Music, Radio, Settings, All apps, plus
-  apps pinned from All Apps.
+- A prominent media-volume card: play/pause ring, slider, −/+, mute.
+- Up to **16 home buttons**: Navigation, Music, Radio, All apps, plus apps
+  pinned from All Apps. Settings lives next to Wi-Fi in the top-right status
+  area, outside the grid.
 - Navigation/Music/Radio are **app slots** with show/hide switches. They can be
-  assigned, changed and cleared. Settings and All apps are always present.
+  assigned, changed and cleared. All apps is always present and last.
 - Pinned apps are added by long-pressing an app in All Apps and removed by
-  long-pressing the added button on Home. Settings and All apps are always last.
+  long-pressing the added button on Home.
   Only pinned apps show their own icon by default; the fixed buttons and
   predefined slots stay stylized.
 - The grid adapts from 2 to 6 columns and shrinks its tiles as the count grows.
@@ -47,9 +48,11 @@ compatibility:
 | 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 + up to 16 buttons | Matches the refined brief; tiles scale from 2 to 6 columns. |
-| App slots | Navigation, Music, Radio with visibility switches | Kept from the original brief; Settings and All apps are fixed. |
+| App slots | Navigation, Music, Radio with visibility switches | Kept from the original brief; All apps is fixed. |
 | Pinned apps | All Apps long-press adds, Home long-press removes | Makes any installed app reachable from Home without editing a separate layout screen. |
-| Home cap | 16 buttons total, including Settings and All apps | Keeps the single page legible at tablet resolutions; enforced in `LauncherController`. |
+| Home cap | 16 buttons total, including All apps; Settings is outside the grid | Keeps the single page legible at tablet resolutions; enforced in `LauncherController`. |
+| Settings placement | Gear next to Wi-Fi in `StatusCluster` | Keeps Settings reachable without spending a grid slot. |
+| Playback control | Circled volume ring is play/pause; Mute pill remains | One prominent control for play/resume/pause without adding another card. |
 | Icons | Only pinned apps show their own icon by default; fixed slots stay stylized | Keeps the dashboard consistent while making added apps recognizable. |
 | Accent | Six curated choices, persisted in Settings | Changes the whole theme without a color picker or arbitrary contrast failures. |
 | Battery visibility | One switch hides level and charging bolt | One control for both indicators, as requested. |
@@ -81,9 +84,9 @@ Flutter
   HomeScreen
     ClockBlock / StatusCluster / VolumeCard / ShortcutGrid
     LauncherController  -> apps, slots, pinned apps, 16-button cap, default-home state
-    SystemController    -> battery/Wi-Fi/volume/media polling
+    SystemController    -> battery/Wi-Fi/volume/media polling + media keys
   AllAppsScreen / AppPickerSheet
-  SettingsScreen -> General / Security
+  SettingsScreen -> General / Car data / Security
   PinSetupScreen / LockScreen -> LockController -> PinService
 Android (Kotlin)
   MainActivity -> keep-screen-on, volume keys, permission forwarding
@@ -120,6 +123,7 @@ Android (Kotlin)
 | M10 | Remove Home-role banner, one-time first-run tip, pinned Rename/icon/remove menu | Done |
 | M11 | Restore stylized fixed icons; pinned apps show own icons by default; 29-icon selector | Done |
 | M12 | Accent-tinted dark theme, OBD-II adapter/telemetry, location weather fallback, Car data Settings | Done |
+| M13 | Play/pause volume ring, Settings gear in status area, 16-button cap without Settings | Done |
 
 ## 5. Verification checklist
 
@@ -145,7 +149,7 @@ Android (Kotlin)
 - [x] `FLAG_KEEP_SCREEN_ON` is present on the dashboard window.
 - [x] Six accent choices change the Material theme immediately and persist.
 - [x] Battery switch hides both the battery level and charging bolt.
-- [x] Settings/All apps are the last two Home buttons.
+- [x] Settings is next to Wi-Fi; All apps is the last grid button.
 - [x] A user-installed app (F-Droid) shows its own icon; system apps stay generic.
 - [x] Music equalizer widget animates; native media-active polling is wired.
 - [x] Home hint is gone after two minutes from process start.
@@ -160,14 +164,16 @@ Android (Kotlin)
 - [x] OBD economy/card tests pass; native ELM327 path compiles.
 - [x] Location permission, reverse geocoding and Open-Meteo fallback verified live (Ukiah, 17°C).
 - [x] Temperature row renders below the date and follows OBD/weather priority.
-- [x] Release 0.6.0 installed on the emulator and physical Android 17 tablet.
+- [x] Circled volume ring shows Play/Pause; 33 tests include media keys and the ring.
+- [x] Settings gear is next to Wi-Fi; the grid ends with All apps.
+- [x] Release 0.7.0 installed on the emulator; the tablet was offline, so 0.6.0 remains there pending a reconnect.
 - [x] Screenshots in `docs/screenshots/`; README/AGENTS/plan/OBD doc 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.
+   the polling. Play/pause uses standard Android media keys.
 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
@@ -218,7 +224,7 @@ Android (Kotlin)
   foreground keep-screen-on flag. Verified 17 tests, 16-button layouts at
   1280×800 and 1024×600, cap/removal/re-add, and 0.2.0 on the physical tablet.
 - 2026-10-29: Added six accent choices, battery/charging visibility, a two-minute
-  fading hint anchored to process start, Settings/All apps last, user-app icons,
+  fading hint anchored to process start, All apps last, user-app icons,
   a media-active equalizer, and a smaller All Apps search icon. Added the
   two-attempt ADB install rule. Verified 21 tests, accent/battery toggles, a
   user-app icon (F-Droid), hint expiry, and 0.3.0 on the emulator/tablet.
@@ -235,3 +241,7 @@ Android (Kotlin)
   Car data Settings with adapter dropdown and weather toggle, and permission
   flows. Verified 30 tests, live weather on the emulator and 0.6.0 installation;
   OBD hardware remains a car test.
+- 2026-10-29: The circled volume ring is now play/pause (media-key resume, or
+  opens the assigned Music app when nothing responds). Settings moved next to
+  Wi-Fi in the status area and no longer consumes a grid slot. Verified 33 tests
+  and 0.7.0 on the emulator; the tablet was offline at install time.

BIN
docs/screenshots/home-16-1024.png


BIN
docs/screenshots/home-16.png


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

@@ -33,6 +33,9 @@ abstract class LauncherPlatform {
   Future<void> setVolume(int index);
 
   Future<bool> isMusicPlaying();
+
+  /// Sends a standard media key to the current media session.
+  Future<bool> sendMediaKey(String action);
 }
 
 class NativeBridge implements LauncherPlatform {
@@ -142,6 +145,20 @@ class NativeBridge implements LauncherPlatform {
     }
   }
 
+  @override
+  Future<bool> sendMediaKey(String action) async {
+    try {
+      return await _systemChannel.invokeMethod<bool>('mediaKey', {
+            'action': action,
+          }) ??
+          false;
+    } on PlatformException {
+      return false;
+    } on MissingPluginException {
+      return false;
+    }
+  }
+
   @override
   Future<void> setVolume(int index) async {
     try {

+ 29 - 7
lib/features/home/home_screen.dart

@@ -93,13 +93,6 @@ class HomeScreen extends StatelessWidget {
       for (final slot in SlotType.values)
         if (launcher.isSlotEnabled(slot)) slotSpec(slot),
       for (final pinned in launcher.pinnedApps) pinnedSpec(pinned),
-      ShortcutSpec(
-        title: 'Settings',
-        subtitle: 'Theme & PIN',
-        iconAsset: 'assets/icons/settings.svg',
-        assigned: true,
-        onTap: () => _openSettings(context),
-      ),
       ShortcutSpec(
         title: 'All apps',
         subtitle: 'Installed apps',
@@ -171,6 +164,7 @@ class HomeScreen extends StatelessWidget {
                           status: system.status,
                           compact: compact,
                           showBattery: appearance.showBattery,
+                          onOpenSettings: () => _openSettings(context),
                         ),
                       ],
                     ),
@@ -199,6 +193,9 @@ class HomeScreen extends StatelessWidget {
                                   compact: compact,
                                   onSetVolume: system.setVolume,
                                   onToggleMute: system.toggleMute,
+                                  isMusicPlaying: system.isMusicPlaying,
+                                  onPlayPause: () =>
+                                      unawaited(_toggleMusic(context)),
                                 ),
                               ],
                             ),
@@ -252,6 +249,31 @@ class HomeScreen extends StatelessWidget {
     }
   }
 
+  Future<void> _toggleMusic(BuildContext context) async {
+    final system = context.read<SystemController>();
+    final launcher = context.read<LauncherController>();
+
+    if (system.isMusicPlaying) {
+      await system.pauseMusic();
+      return;
+    }
+
+    // A media play key resumes a paused session. If nothing responds, open the
+    // app assigned to the Music slot (or ask the user to choose one).
+    final started = await system.playMusic();
+    if (started || !context.mounted) return;
+
+    final assignment = launcher.assignmentFor(SlotType.music);
+    if (assignment == null) {
+      await showAppPicker(context, SlotType.music);
+      return;
+    }
+    final error = await launcher.launchSlot(SlotType.music);
+    if (error != null && context.mounted) {
+      _showMessage(context, error);
+    }
+  }
+
   Future<void> _openSlot(BuildContext context, SlotType slot) async {
     final launcher = context.read<LauncherController>();
     if (launcher.assignmentFor(slot) == null) {

+ 3 - 3
lib/features/home/launcher_controller.dart

@@ -41,10 +41,10 @@ class LauncherController extends ChangeNotifier {
 
   bool isSlotEnabled(SlotType slot) => _slotEnabled[slot] ?? true;
 
-  /// Settings and All apps are always present; the three fixed slots count only
-  /// while enabled, and each pinned app adds one.
+  /// All apps is always present. Settings lives in the top-right status area,
+  /// so the three fixed slots (while enabled) and each pinned app fill the cap.
   int get homeButtonCount =>
-      2 + SlotType.values.where(isSlotEnabled).length + _pinnedApps.length;
+      1 + SlotType.values.where(isSlotEnabled).length + _pinnedApps.length;
 
   int get enabledSlotCount => SlotType.values.where(isSlotEnabled).length;
 

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

@@ -115,6 +115,37 @@ class SystemController extends ChangeNotifier {
 
   Future<void> adjustVolume(int delta) => setVolume(_volume.index + delta);
 
+  /// Sends a pause key immediately. The next poll reconciles the real state.
+  Future<void> pauseMusic() async {
+    try {
+      await _platform.sendMediaKey('pause');
+    } catch (_) {
+      // Media keys are best-effort; the next poll still reflects reality.
+    }
+    if (_musicPlaying) {
+      _musicPlaying = false;
+      _notify();
+    }
+  }
+
+  /// Tries to resume a paused session. Returns false when nothing started, so
+  /// the caller can open the configured music app instead.
+  Future<bool> playMusic() async {
+    try {
+      final sent = await _platform.sendMediaKey('play');
+      if (!sent) return false;
+      await Future<void>.delayed(const Duration(milliseconds: 450));
+      final playing = await _platform.isMusicPlaying();
+      if (playing != _musicPlaying) {
+        _musicPlaying = playing;
+        _notify();
+      }
+      return playing;
+    } catch (_) {
+      return false;
+    }
+  }
+
   Future<void> toggleMute() async {
     if (_volume.index > 0) {
       _lastNonZeroVolume = _volume.index;

+ 27 - 1
lib/features/home/widgets/status_cluster.dart

@@ -2,6 +2,7 @@ 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';
 
@@ -12,11 +13,13 @@ class StatusCluster extends StatelessWidget {
     required this.status,
     this.compact = false,
     this.showBattery = true,
+    this.onOpenSettings,
   });
 
   final SystemStatus status;
   final bool compact;
   final bool showBattery;
+  final VoidCallback? onOpenSettings;
 
   @override
   Widget build(BuildContext context) {
@@ -47,8 +50,31 @@ class StatusCluster extends StatelessWidget {
               color: status.wifi ? colors.ink : colors.inkFaint,
             ),
           ),
+          if (onOpenSettings != null) ...[
+            SizedBox(width: compact ? 10 : 12),
+            Pressable(
+              onTap: onOpenSettings!,
+              semanticLabel: 'Open settings',
+              pressedScale: 0.92,
+              child: Container(
+                width: compact ? 28 : 30,
+                height: compact ? 28 : 30,
+                decoration: BoxDecoration(
+                  color: colors.surfaceStrong,
+                  shape: BoxShape.circle,
+                  border: Border.all(color: colors.border),
+                ),
+                alignment: Alignment.center,
+                child: AppIcon(
+                  'assets/icons/settings.svg',
+                  size: compact ? 15 : 17,
+                  color: colors.inkMuted,
+                ),
+              ),
+            ),
+          ],
           if (showBattery) ...[
-            SizedBox(width: compact ? 14 : 18),
+            SizedBox(width: compact ? 12 : 16),
             _BatteryGlyph(
               level: status.batteryLevel,
               charging: status.charging,

+ 16 - 13
lib/features/home/widgets/volume_card.dart

@@ -8,19 +8,24 @@ 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.
+/// The primary media control: a play/pause ring, a slider and step/mute
+/// actions. The ring remains a volume gauge; its centre is the playback button.
 class VolumeCard extends StatefulWidget {
   const VolumeCard({
     super.key,
     required this.volume,
     required this.onSetVolume,
     required this.onToggleMute,
+    required this.isMusicPlaying,
+    required this.onPlayPause,
     this.compact = false,
   });
 
   final VolumeInfo volume;
   final ValueChanged<int> onSetVolume;
   final VoidCallback onToggleMute;
+  final bool isMusicPlaying;
+  final VoidCallback onPlayPause;
   final bool compact;
 
   @override
@@ -51,12 +56,12 @@ class _VolumeCardState extends State<VolumeCard> {
         crossAxisAlignment: CrossAxisAlignment.center,
         children: [
           Pressable(
-            onTap: widget.onToggleMute,
-            semanticLabel: _muted ? 'Unmute media' : 'Mute media',
+            onTap: widget.onPlayPause,
+            semanticLabel: widget.isMusicPlaying ? 'Pause music' : 'Play music',
             pressedScale: 0.94,
             child: _VolumeRing(
               fraction: displayValue / _max,
-              muted: _muted,
+              isPlaying: widget.isMusicPlaying,
               size: compact ? 78 : 92,
             ),
           ),
@@ -214,12 +219,12 @@ class _StepButton extends StatelessWidget {
 class _VolumeRing extends StatelessWidget {
   const _VolumeRing({
     required this.fraction,
-    required this.muted,
+    required this.isPlaying,
     required this.size,
   });
 
   final double fraction;
-  final bool muted;
+  final bool isPlaying;
   final double size;
 
   @override
@@ -248,16 +253,14 @@ class _VolumeRing extends StatelessWidget {
                 width: size * 0.62,
                 height: size * 0.62,
                 decoration: BoxDecoration(
-                  color: colors.accentSoft.withValues(alpha: muted ? 0.55 : 1),
+                  color: colors.accentSoft,
                   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,
+                child: Icon(
+                  isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
+                  size: size * 0.34,
+                  color: colors.accent,
                 ),
               ),
             ],

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

@@ -197,7 +197,7 @@ class SettingsScreen extends StatelessWidget {
                           ),
                           const SizedBox(height: 3),
                           Text(
-                            'Turn one off to hide it. Settings and All apps are always shown.',
+                            'Turn one off to hide it. All apps is always shown; Settings lives in the top-right status area.',
                             style: theme.textTheme.bodySmall?.copyWith(
                               color: colors.inkMuted,
                             ),
@@ -318,7 +318,7 @@ class SettingsScreen extends StatelessWidget {
                     const SizedBox(height: 28),
                     Center(
                       child: Text(
-                        'CarDash 0.6.0 · Android 10+',
+                        'CarDash 0.7.0 · Android 10+',
                         style: theme.textTheme.bodySmall?.copyWith(
                           color: colors.inkFaint,
                         ),

+ 1 - 1
pubspec.yaml

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

+ 13 - 10
test/launcher_controller_test.dart

@@ -38,6 +38,9 @@ class FakeLauncherPlatform implements LauncherPlatform {
 
   @override
   Future<bool> isMusicPlaying() async => false;
+
+  @override
+  Future<bool> sendMediaKey(String action) async => true;
 }
 
 List<AppEntry> _apps(int count) {
@@ -93,7 +96,7 @@ void main() {
     expect(controller.isSlotEnabled(SlotType.navigation), isTrue);
     expect(controller.isSlotEnabled(SlotType.music), isTrue);
     expect(controller.isSlotEnabled(SlotType.radio), isTrue);
-    expect(controller.homeButtonCount, 5);
+    expect(controller.homeButtonCount, 4);
     expect(controller.pinnedApps, isEmpty);
   });
 
@@ -105,11 +108,11 @@ void main() {
     await controller.assign(SlotType.navigation, app);
     expect(await controller.setSlotEnabled(SlotType.navigation, false), isNull);
     expect(controller.isSlotEnabled(SlotType.navigation), isFalse);
-    expect(controller.homeButtonCount, 4);
+    expect(controller.homeButtonCount, 3);
     expect(controller.assignmentFor(SlotType.navigation)?.label, 'App 0');
 
     expect(await controller.setSlotEnabled(SlotType.navigation, true), isNull);
-    expect(controller.homeButtonCount, 5);
+    expect(controller.homeButtonCount, 4);
   });
 
   test('pinned apps can be added once and removed again', () async {
@@ -119,13 +122,13 @@ void main() {
 
     expect(await controller.addPinnedApp(apps[0]), isNull);
     expect(controller.pinnedApps.length, 1);
-    expect(controller.homeButtonCount, 6);
+    expect(controller.homeButtonCount, 5);
 
     expect(await controller.addPinnedApp(apps[0]), contains('already'));
 
     await controller.removePinnedApp(apps[0].toAssignment());
     expect(controller.pinnedApps, isEmpty);
-    expect(controller.homeButtonCount, 5);
+    expect(controller.homeButtonCount, 4);
   });
 
   test('the home screen stops at 16 buttons', () async {
@@ -133,17 +136,17 @@ void main() {
     addTearDown(controller.dispose);
     final apps = _apps(20);
 
-    for (var index = 0; index < 11; index++) {
+    for (var index = 0; index < 12; index++) {
       expect(await controller.addPinnedApp(apps[index]), isNull);
     }
     expect(controller.homeButtonCount, 16);
 
-    expect(await controller.addPinnedApp(apps[11]), contains('full'));
-    expect(controller.pinnedApps.length, 11);
+    expect(await controller.addPinnedApp(apps[12]), contains('full'));
+    expect(controller.pinnedApps.length, 12);
 
     await controller.removePinnedApp(apps[0].toAssignment());
     expect(controller.homeButtonCount, 15);
-    expect(await controller.addPinnedApp(apps[11]), isNull);
+    expect(await controller.addPinnedApp(apps[12]), isNull);
     expect(controller.homeButtonCount, 16);
   });
 
@@ -189,6 +192,6 @@ void main() {
     expect(second.isSlotEnabled(SlotType.radio), isFalse);
     expect(second.pinnedApps.length, 1);
     expect(second.pinnedApps.first.label, 'App 0');
-    expect(second.homeButtonCount, 5);
+    expect(second.homeButtonCount, 4);
   });
 }

+ 73 - 0
test/system_controller_test.dart

@@ -0,0 +1,73 @@
+import 'package:cardash_launcher/data/models/app_entry.dart';
+import 'package:cardash_launcher/data/models/system_status.dart';
+import 'package:cardash_launcher/data/services/native_bridge.dart';
+import 'package:cardash_launcher/features/home/system_controller.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+class _MediaPlatform implements LauncherPlatform {
+  bool playing = false;
+  final List<String> keys = [];
+
+  @override
+  Future<List<AppEntry>> listApps() async => const [];
+
+  @override
+  Future<void> launchApp({
+    required String packageName,
+    required String activityName,
+  }) async {}
+
+  @override
+  Future<bool> isDefaultHome() async => true;
+
+  @override
+  Future<bool> requestDefaultHome() async => true;
+
+  @override
+  Future<SystemStatus> getStatus() async => const SystemStatus();
+
+  @override
+  Future<VolumeInfo> getVolume() async => const VolumeInfo(index: 8, max: 15);
+
+  @override
+  Future<void> setVolume(int index) async {}
+
+  @override
+  Future<bool> isMusicPlaying() async => playing;
+
+  @override
+  Future<bool> sendMediaKey(String action) async {
+    keys.add(action);
+    if (action == 'play') playing = true;
+    if (action == 'pause') playing = false;
+    return true;
+  }
+}
+
+void main() {
+  test('pause sends the pause key and clears playing state', () async {
+    final platform = _MediaPlatform()..playing = true;
+    final controller = SystemController(platform);
+    await controller.start();
+    addTearDown(controller.dispose);
+
+    expect(controller.isMusicPlaying, isTrue);
+    await controller.pauseMusic();
+    expect(platform.keys, contains('pause'));
+    expect(controller.isMusicPlaying, isFalse);
+    controller.stop();
+  });
+
+  test('play resumes and reports whether audio started', () async {
+    final platform = _MediaPlatform();
+    final controller = SystemController(platform);
+    await controller.start();
+    addTearDown(controller.dispose);
+
+    expect(controller.isMusicPlaying, isFalse);
+    expect(await controller.playMusic(), isTrue);
+    expect(platform.keys, contains('play'));
+    expect(controller.isMusicPlaying, isTrue);
+    controller.stop();
+  });
+}

+ 43 - 0
test/volume_card_test.dart

@@ -0,0 +1,43 @@
+import 'package:cardash_launcher/core/theme/app_theme.dart';
+import 'package:cardash_launcher/data/models/system_status.dart';
+import 'package:cardash_launcher/features/home/widgets/volume_card.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  testWidgets('the ring switches between play and pause', (tester) async {
+    var playing = false;
+    var taps = 0;
+
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: AppTheme.dark(),
+        home: Scaffold(
+          body: StatefulBuilder(
+            builder: (context, setState) {
+              return VolumeCard(
+                volume: const VolumeInfo(index: 8, max: 15),
+                onSetVolume: (_) {},
+                onToggleMute: () {},
+                isMusicPlaying: playing,
+                onPlayPause: () {
+                  taps++;
+                  setState(() => playing = !playing);
+                },
+              );
+            },
+          ),
+        ),
+      ),
+    );
+
+    expect(find.byIcon(Icons.play_arrow_rounded), findsOneWidget);
+    expect(find.byIcon(Icons.pause_rounded), findsNothing);
+
+    await tester.tap(find.byIcon(Icons.play_arrow_rounded));
+    await tester.pump();
+    expect(taps, 1);
+    expect(find.byIcon(Icons.pause_rounded), findsOneWidget);
+    expect(find.byIcon(Icons.play_arrow_rounded), findsNothing);
+  });
+}