Преглед изворни кода

feat: add pinned-app editing and first-run launcher tip

Krasimir Kazakov пре 1 недеља
родитељ
комит
9795e8df4e

+ 17 - 0
assets/icons/image.svg

@@ -0,0 +1,17 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-image"
+  xmlns="http://www.w3.org/2000/svg"
+  width="24"
+  height="24"
+  viewBox="0 0 24 24"
+  fill="none"
+  stroke="currentColor"
+  stroke-width="2"
+  stroke-linecap="round"
+  stroke-linejoin="round"
+>
+  <rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
+  <circle cx="9" cy="9" r="2" />
+  <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
+</svg>

+ 16 - 0
assets/icons/pencil.svg

@@ -0,0 +1,16 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-pencil"
+  xmlns="http://www.w3.org/2000/svg"
+  width="24"
+  height="24"
+  viewBox="0 0 24 24"
+  fill="none"
+  stroke="currentColor"
+  stroke-width="2"
+  stroke-linecap="round"
+  stroke-linejoin="round"
+>
+  <path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
+  <path d="m15 5 4 4" />
+</svg>

+ 22 - 5
lib/data/models/slot_assignment.dart

@@ -7,25 +7,42 @@ class SlotAssignment {
     required this.packageName,
     required this.activityName,
     required this.label,
+    this.iconPackageName,
+    this.iconActivityName,
   });
 
   final String packageName;
   final String activityName;
   final String label;
 
+  /// Optional app whose icon should be used for this Home button. Only pinned
+  /// apps use this; slot assignments leave both fields null.
+  final String? iconPackageName;
+  final String? iconActivityName;
+
+  bool get hasCustomIcon =>
+      (iconPackageName?.isNotEmpty ?? false) &&
+      (iconActivityName?.isNotEmpty ?? false);
+
   factory SlotAssignment.fromMap(Map<dynamic, dynamic> map) {
     return SlotAssignment(
       packageName: (map['package'] as String?) ?? '',
       activityName: (map['activity'] as String?) ?? '',
       label: (map['label'] as String?) ?? '',
+      iconPackageName: map['iconPackage'] as String?,
+      iconActivityName: map['iconActivity'] as String?,
     );
   }
 
-  Map<String, Object?> toMap() => {
-    'package': packageName,
-    'activity': activityName,
-    'label': label,
-  };
+  Map<String, Object?> toMap() {
+    return {
+      'package': packageName,
+      'activity': activityName,
+      'label': label,
+      if (hasCustomIcon) 'iconPackage': iconPackageName,
+      if (hasCustomIcon) 'iconActivity': iconActivityName,
+    };
+  }
 
   @override
   bool operator ==(Object other) {

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

@@ -22,6 +22,7 @@ class SettingsRepository {
   static const _showBatteryKey = 'settings.showBattery';
   static const _slotPrefix = 'slot';
   static const _pinnedKey = 'home.pinned';
+  static const _homeRoleHintShownKey = 'onboarding.homeRoleHintShown';
 
   ThemePreference get themePreference {
     return ThemePreference.fromStorage(_prefs.getString(_themeKey));
@@ -37,6 +38,10 @@ class SettingsRepository {
 
   bool get showBattery => _prefs.getBool(_showBatteryKey) ?? true;
 
+  bool get homeRoleHintShown {
+    return _prefs.getBool(_homeRoleHintShownKey) ?? false;
+  }
+
   Future<void> setThemePreference(ThemePreference preference) {
     return _prefs.setString(_themeKey, preference.storageValue);
   }
@@ -49,6 +54,10 @@ class SettingsRepository {
     return _prefs.setBool(_showBatteryKey, show);
   }
 
+  Future<void> setHomeRoleHintShown(bool shown) {
+    return _prefs.setBool(_homeRoleHintShownKey, shown);
+  }
+
   Future<void> setNightWindow({
     required int startMinutes,
     required int endMinutes,

+ 37 - 90
lib/features/home/home_screen.dart

@@ -6,14 +6,13 @@ 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';
 import '../../data/models/slot_type.dart';
 import '../apps/all_apps_screen.dart';
 import '../apps/app_picker_sheet.dart';
 import '../settings/settings_screen.dart';
 import 'launcher_controller.dart';
+import 'pinned_app_actions.dart';
 import 'system_controller.dart';
 import 'widgets/clock_block.dart';
 import 'widgets/shortcut_grid.dart';
@@ -40,7 +39,6 @@ class HomeScreen extends StatelessWidget {
         ? (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;
 
     ShortcutSpec slotSpec(SlotType slot) {
       final assignment = launcher.assignmentFor(slot);
@@ -66,16 +64,19 @@ class HomeScreen extends StatelessWidget {
 
     ShortcutSpec pinnedSpec(SlotAssignment pinned) {
       final app = launcher.appForAssignment(pinned);
-      final useRealIcon = app != null && !app.isSystemApp;
+      final customIcon = launcher.appForCustomIcon(pinned);
+      final iconSource = customIcon ?? app;
+      final useRealIcon =
+          iconSource != null && (customIcon != null || !iconSource.isSystemApp);
       return ShortcutSpec(
         title: pinned.label.isEmpty ? pinned.packageName : pinned.label,
         subtitle: null,
         iconAsset: 'assets/icons/app-window.svg',
-        iconBytes: useRealIcon ? app.icon : null,
+        iconBytes: useRealIcon ? iconSource.icon : null,
         assigned: true,
         onTap: () => _openPinned(context, pinned),
-        onLongPress: () => _removePinned(context, pinned),
-        longPressHint: 'remove from Home',
+        onLongPress: () => showPinnedAppMenu(context, pinned),
+        longPressHint: 'open app options',
       );
     }
 
@@ -113,6 +114,7 @@ class HomeScreen extends StatelessWidget {
                 ),
               ),
             ),
+            const _FirstRunLauncherTip(),
             Positioned(
               top: -190,
               right: -120,
@@ -170,13 +172,6 @@ class HomeScreen extends StatelessWidget {
                               crossAxisAlignment: CrossAxisAlignment.stretch,
                               mainAxisAlignment: MainAxisAlignment.end,
                               children: [
-                                if (showHomeBanner) ...[
-                                  _HomeRoleBanner(
-                                    launcher: launcher,
-                                    onSetUp: () => _requestHome(context),
-                                  ),
-                                  const SizedBox(height: 14),
-                                ],
                                 VolumeCard(
                                   volume: system.volume,
                                   compact: compact,
@@ -201,7 +196,7 @@ class HomeScreen extends StatelessWidget {
                                   _HomeHint(
                                     text: launcher.pinnedApps.isEmpty
                                         ? 'Hold a shortcut to change the app'
-                                        : 'Hold a slot to change · hold an added app to remove',
+                                        : 'Hold a slot to change · hold an added app for options',
                                     style: theme.textTheme.bodySmall?.copyWith(
                                       color: colors.inkFaint,
                                     ),
@@ -235,14 +230,6 @@ class HomeScreen extends StatelessWidget {
     }
   }
 
-  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) {
@@ -265,14 +252,6 @@ class HomeScreen extends StatelessWidget {
         .push(MaterialPageRoute<void>(builder: (_) => const AllAppsScreen()));
   }
 
-  Future<void> _requestHome(BuildContext context) async {
-    final launcher = context.read<LauncherController>();
-    final launched = await launcher.requestDefaultHome();
-    if (!launched && context.mounted) {
-      _showMessage(context, 'Could not open the home app settings.');
-    }
-  }
-
   void _showMessage(BuildContext context, String message) {
     final messenger = ScaffoldMessenger.of(context);
     messenger.hideCurrentSnackBar();
@@ -280,70 +259,38 @@ class HomeScreen extends StatelessWidget {
   }
 }
 
-class _HomeRoleBanner extends StatelessWidget {
-  const _HomeRoleBanner({required this.launcher, required this.onSetUp});
-
-  final LauncherController launcher;
-  final VoidCallback onSetUp;
+class _FirstRunLauncherTip extends StatefulWidget {
+  const _FirstRunLauncherTip();
 
   @override
-  Widget build(BuildContext context) {
-    final colors = context.colors;
-    final theme = Theme.of(context);
+  State<_FirstRunLauncherTip> createState() => _FirstRunLauncherTipState();
+}
 
-    return SurfaceCard(
-      padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
-      radius: 24,
-      color: colors.accentSoft.withValues(alpha: 0.62),
-      borderColor: colors.accent.withValues(alpha: 0.18),
-      showShadow: false,
-      child: Row(
-        children: [
-          Container(
-            width: 38,
-            height: 38,
-            decoration: BoxDecoration(
-              color: colors.accent.withValues(alpha: 0.14),
-              borderRadius: BorderRadius.circular(13),
-            ),
-            alignment: Alignment.center,
-            child: AppIcon(
-              'assets/icons/smartphone.svg',
-              size: 18,
-              color: colors.accent,
-            ),
-          ),
-          const SizedBox(width: 13),
-          Expanded(
-            child: Column(
-              crossAxisAlignment: CrossAxisAlignment.start,
-              children: [
-                Text(
-                  'Make CarDash your home app',
-                  style: theme.textTheme.titleSmall,
-                ),
-                const SizedBox(height: 2),
-                Text(
-                  'Open it with the Home button.',
-                  style: theme.textTheme.bodySmall?.copyWith(
-                    color: colors.inkMuted,
-                  ),
-                ),
-              ],
-            ),
-          ),
-          TextButton(
-            onPressed: onSetUp,
-            style: TextButton.styleFrom(
-              foregroundColor: colors.accent,
-              textStyle: theme.textTheme.labelLarge,
-            ),
-            child: const Text('Set up'),
+class _FirstRunLauncherTipState extends State<_FirstRunLauncherTip> {
+  @override
+  void initState() {
+    super.initState();
+    WidgetsBinding.instance.addPostFrameCallback((_) async {
+      if (!mounted) return;
+      final launcher = context.read<LauncherController>();
+      if (launcher.homeRoleHintShown) return;
+      await launcher.markHomeRoleHintShown();
+      if (!mounted) return;
+      final messenger = ScaffoldMessenger.of(context);
+      messenger.hideCurrentSnackBar();
+      messenger.showSnackBar(
+        const SnackBar(
+          content: Text(
+            'You can set CarDash as your default launcher in Settings.',
           ),
-        ],
-      ),
-    );
+          duration: Duration(seconds: 5),
+        ),
+      );
+    });
   }
+
+  @override
+  Widget build(BuildContext context) => const SizedBox.shrink();
 }
 
 class _HomeHint extends StatefulWidget {

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

@@ -52,6 +52,12 @@ class LauncherController extends ChangeNotifier {
 
   bool isPinned(SlotAssignment assignment) => _pinnedApps.contains(assignment);
 
+  bool get homeRoleHintShown => _settings.homeRoleHintShown;
+
+  Future<void> markHomeRoleHintShown() {
+    return _settings.setHomeRoleHintShown(true);
+  }
+
   AppEntry? appForAssignment(SlotAssignment assignment) {
     for (final app in _apps) {
       if (app.matches(assignment)) return app;
@@ -59,6 +65,19 @@ class LauncherController extends ChangeNotifier {
     return null;
   }
 
+  AppEntry? appForCustomIcon(SlotAssignment assignment) {
+    if (!assignment.hasCustomIcon) return null;
+    final package = assignment.iconPackageName;
+    final activity = assignment.iconActivityName;
+    if (package == null || activity == null) return null;
+    for (final app in _apps) {
+      if (app.packageName == package && app.activityName == activity) {
+        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() {
@@ -203,6 +222,41 @@ class LauncherController extends ChangeNotifier {
     _notify();
   }
 
+  Future<void> renamePinnedApp(SlotAssignment assignment, String label) async {
+    final trimmed = label.trim();
+    if (trimmed.isEmpty) return;
+    final index = _pinnedApps.indexOf(assignment);
+    if (index < 0) return;
+    final current = _pinnedApps[index];
+    _pinnedApps[index] = SlotAssignment(
+      packageName: current.packageName,
+      activityName: current.activityName,
+      label: trimmed,
+      iconPackageName: current.iconPackageName,
+      iconActivityName: current.iconActivityName,
+    );
+    await _settings.setPinnedApps(_pinnedApps);
+    _notify();
+  }
+
+  Future<void> setPinnedIcon(
+    SlotAssignment assignment,
+    AppEntry? iconSource,
+  ) async {
+    final index = _pinnedApps.indexOf(assignment);
+    if (index < 0) return;
+    final current = _pinnedApps[index];
+    _pinnedApps[index] = SlotAssignment(
+      packageName: current.packageName,
+      activityName: current.activityName,
+      label: current.label,
+      iconPackageName: iconSource?.packageName,
+      iconActivityName: iconSource?.activityName,
+    );
+    await _settings.setPinnedApps(_pinnedApps);
+    _notify();
+  }
+
   Future<String?> launchSlot(SlotType slot) async {
     final assignment = _slots[slot];
     if (assignment == null) return null;

+ 451 - 0
lib/features/home/pinned_app_actions.dart

@@ -0,0 +1,451 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/widgets/app_icon.dart';
+import '../../core/widgets/app_icon_image.dart';
+import '../../core/widgets/pressable.dart';
+import '../../core/widgets/surface_card.dart';
+import '../../data/models/app_entry.dart';
+import '../../data/models/slot_assignment.dart';
+import 'launcher_controller.dart';
+
+const _sheetBarrier = Color(0x8A000000);
+
+/// The long-press menu for an app pinned to Home.
+Future<void> showPinnedAppMenu(
+  BuildContext context,
+  SlotAssignment assignment,
+) async {
+  final launcher = context.read<LauncherController>();
+  final app = launcher.appForAssignment(assignment);
+  final iconApp = launcher.appForCustomIcon(assignment) ?? app;
+
+  await showModalBottomSheet<void>(
+    context: context,
+    useSafeArea: true,
+    backgroundColor: Colors.transparent,
+    barrierColor: _sheetBarrier,
+    builder: (sheetContext) {
+      final colors = sheetContext.colors;
+      final theme = Theme.of(sheetContext);
+
+      Future<void> closeThen(Future<void> Function() action) async {
+        Navigator.of(sheetContext).pop();
+        await Future<void>.delayed(const Duration(milliseconds: 190));
+        if (!context.mounted) return;
+        await action();
+      }
+
+      return Padding(
+        padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
+        child: Center(
+          heightFactor: 1,
+          child: ConstrainedBox(
+            constraints: const BoxConstraints(maxWidth: 480),
+            child: SurfaceCard(
+              padding: const EdgeInsets.fromLTRB(22, 18, 22, 16),
+              radius: 28,
+              child: Column(
+                mainAxisSize: MainAxisSize.min,
+                children: [
+                  Row(
+                    children: [
+                      AppIconImage(bytes: iconApp?.icon, size: 44),
+                      const SizedBox(width: 14),
+                      Expanded(
+                        child: Column(
+                          crossAxisAlignment: CrossAxisAlignment.start,
+                          children: [
+                            Text(
+                              assignment.label.isEmpty
+                                  ? assignment.packageName
+                                  : assignment.label,
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: theme.textTheme.titleMedium,
+                            ),
+                            const SizedBox(height: 2),
+                            Text(
+                              'Home shortcut',
+                              style: theme.textTheme.bodySmall?.copyWith(
+                                color: colors.inkMuted,
+                              ),
+                            ),
+                          ],
+                        ),
+                      ),
+                      Pressable(
+                        onTap: () => Navigator.of(sheetContext).pop(),
+                        semanticLabel: 'Close',
+                        pressedScale: 0.92,
+                        child: Container(
+                          width: 38,
+                          height: 38,
+                          decoration: BoxDecoration(
+                            color: colors.surfaceStrong,
+                            shape: BoxShape.circle,
+                            border: Border.all(color: colors.border),
+                          ),
+                          alignment: Alignment.center,
+                          child: AppIcon(
+                            'assets/icons/x.svg',
+                            size: 16,
+                            color: colors.inkMuted,
+                          ),
+                        ),
+                      ),
+                    ],
+                  ),
+                  const SizedBox(height: 12),
+                  Divider(color: colors.border, height: 1),
+                  const SizedBox(height: 4),
+                  _MenuAction(
+                    iconAsset: 'assets/icons/pencil.svg',
+                    title: 'Rename',
+                    onTap: () => closeThen(() async {
+                      final name = await _showRenameDialog(context, assignment);
+                      if (name != null) {
+                        await launcher.renamePinnedApp(assignment, name);
+                      }
+                    }),
+                  ),
+                  _MenuAction(
+                    iconAsset: 'assets/icons/image.svg',
+                    title: 'Change icon',
+                    onTap: () => closeThen(
+                      () => showPinnedIconPicker(context, assignment),
+                    ),
+                  ),
+                  _MenuAction(
+                    iconAsset: 'assets/icons/delete.svg',
+                    title: 'Remove',
+                    danger: true,
+                    onTap: () => closeThen(() async {
+                      await launcher.removePinnedApp(assignment);
+                      if (context.mounted) {
+                        ScaffoldMessenger.of(context).hideCurrentSnackBar();
+                        ScaffoldMessenger.of(context).showSnackBar(
+                          SnackBar(
+                            content: Text(
+                              '${assignment.label.isEmpty ? 'App' : assignment.label} '
+                              'removed from Home',
+                            ),
+                          ),
+                        );
+                      }
+                    }),
+                  ),
+                ],
+              ),
+            ),
+          ),
+        ),
+      );
+    },
+  );
+}
+
+Future<String?> _showRenameDialog(
+  BuildContext context,
+  SlotAssignment assignment,
+) async {
+  final controller = TextEditingController(text: assignment.label);
+  try {
+    return await showDialog<String>(
+      context: context,
+      builder: (dialogContext) {
+        void submit() {
+          final name = controller.text.trim();
+          if (name.isEmpty) return;
+          Navigator.of(dialogContext).pop(name);
+        }
+
+        return AlertDialog(
+          title: const Text('Rename shortcut'),
+          content: TextField(
+            controller: controller,
+            autofocus: true,
+            maxLength: 32,
+            textInputAction: TextInputAction.done,
+            decoration: const InputDecoration(
+              labelText: 'Home label',
+              helperText: 'Only the Home label changes.',
+            ),
+            onSubmitted: (_) => submit(),
+          ),
+          actions: [
+            TextButton(
+              onPressed: () => Navigator.of(dialogContext).pop(),
+              child: const Text('Cancel'),
+            ),
+            FilledButton(onPressed: submit, child: const Text('Save')),
+          ],
+        );
+      },
+    );
+  } finally {
+    controller.dispose();
+  }
+}
+
+Future<void> showPinnedIconPicker(
+  BuildContext context,
+  SlotAssignment assignment,
+) {
+  return showModalBottomSheet<void>(
+    context: context,
+    isScrollControlled: true,
+    backgroundColor: Colors.transparent,
+    barrierColor: _sheetBarrier,
+    builder: (_) => _PinnedIconSheet(assignment: assignment),
+  );
+}
+
+class _PinnedIconSheet extends StatefulWidget {
+  const _PinnedIconSheet({required this.assignment});
+
+  final SlotAssignment assignment;
+
+  @override
+  State<_PinnedIconSheet> createState() => _PinnedIconSheetState();
+}
+
+class _PinnedIconSheetState extends State<_PinnedIconSheet> {
+  @override
+  void initState() {
+    super.initState();
+    WidgetsBinding.instance.addPostFrameCallback((_) {
+      if (!mounted) return;
+      final launcher = context.read<LauncherController>();
+      if (!launcher.isLoadingApps) launcher.loadApps(force: true);
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final launcher = context.watch<LauncherController>();
+    final current = launcher.appForAssignment(widget.assignment);
+    final choices = <AppEntry>[];
+    final seen = <String>{};
+
+    if (current != null) {
+      choices.add(current);
+      seen.add(current.componentId);
+    }
+    for (final app in launcher.apps) {
+      if (choices.length >= 25) break;
+      if (seen.add(app.componentId)) choices.add(app);
+    }
+
+    final selectedComponent = widget.assignment.hasCustomIcon
+        ? '${widget.assignment.iconPackageName}/${widget.assignment.iconActivityName}'
+        : '${widget.assignment.packageName}/${widget.assignment.activityName}';
+
+    return Container(
+      height: MediaQuery.sizeOf(context).height * 0.86,
+      decoration: BoxDecoration(
+        color: colors.surface,
+        borderRadius: const BorderRadius.vertical(top: Radius.circular(32)),
+      ),
+      child: Column(
+        children: [
+          const SizedBox(height: 12),
+          Container(
+            width: 44,
+            height: 4,
+            decoration: BoxDecoration(
+              color: colors.inkFaint.withValues(alpha: 0.4),
+              borderRadius: BorderRadius.circular(99),
+            ),
+          ),
+          Padding(
+            padding: const EdgeInsets.fromLTRB(28, 20, 18, 8),
+            child: Row(
+              children: [
+                Expanded(
+                  child: Column(
+                    crossAxisAlignment: CrossAxisAlignment.start,
+                    children: [
+                      Text('Change icon', style: theme.textTheme.headlineSmall),
+                      const SizedBox(height: 3),
+                      Text(
+                        'Choose an icon for ${widget.assignment.label}.',
+                        maxLines: 1,
+                        overflow: TextOverflow.ellipsis,
+                        style: theme.textTheme.bodySmall?.copyWith(
+                          color: colors.inkMuted,
+                        ),
+                      ),
+                    ],
+                  ),
+                ),
+                Pressable(
+                  onTap: () => Navigator.of(context).pop(),
+                  semanticLabel: 'Close',
+                  pressedScale: 0.92,
+                  child: Container(
+                    width: 40,
+                    height: 40,
+                    decoration: BoxDecoration(
+                      color: colors.surfaceStrong,
+                      shape: BoxShape.circle,
+                      border: Border.all(color: colors.border),
+                    ),
+                    alignment: Alignment.center,
+                    child: AppIcon(
+                      'assets/icons/x.svg',
+                      size: 16,
+                      color: colors.inkMuted,
+                    ),
+                  ),
+                ),
+              ],
+            ),
+          ),
+          const SizedBox(height: 8),
+          Expanded(
+            child: choices.isEmpty
+                ? Center(
+                    child: Text(
+                      'No installed apps to choose from.',
+                      style: theme.textTheme.bodyMedium?.copyWith(
+                        color: colors.inkMuted,
+                      ),
+                    ),
+                  )
+                : GridView.builder(
+                    padding: const EdgeInsets.fromLTRB(24, 4, 24, 28),
+                    gridDelegate:
+                        const SliverGridDelegateWithMaxCrossAxisExtent(
+                          maxCrossAxisExtent: 116,
+                          mainAxisExtent: 112,
+                          crossAxisSpacing: 10,
+                          mainAxisSpacing: 10,
+                        ),
+                    itemCount: choices.length,
+                    itemBuilder: (context, index) {
+                      final app = choices[index];
+                      final selected = app.componentId == selectedComponent;
+                      return Material(
+                        color: Colors.transparent,
+                        child: InkWell(
+                          borderRadius: BorderRadius.circular(24),
+                          onTap: () async {
+                            await launcher.setPinnedIcon(
+                              widget.assignment,
+                              app,
+                            );
+                            if (context.mounted) Navigator.of(context).pop();
+                          },
+                          child: Container(
+                            padding: const EdgeInsets.fromLTRB(8, 10, 8, 8),
+                            decoration: BoxDecoration(
+                              color: colors.surfaceStrong,
+                              borderRadius: BorderRadius.circular(24),
+                              border: Border.all(
+                                color: selected ? colors.accent : colors.border,
+                                width: selected ? 1.8 : 1,
+                              ),
+                            ),
+                            child: Column(
+                              mainAxisAlignment: MainAxisAlignment.center,
+                              children: [
+                                AppIconImage(bytes: app.icon, size: 44),
+                                const SizedBox(height: 6),
+                                Text(
+                                  app.label,
+                                  maxLines: 1,
+                                  overflow: TextOverflow.ellipsis,
+                                  textAlign: TextAlign.center,
+                                  style: theme.textTheme.bodySmall?.copyWith(
+                                    color: colors.ink,
+                                  ),
+                                ),
+                              ],
+                            ),
+                          ),
+                        ),
+                      );
+                    },
+                  ),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class _MenuAction extends StatelessWidget {
+  const _MenuAction({
+    required this.iconAsset,
+    required this.title,
+    required this.onTap,
+    this.danger = false,
+  });
+
+  final String iconAsset;
+  final String title;
+  final VoidCallback onTap;
+  final bool danger;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final color = danger ? colors.danger : colors.ink;
+
+    return Pressable(
+      onTap: onTap,
+      semanticLabel: title,
+      pressedScale: 0.98,
+      child: Padding(
+        padding: const EdgeInsets.symmetric(vertical: 6),
+        child: Row(
+          children: [
+            Container(
+              width: 38,
+              height: 38,
+              decoration: BoxDecoration(
+                color: danger
+                    ? colors.danger.withValues(alpha: 0.1)
+                    : colors.surfaceStrong,
+                borderRadius: BorderRadius.circular(13),
+                border: Border.all(
+                  color: danger
+                      ? colors.danger.withValues(alpha: 0.22)
+                      : colors.border,
+                ),
+              ),
+              alignment: Alignment.center,
+              child: AppIcon(
+                iconAsset,
+                size: 17,
+                color: danger ? colors.danger : colors.inkMuted,
+              ),
+            ),
+            const SizedBox(width: 14),
+            Expanded(
+              child: Text(
+                title,
+                style: theme.textTheme.bodyLarge?.copyWith(
+                  color: color,
+                  fontWeight: FontWeight.w500,
+                ),
+              ),
+            ),
+            AppIcon(
+              'assets/icons/chevron-right.svg',
+              size: 16,
+              color: danger
+                  ? colors.danger.withValues(alpha: 0.7)
+                  : colors.inkFaint,
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}

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

@@ -306,7 +306,7 @@ class SettingsScreen extends StatelessWidget {
                     const SizedBox(height: 28),
                     Center(
                       child: Text(
-                        'CarDash 0.3.0 · Android 10+',
+                        'CarDash 0.4.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.3.0+3
+version: 0.4.0+4
 
 environment:
   sdk: ^3.13.7

+ 24 - 0
test/launcher_controller_test.dart

@@ -147,6 +147,30 @@ void main() {
     expect(controller.homeButtonCount, 16);
   });
 
+  test('pinned labels and custom icons persist', () async {
+    SharedPreferences.setMockInitialValues({});
+    final prefs = await SharedPreferences.getInstance();
+    final apps = _apps(3);
+    final platform = FakeLauncherPlatform(apps);
+    final first = LauncherController(platform, SettingsRepository(prefs));
+    first.initialize();
+    addTearDown(first.dispose);
+
+    expect(await first.addPinnedApp(apps[0]), isNull);
+    final pinned = first.pinnedApps.first;
+    await first.renamePinnedApp(pinned, 'My shortcut');
+    await first.setPinnedIcon(pinned, apps[1]);
+
+    final second = LauncherController(platform, SettingsRepository(prefs));
+    second.initialize();
+    addTearDown(second.dispose);
+
+    expect(second.pinnedApps.length, 1);
+    expect(second.pinnedApps.first.label, 'My shortcut');
+    expect(second.pinnedApps.first.iconPackageName, apps[1].packageName);
+    expect(second.pinnedApps.first.iconActivityName, apps[1].activityName);
+  });
+
   test('pinned apps and slot visibility survive a new controller', () async {
     SharedPreferences.setMockInitialValues({});
     final prefs = await SharedPreferences.getInstance();

+ 15 - 0
test/pin_service_test.dart

@@ -56,6 +56,16 @@ void main() {
       expect(repository.nightEndMinutes, 6 * 60);
     });
 
+    test('the first-run launcher hint is shown once', () async {
+      SharedPreferences.setMockInitialValues({});
+      final prefs = await SharedPreferences.getInstance();
+      final repository = SettingsRepository(prefs);
+
+      expect(repository.homeRoleHintShown, isFalse);
+      await repository.setHomeRoleHintShown(true);
+      expect(repository.homeRoleHintShown, isTrue);
+    });
+
     test('home buttons default to enabled and persist a toggle', () async {
       SharedPreferences.setMockInitialValues({});
       final prefs = await SharedPreferences.getInstance();
@@ -79,6 +89,8 @@ void main() {
           packageName: 'com.example.one',
           activityName: 'com.example.one.Main',
           label: 'One',
+          iconPackageName: 'com.example.icon',
+          iconActivityName: 'com.example.icon.Main',
         ),
         SlotAssignment(
           packageName: 'com.example.two',
@@ -91,7 +103,10 @@ void main() {
       final restored = repository.pinnedApps;
       expect(restored.length, 2);
       expect(restored.first.label, 'One');
+      expect(restored.first.hasCustomIcon, isTrue);
+      expect(restored.first.iconPackageName, 'com.example.icon');
       expect(restored.last.packageName, 'com.example.two');
+      expect(restored.last.hasCustomIcon, isFalse);
 
       await repository.setPinnedApps(const []);
       expect(repository.pinnedApps, isEmpty);