|
@@ -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,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+}
|