all_apps_screen.dart 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  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: AppIcon(
  89. 'assets/icons/search.svg',
  90. size: 18,
  91. color: colors.inkFaint,
  92. ),
  93. ),
  94. ),
  95. ),
  96. ),
  97. ],
  98. ),
  99. const SizedBox(height: 24),
  100. Expanded(child: _buildBody(context, launcher, apps)),
  101. ],
  102. ),
  103. ),
  104. ),
  105. ),
  106. );
  107. }
  108. Widget _buildBody(
  109. BuildContext context,
  110. LauncherController launcher,
  111. List<AppEntry> apps,
  112. ) {
  113. final colors = context.colors;
  114. final theme = Theme.of(context);
  115. if (apps.isEmpty && launcher.isLoadingApps) {
  116. return Center(
  117. child: SizedBox(
  118. width: 30,
  119. height: 30,
  120. child: CircularProgressIndicator(
  121. strokeWidth: 2.4,
  122. color: colors.accent,
  123. ),
  124. ),
  125. );
  126. }
  127. if (apps.isEmpty && launcher.appsError != null) {
  128. return Center(
  129. child: Column(
  130. mainAxisSize: MainAxisSize.min,
  131. children: [
  132. Text(
  133. 'Could not read installed apps.',
  134. style: theme.textTheme.bodyLarge,
  135. ),
  136. const SizedBox(height: 12),
  137. TextButton(
  138. onPressed: () => launcher.loadApps(force: true),
  139. child: const Text('Try again'),
  140. ),
  141. ],
  142. ),
  143. );
  144. }
  145. if (apps.isEmpty) {
  146. return Center(
  147. child: Text(
  148. 'No apps match “${_query.trim()}”.',
  149. style: theme.textTheme.bodyMedium?.copyWith(color: colors.inkMuted),
  150. ),
  151. );
  152. }
  153. return GridView.builder(
  154. padding: const EdgeInsets.only(bottom: 20),
  155. gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
  156. maxCrossAxisExtent: 162,
  157. mainAxisExtent: 150,
  158. crossAxisSpacing: 12,
  159. mainAxisSpacing: 12,
  160. ),
  161. itemCount: apps.length,
  162. itemBuilder: (context, index) {
  163. final app = apps[index];
  164. return Pressable(
  165. semanticLabel:
  166. '${app.label}. Tap to open. Long press to add to Home.',
  167. onTap: () => _launch(app),
  168. onLongPress: () => _addToHome(app),
  169. child: Container(
  170. decoration: BoxDecoration(
  171. color: colors.surface,
  172. borderRadius: BorderRadius.circular(24),
  173. border: Border.all(color: colors.border),
  174. ),
  175. padding: const EdgeInsets.fromLTRB(12, 16, 12, 12),
  176. child: Column(
  177. mainAxisAlignment: MainAxisAlignment.center,
  178. children: [
  179. AppIconImage(bytes: app.icon, size: 46),
  180. const SizedBox(height: 10),
  181. Text(
  182. app.label,
  183. maxLines: 2,
  184. overflow: TextOverflow.ellipsis,
  185. textAlign: TextAlign.center,
  186. style: theme.textTheme.bodySmall?.copyWith(
  187. color: colors.ink,
  188. fontWeight: FontWeight.w500,
  189. ),
  190. ),
  191. ],
  192. ),
  193. ),
  194. );
  195. },
  196. );
  197. }
  198. Future<void> _addToHome(AppEntry app) async {
  199. final launcher = context.read<LauncherController>();
  200. final error = await launcher.addPinnedApp(app);
  201. if (!mounted) return;
  202. final message = error ?? '${app.label} added to Home';
  203. ScaffoldMessenger.of(context)
  204. .showSnackBar(SnackBar(content: Text(message)));
  205. }
  206. Future<void> _launch(AppEntry app) async {
  207. final launcher = context.read<LauncherController>();
  208. final error = await launcher.launchAppEntry(app);
  209. if (!mounted) return;
  210. if (error != null) {
  211. ScaffoldMessenger.of(context)
  212. .showSnackBar(SnackBar(content: Text(error)));
  213. return;
  214. }
  215. // Pressing Home should land on the dashboard, not on All apps.
  216. Navigator.of(context).popUntil((route) => route.isFirst);
  217. }
  218. }
  219. class _BackButton extends StatelessWidget {
  220. const _BackButton({required this.onTap});
  221. final VoidCallback onTap;
  222. @override
  223. Widget build(BuildContext context) {
  224. final colors = context.colors;
  225. return Pressable(
  226. onTap: onTap,
  227. semanticLabel: 'Back',
  228. pressedScale: 0.92,
  229. child: Container(
  230. width: 46,
  231. height: 46,
  232. decoration: BoxDecoration(
  233. color: colors.surface,
  234. shape: BoxShape.circle,
  235. border: Border.all(color: colors.border),
  236. ),
  237. alignment: Alignment.center,
  238. child: AppIcon(
  239. 'assets/icons/arrow-left.svg',
  240. size: 20,
  241. color: colors.ink,
  242. ),
  243. ),
  244. );
  245. }
  246. }