Ver código fonte

feat: expand the home screen to 16 configurable buttons

Krasimir Kazakov 1 semana atrás
pai
commit
5ee598bd40

+ 18 - 0
assets/icons/app-window.svg

@@ -0,0 +1,18 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-app-window"
+  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 x="2" y="4" width="20" height="16" rx="2" />
+  <path d="M10 4v4" />
+  <path d="M2 8h20" />
+  <path d="M6 4v4" />
+</svg>

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

@@ -1,3 +1,5 @@
+import 'dart:convert';
+
 import 'package:shared_preferences/shared_preferences.dart';
 
 import '../../core/theme/theme_preference.dart';
@@ -15,6 +17,7 @@ class SettingsRepository {
   static const _nightStartKey = 'settings.nightStartMinutes';
   static const _nightEndKey = 'settings.nightEndMinutes';
   static const _slotPrefix = 'slot';
+  static const _pinnedKey = 'home.pinned';
 
   ThemePreference get themePreference {
     return ThemePreference.fromStorage(_prefs.getString(_themeKey));
@@ -36,6 +39,14 @@ class SettingsRepository {
     await _prefs.setInt(_nightEndKey, endMinutes);
   }
 
+  bool slotEnabled(SlotType slot) {
+    return _prefs.getBool('$_slotPrefix.${slot.key}.enabled') ?? true;
+  }
+
+  Future<void> setSlotEnabled(SlotType slot, bool enabled) {
+    return _prefs.setBool('$_slotPrefix.${slot.key}.enabled', enabled);
+  }
+
   SlotAssignment? slot(SlotType slot) {
     final packageName = _prefs.getString('$_slotPrefix.${slot.key}.package');
     final activityName = _prefs.getString('$_slotPrefix.${slot.key}.activity');
@@ -65,4 +76,32 @@ class SettingsRepository {
     await _prefs.remove('$_slotPrefix.${slot.key}.activity');
     await _prefs.remove('$_slotPrefix.${slot.key}.label');
   }
+
+  /// Apps explicitly pinned to the home screen via All Apps. The three
+  /// predefined slots are stored separately.
+  List<SlotAssignment> get pinnedApps {
+    final raw = _prefs.getString(_pinnedKey);
+    if (raw == null || raw.isEmpty) return const <SlotAssignment>[];
+    try {
+      final decoded = jsonDecode(raw);
+      if (decoded is! List) return const <SlotAssignment>[];
+      return decoded
+          .whereType<Map>()
+          .map((entry) => SlotAssignment.fromMap(entry))
+          .where(
+            (entry) =>
+                entry.packageName.isNotEmpty && entry.activityName.isNotEmpty,
+          )
+          .toList(growable: false);
+    } catch (_) {
+      return const <SlotAssignment>[];
+    }
+  }
+
+  Future<void> setPinnedApps(List<SlotAssignment> apps) {
+    return _prefs.setString(
+      _pinnedKey,
+      jsonEncode(apps.map((app) => app.toMap()).toList(growable: false)),
+    );
+  }
 }

+ 16 - 2
lib/features/apps/all_apps_screen.dart

@@ -71,7 +71,10 @@ class _AllAppsScreenState extends State<AllAppsScreen> {
                           ),
                           const SizedBox(height: 2),
                           Text(
-                            '${apps.length} installed ${apps.length == 1 ? 'app' : 'apps'}',
+                            '${apps.length} installed ${apps.length == 1 ? 'app' : 'apps'}'
+                            ' · hold an app to add it to Home',
+                            maxLines: 1,
+                            overflow: TextOverflow.ellipsis,
                             style: theme.textTheme.bodySmall?.copyWith(
                               color: colors.inkMuted,
                             ),
@@ -172,8 +175,10 @@ class _AllAppsScreenState extends State<AllAppsScreen> {
       itemBuilder: (context, index) {
         final app = apps[index];
         return Pressable(
-          semanticLabel: '${app.label}. Open app.',
+          semanticLabel:
+              '${app.label}. Tap to open. Long press to add to Home.',
           onTap: () => _launch(app),
+          onLongPress: () => _addToHome(app),
           child: Container(
             decoration: BoxDecoration(
               color: colors.surface,
@@ -204,6 +209,15 @@ class _AllAppsScreenState extends State<AllAppsScreen> {
     );
   }
 
+  Future<void> _addToHome(AppEntry app) async {
+    final launcher = context.read<LauncherController>();
+    final error = await launcher.addPinnedApp(app);
+    if (!mounted) return;
+    final message = error ?? '${app.label} added to Home';
+    ScaffoldMessenger.of(context)
+        .showSnackBar(SnackBar(content: Text(message)));
+  }
+
   Future<void> _launch(AppEntry app) async {
     final launcher = context.read<LauncherController>();
     final error = await launcher.launchAppEntry(app);

+ 55 - 33
lib/features/home/home_screen.dart

@@ -1,9 +1,12 @@
+import 'dart:async';
+
 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_assignment.dart';
 import '../../data/models/slot_type.dart';
 import '../apps/all_apps_screen.dart';
 import '../apps/app_picker_sheet.dart';
@@ -16,7 +19,7 @@ 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.
+/// volume control, and up to 16 configurable shortcuts.
 class HomeScreen extends StatelessWidget {
   const HomeScreen({super.key});
 
@@ -29,39 +32,25 @@ class HomeScreen extends StatelessWidget {
     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 denseHome = launcher.homeButtonCount > 8;
+    final leftWidth = denseHome
+        ? (screen.width * 0.31).clamp(300.0, 400.0)
+        : (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),
-      ),
+      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',
+          ),
       ShortcutSpec(
         title: 'Settings',
         subtitle: 'Theme & PIN',
@@ -76,6 +65,16 @@ 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(
@@ -174,7 +173,9 @@ class HomeScreen extends StatelessWidget {
                                 if (!compact) ...[
                                   const SizedBox(height: 4),
                                   Text(
-                                    'Hold a shortcut to change the app',
+                                    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(
                                       color: colors.inkFaint,
                                     ),
@@ -196,6 +197,26 @@ class HomeScreen extends StatelessWidget {
     );
   }
 
+  Future<void> _openPinned(
+    BuildContext context,
+    SlotAssignment assignment,
+  ) async {
+    final error = await context.read<LauncherController>().launchAssignment(
+      assignment,
+    );
+    if (error != null && context.mounted) {
+      _showMessage(context, error);
+    }
+  }
+
+  void _removePinned(BuildContext context, SlotAssignment assignment) {
+    unawaited(context.read<LauncherController>().removePinnedApp(assignment));
+    _showMessage(
+      context,
+      '${assignment.label.isEmpty ? 'App' : assignment.label} removed from Home',
+    );
+  }
+
   Future<void> _openSlot(BuildContext context, SlotType slot) async {
     final launcher = context.read<LauncherController>();
     if (launcher.assignmentFor(slot) == null) {
@@ -227,8 +248,9 @@ class HomeScreen extends StatelessWidget {
   }
 
   void _showMessage(BuildContext context, String message) {
-    ScaffoldMessenger.of(context)
-        .showSnackBar(SnackBar(content: Text(message)));
+    final messenger = ScaffoldMessenger.of(context);
+    messenger.hideCurrentSnackBar();
+    messenger.showSnackBar(SnackBar(content: Text(message)));
   }
 }
 

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

@@ -15,7 +15,11 @@ class LauncherController extends ChangeNotifier {
   final LauncherPlatform _platform;
   final SettingsRepository _settings;
 
+  static const maxHomeButtons = 16;
+
   final Map<SlotType, SlotAssignment> _slots = {};
+  final Map<SlotType, bool> _slotEnabled = {};
+  final List<SlotAssignment> _pinnedApps = [];
   List<AppEntry> _apps = const [];
   bool _appsLoaded = false;
   bool _loadingApps = false;
@@ -35,13 +39,39 @@ class LauncherController extends ChangeNotifier {
 
   SlotAssignment? assignmentFor(SlotType slot) => _slots[slot];
 
+  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.
+  int get homeButtonCount =>
+      2 + SlotType.values.where(isSlotEnabled).length + _pinnedApps.length;
+
+  int get enabledSlotCount => SlotType.values.where(isSlotEnabled).length;
+
+  List<SlotAssignment> get pinnedApps => List.unmodifiable(_pinnedApps);
+
+  bool isPinned(SlotAssignment assignment) => _pinnedApps.contains(assignment);
+
   /// 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) {
+      _slotEnabled[slot] = _settings.slotEnabled(slot);
       final assignment = _settings.slot(slot);
       if (assignment != null) _slots[slot] = assignment;
     }
+
+    _pinnedApps.addAll(_settings.pinnedApps);
+    final loadedCount = homeButtonCount;
+    var trimmed = false;
+    while (homeButtonCount > maxHomeButtons && _pinnedApps.isNotEmpty) {
+      _pinnedApps.removeLast();
+      trimmed = true;
+    }
+    if (trimmed || loadedCount != homeButtonCount) {
+      unawaited(_settings.setPinnedApps(_pinnedApps));
+    }
+
     _notify();
 
     unawaited(refreshDefaultHome());
@@ -98,6 +128,18 @@ class LauncherController extends ChangeNotifier {
       _slots.remove(slot);
       await _settings.clearSlot(slot);
     }
+
+    final missingPinned = _pinnedApps
+        .where(
+          (assignment) => !installed.contains(
+            '${assignment.packageName}/${assignment.activityName}',
+          ),
+        )
+        .toList(growable: false);
+    if (missingPinned.isNotEmpty) {
+      _pinnedApps.removeWhere(missingPinned.contains);
+      await _settings.setPinnedApps(_pinnedApps);
+    }
   }
 
   Future<void> assign(SlotType slot, AppEntry app) async {
@@ -113,6 +155,47 @@ class LauncherController extends ChangeNotifier {
     _notify();
   }
 
+  /// Hides or shows a predefined home button. Disabled slots keep their app
+  /// assignment, so re-enabling restores the same shortcut.
+  Future<String?> setSlotEnabled(SlotType slot, bool enabled) async {
+    if (isSlotEnabled(slot) == enabled) return null;
+    if (enabled && homeButtonCount >= maxHomeButtons) {
+      return 'Home screen is full ($maxHomeButtons buttons). '
+          'Remove an app or turn off another button first.';
+    }
+    _slotEnabled[slot] = enabled;
+    await _settings.setSlotEnabled(slot, enabled);
+    _notify();
+    return null;
+  }
+
+  /// Pins an app from All Apps to the home screen. Returns an error message
+  /// when the app is already present or the 16-button limit is reached.
+  Future<String?> addPinnedApp(AppEntry app) async {
+    final assignment = app.toAssignment();
+    final alreadyPinned = _pinnedApps.contains(assignment);
+    final alreadyInVisibleSlot = SlotType.values.any(
+      (slot) => isSlotEnabled(slot) && _slots[slot] == assignment,
+    );
+    if (alreadyPinned || alreadyInVisibleSlot) {
+      return '${app.label} is already on the Home screen.';
+    }
+    if (homeButtonCount >= maxHomeButtons) {
+      return 'Home screen is full ($maxHomeButtons buttons). '
+          'Remove a button first.';
+    }
+    _pinnedApps.add(assignment);
+    await _settings.setPinnedApps(_pinnedApps);
+    _notify();
+    return null;
+  }
+
+  Future<void> removePinnedApp(SlotAssignment assignment) async {
+    if (!_pinnedApps.remove(assignment)) return;
+    await _settings.setPinnedApps(_pinnedApps);
+    _notify();
+  }
+
   Future<String?> launchSlot(SlotType slot) async {
     final assignment = _slots[slot];
     if (assignment == null) return null;

+ 32 - 15
lib/features/home/widgets/shortcut_grid.dart

@@ -12,6 +12,7 @@ class ShortcutSpec {
     required this.assigned,
     required this.onTap,
     this.onLongPress,
+    this.longPressHint,
   });
 
   final String title;
@@ -20,10 +21,11 @@ class ShortcutSpec {
   final bool assigned;
   final VoidCallback onTap;
   final VoidCallback? onLongPress;
+  final String? longPressHint;
 }
 
-/// Arranges the five home shortcuts in a centered 3+2 block on the car
-/// display, falling back to two columns on narrow windows.
+/// Fits up to 16 home shortcuts. Columns and tile size are chosen together so
+/// the whole set stays visible and grows denser as apps are added.
 class ShortcutGrid extends StatelessWidget {
   const ShortcutGrid({
     super.key,
@@ -42,20 +44,34 @@ class ShortcutGrid extends StatelessWidget {
           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();
+        if (shortcuts.isEmpty) return const SizedBox.shrink();
 
-        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);
+        final dense = shortcuts.length > 8;
+        final horizontalGap = dense
+            ? (compact ? 10.0 : 14.0)
+            : (compact ? 18.0 : 24.0);
+        final rowGap = dense
+            ? (compact ? 12.0 : 16.0)
+            : (compact ? 18.0 : 26.0);
+        final labelAllowance = compact ? 46.0 : 50.0;
+        final maxTileSize = compact ? 168.0 : 196.0;
+
+        var bestSize = 0.0;
+        for (var columns = 2; columns <= 6; columns++) {
+          if (columns > shortcuts.length) break;
+          final rows = (shortcuts.length / columns).ceil();
+          final widthLimited =
+              (constraints.maxWidth - horizontalGap * (columns - 1)) / columns;
+          final heightLimited =
+              (constraints.maxHeight - rowGap * (rows - 1)) / rows -
+              labelAllowance;
+          final candidate = math
+              .min(widthLimited, heightLimited)
+              .clamp(0.0, maxTileSize);
+          if (candidate > bestSize) bestSize = candidate;
+        }
+
+        final size = bestSize.clamp(64.0, maxTileSize);
 
         return Center(
           child: Wrap(
@@ -73,6 +89,7 @@ class ShortcutGrid extends StatelessWidget {
                   assigned: shortcut.assigned,
                   onTap: shortcut.onTap,
                   onLongPress: shortcut.onLongPress,
+                  longPressHint: shortcut.longPressHint,
                 ),
             ],
           ),

+ 30 - 25
lib/features/home/widgets/shortcut_tile.dart

@@ -15,6 +15,7 @@ class ShortcutTile extends StatelessWidget {
     required this.assigned,
     required this.onTap,
     this.onLongPress,
+    this.longPressHint,
   });
 
   final String title;
@@ -24,20 +25,24 @@ class ShortcutTile extends StatelessWidget {
   final bool assigned;
   final VoidCallback onTap;
   final VoidCallback? onLongPress;
+  final String? longPressHint;
 
   @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 iconSize = (size * 0.27).clamp(24.0, 46.0);
+    final badgeSize = (size * 0.18).clamp(20.0, 26.0);
+    final titleSize = size >= 160 ? 16.0 : (size >= 110 ? 14.0 : 12.0);
+    final subtitleSize = size >= 160 ? 12.0 : (size >= 110 ? 11.0 : 10.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.');
+      semantics.write(' Long press to ${longPressHint ?? 'change the app'}.');
     }
 
     return Pressable(
@@ -84,8 +89,8 @@ class ShortcutTile extends StatelessWidget {
                     top: size * 0.09,
                     right: size * 0.09,
                     child: Container(
-                      width: 26,
-                      height: 26,
+                      width: badgeSize,
+                      height: badgeSize,
                       decoration: BoxDecoration(
                         color: colors.accent,
                         shape: BoxShape.circle,
@@ -93,7 +98,7 @@ class ShortcutTile extends StatelessWidget {
                       alignment: Alignment.center,
                       child: AppIcon(
                         'assets/icons/plus.svg',
-                        size: 14,
+                        size: badgeSize * 0.54,
                         color: colors.accentInk,
                       ),
                     ),
@@ -107,27 +112,27 @@ class ShortcutTile extends StatelessWidget {
             maxLines: 1,
             overflow: TextOverflow.ellipsis,
             textAlign: TextAlign.center,
-            style: theme.textTheme.titleMedium?.copyWith(
-              fontSize: size >= 160 ? 16 : 14,
-            ),
+            style: theme.textTheme.titleMedium?.copyWith(fontSize: titleSize),
+          ),
+          const SizedBox(height: 2),
+          SizedBox(
+            height: 17,
+            child: subtitle == null
+                ? null
+                : 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: subtitleSize,
+                    ),
+                  ),
           ),
-          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,
-                ),
-              ),
-            ),
-          ],
         ],
       ),
     );

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

@@ -146,22 +146,35 @@ class SettingsScreen extends StatelessWidget {
                             ),
                           ],
                           Divider(color: colors.border, height: 30),
+                          Row(
+                            children: [
+                              Text(
+                                'Home buttons',
+                                style: theme.textTheme.titleMedium,
+                              ),
+                              const Spacer(),
+                              Text(
+                                '${launcher.homeButtonCount} / '
+                                '${LauncherController.maxHomeButtons}',
+                                style: theme.textTheme.labelMedium?.copyWith(
+                                  color: colors.inkMuted,
+                                ),
+                              ),
+                            ],
+                          ),
+                          const SizedBox(height: 3),
                           Text(
-                            'Home shortcuts',
-                            style: theme.textTheme.titleMedium,
+                            'Turn one off to hide it. Settings and All apps are always shown.',
+                            style: theme.textTheme.bodySmall?.copyWith(
+                              color: colors.inkMuted,
+                            ),
                           ),
                           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),
+                            _ShortcutToggleRow(
+                              slot: slot,
+                              launcher: launcher,
+                              onPick: () => showAppPicker(context, slot),
                             ),
                           Divider(color: colors.border, height: 30),
                           _HomeRoleRow(launcher: launcher),
@@ -261,7 +274,7 @@ class SettingsScreen extends StatelessWidget {
                     const SizedBox(height: 28),
                     Center(
                       child: Text(
-                        'CarDash 0.1.0 · Android 10+',
+                        'CarDash 0.2.0 · Android 10+',
                         style: theme.textTheme.bodySmall?.copyWith(
                           color: colors.inkFaint,
                         ),
@@ -418,20 +431,16 @@ 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) {
@@ -453,30 +462,7 @@ class _ActionRow extends StatelessWidget {
             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,
-                    ),
-                  ),
-                ],
-              ],
-            ),
-          ),
+          Expanded(child: Text(title, style: theme.textTheme.bodyLarge)),
           if (value != null)
             Text(
               value!,
@@ -501,6 +487,122 @@ class _ActionRow extends StatelessWidget {
   }
 }
 
+class _ShortcutToggleRow extends StatelessWidget {
+  const _ShortcutToggleRow({
+    required this.slot,
+    required this.launcher,
+    required this.onPick,
+  });
+
+  final SlotType slot;
+  final LauncherController launcher;
+  final VoidCallback onPick;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final enabled = launcher.isSlotEnabled(slot);
+    final assignment = launcher.assignmentFor(slot);
+
+    return Row(
+      children: [
+        Expanded(
+          child: Material(
+            color: Colors.transparent,
+            child: InkWell(
+              borderRadius: BorderRadius.circular(14),
+              onTap: onPick,
+              child: Padding(
+                padding: const EdgeInsets.symmetric(vertical: 6),
+                child: Row(
+                  children: [
+                    Opacity(
+                      opacity: enabled ? 1 : 0.45,
+                      child: 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(
+                          slot.iconAsset,
+                          size: 18,
+                          color: colors.inkMuted,
+                        ),
+                      ),
+                    ),
+                    const SizedBox(width: 14),
+                    Expanded(
+                      child: Column(
+                        crossAxisAlignment: CrossAxisAlignment.start,
+                        children: [
+                          Text(
+                            slot.label,
+                            style: theme.textTheme.bodyLarge?.copyWith(
+                              color: enabled ? colors.ink : colors.inkFaint,
+                            ),
+                          ),
+                          const SizedBox(height: 2),
+                          Text(
+                            assignment?.label ?? 'Select app',
+                            maxLines: 1,
+                            overflow: TextOverflow.ellipsis,
+                            style: theme.textTheme.bodySmall?.copyWith(
+                              color: !enabled
+                                  ? colors.inkFaint
+                                  : assignment == null
+                                  ? colors.accent
+                                  : colors.inkMuted,
+                              fontWeight: assignment == null && enabled
+                                  ? FontWeight.w600
+                                  : FontWeight.w400,
+                            ),
+                          ),
+                        ],
+                      ),
+                    ),
+                  ],
+                ),
+              ),
+            ),
+          ),
+        ),
+        const SizedBox(width: 8),
+        Semantics(
+          label: 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),
+          ),
+        ),
+      ],
+    );
+  }
+}
+
 class _Chevron extends StatelessWidget {
   const _Chevron();
 

+ 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.1.0+1
+version: 0.2.0+2
 
 environment:
   sdk: ^3.13.7

+ 150 - 0
test/launcher_controller_test.dart

@@ -0,0 +1,150 @@
+import 'package:cardash_launcher/data/models/app_entry.dart';
+import 'package:cardash_launcher/data/models/slot_type.dart';
+import 'package:cardash_launcher/data/models/system_status.dart';
+import 'package:cardash_launcher/data/repositories/settings_repository.dart';
+import 'package:cardash_launcher/data/services/native_bridge.dart';
+import 'package:cardash_launcher/features/home/launcher_controller.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class FakeLauncherPlatform implements LauncherPlatform {
+  FakeLauncherPlatform(this.apps);
+
+  final List<AppEntry> apps;
+
+  @override
+  Future<List<AppEntry>> listApps() async => apps;
+
+  @override
+  Future<void> launchApp({
+    required String packageName,
+    required String activityName,
+  }) async {}
+
+  @override
+  Future<bool> isDefaultHome() async => true;
+
+  @override
+  Future<bool> requestDefaultHome() async => true;
+
+  @override
+  Future<SystemStatus> getStatus() async => const SystemStatus();
+
+  @override
+  Future<VolumeInfo> getVolume() async => const VolumeInfo();
+
+  @override
+  Future<void> setVolume(int index) async {}
+}
+
+List<AppEntry> _apps(int count) {
+  return List.generate(
+    count,
+    (index) => AppEntry(
+      packageName: 'com.example.app$index',
+      activityName: 'com.example.app$index.Main',
+      label: 'App $index',
+    ),
+  );
+}
+
+Future<LauncherController> _controller({
+  int appCount = 20,
+  FakeLauncherPlatform? platform,
+}) async {
+  SharedPreferences.setMockInitialValues({});
+  final prefs = await SharedPreferences.getInstance();
+  final controller = LauncherController(
+    platform ?? FakeLauncherPlatform(_apps(appCount)),
+    SettingsRepository(prefs),
+  );
+  controller.initialize();
+  return controller;
+}
+
+void main() {
+  TestWidgetsFlutterBinding.ensureInitialized();
+
+  test('home buttons default to enabled and count the fixed buttons', () async {
+    final controller = await _controller();
+    addTearDown(controller.dispose);
+
+    expect(controller.isSlotEnabled(SlotType.navigation), isTrue);
+    expect(controller.isSlotEnabled(SlotType.music), isTrue);
+    expect(controller.isSlotEnabled(SlotType.radio), isTrue);
+    expect(controller.homeButtonCount, 5);
+    expect(controller.pinnedApps, isEmpty);
+  });
+
+  test('disabling a slot hides it and keeps the assignment', () async {
+    final controller = await _controller();
+    addTearDown(controller.dispose);
+    final app = _apps(1).first;
+
+    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.assignmentFor(SlotType.navigation)?.label, 'App 0');
+
+    expect(await controller.setSlotEnabled(SlotType.navigation, true), isNull);
+    expect(controller.homeButtonCount, 5);
+  });
+
+  test('pinned apps can be added once and removed again', () async {
+    final controller = await _controller();
+    addTearDown(controller.dispose);
+    final apps = _apps(3);
+
+    expect(await controller.addPinnedApp(apps[0]), isNull);
+    expect(controller.pinnedApps.length, 1);
+    expect(controller.homeButtonCount, 6);
+
+    expect(await controller.addPinnedApp(apps[0]), contains('already'));
+
+    await controller.removePinnedApp(apps[0].toAssignment());
+    expect(controller.pinnedApps, isEmpty);
+    expect(controller.homeButtonCount, 5);
+  });
+
+  test('the home screen stops at 16 buttons', () async {
+    final controller = await _controller();
+    addTearDown(controller.dispose);
+    final apps = _apps(20);
+
+    for (var index = 0; index < 11; 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);
+
+    await controller.removePinnedApp(apps[0].toAssignment());
+    expect(controller.homeButtonCount, 15);
+    expect(await controller.addPinnedApp(apps[11]), isNull);
+    expect(controller.homeButtonCount, 16);
+  });
+
+  test('pinned apps and slot visibility survive a new controller', () async {
+    SharedPreferences.setMockInitialValues({});
+    final prefs = await SharedPreferences.getInstance();
+    final platform = FakeLauncherPlatform(_apps(3));
+    final settings = SettingsRepository(prefs);
+    final first = LauncherController(platform, settings);
+    first.initialize();
+    addTearDown(first.dispose);
+
+    await first.setSlotEnabled(SlotType.radio, false);
+    expect(await first.addPinnedApp(_apps(1).first), isNull);
+
+    final second = LauncherController(platform, SettingsRepository(prefs));
+    second.initialize();
+    addTearDown(second.dispose);
+
+    expect(second.isSlotEnabled(SlotType.radio), isFalse);
+    expect(second.pinnedApps.length, 1);
+    expect(second.pinnedApps.first.label, 'App 0');
+    expect(second.homeButtonCount, 5);
+  });
+}

+ 41 - 0
test/pin_service_test.dart

@@ -55,6 +55,47 @@ void main() {
       expect(repository.nightStartMinutes, 20 * 60);
       expect(repository.nightEndMinutes, 6 * 60);
     });
+
+    test('home buttons default to enabled and persist a toggle', () async {
+      SharedPreferences.setMockInitialValues({});
+      final prefs = await SharedPreferences.getInstance();
+      final repository = SettingsRepository(prefs);
+
+      expect(repository.slotEnabled(SlotType.navigation), isTrue);
+      expect(repository.slotEnabled(SlotType.music), isTrue);
+      expect(repository.slotEnabled(SlotType.radio), isTrue);
+
+      await repository.setSlotEnabled(SlotType.music, false);
+      expect(repository.slotEnabled(SlotType.music), isFalse);
+      expect(repository.slotEnabled(SlotType.navigation), isTrue);
+    });
+
+    test('pinned apps round-trip through the repository', () async {
+      SharedPreferences.setMockInitialValues({});
+      final prefs = await SharedPreferences.getInstance();
+      final repository = SettingsRepository(prefs);
+      const pinned = [
+        SlotAssignment(
+          packageName: 'com.example.one',
+          activityName: 'com.example.one.Main',
+          label: 'One',
+        ),
+        SlotAssignment(
+          packageName: 'com.example.two',
+          activityName: 'com.example.two.Main',
+          label: 'Two',
+        ),
+      ];
+
+      await repository.setPinnedApps(pinned);
+      final restored = repository.pinnedApps;
+      expect(restored.length, 2);
+      expect(restored.first.label, 'One');
+      expect(restored.last.packageName, 'com.example.two');
+
+      await repository.setPinnedApps(const []);
+      expect(repository.pinnedApps, isEmpty);
+    });
   });
 
   group('pin service', () {

+ 66 - 0
test/shortcut_grid_test.dart

@@ -0,0 +1,66 @@
+import 'package:cardash_launcher/core/theme/app_theme.dart';
+import 'package:cardash_launcher/features/home/widgets/shortcut_grid.dart';
+import 'package:cardash_launcher/features/home/widgets/shortcut_tile.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+List<ShortcutSpec> _specs(int count) {
+  return List.generate(
+    count,
+    (index) => ShortcutSpec(
+      title: 'App $index',
+      subtitle: index < 5 ? 'Select app' : null,
+      iconAsset: 'assets/icons/app-window.svg',
+      assigned: true,
+      onTap: () {},
+      onLongPress: () {},
+      longPressHint: 'remove from Home',
+    ),
+  );
+}
+
+Future<void> _pumpGrid(
+  WidgetTester tester, {
+  required Size size,
+  required bool compact,
+}) async {
+  await tester.pumpWidget(
+    MaterialApp(
+      theme: AppTheme.light(),
+      home: Scaffold(
+        body: Center(
+          child: SizedBox(
+            width: size.width,
+            height: size.height,
+            child: ShortcutGrid(shortcuts: _specs(16), compact: compact),
+          ),
+        ),
+      ),
+    ),
+  );
+  await tester.pump();
+}
+
+void main() {
+  TestWidgetsFlutterBinding.ensureInitialized();
+
+  testWidgets('16 buttons fit at 1280x800 without overflow', (tester) async {
+    await _pumpGrid(tester, size: const Size(700, 610), compact: false);
+
+    expect(tester.takeException(), isNull);
+    expect(find.byType(ShortcutTile), findsNWidgets(16));
+    final first = tester.widget<ShortcutTile>(find.byType(ShortcutTile).first);
+    expect(first.size, lessThanOrEqualTo(196));
+    expect(first.size, greaterThan(64));
+  });
+
+  testWidgets('16 buttons fit at the 1024x600 compact layout', (tester) async {
+    await _pumpGrid(tester, size: const Size(559, 476), compact: true);
+
+    expect(tester.takeException(), isNull);
+    expect(find.byType(ShortcutTile), findsNWidgets(16));
+    final first = tester.widget<ShortcutTile>(find.byType(ShortcutTile).first);
+    expect(first.size, lessThanOrEqualTo(168));
+    expect(first.size, greaterThan(64));
+  });
+}