all_apps_screen.dart 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  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 '../home/launcher_controller.dart';
  9. /// Every launchable app, with real icons and a search field. The header is
  10. /// page content; there is no persistent app bar.
  11. class AllAppsScreen extends StatefulWidget {
  12. const AllAppsScreen({super.key});
  13. @override
  14. State<AllAppsScreen> createState() => _AllAppsScreenState();
  15. }
  16. class _AllAppsScreenState extends State<AllAppsScreen> {
  17. final _search = TextEditingController();
  18. String _query = '';
  19. @override
  20. void initState() {
  21. super.initState();
  22. WidgetsBinding.instance.addPostFrameCallback((_) {
  23. if (mounted) context.read<LauncherController>().loadApps(force: true);
  24. });
  25. }
  26. @override
  27. void dispose() {
  28. _search.dispose();
  29. super.dispose();
  30. }
  31. @override
  32. Widget build(BuildContext context) {
  33. final colors = context.colors;
  34. final theme = Theme.of(context);
  35. final launcher = context.watch<LauncherController>();
  36. final query = _query.trim().toLowerCase();
  37. final apps = launcher.apps
  38. .where(
  39. (app) => query.isEmpty || app.label.toLowerCase().contains(query),
  40. )
  41. .toList(growable: false);
  42. return Scaffold(
  43. backgroundColor: colors.canvas,
  44. body: Container(
  45. decoration: homeBackground(colors, Theme.of(context).brightness),
  46. child: SafeArea(
  47. child: Padding(
  48. padding: const EdgeInsets.fromLTRB(36, 24, 36, 16),
  49. child: Column(
  50. crossAxisAlignment: CrossAxisAlignment.start,
  51. children: [
  52. Row(
  53. children: [
  54. _BackButton(onTap: () => Navigator.of(context).pop()),
  55. const SizedBox(width: 18),
  56. Expanded(
  57. child: Column(
  58. crossAxisAlignment: CrossAxisAlignment.start,
  59. children: [
  60. Text(
  61. 'All apps',
  62. style: theme.textTheme.headlineLarge,
  63. ),
  64. const SizedBox(height: 2),
  65. Text(
  66. '${apps.length} installed ${apps.length == 1 ? 'app' : 'apps'}'
  67. ' · hold an app to add it to Home',
  68. maxLines: 1,
  69. overflow: TextOverflow.ellipsis,
  70. style: theme.textTheme.bodySmall?.copyWith(
  71. color: colors.inkMuted,
  72. ),
  73. ),
  74. ],
  75. ),
  76. ),
  77. SizedBox(
  78. width: 300,
  79. child: TextField(
  80. controller: _search,
  81. onChanged: (value) => setState(() => _query = value),
  82. style: theme.textTheme.bodyMedium,
  83. decoration: InputDecoration(
  84. hintText: 'Search apps',
  85. isDense: true,
  86. prefixIcon: Padding(
  87. padding: const EdgeInsets.only(left: 2),
  88. child: SizedBox(
  89. width: 18,
  90. height: 18,
  91. child: Center(
  92. child: AppIcon(
  93. 'assets/icons/search.svg',
  94. size: 15,
  95. color: colors.inkFaint,
  96. ),
  97. ),
  98. ),
  99. ),
  100. prefixIconConstraints: const BoxConstraints(
  101. minWidth: 34,
  102. minHeight: 34,
  103. ),
  104. ),
  105. ),
  106. ),
  107. ],
  108. ),
  109. const SizedBox(height: 24),
  110. Expanded(child: _buildBody(context, launcher, apps)),
  111. ],
  112. ),
  113. ),
  114. ),
  115. ),
  116. );
  117. }
  118. Widget _buildBody(
  119. BuildContext context,
  120. LauncherController launcher,
  121. List<AppEntry> apps,
  122. ) {
  123. final colors = context.colors;
  124. final theme = Theme.of(context);
  125. if (apps.isEmpty && launcher.isLoadingApps) {
  126. return Center(
  127. child: SizedBox(
  128. width: 30,
  129. height: 30,
  130. child: CircularProgressIndicator(
  131. strokeWidth: 2.4,
  132. color: colors.accent,
  133. ),
  134. ),
  135. );
  136. }
  137. if (apps.isEmpty && launcher.appsError != null) {
  138. return Center(
  139. child: Column(
  140. mainAxisSize: MainAxisSize.min,
  141. children: [
  142. Text(
  143. 'Could not read installed apps.',
  144. style: theme.textTheme.bodyLarge,
  145. ),
  146. const SizedBox(height: 12),
  147. TextButton(
  148. onPressed: () => launcher.loadApps(force: true),
  149. child: const Text('Try again'),
  150. ),
  151. ],
  152. ),
  153. );
  154. }
  155. if (apps.isEmpty) {
  156. return Center(
  157. child: Text(
  158. 'No apps match “${_query.trim()}”.',
  159. style: theme.textTheme.bodyMedium?.copyWith(color: colors.inkMuted),
  160. ),
  161. );
  162. }
  163. return GridView.builder(
  164. padding: const EdgeInsets.only(bottom: 20),
  165. gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
  166. maxCrossAxisExtent: 162,
  167. mainAxisExtent: 150,
  168. crossAxisSpacing: 12,
  169. mainAxisSpacing: 12,
  170. ),
  171. itemCount: apps.length,
  172. itemBuilder: (context, index) {
  173. final app = apps[index];
  174. return Pressable(
  175. semanticLabel:
  176. '${app.label}. Tap to open. Long press to add to Home.',
  177. onTap: () => _launch(app),
  178. onLongPress: () => _addToHome(app),
  179. child: Container(
  180. decoration: BoxDecoration(
  181. color: colors.surface,
  182. borderRadius: BorderRadius.circular(24),
  183. border: Border.all(color: colors.border),
  184. ),
  185. padding: const EdgeInsets.fromLTRB(12, 16, 12, 12),
  186. child: Column(
  187. mainAxisAlignment: MainAxisAlignment.center,
  188. children: [
  189. AppIconImage(bytes: app.icon, size: 46),
  190. const SizedBox(height: 10),
  191. Text(
  192. app.label,
  193. maxLines: 2,
  194. overflow: TextOverflow.ellipsis,
  195. textAlign: TextAlign.center,
  196. style: theme.textTheme.bodySmall?.copyWith(
  197. color: colors.ink,
  198. fontWeight: FontWeight.w500,
  199. ),
  200. ),
  201. ],
  202. ),
  203. ),
  204. );
  205. },
  206. );
  207. }
  208. Future<void> _addToHome(AppEntry app) async {
  209. final launcher = context.read<LauncherController>();
  210. final error = await launcher.addPinnedApp(app);
  211. if (!mounted) return;
  212. final message = error ?? '${app.label} added to Home';
  213. ScaffoldMessenger.of(context)
  214. .showSnackBar(SnackBar(content: Text(message)));
  215. }
  216. Future<void> _launch(AppEntry app) async {
  217. final launcher = context.read<LauncherController>();
  218. final error = await launcher.launchAppEntry(app);
  219. if (!mounted) return;
  220. if (error != null) {
  221. ScaffoldMessenger.of(context)
  222. .showSnackBar(SnackBar(content: Text(error)));
  223. return;
  224. }
  225. // Pressing Home should land on the dashboard, not on All apps.
  226. Navigator.of(context).popUntil((route) => route.isFirst);
  227. }
  228. }
  229. class _BackButton extends StatelessWidget {
  230. const _BackButton({required this.onTap});
  231. final VoidCallback onTap;
  232. @override
  233. Widget build(BuildContext context) {
  234. final colors = context.colors;
  235. return Pressable(
  236. onTap: onTap,
  237. semanticLabel: 'Back',
  238. pressedScale: 0.92,
  239. child: Container(
  240. width: 83,
  241. height: 83,
  242. decoration: BoxDecoration(
  243. color: colors.surface,
  244. shape: BoxShape.circle,
  245. border: Border.all(color: colors.border),
  246. ),
  247. alignment: Alignment.center,
  248. child: AppIcon(
  249. 'assets/icons/arrow-left.svg',
  250. size: 36,
  251. color: colors.ink,
  252. ),
  253. ),
  254. );
  255. }
  256. }