pinned_app_actions.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. import 'package:flutter/material.dart';
  2. import 'package:provider/provider.dart';
  3. import '../../core/theme/app_colors.dart';
  4. import '../../core/widgets/app_icon.dart';
  5. import '../../core/widgets/app_icon_image.dart';
  6. import '../../core/widgets/pressable.dart';
  7. import '../../core/widgets/surface_card.dart';
  8. import '../../data/models/app_entry.dart';
  9. import '../../data/models/slot_assignment.dart';
  10. import 'launcher_controller.dart';
  11. const _sheetBarrier = Color(0x8A000000);
  12. /// The long-press menu for an app pinned to Home.
  13. Future<void> showPinnedAppMenu(
  14. BuildContext context,
  15. SlotAssignment assignment,
  16. ) async {
  17. final launcher = context.read<LauncherController>();
  18. final app = launcher.appForAssignment(assignment);
  19. final iconApp = launcher.appForCustomIcon(assignment) ?? app;
  20. await showModalBottomSheet<void>(
  21. context: context,
  22. useSafeArea: true,
  23. backgroundColor: Colors.transparent,
  24. barrierColor: _sheetBarrier,
  25. builder: (sheetContext) {
  26. final colors = sheetContext.colors;
  27. final theme = Theme.of(sheetContext);
  28. Future<void> closeThen(Future<void> Function() action) async {
  29. Navigator.of(sheetContext).pop();
  30. await Future<void>.delayed(const Duration(milliseconds: 190));
  31. if (!context.mounted) return;
  32. await action();
  33. }
  34. return Padding(
  35. padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
  36. child: Center(
  37. heightFactor: 1,
  38. child: ConstrainedBox(
  39. constraints: const BoxConstraints(maxWidth: 480),
  40. child: SurfaceCard(
  41. padding: const EdgeInsets.fromLTRB(22, 18, 22, 16),
  42. radius: 28,
  43. child: Column(
  44. mainAxisSize: MainAxisSize.min,
  45. children: [
  46. Row(
  47. children: [
  48. AppIconImage(bytes: iconApp?.icon, size: 44),
  49. const SizedBox(width: 14),
  50. Expanded(
  51. child: Column(
  52. crossAxisAlignment: CrossAxisAlignment.start,
  53. children: [
  54. Text(
  55. assignment.label.isEmpty
  56. ? assignment.packageName
  57. : assignment.label,
  58. maxLines: 1,
  59. overflow: TextOverflow.ellipsis,
  60. style: theme.textTheme.titleMedium,
  61. ),
  62. const SizedBox(height: 2),
  63. Text(
  64. 'Home shortcut',
  65. style: theme.textTheme.bodySmall?.copyWith(
  66. color: colors.inkMuted,
  67. ),
  68. ),
  69. ],
  70. ),
  71. ),
  72. Pressable(
  73. onTap: () => Navigator.of(sheetContext).pop(),
  74. semanticLabel: 'Close',
  75. pressedScale: 0.92,
  76. child: Container(
  77. width: 38,
  78. height: 38,
  79. decoration: BoxDecoration(
  80. color: colors.surfaceStrong,
  81. shape: BoxShape.circle,
  82. border: Border.all(color: colors.border),
  83. ),
  84. alignment: Alignment.center,
  85. child: AppIcon(
  86. 'assets/icons/x.svg',
  87. size: 16,
  88. color: colors.inkMuted,
  89. ),
  90. ),
  91. ),
  92. ],
  93. ),
  94. const SizedBox(height: 12),
  95. Divider(color: colors.border, height: 1),
  96. const SizedBox(height: 4),
  97. _MenuAction(
  98. iconAsset: 'assets/icons/pencil.svg',
  99. title: 'Rename',
  100. onTap: () => closeThen(() async {
  101. final name = await _showRenameDialog(context, assignment);
  102. if (name != null) {
  103. await launcher.renamePinnedApp(assignment, name);
  104. }
  105. }),
  106. ),
  107. _MenuAction(
  108. iconAsset: 'assets/icons/image.svg',
  109. title: 'Change icon',
  110. onTap: () => closeThen(
  111. () => showPinnedIconPicker(context, assignment),
  112. ),
  113. ),
  114. _MenuAction(
  115. iconAsset: 'assets/icons/delete.svg',
  116. title: 'Remove',
  117. danger: true,
  118. onTap: () => closeThen(() async {
  119. await launcher.removePinnedApp(assignment);
  120. if (context.mounted) {
  121. ScaffoldMessenger.of(context).hideCurrentSnackBar();
  122. ScaffoldMessenger.of(context).showSnackBar(
  123. SnackBar(
  124. content: Text(
  125. '${assignment.label.isEmpty ? 'App' : assignment.label} '
  126. 'removed from Home',
  127. ),
  128. ),
  129. );
  130. }
  131. }),
  132. ),
  133. ],
  134. ),
  135. ),
  136. ),
  137. ),
  138. );
  139. },
  140. );
  141. }
  142. Future<String?> _showRenameDialog(
  143. BuildContext context,
  144. SlotAssignment assignment,
  145. ) async {
  146. final controller = TextEditingController(text: assignment.label);
  147. try {
  148. return await showDialog<String>(
  149. context: context,
  150. builder: (dialogContext) {
  151. void submit() {
  152. final name = controller.text.trim();
  153. if (name.isEmpty) return;
  154. Navigator.of(dialogContext).pop(name);
  155. }
  156. return AlertDialog(
  157. title: const Text('Rename shortcut'),
  158. content: TextField(
  159. controller: controller,
  160. autofocus: true,
  161. maxLength: 32,
  162. textInputAction: TextInputAction.done,
  163. decoration: const InputDecoration(
  164. labelText: 'Home label',
  165. helperText: 'Only the Home label changes.',
  166. ),
  167. onSubmitted: (_) => submit(),
  168. ),
  169. actions: [
  170. TextButton(
  171. onPressed: () => Navigator.of(dialogContext).pop(),
  172. child: const Text('Cancel'),
  173. ),
  174. FilledButton(onPressed: submit, child: const Text('Save')),
  175. ],
  176. );
  177. },
  178. );
  179. } finally {
  180. controller.dispose();
  181. }
  182. }
  183. Future<void> showPinnedIconPicker(
  184. BuildContext context,
  185. SlotAssignment assignment,
  186. ) {
  187. return showModalBottomSheet<void>(
  188. context: context,
  189. isScrollControlled: true,
  190. backgroundColor: Colors.transparent,
  191. barrierColor: _sheetBarrier,
  192. builder: (_) => _PinnedIconSheet(assignment: assignment),
  193. );
  194. }
  195. class _PinnedIconSheet extends StatefulWidget {
  196. const _PinnedIconSheet({required this.assignment});
  197. final SlotAssignment assignment;
  198. @override
  199. State<_PinnedIconSheet> createState() => _PinnedIconSheetState();
  200. }
  201. class _PinnedIconSheetState extends State<_PinnedIconSheet> {
  202. @override
  203. void initState() {
  204. super.initState();
  205. WidgetsBinding.instance.addPostFrameCallback((_) {
  206. if (!mounted) return;
  207. final launcher = context.read<LauncherController>();
  208. if (!launcher.isLoadingApps) launcher.loadApps(force: true);
  209. });
  210. }
  211. @override
  212. Widget build(BuildContext context) {
  213. final colors = context.colors;
  214. final theme = Theme.of(context);
  215. final launcher = context.watch<LauncherController>();
  216. final current = launcher.appForAssignment(widget.assignment);
  217. final choices = <AppEntry>[];
  218. final seen = <String>{};
  219. if (current != null) {
  220. choices.add(current);
  221. seen.add(current.componentId);
  222. }
  223. for (final app in launcher.apps) {
  224. if (choices.length >= 25) break;
  225. if (seen.add(app.componentId)) choices.add(app);
  226. }
  227. final selectedComponent = widget.assignment.hasCustomIcon
  228. ? '${widget.assignment.iconPackageName}/${widget.assignment.iconActivityName}'
  229. : '${widget.assignment.packageName}/${widget.assignment.activityName}';
  230. return Container(
  231. height: MediaQuery.sizeOf(context).height * 0.86,
  232. decoration: BoxDecoration(
  233. color: colors.surface,
  234. borderRadius: const BorderRadius.vertical(top: Radius.circular(32)),
  235. ),
  236. child: Column(
  237. children: [
  238. const SizedBox(height: 12),
  239. Container(
  240. width: 44,
  241. height: 4,
  242. decoration: BoxDecoration(
  243. color: colors.inkFaint.withValues(alpha: 0.4),
  244. borderRadius: BorderRadius.circular(99),
  245. ),
  246. ),
  247. Padding(
  248. padding: const EdgeInsets.fromLTRB(28, 20, 18, 8),
  249. child: Row(
  250. children: [
  251. Expanded(
  252. child: Column(
  253. crossAxisAlignment: CrossAxisAlignment.start,
  254. children: [
  255. Text('Change icon', style: theme.textTheme.headlineSmall),
  256. const SizedBox(height: 3),
  257. Text(
  258. 'Choose an icon for ${widget.assignment.label}.',
  259. maxLines: 1,
  260. overflow: TextOverflow.ellipsis,
  261. style: theme.textTheme.bodySmall?.copyWith(
  262. color: colors.inkMuted,
  263. ),
  264. ),
  265. ],
  266. ),
  267. ),
  268. Pressable(
  269. onTap: () => Navigator.of(context).pop(),
  270. semanticLabel: 'Close',
  271. pressedScale: 0.92,
  272. child: Container(
  273. width: 40,
  274. height: 40,
  275. decoration: BoxDecoration(
  276. color: colors.surfaceStrong,
  277. shape: BoxShape.circle,
  278. border: Border.all(color: colors.border),
  279. ),
  280. alignment: Alignment.center,
  281. child: AppIcon(
  282. 'assets/icons/x.svg',
  283. size: 16,
  284. color: colors.inkMuted,
  285. ),
  286. ),
  287. ),
  288. ],
  289. ),
  290. ),
  291. const SizedBox(height: 8),
  292. Expanded(
  293. child: choices.isEmpty
  294. ? Center(
  295. child: Text(
  296. 'No installed apps to choose from.',
  297. style: theme.textTheme.bodyMedium?.copyWith(
  298. color: colors.inkMuted,
  299. ),
  300. ),
  301. )
  302. : GridView.builder(
  303. padding: const EdgeInsets.fromLTRB(24, 4, 24, 28),
  304. gridDelegate:
  305. const SliverGridDelegateWithMaxCrossAxisExtent(
  306. maxCrossAxisExtent: 116,
  307. mainAxisExtent: 112,
  308. crossAxisSpacing: 10,
  309. mainAxisSpacing: 10,
  310. ),
  311. itemCount: choices.length,
  312. itemBuilder: (context, index) {
  313. final app = choices[index];
  314. final selected = app.componentId == selectedComponent;
  315. return Material(
  316. color: Colors.transparent,
  317. child: InkWell(
  318. borderRadius: BorderRadius.circular(24),
  319. onTap: () async {
  320. await launcher.setPinnedIcon(
  321. widget.assignment,
  322. app,
  323. );
  324. if (context.mounted) Navigator.of(context).pop();
  325. },
  326. child: Container(
  327. padding: const EdgeInsets.fromLTRB(8, 10, 8, 8),
  328. decoration: BoxDecoration(
  329. color: colors.surfaceStrong,
  330. borderRadius: BorderRadius.circular(24),
  331. border: Border.all(
  332. color: selected ? colors.accent : colors.border,
  333. width: selected ? 1.8 : 1,
  334. ),
  335. ),
  336. child: Column(
  337. mainAxisAlignment: MainAxisAlignment.center,
  338. children: [
  339. AppIconImage(bytes: app.icon, size: 44),
  340. const SizedBox(height: 6),
  341. Text(
  342. app.label,
  343. maxLines: 1,
  344. overflow: TextOverflow.ellipsis,
  345. textAlign: TextAlign.center,
  346. style: theme.textTheme.bodySmall?.copyWith(
  347. color: colors.ink,
  348. ),
  349. ),
  350. ],
  351. ),
  352. ),
  353. ),
  354. );
  355. },
  356. ),
  357. ),
  358. ],
  359. ),
  360. );
  361. }
  362. }
  363. class _MenuAction extends StatelessWidget {
  364. const _MenuAction({
  365. required this.iconAsset,
  366. required this.title,
  367. required this.onTap,
  368. this.danger = false,
  369. });
  370. final String iconAsset;
  371. final String title;
  372. final VoidCallback onTap;
  373. final bool danger;
  374. @override
  375. Widget build(BuildContext context) {
  376. final colors = context.colors;
  377. final theme = Theme.of(context);
  378. final color = danger ? colors.danger : colors.ink;
  379. return Pressable(
  380. onTap: onTap,
  381. semanticLabel: title,
  382. pressedScale: 0.98,
  383. child: Padding(
  384. padding: const EdgeInsets.symmetric(vertical: 6),
  385. child: Row(
  386. children: [
  387. Container(
  388. width: 38,
  389. height: 38,
  390. decoration: BoxDecoration(
  391. color: danger
  392. ? colors.danger.withValues(alpha: 0.1)
  393. : colors.surfaceStrong,
  394. borderRadius: BorderRadius.circular(13),
  395. border: Border.all(
  396. color: danger
  397. ? colors.danger.withValues(alpha: 0.22)
  398. : colors.border,
  399. ),
  400. ),
  401. alignment: Alignment.center,
  402. child: AppIcon(
  403. iconAsset,
  404. size: 17,
  405. color: danger ? colors.danger : colors.inkMuted,
  406. ),
  407. ),
  408. const SizedBox(width: 14),
  409. Expanded(
  410. child: Text(
  411. title,
  412. style: theme.textTheme.bodyLarge?.copyWith(
  413. color: color,
  414. fontWeight: FontWeight.w500,
  415. ),
  416. ),
  417. ),
  418. AppIcon(
  419. 'assets/icons/chevron-right.svg',
  420. size: 16,
  421. color: danger
  422. ? colors.danger.withValues(alpha: 0.7)
  423. : colors.inkFaint,
  424. ),
  425. ],
  426. ),
  427. ),
  428. );
  429. }
  430. }