home_screen.dart 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import 'package:provider/provider.dart';
  4. import '../../core/theme/app_colors.dart';
  5. import '../../core/widgets/app_icon.dart';
  6. import '../../core/widgets/surface_card.dart';
  7. import '../../data/models/slot_assignment.dart';
  8. import '../../data/models/slot_type.dart';
  9. import '../apps/all_apps_screen.dart';
  10. import '../apps/app_picker_sheet.dart';
  11. import '../settings/settings_screen.dart';
  12. import 'launcher_controller.dart';
  13. import 'system_controller.dart';
  14. import 'widgets/clock_block.dart';
  15. import 'widgets/shortcut_grid.dart';
  16. import 'widgets/status_cluster.dart';
  17. import 'widgets/volume_card.dart';
  18. /// The only home page. No app bar, no dock, no pages: information, the main
  19. /// volume control, and up to 16 configurable shortcuts.
  20. class HomeScreen extends StatelessWidget {
  21. const HomeScreen({super.key});
  22. @override
  23. Widget build(BuildContext context) {
  24. final colors = context.colors;
  25. final theme = Theme.of(context);
  26. final launcher = context.watch<LauncherController>();
  27. final system = context.watch<SystemController>();
  28. final screen = MediaQuery.sizeOf(context);
  29. final compact = screen.height < 700 || screen.width < 1100;
  30. final horizontalPadding = compact ? 30.0 : 44.0;
  31. final denseHome = launcher.homeButtonCount > 8;
  32. final leftWidth = denseHome
  33. ? (screen.width * 0.31).clamp(300.0, 400.0)
  34. : (screen.width * 0.37).clamp(300.0, 440.0);
  35. final gap = compact ? 26.0 : 40.0;
  36. final showHomeBanner = launcher.defaultHomeKnown && !launcher.isDefaultHome;
  37. final shortcuts = <ShortcutSpec>[
  38. for (final slot in SlotType.values)
  39. if (launcher.isSlotEnabled(slot))
  40. ShortcutSpec(
  41. title: slot.label,
  42. subtitle: launcher.assignmentFor(slot)?.label ?? 'Select app',
  43. iconAsset: slot.iconAsset,
  44. assigned: launcher.assignmentFor(slot) != null,
  45. onTap: () => _openSlot(context, slot),
  46. onLongPress: () => showAppPicker(context, slot),
  47. longPressHint: 'change the app',
  48. ),
  49. ShortcutSpec(
  50. title: 'Settings',
  51. subtitle: 'Theme & PIN',
  52. iconAsset: 'assets/icons/settings.svg',
  53. assigned: true,
  54. onTap: () => _openSettings(context),
  55. ),
  56. ShortcutSpec(
  57. title: 'All apps',
  58. subtitle: 'Installed apps',
  59. iconAsset: 'assets/icons/layout-grid.svg',
  60. assigned: true,
  61. onTap: () => _openAllApps(context),
  62. ),
  63. for (final pinned in launcher.pinnedApps)
  64. ShortcutSpec(
  65. title: pinned.label.isEmpty ? pinned.packageName : pinned.label,
  66. subtitle: null,
  67. iconAsset: 'assets/icons/app-window.svg',
  68. assigned: true,
  69. onTap: () => _openPinned(context, pinned),
  70. onLongPress: () => _removePinned(context, pinned),
  71. longPressHint: 'remove from Home',
  72. ),
  73. ];
  74. return PopScope(
  75. canPop: false,
  76. child: Scaffold(
  77. backgroundColor: colors.canvas,
  78. body: Stack(
  79. children: [
  80. Positioned.fill(
  81. child: DecoratedBox(
  82. decoration: homeBackground(
  83. colors,
  84. Theme.of(context).brightness,
  85. ),
  86. ),
  87. ),
  88. Positioned(
  89. top: -190,
  90. right: -120,
  91. child: IgnorePointer(
  92. child: Container(
  93. width: 560,
  94. height: 560,
  95. decoration: BoxDecoration(
  96. shape: BoxShape.circle,
  97. gradient: RadialGradient(
  98. colors: [
  99. colors.accent.withValues(
  100. alpha: Theme.of(context).brightness == Brightness.dark
  101. ? 0.16
  102. : 0.10,
  103. ),
  104. colors.accent.withValues(alpha: 0),
  105. ],
  106. ),
  107. ),
  108. ),
  109. ),
  110. ),
  111. SafeArea(
  112. minimum: EdgeInsets.fromLTRB(
  113. horizontalPadding,
  114. compact ? 18 : 24,
  115. horizontalPadding,
  116. compact ? 14 : 18,
  117. ),
  118. child: SizedBox.expand(
  119. child: Column(
  120. crossAxisAlignment: CrossAxisAlignment.stretch,
  121. children: [
  122. Row(
  123. crossAxisAlignment: CrossAxisAlignment.start,
  124. children: [
  125. ClockBlock(compact: compact),
  126. const Spacer(),
  127. StatusCluster(status: system.status, compact: compact),
  128. ],
  129. ),
  130. SizedBox(height: compact ? 16 : 24),
  131. Expanded(
  132. child: Row(
  133. crossAxisAlignment: CrossAxisAlignment.stretch,
  134. children: [
  135. SizedBox(
  136. width: leftWidth,
  137. child: Column(
  138. crossAxisAlignment: CrossAxisAlignment.stretch,
  139. mainAxisAlignment: MainAxisAlignment.end,
  140. children: [
  141. if (showHomeBanner) ...[
  142. _HomeRoleBanner(
  143. launcher: launcher,
  144. onSetUp: () => _requestHome(context),
  145. ),
  146. const SizedBox(height: 14),
  147. ],
  148. VolumeCard(
  149. volume: system.volume,
  150. compact: compact,
  151. onSetVolume: system.setVolume,
  152. onToggleMute: system.toggleMute,
  153. ),
  154. ],
  155. ),
  156. ),
  157. SizedBox(width: gap),
  158. Expanded(
  159. child: Column(
  160. children: [
  161. Expanded(
  162. child: ShortcutGrid(
  163. shortcuts: shortcuts,
  164. compact: compact,
  165. ),
  166. ),
  167. if (!compact) ...[
  168. const SizedBox(height: 4),
  169. Text(
  170. launcher.pinnedApps.isEmpty
  171. ? 'Hold a shortcut to change the app'
  172. : 'Hold a slot to change · hold an added app to remove',
  173. style: theme.textTheme.bodySmall?.copyWith(
  174. color: colors.inkFaint,
  175. ),
  176. ),
  177. ],
  178. ],
  179. ),
  180. ),
  181. ],
  182. ),
  183. ),
  184. ],
  185. ),
  186. ),
  187. ),
  188. ],
  189. ),
  190. ),
  191. );
  192. }
  193. Future<void> _openPinned(
  194. BuildContext context,
  195. SlotAssignment assignment,
  196. ) async {
  197. final error = await context.read<LauncherController>().launchAssignment(
  198. assignment,
  199. );
  200. if (error != null && context.mounted) {
  201. _showMessage(context, error);
  202. }
  203. }
  204. void _removePinned(BuildContext context, SlotAssignment assignment) {
  205. unawaited(context.read<LauncherController>().removePinnedApp(assignment));
  206. _showMessage(
  207. context,
  208. '${assignment.label.isEmpty ? 'App' : assignment.label} removed from Home',
  209. );
  210. }
  211. Future<void> _openSlot(BuildContext context, SlotType slot) async {
  212. final launcher = context.read<LauncherController>();
  213. if (launcher.assignmentFor(slot) == null) {
  214. await showAppPicker(context, slot);
  215. return;
  216. }
  217. final error = await launcher.launchSlot(slot);
  218. if (error != null && context.mounted) {
  219. _showMessage(context, error);
  220. }
  221. }
  222. void _openSettings(BuildContext context) {
  223. Navigator.of(context)
  224. .push(MaterialPageRoute<void>(builder: (_) => const SettingsScreen()));
  225. }
  226. void _openAllApps(BuildContext context) {
  227. Navigator.of(context)
  228. .push(MaterialPageRoute<void>(builder: (_) => const AllAppsScreen()));
  229. }
  230. Future<void> _requestHome(BuildContext context) async {
  231. final launcher = context.read<LauncherController>();
  232. final launched = await launcher.requestDefaultHome();
  233. if (!launched && context.mounted) {
  234. _showMessage(context, 'Could not open the home app settings.');
  235. }
  236. }
  237. void _showMessage(BuildContext context, String message) {
  238. final messenger = ScaffoldMessenger.of(context);
  239. messenger.hideCurrentSnackBar();
  240. messenger.showSnackBar(SnackBar(content: Text(message)));
  241. }
  242. }
  243. class _HomeRoleBanner extends StatelessWidget {
  244. const _HomeRoleBanner({required this.launcher, required this.onSetUp});
  245. final LauncherController launcher;
  246. final VoidCallback onSetUp;
  247. @override
  248. Widget build(BuildContext context) {
  249. final colors = context.colors;
  250. final theme = Theme.of(context);
  251. return SurfaceCard(
  252. padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
  253. radius: 24,
  254. color: colors.accentSoft.withValues(alpha: 0.62),
  255. borderColor: colors.accent.withValues(alpha: 0.18),
  256. showShadow: false,
  257. child: Row(
  258. children: [
  259. Container(
  260. width: 38,
  261. height: 38,
  262. decoration: BoxDecoration(
  263. color: colors.accent.withValues(alpha: 0.14),
  264. borderRadius: BorderRadius.circular(13),
  265. ),
  266. alignment: Alignment.center,
  267. child: AppIcon(
  268. 'assets/icons/smartphone.svg',
  269. size: 18,
  270. color: colors.accent,
  271. ),
  272. ),
  273. const SizedBox(width: 13),
  274. Expanded(
  275. child: Column(
  276. crossAxisAlignment: CrossAxisAlignment.start,
  277. children: [
  278. Text(
  279. 'Make CarDash your home app',
  280. style: theme.textTheme.titleSmall,
  281. ),
  282. const SizedBox(height: 2),
  283. Text(
  284. 'Open it with the Home button.',
  285. style: theme.textTheme.bodySmall?.copyWith(
  286. color: colors.inkMuted,
  287. ),
  288. ),
  289. ],
  290. ),
  291. ),
  292. TextButton(
  293. onPressed: onSetUp,
  294. style: TextButton.styleFrom(
  295. foregroundColor: colors.accent,
  296. textStyle: theme.textTheme.labelLarge,
  297. ),
  298. child: const Text('Set up'),
  299. ),
  300. ],
  301. ),
  302. );
  303. }
  304. }