app_picker_sheet.dart 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  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 '../../data/models/app_entry.dart';
  8. import '../../data/models/slot_assignment.dart';
  9. import '../../data/models/slot_type.dart';
  10. import '../home/launcher_controller.dart';
  11. Future<void> showAppPicker(BuildContext context, SlotType slot) {
  12. return showModalBottomSheet<void>(
  13. context: context,
  14. isScrollControlled: true,
  15. useSafeArea: false,
  16. backgroundColor: Colors.transparent,
  17. barrierColor: const Color(0x8A000000),
  18. builder: (_) => AppPickerSheet(slot: slot),
  19. );
  20. }
  21. class AppPickerSheet extends StatefulWidget {
  22. const AppPickerSheet({super.key, required this.slot});
  23. final SlotType slot;
  24. @override
  25. State<AppPickerSheet> createState() => _AppPickerSheetState();
  26. }
  27. class _AppPickerSheetState extends State<AppPickerSheet> {
  28. final _search = TextEditingController();
  29. String _query = '';
  30. @override
  31. void initState() {
  32. super.initState();
  33. final controller = context.read<LauncherController>();
  34. if (!controller.isLoadingApps) {
  35. controller.loadApps(force: true);
  36. }
  37. }
  38. @override
  39. void dispose() {
  40. _search.dispose();
  41. super.dispose();
  42. }
  43. @override
  44. Widget build(BuildContext context) {
  45. final colors = context.colors;
  46. final theme = Theme.of(context);
  47. final launcher = context.watch<LauncherController>();
  48. final current = launcher.assignmentFor(widget.slot);
  49. final query = _query.trim().toLowerCase();
  50. final apps = launcher.apps
  51. .where(
  52. (app) => query.isEmpty || app.label.toLowerCase().contains(query),
  53. )
  54. .toList(growable: false);
  55. return Padding(
  56. padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
  57. child: Container(
  58. height: MediaQuery.sizeOf(context).height * 0.86,
  59. decoration: BoxDecoration(
  60. color: colors.surface,
  61. borderRadius: const BorderRadius.vertical(top: Radius.circular(32)),
  62. ),
  63. child: Column(
  64. children: [
  65. const SizedBox(height: 12),
  66. Container(
  67. width: 44,
  68. height: 4,
  69. decoration: BoxDecoration(
  70. color: colors.inkFaint.withValues(alpha: 0.4),
  71. borderRadius: BorderRadius.circular(99),
  72. ),
  73. ),
  74. Padding(
  75. padding: const EdgeInsets.fromLTRB(28, 20, 18, 12),
  76. child: Row(
  77. children: [
  78. Expanded(
  79. child: Column(
  80. crossAxisAlignment: CrossAxisAlignment.start,
  81. children: [
  82. Text(
  83. 'Choose ${widget.slot.label.toLowerCase()} app',
  84. style: theme.textTheme.headlineSmall,
  85. ),
  86. const SizedBox(height: 3),
  87. Text(
  88. current == null
  89. ? 'Pick any installed app'
  90. : 'Currently ${current.label}',
  91. style: theme.textTheme.bodySmall?.copyWith(
  92. color: colors.inkMuted,
  93. ),
  94. ),
  95. ],
  96. ),
  97. ),
  98. if (current != null)
  99. TextButton(
  100. onPressed: () async {
  101. await launcher.clearSlot(widget.slot);
  102. if (context.mounted) Navigator.of(context).pop();
  103. },
  104. child: const Text('Clear'),
  105. ),
  106. _CircleButton(
  107. iconAsset: 'assets/icons/x.svg',
  108. semanticLabel: 'Close',
  109. onTap: () => Navigator.of(context).pop(),
  110. ),
  111. ],
  112. ),
  113. ),
  114. Padding(
  115. padding: const EdgeInsets.symmetric(horizontal: 24),
  116. child: TextField(
  117. controller: _search,
  118. onChanged: (value) => setState(() => _query = value),
  119. textInputAction: TextInputAction.search,
  120. style: theme.textTheme.bodyLarge,
  121. decoration: InputDecoration(
  122. hintText: 'Search apps',
  123. prefixIcon: Padding(
  124. padding: const EdgeInsets.only(left: 4),
  125. child: AppIcon(
  126. 'assets/icons/search.svg',
  127. size: 20,
  128. color: colors.inkFaint,
  129. ),
  130. ),
  131. suffixIcon: _query.isEmpty
  132. ? null
  133. : IconButton(
  134. onPressed: () {
  135. _search.clear();
  136. setState(() => _query = '');
  137. },
  138. icon: Icon(
  139. Icons.close_rounded,
  140. size: 20,
  141. color: colors.inkFaint,
  142. ),
  143. ),
  144. ),
  145. ),
  146. ),
  147. const SizedBox(height: 12),
  148. Expanded(child: _buildBody(context, launcher, apps, current)),
  149. ],
  150. ),
  151. ),
  152. );
  153. }
  154. Widget _buildBody(
  155. BuildContext context,
  156. LauncherController launcher,
  157. List<AppEntry> apps,
  158. SlotAssignment? current,
  159. ) {
  160. final colors = context.colors;
  161. final theme = Theme.of(context);
  162. if (apps.isEmpty && launcher.isLoadingApps) {
  163. return Center(
  164. child: SizedBox(
  165. width: 28,
  166. height: 28,
  167. child: CircularProgressIndicator(
  168. strokeWidth: 2.4,
  169. color: colors.accent,
  170. ),
  171. ),
  172. );
  173. }
  174. if (apps.isEmpty && launcher.appsError != null) {
  175. return Center(
  176. child: Padding(
  177. padding: const EdgeInsets.all(32),
  178. child: Column(
  179. mainAxisSize: MainAxisSize.min,
  180. children: [
  181. Text(
  182. 'Could not read installed apps.',
  183. style: theme.textTheme.bodyLarge,
  184. textAlign: TextAlign.center,
  185. ),
  186. const SizedBox(height: 12),
  187. TextButton(
  188. onPressed: () => launcher.loadApps(force: true),
  189. child: const Text('Try again'),
  190. ),
  191. ],
  192. ),
  193. ),
  194. );
  195. }
  196. if (apps.isEmpty) {
  197. return Center(
  198. child: Text(
  199. 'No apps match “${_query.trim()}”.',
  200. style: theme.textTheme.bodyMedium?.copyWith(color: colors.inkMuted),
  201. ),
  202. );
  203. }
  204. return ListView.builder(
  205. padding: const EdgeInsets.fromLTRB(16, 0, 16, 28),
  206. itemCount: apps.length,
  207. itemBuilder: (context, index) {
  208. final app = apps[index];
  209. final selected =
  210. current != null &&
  211. current.packageName == app.packageName &&
  212. current.activityName == app.activityName;
  213. return Padding(
  214. padding: const EdgeInsets.symmetric(vertical: 2),
  215. child: Material(
  216. color: Colors.transparent,
  217. child: InkWell(
  218. borderRadius: BorderRadius.circular(20),
  219. onTap: () async {
  220. await launcher.assign(widget.slot, app);
  221. if (context.mounted) Navigator.of(context).pop();
  222. },
  223. child: Padding(
  224. padding: const EdgeInsets.symmetric(
  225. horizontal: 12,
  226. vertical: 11,
  227. ),
  228. child: Row(
  229. children: [
  230. AppIconImage(bytes: app.icon, size: 42),
  231. const SizedBox(width: 16),
  232. Expanded(
  233. child: Text(
  234. app.label,
  235. maxLines: 1,
  236. overflow: TextOverflow.ellipsis,
  237. style: theme.textTheme.bodyLarge?.copyWith(
  238. fontWeight: FontWeight.w500,
  239. ),
  240. ),
  241. ),
  242. if (selected)
  243. Container(
  244. width: 26,
  245. height: 26,
  246. decoration: BoxDecoration(
  247. color: colors.accent,
  248. shape: BoxShape.circle,
  249. ),
  250. child: AppIcon(
  251. 'assets/icons/check.svg',
  252. size: 15,
  253. color: colors.accentInk,
  254. ),
  255. ),
  256. ],
  257. ),
  258. ),
  259. ),
  260. ),
  261. );
  262. },
  263. );
  264. }
  265. }
  266. class _CircleButton extends StatelessWidget {
  267. const _CircleButton({
  268. required this.iconAsset,
  269. required this.semanticLabel,
  270. required this.onTap,
  271. });
  272. final String iconAsset;
  273. final String semanticLabel;
  274. final VoidCallback onTap;
  275. @override
  276. Widget build(BuildContext context) {
  277. final colors = context.colors;
  278. return Pressable(
  279. onTap: onTap,
  280. semanticLabel: semanticLabel,
  281. pressedScale: 0.92,
  282. child: Container(
  283. width: 40,
  284. height: 40,
  285. decoration: BoxDecoration(
  286. color: colors.surfaceStrong,
  287. shape: BoxShape.circle,
  288. border: Border.all(color: colors.border),
  289. ),
  290. alignment: Alignment.center,
  291. child: AppIcon(iconAsset, size: 17, color: colors.inkMuted),
  292. ),
  293. );
  294. }
  295. }