pinned_app_actions.dart 15 KB

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