Преглед на файлове

feat: add appearance controls and media-aware home

Krasimir Kazakov преди 1 седмица
родител
ревизия
d718a681c2

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

@@ -4,6 +4,7 @@ import android.app.Activity
 import android.app.role.RoleManager
 import android.content.ComponentName
 import android.content.Intent
+import android.content.pm.ApplicationInfo
 import android.content.pm.PackageManager
 import android.graphics.Bitmap
 import android.graphics.Canvas
@@ -96,12 +97,23 @@ class LauncherBridge(private val activity: Activity) : MethodChannel.MethodCallH
                 null
             }
 
+            val isSystem = try {
+                val appInfo = pm.getApplicationInfo(packageName, 0)
+                (appInfo.flags and (
+                    ApplicationInfo.FLAG_SYSTEM or
+                        ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
+                    )) != 0
+            } catch (_: Throwable) {
+                false
+            }
+
             apps.add(
                 mapOf(
                     "package" to packageName,
                     "activity" to activityName,
                     "label" to label,
                     "icon" to iconBytes,
+                    "system" to isSystem,
                 ),
             )
         }

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

@@ -33,6 +33,7 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
         when (call.method) {
             "getStatus" -> result.success(readStatus())
             "getVolume" -> result.success(readVolume())
+            "isMusicPlaying" -> result.success(isMusicPlaying())
             "setVolume" -> {
                 val index = call.argument<Int>("index")
                 if (index == null) {
@@ -84,6 +85,14 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
         )
     }
 
+    private fun isMusicPlaying(): Boolean {
+        return try {
+            audioManager().isMusicActive
+        } catch (_: Throwable) {
+            false
+        }
+    }
+
     private fun setVolume(index: Int): Boolean {
         return try {
             val audio = audioManager()

+ 4 - 5
lib/app.dart

@@ -18,9 +18,6 @@ class CarDashApp extends StatefulWidget {
 }
 
 class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
-  late final ThemeData _lightTheme = AppTheme.light();
-  late final ThemeData _darkTheme = AppTheme.dark();
-
   @override
   void initState() {
     super.initState();
@@ -71,12 +68,14 @@ class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
   @override
   Widget build(BuildContext context) {
     final themeController = context.watch<ThemeController>();
+    final lightTheme = AppTheme.light(accent: themeController.accent);
+    final darkTheme = AppTheme.dark(accent: themeController.accent);
 
     return MaterialApp(
       debugShowCheckedModeBanner: false,
       title: 'CarDash',
-      theme: _lightTheme,
-      darkTheme: _darkTheme,
+      theme: lightTheme,
+      darkTheme: darkTheme,
       themeMode: themeController.themeMode,
       themeAnimationDuration: const Duration(milliseconds: 260),
       themeAnimationCurve: Curves.easeOutCubic,

+ 6 - 0
lib/core/app_clock.dart

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

+ 99 - 0
lib/core/theme/accent_choice.dart

@@ -0,0 +1,99 @@
+import 'package:flutter/material.dart';
+
+/// Curated accent themes. Each choice has a deep light-theme color and a bright
+/// dark-theme color, plus matching soft surfaces and ink for filled controls.
+enum AccentChoice {
+  green(
+    'Green',
+    lightAccent: Color(0xFF3E7A32),
+    lightInk: Color(0xFFFFFFFF),
+    lightSoft: Color(0xFFDCEAD4),
+    darkAccent: Color(0xFF8BCB77),
+    darkInk: Color(0xFF0A1208),
+    darkSoft: Color(0xFF22321B),
+  ),
+  blue(
+    'Blue',
+    lightAccent: Color(0xFF2F6FED),
+    lightInk: Color(0xFFFFFFFF),
+    lightSoft: Color(0xFFDCE8FF),
+    darkAccent: Color(0xFF7FB0FF),
+    darkInk: Color(0xFF071426),
+    darkSoft: Color(0xFF1A2B45),
+  ),
+  amber(
+    'Amber',
+    lightAccent: Color(0xFFA96700),
+    lightInk: Color(0xFFFFFFFF),
+    lightSoft: Color(0xFFF7E6C4),
+    darkAccent: Color(0xFFEDC15A),
+    darkInk: Color(0xFF211704),
+    darkSoft: Color(0xFF3A2D12),
+  ),
+  rose(
+    'Rose',
+    lightAccent: Color(0xFFB23B62),
+    lightInk: Color(0xFFFFFFFF),
+    lightSoft: Color(0xFFF7DCE5),
+    darkAccent: Color(0xFFF08CA8),
+    darkInk: Color(0xFF2A0712),
+    darkSoft: Color(0xFF3D1C27),
+  ),
+  violet(
+    'Violet',
+    lightAccent: Color(0xFF6A4AC8),
+    lightInk: Color(0xFFFFFFFF),
+    lightSoft: Color(0xFFE6DFFC),
+    darkAccent: Color(0xFFB9A4FF),
+    darkInk: Color(0xFF140A33),
+    darkSoft: Color(0xFF2C224A),
+  ),
+  teal(
+    'Teal',
+    lightAccent: Color(0xFF0D726B),
+    lightInk: Color(0xFFFFFFFF),
+    lightSoft: Color(0xFFD4EDEA),
+    darkAccent: Color(0xFF68D1C5),
+    darkInk: Color(0xFF041714),
+    darkSoft: Color(0xFF123330),
+  );
+
+  const AccentChoice(
+    this.label, {
+    required this.lightAccent,
+    required this.lightInk,
+    required this.lightSoft,
+    required this.darkAccent,
+    required this.darkInk,
+    required this.darkSoft,
+  });
+
+  final String label;
+  final Color lightAccent;
+  final Color lightInk;
+  final Color lightSoft;
+  final Color darkAccent;
+  final Color darkInk;
+  final Color darkSoft;
+
+  Color accentFor(Brightness brightness) {
+    return brightness == Brightness.dark ? darkAccent : lightAccent;
+  }
+
+  Color inkFor(Brightness brightness) {
+    return brightness == Brightness.dark ? darkInk : lightInk;
+  }
+
+  Color softFor(Brightness brightness) {
+    return brightness == Brightness.dark ? darkSoft : lightSoft;
+  }
+
+  String get storageValue => name;
+
+  static AccentChoice fromStorage(String? value) {
+    for (final choice in values) {
+      if (choice.name == value) return choice;
+    }
+    return AccentChoice.green;
+  }
+}

+ 21 - 3
lib/core/theme/app_colors.dart

@@ -1,10 +1,12 @@
 import 'package:flutter/material.dart';
 
+import 'accent_choice.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.
+/// The palette is deliberately warm-neutral with a configurable 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({
@@ -53,6 +55,22 @@ class AppColors extends ThemeExtension<AppColors> {
     shadow: Color(0xFF141A12),
   );
 
+  static AppColors lightFor(AccentChoice accent) {
+    return light.copyWith(
+      accent: accent.lightAccent,
+      accentInk: accent.lightInk,
+      accentSoft: accent.lightSoft,
+    );
+  }
+
+  static AppColors darkFor(AccentChoice accent) {
+    return dark.copyWith(
+      accent: accent.darkAccent,
+      accentInk: accent.darkInk,
+      accentSoft: accent.darkSoft,
+    );
+  }
+
   static const dark = AppColors(
     canvas: Color(0xFF0B0F0C),
     canvasDeep: Color(0xFF070A08),

+ 5 - 2
lib/core/theme/app_theme.dart

@@ -1,14 +1,17 @@
 import 'package:flutter/material.dart';
 
+import 'accent_choice.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 light({AccentChoice accent = AccentChoice.green}) =>
+      _build(AppColors.lightFor(accent), Brightness.light);
 
-  static ThemeData dark() => _build(AppColors.dark, Brightness.dark);
+  static ThemeData dark({AccentChoice accent = AccentChoice.green}) =>
+      _build(AppColors.darkFor(accent), Brightness.dark);
 
   static ThemeData _build(AppColors colors, Brightness brightness) {
     final scheme =

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

@@ -3,6 +3,7 @@ import 'dart:async';
 import 'package:flutter/material.dart';
 
 import '../../data/repositories/settings_repository.dart';
+import 'accent_choice.dart';
 import 'theme_preference.dart';
 
 /// Resolves the light/dark choice, including the fixed time-of-day schedule.
@@ -15,6 +16,8 @@ class ThemeController extends ChangeNotifier {
     _preference = _settings.themePreference;
     _nightStartMinutes = _settings.nightStartMinutes;
     _nightEndMinutes = _settings.nightEndMinutes;
+    _accent = _settings.accent;
+    _showBattery = _settings.showBattery;
     _resolvedDark = _computeDark(DateTime.now());
     _scheduleBoundary();
   }
@@ -24,6 +27,8 @@ class ThemeController extends ChangeNotifier {
   ThemePreference _preference = ThemePreference.automatic;
   int _nightStartMinutes = 19 * 60;
   int _nightEndMinutes = 7 * 60;
+  AccentChoice _accent = AccentChoice.green;
+  bool _showBattery = true;
   bool _resolvedDark = false;
   Timer? _boundaryTimer;
   bool _disposed = false;
@@ -33,6 +38,8 @@ class ThemeController extends ChangeNotifier {
   int get nightEndMinutes => _nightEndMinutes;
   bool get isDark => _resolvedDark;
   ThemeMode get themeMode => _resolvedDark ? ThemeMode.dark : ThemeMode.light;
+  AccentChoice get accent => _accent;
+  bool get showBattery => _showBattery;
 
   Future<void> setPreference(ThemePreference preference) async {
     if (_preference == preference) return;
@@ -43,6 +50,20 @@ class ThemeController extends ChangeNotifier {
     _notify();
   }
 
+  Future<void> setAccent(AccentChoice accent) async {
+    if (_accent == accent) return;
+    _accent = accent;
+    await _settings.setAccent(accent);
+    _notify();
+  }
+
+  Future<void> setShowBattery(bool show) async {
+    if (_showBattery == show) return;
+    _showBattery = show;
+    await _settings.setShowBattery(show);
+    _notify();
+  }
+
   Future<void> setNightWindow({
     required int startMinutes,
     required int endMinutes,

+ 2 - 2
lib/core/widgets/app_icon_image.dart

@@ -4,8 +4,8 @@ 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.
+/// Renders a real installed-app icon where identification matters. Used in the
+/// app picker, All Apps, and user-installed (non-system) home shortcuts.
 class AppIconImage extends StatelessWidget {
   const AppIconImage({super.key, this.bytes, this.size = 40});
 

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

@@ -9,12 +9,14 @@ class AppEntry {
     required this.activityName,
     required this.label,
     this.icon,
+    this.isSystemApp = false,
   });
 
   final String packageName;
   final String activityName;
   final String label;
   final Uint8List? icon;
+  final bool isSystemApp;
 
   String get componentId => '$packageName/$activityName';
 
@@ -33,6 +35,7 @@ class AppEntry {
       activityName: (map['activity'] as String?) ?? '',
       label: ((map['label'] as String?) ?? '').trim(),
       icon: icon,
+      isSystemApp: map['system'] == true,
     );
   }
 

+ 19 - 1
lib/data/repositories/settings_repository.dart

@@ -2,12 +2,14 @@ import 'dart:convert';
 
 import 'package:shared_preferences/shared_preferences.dart';
 
+import '../../core/theme/accent_choice.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.
+/// appearance, home-button visibility, the automatic night window, and pinned
+/// apps as well as the three fixed slots.
 class SettingsRepository {
   SettingsRepository(this._prefs);
 
@@ -16,6 +18,8 @@ class SettingsRepository {
   static const _themeKey = 'settings.theme';
   static const _nightStartKey = 'settings.nightStartMinutes';
   static const _nightEndKey = 'settings.nightEndMinutes';
+  static const _accentKey = 'settings.accent';
+  static const _showBatteryKey = 'settings.showBattery';
   static const _slotPrefix = 'slot';
   static const _pinnedKey = 'home.pinned';
 
@@ -27,10 +31,24 @@ class SettingsRepository {
 
   int get nightEndMinutes => _prefs.getInt(_nightEndKey) ?? 7 * 60;
 
+  AccentChoice get accent {
+    return AccentChoice.fromStorage(_prefs.getString(_accentKey));
+  }
+
+  bool get showBattery => _prefs.getBool(_showBatteryKey) ?? true;
+
   Future<void> setThemePreference(ThemePreference preference) {
     return _prefs.setString(_themeKey, preference.storageValue);
   }
 
+  Future<void> setAccent(AccentChoice accent) {
+    return _prefs.setString(_accentKey, accent.storageValue);
+  }
+
+  Future<void> setShowBattery(bool show) {
+    return _prefs.setBool(_showBatteryKey, show);
+  }
+
   Future<void> setNightWindow({
     required int startMinutes,
     required int endMinutes,

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

@@ -31,6 +31,8 @@ abstract class LauncherPlatform {
   Future<VolumeInfo> getVolume();
 
   Future<void> setVolume(int index);
+
+  Future<bool> isMusicPlaying();
 }
 
 class NativeBridge implements LauncherPlatform {
@@ -129,6 +131,17 @@ class NativeBridge implements LauncherPlatform {
     }
   }
 
+  @override
+  Future<bool> isMusicPlaying() async {
+    try {
+      return await _systemChannel.invokeMethod<bool>('isMusicPlaying') ?? false;
+    } on PlatformException {
+      return false;
+    } on MissingPluginException {
+      return false;
+    }
+  }
+
   @override
   Future<void> setVolume(int index) async {
     try {

+ 14 - 4
lib/features/apps/all_apps_screen.dart

@@ -93,12 +93,22 @@ class _AllAppsScreenState extends State<AllAppsScreen> {
                           isDense: true,
                           prefixIcon: Padding(
                             padding: const EdgeInsets.only(left: 2),
-                            child: AppIcon(
-                              'assets/icons/search.svg',
-                              size: 18,
-                              color: colors.inkFaint,
+                            child: SizedBox(
+                              width: 18,
+                              height: 18,
+                              child: Center(
+                                child: AppIcon(
+                                  'assets/icons/search.svg',
+                                  size: 15,
+                                  color: colors.inkFaint,
+                                ),
+                              ),
                             ),
                           ),
+                          prefixIconConstraints: const BoxConstraints(
+                            minWidth: 34,
+                            minHeight: 34,
+                          ),
                         ),
                       ),
                     ),

+ 101 - 23
lib/features/home/home_screen.dart

@@ -3,7 +3,9 @@ import 'dart:async';
 import 'package:flutter/material.dart';
 import 'package:provider/provider.dart';
 
+import '../../core/app_clock.dart';
 import '../../core/theme/app_colors.dart';
+import '../../core/theme/theme_controller.dart';
 import '../../core/widgets/app_icon.dart';
 import '../../core/widgets/surface_card.dart';
 import '../../data/models/slot_assignment.dart';
@@ -29,6 +31,7 @@ class HomeScreen extends StatelessWidget {
     final theme = Theme.of(context);
     final launcher = context.watch<LauncherController>();
     final system = context.watch<SystemController>();
+    final appearance = context.watch<ThemeController>();
     final screen = MediaQuery.sizeOf(context);
     final compact = screen.height < 700 || screen.width < 1100;
     final horizontalPadding = compact ? 30.0 : 44.0;
@@ -39,18 +42,47 @@ class HomeScreen extends StatelessWidget {
     final gap = compact ? 26.0 : 40.0;
     final showHomeBanner = launcher.defaultHomeKnown && !launcher.isDefaultHome;
 
+    ShortcutSpec slotSpec(SlotType slot) {
+      final assignment = launcher.assignmentFor(slot);
+      final app = assignment == null
+          ? null
+          : launcher.appForAssignment(assignment);
+      final playing =
+          slot == SlotType.music && system.isMusicPlaying && assignment != null;
+      final nonSystemIcon = app != null && !app.isSystemApp ? app.icon : null;
+
+      return ShortcutSpec(
+        title: slot.label,
+        subtitle: assignment?.label ?? 'Select app',
+        iconAsset: slot.iconAsset,
+        iconBytes: playing ? app?.icon : nonSystemIcon,
+        showEqualizer: playing,
+        assigned: assignment != null,
+        onTap: () => _openSlot(context, slot),
+        onLongPress: () => showAppPicker(context, slot),
+        longPressHint: 'change the app',
+      );
+    }
+
+    ShortcutSpec pinnedSpec(SlotAssignment pinned) {
+      final app = launcher.appForAssignment(pinned);
+      final useRealIcon = app != null && !app.isSystemApp;
+      return ShortcutSpec(
+        title: pinned.label.isEmpty ? pinned.packageName : pinned.label,
+        subtitle: null,
+        iconAsset: 'assets/icons/app-window.svg',
+        iconBytes: useRealIcon ? app.icon : null,
+        assigned: true,
+        onTap: () => _openPinned(context, pinned),
+        onLongPress: () => _removePinned(context, pinned),
+        longPressHint: 'remove from Home',
+      );
+    }
+
     final shortcuts = <ShortcutSpec>[
       for (final slot in SlotType.values)
-        if (launcher.isSlotEnabled(slot))
-          ShortcutSpec(
-            title: slot.label,
-            subtitle: launcher.assignmentFor(slot)?.label ?? 'Select app',
-            iconAsset: slot.iconAsset,
-            assigned: launcher.assignmentFor(slot) != null,
-            onTap: () => _openSlot(context, slot),
-            onLongPress: () => showAppPicker(context, slot),
-            longPressHint: 'change the app',
-          ),
+        if (launcher.isSlotEnabled(slot)) slotSpec(slot),
+      for (final pinned in launcher.pinnedApps) pinnedSpec(pinned),
       ShortcutSpec(
         title: 'Settings',
         subtitle: 'Theme & PIN',
@@ -65,16 +97,6 @@ class HomeScreen extends StatelessWidget {
         assigned: true,
         onTap: () => _openAllApps(context),
       ),
-      for (final pinned in launcher.pinnedApps)
-        ShortcutSpec(
-          title: pinned.label.isEmpty ? pinned.packageName : pinned.label,
-          subtitle: null,
-          iconAsset: 'assets/icons/app-window.svg',
-          assigned: true,
-          onTap: () => _openPinned(context, pinned),
-          onLongPress: () => _removePinned(context, pinned),
-          longPressHint: 'remove from Home',
-        ),
     ];
 
     return PopScope(
@@ -130,7 +152,11 @@ class HomeScreen extends StatelessWidget {
                       children: [
                         ClockBlock(compact: compact),
                         const Spacer(),
-                        StatusCluster(status: system.status, compact: compact),
+                        StatusCluster(
+                          status: system.status,
+                          compact: compact,
+                          showBattery: appearance.showBattery,
+                        ),
                       ],
                     ),
                     SizedBox(height: compact ? 16 : 24),
@@ -172,8 +198,8 @@ class HomeScreen extends StatelessWidget {
                                 ),
                                 if (!compact) ...[
                                   const SizedBox(height: 4),
-                                  Text(
-                                    launcher.pinnedApps.isEmpty
+                                  _HomeHint(
+                                    text: launcher.pinnedApps.isEmpty
                                         ? 'Hold a shortcut to change the app'
                                         : 'Hold a slot to change · hold an added app to remove',
                                     style: theme.textTheme.bodySmall?.copyWith(
@@ -319,3 +345,55 @@ class _HomeRoleBanner extends StatelessWidget {
     );
   }
 }
+
+class _HomeHint extends StatefulWidget {
+  const _HomeHint({required this.text, this.style});
+
+  final String text;
+  final TextStyle? style;
+
+  @override
+  State<_HomeHint> createState() => _HomeHintState();
+}
+
+class _HomeHintState extends State<_HomeHint> {
+  Timer? _timer;
+  bool _visible = true;
+
+  @override
+  void initState() {
+    super.initState();
+    final elapsed = DateTime.now().difference(AppClock.startedAt);
+    final remaining = const Duration(minutes: 2) - elapsed;
+    if (remaining <= Duration.zero) {
+      _visible = false;
+    } else {
+      _timer = Timer(remaining, () {
+        if (mounted) setState(() => _visible = false);
+      });
+    }
+  }
+
+  @override
+  void dispose() {
+    _timer?.cancel();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return AnimatedSwitcher(
+      duration: const Duration(milliseconds: 350),
+      switchInCurve: Curves.easeOut,
+      switchOutCurve: Curves.easeIn,
+      child: _visible
+          ? Text(
+              widget.text,
+              key: const ValueKey('home-hint'),
+              textAlign: TextAlign.center,
+              style: widget.style,
+            )
+          : const SizedBox.shrink(key: ValueKey('home-hint-gone')),
+    );
+  }
+}

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

@@ -52,6 +52,13 @@ class LauncherController extends ChangeNotifier {
 
   bool isPinned(SlotAssignment assignment) => _pinnedApps.contains(assignment);
 
+  AppEntry? appForAssignment(SlotAssignment assignment) {
+    for (final app in _apps) {
+      if (app.matches(assignment)) return app;
+    }
+    return null;
+  }
+
   /// Loads persisted choices synchronously, then refreshes platform state in
   /// the background so the first frame is not blocked by package enumeration.
   void initialize() {

+ 23 - 6
lib/features/home/system_controller.dart

@@ -5,7 +5,7 @@ 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.
+/// Polls the system for battery, Wi-Fi, media volume and media activity.
 ///
 /// 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
@@ -22,6 +22,8 @@ class SystemController extends ChangeNotifier {
   bool _active = false;
   bool _volumeBusy = false;
   bool _statusBusy = false;
+  bool _musicBusy = false;
+  bool _musicPlaying = false;
   bool _disposed = false;
   int _lastNonZeroVolume = 0;
 
@@ -29,15 +31,16 @@ class SystemController extends ChangeNotifier {
   VolumeInfo get volume => _volume;
   int get volumePercent => _volume.percent;
   bool get isMuted => _volume.index <= 0;
+  bool get isMusicPlaying => _musicPlaying;
 
   Future<void> start() async {
     if (_active) return;
     _active = true;
     await refreshAll();
-    _volumeTimer = Timer.periodic(
-      const Duration(milliseconds: 900),
-      (_) => _pollVolume(),
-    );
+    _volumeTimer = Timer.periodic(const Duration(milliseconds: 900), (_) {
+      _pollVolume();
+      _pollMusic();
+    });
     _statusTimer = Timer.periodic(
       const Duration(seconds: 20),
       (_) => _pollStatus(),
@@ -53,7 +56,7 @@ class SystemController extends ChangeNotifier {
   }
 
   Future<void> refreshAll() async {
-    await Future.wait([_pollVolume(), _pollStatus()]);
+    await Future.wait([_pollVolume(), _pollStatus(), _pollMusic()]);
   }
 
   Future<void> _pollVolume() async {
@@ -71,6 +74,20 @@ class SystemController extends ChangeNotifier {
     }
   }
 
+  Future<void> _pollMusic() async {
+    if (_musicBusy || !_active) return;
+    _musicBusy = true;
+    try {
+      final playing = await _platform.isMusicPlaying();
+      if (playing != _musicPlaying) {
+        _musicPlaying = playing;
+        _notify();
+      }
+    } finally {
+      _musicBusy = false;
+    }
+  }
+
   Future<void> _pollStatus() async {
     if (_statusBusy) return;
     _statusBusy = true;

+ 86 - 0
lib/features/home/widgets/music_equalizer.dart

@@ -0,0 +1,86 @@
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+
+import '../../../core/theme/app_colors.dart';
+
+/// A tiny decorative equalizer shown while media audio is active. It is not a
+/// real analyser; the bars are phased sine waves.
+class MusicEqualizer extends StatefulWidget {
+  const MusicEqualizer({super.key, this.compact = false});
+
+  final bool compact;
+
+  @override
+  State<MusicEqualizer> createState() => _MusicEqualizerState();
+}
+
+class _MusicEqualizerState extends State<MusicEqualizer>
+    with SingleTickerProviderStateMixin {
+  late final AnimationController _controller;
+
+  @override
+  void initState() {
+    super.initState();
+    _controller = AnimationController(
+      vsync: this,
+      duration: const Duration(milliseconds: 900),
+    )..repeat();
+  }
+
+  @override
+  void dispose() {
+    _controller.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final width = widget.compact ? 24.0 : 30.0;
+    final height = widget.compact ? 14.0 : 16.0;
+    final minBar = widget.compact ? 3.0 : 3.5;
+    final maxBar = widget.compact ? 7.0 : 8.0;
+    const phases = <double>[0.0, 2.1, 4.2];
+
+    return Semantics(
+      label: 'Music playing',
+      child: Container(
+        width: width,
+        height: height,
+        padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
+        decoration: BoxDecoration(
+          color: colors.surface.withValues(alpha: 0.9),
+          borderRadius: BorderRadius.circular(6),
+          border: Border.all(color: colors.border, width: 0.6),
+        ),
+        child: AnimatedBuilder(
+          animation: _controller,
+          builder: (context, _) {
+            final phase = _controller.value * 2 * math.pi;
+            return Row(
+              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+              crossAxisAlignment: CrossAxisAlignment.end,
+              children: [
+                for (final offset in phases)
+                  SizedBox(
+                    width: 2.2,
+                    height:
+                        minBar +
+                        (maxBar - minBar) *
+                            ((math.sin(phase + offset) + 1) / 2),
+                    child: DecoratedBox(
+                      decoration: BoxDecoration(
+                        color: colors.accent,
+                        borderRadius: BorderRadius.circular(2),
+                      ),
+                    ),
+                  ),
+              ],
+            );
+          },
+        ),
+      ),
+    );
+  }
+}

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

@@ -1,4 +1,5 @@
 import 'dart:math' as math;
+import 'dart:typed_data';
 
 import 'package:flutter/material.dart';
 
@@ -13,6 +14,8 @@ class ShortcutSpec {
     required this.onTap,
     this.onLongPress,
     this.longPressHint,
+    this.iconBytes,
+    this.showEqualizer = false,
   });
 
   final String title;
@@ -22,6 +25,8 @@ class ShortcutSpec {
   final VoidCallback onTap;
   final VoidCallback? onLongPress;
   final String? longPressHint;
+  final Uint8List? iconBytes;
+  final bool showEqualizer;
 }
 
 /// Fits up to 16 home shortcuts. Columns and tile size are chosen together so
@@ -90,6 +95,8 @@ class ShortcutGrid extends StatelessWidget {
                   onTap: shortcut.onTap,
                   onLongPress: shortcut.onLongPress,
                   longPressHint: shortcut.longPressHint,
+                  iconBytes: shortcut.iconBytes,
+                  showEqualizer: shortcut.showEqualizer,
                 ),
             ],
           ),

+ 26 - 5
lib/features/home/widgets/shortcut_tile.dart

@@ -1,8 +1,12 @@
+import 'dart:typed_data';
+
 import 'package:flutter/material.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 'music_equalizer.dart';
 
 /// One large, generic shortcut on the home screen.
 class ShortcutTile extends StatelessWidget {
@@ -16,6 +20,8 @@ class ShortcutTile extends StatelessWidget {
     required this.onTap,
     this.onLongPress,
     this.longPressHint,
+    this.iconBytes,
+    this.showEqualizer = false,
   });
 
   final String title;
@@ -26,6 +32,8 @@ class ShortcutTile extends StatelessWidget {
   final VoidCallback onTap;
   final VoidCallback? onLongPress;
   final String? longPressHint;
+  final Uint8List? iconBytes;
+  final bool showEqualizer;
 
   @override
   Widget build(BuildContext context) {
@@ -44,6 +52,7 @@ class ShortcutTile extends StatelessWidget {
     if (onLongPress != null) {
       semantics.write(' Long press to ${longPressHint ?? 'change the app'}.');
     }
+    if (showEqualizer) semantics.write(' Music playing.');
 
     return Pressable(
       onTap: onTap,
@@ -78,11 +87,16 @@ class ShortcutTile extends StatelessWidget {
             child: Stack(
               children: [
                 Center(
-                  child: AppIcon(
-                    iconAsset,
-                    size: iconSize,
-                    color: placeholder ? colors.inkMuted : colors.ink,
-                  ),
+                  child: iconBytes != null
+                      ? AppIconImage(
+                          bytes: iconBytes,
+                          size: (size * 0.36).clamp(28.0, 54.0),
+                        )
+                      : AppIcon(
+                          iconAsset,
+                          size: iconSize,
+                          color: placeholder ? colors.inkMuted : colors.ink,
+                        ),
                 ),
                 if (placeholder)
                   Positioned(
@@ -103,6 +117,13 @@ class ShortcutTile extends StatelessWidget {
                       ),
                     ),
                   ),
+                if (showEqualizer)
+                  Positioned(
+                    left: 0,
+                    right: 0,
+                    bottom: size * 0.07,
+                    child: Center(child: MusicEqualizer(compact: size < 130)),
+                  ),
               ],
             ),
           ),

+ 28 - 20
lib/features/home/widgets/status_cluster.dart

@@ -7,10 +7,16 @@ 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});
+  const StatusCluster({
+    super.key,
+    required this.status,
+    this.compact = false,
+    this.showBattery = true,
+  });
 
   final SystemStatus status;
   final bool compact;
+  final bool showBattery;
 
   @override
   Widget build(BuildContext context) {
@@ -41,27 +47,29 @@ class StatusCluster extends StatelessWidget {
               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 (showBattery) ...[
+            SizedBox(width: compact ? 14 : 18),
+            _BatteryGlyph(
+              level: status.batteryLevel,
+              charging: status.charging,
+              color: batteryColor,
             ),
-          ),
-          if (status.charging) ...[
-            const SizedBox(width: 6),
-            Icon(
-              Icons.bolt_rounded,
-              size: compact ? 16 : 18,
-              color: colors.accent,
+            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,
+              ),
+            ],
           ],
         ],
       ),

+ 193 - 24
lib/features/settings/settings_screen.dart

@@ -1,6 +1,7 @@
 import 'package:flutter/material.dart';
 import 'package:provider/provider.dart';
 
+import '../../core/theme/accent_choice.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/theme_controller.dart';
 import '../../core/theme/theme_preference.dart';
@@ -113,6 +114,30 @@ class SettingsScreen extends StatelessWidget {
                               color: colors.inkMuted,
                             ),
                           ),
+                          const SizedBox(height: 8),
+                          Divider(color: colors.border, height: 24),
+                          Row(
+                            children: [
+                              Text(
+                                'Accent',
+                                style: theme.textTheme.titleMedium,
+                              ),
+                              const Spacer(),
+                              Text(
+                                themeController.accent.label,
+                                style: theme.textTheme.labelMedium?.copyWith(
+                                  color: colors.inkMuted,
+                                ),
+                              ),
+                            ],
+                          ),
+                          const SizedBox(height: 10),
+                          _AccentSelector(
+                            selected: themeController.accent,
+                            onSelected: (choice) {
+                              themeController.setAccent(choice);
+                            },
+                          ),
                           if (themeController.preference ==
                               ThemePreference.automatic) ...[
                             const SizedBox(height: 8),
@@ -145,6 +170,13 @@ class SettingsScreen extends StatelessWidget {
                               ),
                             ),
                           ],
+                          Divider(color: colors.border, height: 24),
+                          _BatteryToggleRow(
+                            value: themeController.showBattery,
+                            onChanged: (value) {
+                              themeController.setShowBattery(value);
+                            },
+                          ),
                           Divider(color: colors.border, height: 30),
                           Row(
                             children: [
@@ -274,7 +306,7 @@ class SettingsScreen extends StatelessWidget {
                     const SizedBox(height: 28),
                     Center(
                       child: Text(
-                        'CarDash 0.2.0 · Android 10+',
+                        'CarDash 0.3.0 · Android 10+',
                         style: theme.textTheme.bodySmall?.copyWith(
                           color: colors.inkFaint,
                         ),
@@ -427,6 +459,156 @@ class _ThemeSelector extends StatelessWidget {
   }
 }
 
+class _AccentSelector extends StatelessWidget {
+  const _AccentSelector({required this.selected, required this.onSelected});
+
+  final AccentChoice selected;
+  final ValueChanged<AccentChoice> onSelected;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final brightness = Theme.of(context).brightness;
+
+    return Wrap(
+      spacing: 10,
+      runSpacing: 10,
+      children: [
+        for (final choice in AccentChoice.values)
+          Semantics(
+            label: '${choice.label} accent',
+            selected: choice == selected,
+            button: true,
+            child: Material(
+              color: Colors.transparent,
+              shape: const CircleBorder(),
+              child: InkWell(
+                customBorder: const CircleBorder(),
+                onTap: () => onSelected(choice),
+                child: Padding(
+                  padding: const EdgeInsets.all(3),
+                  child: AnimatedContainer(
+                    duration: const Duration(milliseconds: 180),
+                    width: 30,
+                    height: 30,
+                    decoration: BoxDecoration(
+                      shape: BoxShape.circle,
+                      color: choice.accentFor(brightness),
+                      border: Border.all(
+                        color: choice == selected ? colors.ink : colors.border,
+                        width: choice == selected ? 2 : 1,
+                      ),
+                    ),
+                    alignment: Alignment.center,
+                    child: choice == selected
+                        ? Icon(
+                            Icons.check_rounded,
+                            size: 16,
+                            color: choice.inkFor(brightness),
+                          )
+                        : null,
+                  ),
+                ),
+              ),
+            ),
+          ),
+      ],
+    );
+  }
+}
+
+class _SettingsSwitch extends StatelessWidget {
+  const _SettingsSwitch({
+    required this.value,
+    required this.onChanged,
+    required this.semanticsLabel,
+  });
+
+  final bool value;
+  final ValueChanged<bool> onChanged;
+  final String semanticsLabel;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    return Semantics(
+      label: semanticsLabel,
+      child: Switch(
+        value: value,
+        onChanged: onChanged,
+        thumbColor: WidgetStateProperty.resolveWith<Color?>(
+          (states) => states.contains(WidgetState.selected)
+              ? colors.accentInk
+              : colors.inkFaint,
+        ),
+        trackColor: WidgetStateProperty.resolveWith<Color?>(
+          (states) => states.contains(WidgetState.selected)
+              ? colors.accent
+              : colors.surfaceStrong,
+        ),
+        trackOutlineColor: WidgetStatePropertyAll(colors.border),
+      ),
+    );
+  }
+}
+
+class _BatteryToggleRow extends StatelessWidget {
+  const _BatteryToggleRow({required this.value, required this.onChanged});
+
+  final bool value;
+  final ValueChanged<bool> onChanged;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+
+    return Row(
+      children: [
+        Container(
+          width: 38,
+          height: 38,
+          decoration: BoxDecoration(
+            color: colors.surfaceStrong,
+            borderRadius: BorderRadius.circular(13),
+            border: Border.all(color: colors.border),
+          ),
+          alignment: Alignment.center,
+          child: AppIcon(
+            'assets/icons/battery-charging.svg',
+            size: 18,
+            color: colors.inkMuted,
+          ),
+        ),
+        const SizedBox(width: 14),
+        Expanded(
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Text('Battery & charging', style: theme.textTheme.bodyLarge),
+              const SizedBox(height: 2),
+              Text(
+                'Show the battery level and charging bolt.',
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: colors.inkMuted,
+                ),
+              ),
+            ],
+          ),
+        ),
+        const SizedBox(width: 8),
+        _SettingsSwitch(
+          value: value,
+          semanticsLabel: value
+              ? 'Hide battery and charging status'
+              : 'Show battery and charging status',
+          onChanged: onChanged,
+        ),
+      ],
+    );
+  }
+}
+
 class _ActionRow extends StatelessWidget {
   const _ActionRow({
     required this.iconAsset,
@@ -572,31 +754,18 @@ class _ShortcutToggleRow extends StatelessWidget {
           ),
         ),
         const SizedBox(width: 8),
-        Semantics(
-          label: enabled
+        _SettingsSwitch(
+          value: enabled,
+          semanticsLabel: enabled
               ? 'Hide ${slot.label} on Home'
               : 'Show ${slot.label} on Home',
-          child: Switch(
-            value: enabled,
-            onChanged: (value) async {
-              final error = await launcher.setSlotEnabled(slot, value);
-              if (error != null && context.mounted) {
-                ScaffoldMessenger.of(context)
-                    .showSnackBar(SnackBar(content: Text(error)));
-              }
-            },
-            thumbColor: WidgetStateProperty.resolveWith<Color?>(
-              (states) => states.contains(WidgetState.selected)
-                  ? colors.accentInk
-                  : colors.inkFaint,
-            ),
-            trackColor: WidgetStateProperty.resolveWith<Color?>(
-              (states) => states.contains(WidgetState.selected)
-                  ? colors.accent
-                  : colors.surfaceStrong,
-            ),
-            trackOutlineColor: WidgetStatePropertyAll(colors.border),
-          ),
+          onChanged: (value) async {
+            final error = await launcher.setSlotEnabled(slot, value);
+            if (error != null && context.mounted) {
+              ScaffoldMessenger.of(context)
+                  .showSnackBar(SnackBar(content: Text(error)));
+            }
+          },
         ),
       ],
     );

+ 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.2.0+2
+version: 0.3.0+3
 
 environment:
   sdk: ^3.13.7

+ 21 - 0
test/launcher_controller_test.dart

@@ -35,6 +35,9 @@ class FakeLauncherPlatform implements LauncherPlatform {
 
   @override
   Future<void> setVolume(int index) async {}
+
+  @override
+  Future<bool> isMusicPlaying() async => false;
 }
 
 List<AppEntry> _apps(int count) {
@@ -65,6 +68,24 @@ Future<LauncherController> _controller({
 void main() {
   TestWidgetsFlutterBinding.ensureInitialized();
 
+  test('parses the native system-app flag', () {
+    final system = AppEntry.fromMap({
+      'package': 'com.example.system',
+      'activity': 'com.example.system.Main',
+      'label': 'System',
+      'system': true,
+    });
+    final user = AppEntry.fromMap({
+      'package': 'com.example.user',
+      'activity': 'com.example.user.Main',
+      'label': 'User',
+      'system': false,
+    });
+
+    expect(system.isSystemApp, isTrue);
+    expect(user.isSystemApp, isFalse);
+  });
+
   test('home buttons default to enabled and count the fixed buttons', () async {
     final controller = await _controller();
     addTearDown(controller.dispose);

+ 21 - 0
test/music_equalizer_test.dart

@@ -0,0 +1,21 @@
+import 'package:cardash_launcher/core/theme/app_theme.dart';
+import 'package:cardash_launcher/features/home/widgets/music_equalizer.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  TestWidgetsFlutterBinding.ensureInitialized();
+
+  testWidgets('equalizer animates in a shortcut-sized box', (tester) async {
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: AppTheme.light(),
+        home: const Scaffold(body: Center(child: MusicEqualizer())),
+      ),
+    );
+    await tester.pump(const Duration(milliseconds: 450));
+
+    expect(tester.takeException(), isNull);
+    expect(find.byType(MusicEqualizer), findsOneWidget);
+  });
+}

+ 27 - 0
test/theme_controller_test.dart

@@ -1,3 +1,6 @@
+import 'package:cardash_launcher/core/theme/accent_choice.dart';
+import 'package:cardash_launcher/core/theme/app_colors.dart';
+import 'package:cardash_launcher/core/theme/app_theme.dart';
 import 'package:cardash_launcher/core/theme/theme_controller.dart';
 import 'package:cardash_launcher/core/theme/theme_preference.dart';
 import 'package:cardash_launcher/data/repositories/settings_repository.dart';
@@ -17,6 +20,8 @@ void main() {
     expect(repository.themePreference, ThemePreference.automatic);
     expect(repository.nightStartMinutes, 19 * 60);
     expect(repository.nightEndMinutes, 7 * 60);
+    expect(repository.accent, AccentChoice.green);
+    expect(repository.showBattery, isTrue);
   });
 
   test('explicit light and dark choices resolve immediately', () async {
@@ -34,6 +39,28 @@ void main() {
     expect(controller.isDark, isIn(<bool>[true, false]));
   });
 
+  test('a chosen accent becomes the theme primary', () {
+    final theme = AppTheme.light(accent: AccentChoice.blue);
+    expect(theme.colorScheme.primary, AccentChoice.blue.lightAccent);
+    expect(theme.extension<AppColors>()!.accent, AccentChoice.blue.lightAccent);
+  });
+
+  test('accent and battery visibility survive a new controller', () async {
+    final prefs = await SharedPreferences.getInstance();
+    final first = ThemeController(SettingsRepository(prefs));
+    expect(first.accent, AccentChoice.green);
+    expect(first.showBattery, isTrue);
+
+    await first.setAccent(AccentChoice.blue);
+    await first.setShowBattery(false);
+    first.dispose();
+
+    final reloaded = ThemeController(SettingsRepository(prefs));
+    addTearDown(reloaded.dispose);
+    expect(reloaded.accent, AccentChoice.blue);
+    expect(reloaded.showBattery, isFalse);
+  });
+
   test('theme choices survive a new repository instance', () async {
     final prefs = await SharedPreferences.getInstance();
     final controller = ThemeController(SettingsRepository(prefs));