all_apps_screen.dart 7.5 KB

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