home_screen.dart 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import 'package:provider/provider.dart';
  4. import '../../core/app_clock.dart';
  5. import '../../core/theme/app_colors.dart';
  6. import '../../core/theme/theme_controller.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 'pinned_app_actions.dart';
  14. import 'system_controller.dart';
  15. import 'widgets/clock_block.dart';
  16. import 'widgets/shortcut_grid.dart';
  17. import 'widgets/status_cluster.dart';
  18. import 'widgets/volume_card.dart';
  19. /// The only home page. No app bar, no dock, no pages: information, the main
  20. /// volume control, and up to 16 configurable shortcuts.
  21. class HomeScreen extends StatelessWidget {
  22. const HomeScreen({super.key});
  23. @override
  24. Widget build(BuildContext context) {
  25. final colors = context.colors;
  26. final theme = Theme.of(context);
  27. final launcher = context.watch<LauncherController>();
  28. final system = context.watch<SystemController>();
  29. final appearance = context.watch<ThemeController>();
  30. final screen = MediaQuery.sizeOf(context);
  31. final compact = screen.height < 700 || screen.width < 1100;
  32. final horizontalPadding = compact ? 30.0 : 44.0;
  33. final denseHome = launcher.homeButtonCount > 8;
  34. final leftWidth = denseHome
  35. ? (screen.width * 0.31).clamp(300.0, 400.0)
  36. : (screen.width * 0.37).clamp(300.0, 440.0);
  37. final gap = compact ? 26.0 : 40.0;
  38. ShortcutSpec slotSpec(SlotType slot) {
  39. final assignment = launcher.assignmentFor(slot);
  40. final app = assignment == null
  41. ? null
  42. : launcher.appForAssignment(assignment);
  43. final playing =
  44. slot == SlotType.music && system.isMusicPlaying && assignment != null;
  45. final nonSystemIcon = app != null && !app.isSystemApp ? app.icon : null;
  46. return ShortcutSpec(
  47. title: slot.label,
  48. subtitle: assignment?.label ?? 'Select app',
  49. iconAsset: slot.iconAsset,
  50. iconBytes: playing ? app?.icon : nonSystemIcon,
  51. showEqualizer: playing,
  52. assigned: assignment != null,
  53. onTap: () => _openSlot(context, slot),
  54. onLongPress: () => showAppPicker(context, slot),
  55. longPressHint: 'change the app',
  56. );
  57. }
  58. ShortcutSpec pinnedSpec(SlotAssignment pinned) {
  59. final app = launcher.appForAssignment(pinned);
  60. final customIcon = launcher.appForCustomIcon(pinned);
  61. final iconSource = customIcon ?? app;
  62. final useRealIcon =
  63. iconSource != null && (customIcon != null || !iconSource.isSystemApp);
  64. return ShortcutSpec(
  65. title: pinned.label.isEmpty ? pinned.packageName : pinned.label,
  66. subtitle: null,
  67. iconAsset: 'assets/icons/app-window.svg',
  68. iconBytes: useRealIcon ? iconSource.icon : null,
  69. assigned: true,
  70. onTap: () => _openPinned(context, pinned),
  71. onLongPress: () => showPinnedAppMenu(context, pinned),
  72. longPressHint: 'open app options',
  73. );
  74. }
  75. final shortcuts = <ShortcutSpec>[
  76. for (final slot in SlotType.values)
  77. if (launcher.isSlotEnabled(slot)) slotSpec(slot),
  78. for (final pinned in launcher.pinnedApps) pinnedSpec(pinned),
  79. ShortcutSpec(
  80. title: 'Settings',
  81. subtitle: 'Theme & PIN',
  82. iconAsset: 'assets/icons/settings.svg',
  83. assigned: true,
  84. onTap: () => _openSettings(context),
  85. ),
  86. ShortcutSpec(
  87. title: 'All apps',
  88. subtitle: 'Installed apps',
  89. iconAsset: 'assets/icons/layout-grid.svg',
  90. assigned: true,
  91. onTap: () => _openAllApps(context),
  92. ),
  93. ];
  94. return PopScope(
  95. canPop: false,
  96. child: Scaffold(
  97. backgroundColor: colors.canvas,
  98. body: Stack(
  99. children: [
  100. Positioned.fill(
  101. child: DecoratedBox(
  102. decoration: homeBackground(
  103. colors,
  104. Theme.of(context).brightness,
  105. ),
  106. ),
  107. ),
  108. const _FirstRunLauncherTip(),
  109. Positioned(
  110. top: -190,
  111. right: -120,
  112. child: IgnorePointer(
  113. child: Container(
  114. width: 560,
  115. height: 560,
  116. decoration: BoxDecoration(
  117. shape: BoxShape.circle,
  118. gradient: RadialGradient(
  119. colors: [
  120. colors.accent.withValues(
  121. alpha: Theme.of(context).brightness == Brightness.dark
  122. ? 0.16
  123. : 0.10,
  124. ),
  125. colors.accent.withValues(alpha: 0),
  126. ],
  127. ),
  128. ),
  129. ),
  130. ),
  131. ),
  132. SafeArea(
  133. minimum: EdgeInsets.fromLTRB(
  134. horizontalPadding,
  135. compact ? 18 : 24,
  136. horizontalPadding,
  137. compact ? 14 : 18,
  138. ),
  139. child: SizedBox.expand(
  140. child: Column(
  141. crossAxisAlignment: CrossAxisAlignment.stretch,
  142. children: [
  143. Row(
  144. crossAxisAlignment: CrossAxisAlignment.start,
  145. children: [
  146. ClockBlock(compact: compact),
  147. const Spacer(),
  148. StatusCluster(
  149. status: system.status,
  150. compact: compact,
  151. showBattery: appearance.showBattery,
  152. ),
  153. ],
  154. ),
  155. SizedBox(height: compact ? 16 : 24),
  156. Expanded(
  157. child: Row(
  158. crossAxisAlignment: CrossAxisAlignment.stretch,
  159. children: [
  160. SizedBox(
  161. width: leftWidth,
  162. child: Column(
  163. crossAxisAlignment: CrossAxisAlignment.stretch,
  164. mainAxisAlignment: MainAxisAlignment.end,
  165. children: [
  166. VolumeCard(
  167. volume: system.volume,
  168. compact: compact,
  169. onSetVolume: system.setVolume,
  170. onToggleMute: system.toggleMute,
  171. ),
  172. ],
  173. ),
  174. ),
  175. SizedBox(width: gap),
  176. Expanded(
  177. child: Column(
  178. children: [
  179. Expanded(
  180. child: ShortcutGrid(
  181. shortcuts: shortcuts,
  182. compact: compact,
  183. ),
  184. ),
  185. if (!compact) ...[
  186. const SizedBox(height: 4),
  187. _HomeHint(
  188. text: launcher.pinnedApps.isEmpty
  189. ? 'Hold a shortcut to change the app'
  190. : 'Hold a slot to change · hold an added app for options',
  191. style: theme.textTheme.bodySmall?.copyWith(
  192. color: colors.inkFaint,
  193. ),
  194. ),
  195. ],
  196. ],
  197. ),
  198. ),
  199. ],
  200. ),
  201. ),
  202. ],
  203. ),
  204. ),
  205. ),
  206. ],
  207. ),
  208. ),
  209. );
  210. }
  211. Future<void> _openPinned(
  212. BuildContext context,
  213. SlotAssignment assignment,
  214. ) async {
  215. final error = await context.read<LauncherController>().launchAssignment(
  216. assignment,
  217. );
  218. if (error != null && context.mounted) {
  219. _showMessage(context, error);
  220. }
  221. }
  222. Future<void> _openSlot(BuildContext context, SlotType slot) async {
  223. final launcher = context.read<LauncherController>();
  224. if (launcher.assignmentFor(slot) == null) {
  225. await showAppPicker(context, slot);
  226. return;
  227. }
  228. final error = await launcher.launchSlot(slot);
  229. if (error != null && context.mounted) {
  230. _showMessage(context, error);
  231. }
  232. }
  233. void _openSettings(BuildContext context) {
  234. Navigator.of(context)
  235. .push(MaterialPageRoute<void>(builder: (_) => const SettingsScreen()));
  236. }
  237. void _openAllApps(BuildContext context) {
  238. Navigator.of(context)
  239. .push(MaterialPageRoute<void>(builder: (_) => const AllAppsScreen()));
  240. }
  241. void _showMessage(BuildContext context, String message) {
  242. final messenger = ScaffoldMessenger.of(context);
  243. messenger.hideCurrentSnackBar();
  244. messenger.showSnackBar(SnackBar(content: Text(message)));
  245. }
  246. }
  247. class _FirstRunLauncherTip extends StatefulWidget {
  248. const _FirstRunLauncherTip();
  249. @override
  250. State<_FirstRunLauncherTip> createState() => _FirstRunLauncherTipState();
  251. }
  252. class _FirstRunLauncherTipState extends State<_FirstRunLauncherTip> {
  253. @override
  254. void initState() {
  255. super.initState();
  256. WidgetsBinding.instance.addPostFrameCallback((_) async {
  257. if (!mounted) return;
  258. final launcher = context.read<LauncherController>();
  259. if (launcher.homeRoleHintShown) return;
  260. await launcher.markHomeRoleHintShown();
  261. if (!mounted) return;
  262. final messenger = ScaffoldMessenger.of(context);
  263. messenger.hideCurrentSnackBar();
  264. messenger.showSnackBar(
  265. const SnackBar(
  266. content: Text(
  267. 'You can set CarDash as your default launcher in Settings.',
  268. ),
  269. duration: Duration(seconds: 5),
  270. ),
  271. );
  272. });
  273. }
  274. @override
  275. Widget build(BuildContext context) => const SizedBox.shrink();
  276. }
  277. class _HomeHint extends StatefulWidget {
  278. const _HomeHint({required this.text, this.style});
  279. final String text;
  280. final TextStyle? style;
  281. @override
  282. State<_HomeHint> createState() => _HomeHintState();
  283. }
  284. class _HomeHintState extends State<_HomeHint> {
  285. Timer? _timer;
  286. bool _visible = true;
  287. @override
  288. void initState() {
  289. super.initState();
  290. final elapsed = DateTime.now().difference(AppClock.startedAt);
  291. final remaining = const Duration(minutes: 2) - elapsed;
  292. if (remaining <= Duration.zero) {
  293. _visible = false;
  294. } else {
  295. _timer = Timer(remaining, () {
  296. if (mounted) setState(() => _visible = false);
  297. });
  298. }
  299. }
  300. @override
  301. void dispose() {
  302. _timer?.cancel();
  303. super.dispose();
  304. }
  305. @override
  306. Widget build(BuildContext context) {
  307. return AnimatedSwitcher(
  308. duration: const Duration(milliseconds: 350),
  309. switchInCurve: Curves.easeOut,
  310. switchOutCurve: Curves.easeIn,
  311. child: _visible
  312. ? Text(
  313. widget.text,
  314. key: const ValueKey('home-hint'),
  315. textAlign: TextAlign.center,
  316. style: widget.style,
  317. )
  318. : const SizedBox.shrink(key: ValueKey('home-hint-gone')),
  319. );
  320. }
  321. }