settings_screen.dart 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894
  1. import 'package:flutter/material.dart';
  2. import 'package:provider/provider.dart';
  3. import '../../core/theme/accent_choice.dart';
  4. import '../../core/theme/app_colors.dart';
  5. import '../../core/theme/theme_controller.dart';
  6. import '../../core/theme/theme_preference.dart';
  7. import '../../core/widgets/app_icon.dart';
  8. import '../../core/widgets/pressable.dart';
  9. import '../../core/widgets/surface_card.dart';
  10. import '../../data/models/slot_type.dart';
  11. import '../apps/app_picker_sheet.dart';
  12. import '../home/launcher_controller.dart';
  13. import '../lock/lock_controller.dart';
  14. import '../lock/pin_setup_screen.dart';
  15. import 'car_data_section.dart';
  16. class SettingsScreen extends StatelessWidget {
  17. const SettingsScreen({super.key});
  18. @override
  19. Widget build(BuildContext context) {
  20. final colors = context.colors;
  21. final theme = Theme.of(context);
  22. final themeController = context.watch<ThemeController>();
  23. final launcher = context.watch<LauncherController>();
  24. final lock = context.watch<LockController>();
  25. return Scaffold(
  26. backgroundColor: colors.canvas,
  27. body: Container(
  28. decoration: homeBackground(colors, Theme.of(context).brightness),
  29. child: SafeArea(
  30. child: SingleChildScrollView(
  31. padding: const EdgeInsets.fromLTRB(32, 22, 32, 32),
  32. child: Center(
  33. child: ConstrainedBox(
  34. constraints: const BoxConstraints(maxWidth: 980),
  35. child: Column(
  36. crossAxisAlignment: CrossAxisAlignment.start,
  37. children: [
  38. Row(
  39. children: [
  40. Pressable(
  41. onTap: () => Navigator.of(context).pop(),
  42. semanticLabel: 'Back',
  43. pressedScale: 0.92,
  44. child: Container(
  45. width: 46,
  46. height: 46,
  47. decoration: BoxDecoration(
  48. color: colors.surface,
  49. shape: BoxShape.circle,
  50. border: Border.all(color: colors.border),
  51. ),
  52. alignment: Alignment.center,
  53. child: AppIcon(
  54. 'assets/icons/arrow-left.svg',
  55. size: 20,
  56. color: colors.ink,
  57. ),
  58. ),
  59. ),
  60. const SizedBox(width: 18),
  61. Column(
  62. crossAxisAlignment: CrossAxisAlignment.start,
  63. children: [
  64. Text(
  65. 'Settings',
  66. style: theme.textTheme.headlineLarge,
  67. ),
  68. const SizedBox(height: 2),
  69. Text(
  70. 'Theme, car data and access',
  71. style: theme.textTheme.bodySmall?.copyWith(
  72. color: colors.inkMuted,
  73. ),
  74. ),
  75. ],
  76. ),
  77. ],
  78. ),
  79. const SizedBox(height: 32),
  80. _SectionTitle(
  81. title: 'General',
  82. colors: colors,
  83. theme: theme,
  84. ),
  85. const SizedBox(height: 12),
  86. SurfaceCard(
  87. padding: const EdgeInsets.fromLTRB(26, 22, 26, 18),
  88. child: Column(
  89. crossAxisAlignment: CrossAxisAlignment.start,
  90. children: [
  91. Row(
  92. children: [
  93. Text('Theme', style: theme.textTheme.titleMedium),
  94. const Spacer(),
  95. _ThemeSelector(
  96. selected: themeController.preference,
  97. onSelected: themeController.setPreference,
  98. ),
  99. ],
  100. ),
  101. const SizedBox(height: 8),
  102. Text(
  103. themeController.preference ==
  104. ThemePreference.automatic
  105. ? 'Switches automatically at the times below.'
  106. : themeController.preference ==
  107. ThemePreference.dark
  108. ? 'Always dark.'
  109. : 'Always light.',
  110. style: theme.textTheme.bodySmall?.copyWith(
  111. color: colors.inkMuted,
  112. ),
  113. ),
  114. const SizedBox(height: 8),
  115. Divider(color: colors.border, height: 24),
  116. Row(
  117. children: [
  118. Text(
  119. 'Accent',
  120. style: theme.textTheme.titleMedium,
  121. ),
  122. const Spacer(),
  123. Text(
  124. themeController.accent.label,
  125. style: theme.textTheme.labelMedium?.copyWith(
  126. color: colors.inkMuted,
  127. ),
  128. ),
  129. ],
  130. ),
  131. const SizedBox(height: 10),
  132. _AccentSelector(
  133. selected: themeController.accent,
  134. onSelected: (choice) {
  135. themeController.setAccent(choice);
  136. },
  137. ),
  138. if (themeController.preference ==
  139. ThemePreference.automatic) ...[
  140. const SizedBox(height: 8),
  141. Divider(color: colors.border, height: 24),
  142. _ActionRow(
  143. iconAsset: 'assets/icons/moon.svg',
  144. title: 'Dark from',
  145. value: _formatMinutes(
  146. context,
  147. themeController.nightStartMinutes,
  148. ),
  149. onTap: () => _pickTime(
  150. context,
  151. themeController,
  152. start: true,
  153. ),
  154. ),
  155. const SizedBox(height: 2),
  156. _ActionRow(
  157. iconAsset: 'assets/icons/sun.svg',
  158. title: 'Light from',
  159. value: _formatMinutes(
  160. context,
  161. themeController.nightEndMinutes,
  162. ),
  163. onTap: () => _pickTime(
  164. context,
  165. themeController,
  166. start: false,
  167. ),
  168. ),
  169. ],
  170. Divider(color: colors.border, height: 24),
  171. _BatteryToggleRow(
  172. value: themeController.showBattery,
  173. onChanged: (value) {
  174. themeController.setShowBattery(value);
  175. },
  176. ),
  177. Divider(color: colors.border, height: 30),
  178. Row(
  179. children: [
  180. Text(
  181. 'Home buttons',
  182. style: theme.textTheme.titleMedium,
  183. ),
  184. const Spacer(),
  185. Text(
  186. '${launcher.homeButtonCount} / '
  187. '${LauncherController.maxHomeButtons}',
  188. style: theme.textTheme.labelMedium?.copyWith(
  189. color: colors.inkMuted,
  190. ),
  191. ),
  192. ],
  193. ),
  194. const SizedBox(height: 3),
  195. Text(
  196. 'Turn one off to hide it. All apps is always shown; Settings lives in the top-right status area.',
  197. style: theme.textTheme.bodySmall?.copyWith(
  198. color: colors.inkMuted,
  199. ),
  200. ),
  201. const SizedBox(height: 4),
  202. for (final slot in SlotType.values)
  203. _ShortcutToggleRow(
  204. slot: slot,
  205. launcher: launcher,
  206. onPick: () => showAppPicker(context, slot),
  207. ),
  208. Divider(color: colors.border, height: 30),
  209. _HomeRoleRow(launcher: launcher),
  210. ],
  211. ),
  212. ),
  213. const SizedBox(height: 30),
  214. _SectionTitle(
  215. title: 'Car data',
  216. colors: colors,
  217. theme: theme,
  218. ),
  219. const SizedBox(height: 12),
  220. SurfaceCard(
  221. padding: const EdgeInsets.fromLTRB(26, 22, 26, 20),
  222. child: const CarDataSection(),
  223. ),
  224. const SizedBox(height: 30),
  225. _SectionTitle(
  226. title: 'Security',
  227. colors: colors,
  228. theme: theme,
  229. ),
  230. const SizedBox(height: 12),
  231. SurfaceCard(
  232. padding: const EdgeInsets.fromLTRB(26, 22, 26, 22),
  233. child: Column(
  234. crossAxisAlignment: CrossAxisAlignment.start,
  235. children: [
  236. Row(
  237. children: [
  238. Container(
  239. width: 42,
  240. height: 42,
  241. decoration: BoxDecoration(
  242. color: colors.accentSoft,
  243. borderRadius: BorderRadius.circular(14),
  244. ),
  245. alignment: Alignment.center,
  246. child: AppIcon(
  247. 'assets/icons/lock.svg',
  248. size: 20,
  249. color: colors.accent,
  250. ),
  251. ),
  252. const SizedBox(width: 14),
  253. Expanded(
  254. child: Column(
  255. crossAxisAlignment: CrossAxisAlignment.start,
  256. children: [
  257. Text(
  258. 'PIN protection',
  259. style: theme.textTheme.titleMedium,
  260. ),
  261. const SizedBox(height: 2),
  262. Text(
  263. lock.pinSet ? 'On' : 'Off',
  264. style: theme.textTheme.bodySmall
  265. ?.copyWith(
  266. color: lock.pinSet
  267. ? colors.accent
  268. : colors.inkMuted,
  269. fontWeight: FontWeight.w600,
  270. ),
  271. ),
  272. ],
  273. ),
  274. ),
  275. if (!lock.pinSet)
  276. _SmallButton(
  277. label: 'Set PIN',
  278. onTap: () =>
  279. _openPinSetup(context, PinSetupMode.set),
  280. ),
  281. ],
  282. ),
  283. if (lock.pinSet) ...[
  284. const SizedBox(height: 10),
  285. Divider(color: colors.border, height: 24),
  286. _ActionRow(
  287. iconAsset: 'assets/icons/lock.svg',
  288. title: 'Change PIN',
  289. trailing: const _Chevron(),
  290. onTap: () =>
  291. _openPinSetup(context, PinSetupMode.change),
  292. ),
  293. _ActionRow(
  294. iconAsset: 'assets/icons/x.svg',
  295. title: 'Remove PIN',
  296. trailing: const _Chevron(),
  297. onTap: () =>
  298. _openPinSetup(context, PinSetupMode.remove),
  299. ),
  300. ],
  301. const SizedBox(height: 16),
  302. Text(
  303. 'The PIN is requested only when CarDash starts. It does not lock '
  304. 'again when you switch apps or turn the screen off. It protects the '
  305. 'launcher, not the device itself.',
  306. style: theme.textTheme.bodySmall?.copyWith(
  307. color: colors.inkMuted,
  308. height: 1.5,
  309. ),
  310. ),
  311. ],
  312. ),
  313. ),
  314. const SizedBox(height: 28),
  315. Center(
  316. child: Text(
  317. 'CarDash 0.7.0 · Android 10+',
  318. style: theme.textTheme.bodySmall?.copyWith(
  319. color: colors.inkFaint,
  320. ),
  321. ),
  322. ),
  323. ],
  324. ),
  325. ),
  326. ),
  327. ),
  328. ),
  329. ),
  330. );
  331. }
  332. Future<void> _openPinSetup(BuildContext context, PinSetupMode mode) async {
  333. final result = await Navigator.of(
  334. context,
  335. ).push<bool>(MaterialPageRoute(builder: (_) => PinSetupScreen(mode: mode)));
  336. if (result != true || !context.mounted) return;
  337. final message = switch (mode) {
  338. PinSetupMode.set =>
  339. 'PIN set. It will be requested the next time CarDash starts.',
  340. PinSetupMode.change => 'PIN changed.',
  341. PinSetupMode.remove => 'PIN removed.',
  342. };
  343. ScaffoldMessenger.of(context)
  344. .showSnackBar(SnackBar(content: Text(message)));
  345. }
  346. Future<void> _pickTime(
  347. BuildContext context,
  348. ThemeController controller, {
  349. required bool start,
  350. }) async {
  351. final minutes = start
  352. ? controller.nightStartMinutes
  353. : controller.nightEndMinutes;
  354. final picked = await showTimePicker(
  355. context: context,
  356. initialTime: TimeOfDay(hour: minutes ~/ 60, minute: minutes % 60),
  357. helpText: start ? 'Dark from' : 'Light from',
  358. builder: (context, child) {
  359. final theme = Theme.of(context);
  360. return Theme(
  361. data: theme.copyWith(
  362. timePickerTheme: TimePickerThemeData(
  363. backgroundColor: theme.colorScheme.surface,
  364. dialBackgroundColor: theme.colorScheme.surfaceContainerHighest,
  365. shape: RoundedRectangleBorder(
  366. borderRadius: BorderRadius.circular(28),
  367. ),
  368. ),
  369. ),
  370. child: child ?? const SizedBox.shrink(),
  371. );
  372. },
  373. );
  374. if (picked == null || !context.mounted) return;
  375. final newValue = picked.hour * 60 + picked.minute;
  376. await controller.setNightWindow(
  377. startMinutes: start ? newValue : controller.nightStartMinutes,
  378. endMinutes: start ? controller.nightEndMinutes : newValue,
  379. );
  380. }
  381. String _formatMinutes(BuildContext context, int minutes) {
  382. return TimeOfDay(hour: minutes ~/ 60, minute: minutes % 60).format(context);
  383. }
  384. }
  385. class _SectionTitle extends StatelessWidget {
  386. const _SectionTitle({
  387. required this.title,
  388. required this.colors,
  389. required this.theme,
  390. });
  391. final String title;
  392. final AppColors colors;
  393. final ThemeData theme;
  394. @override
  395. Widget build(BuildContext context) {
  396. return Padding(
  397. padding: const EdgeInsets.only(left: 4),
  398. child: Text(
  399. title,
  400. style: theme.textTheme.titleLarge?.copyWith(color: colors.inkMuted),
  401. ),
  402. );
  403. }
  404. }
  405. class _ThemeSelector extends StatelessWidget {
  406. const _ThemeSelector({required this.selected, required this.onSelected});
  407. final ThemePreference selected;
  408. final ValueChanged<ThemePreference> onSelected;
  409. @override
  410. Widget build(BuildContext context) {
  411. final colors = context.colors;
  412. return Container(
  413. padding: const EdgeInsets.all(4),
  414. decoration: BoxDecoration(
  415. color: colors.surfaceStrong,
  416. borderRadius: BorderRadius.circular(999),
  417. border: Border.all(color: colors.border),
  418. ),
  419. child: Row(
  420. mainAxisSize: MainAxisSize.min,
  421. children: [
  422. for (final preference in ThemePreference.values)
  423. Pressable(
  424. onTap: () => onSelected(preference),
  425. semanticLabel: 'Theme ${preference.storageValue}',
  426. pressedScale: 0.95,
  427. child: AnimatedContainer(
  428. duration: const Duration(milliseconds: 160),
  429. curve: Curves.easeOutCubic,
  430. padding: const EdgeInsets.symmetric(
  431. horizontal: 18,
  432. vertical: 9,
  433. ),
  434. decoration: BoxDecoration(
  435. color: selected == preference
  436. ? colors.accent
  437. : Colors.transparent,
  438. borderRadius: BorderRadius.circular(999),
  439. ),
  440. child: Text(
  441. switch (preference) {
  442. ThemePreference.light => 'Light',
  443. ThemePreference.dark => 'Dark',
  444. ThemePreference.automatic => 'Auto',
  445. },
  446. style: Theme.of(context).textTheme.labelMedium?.copyWith(
  447. color: selected == preference
  448. ? colors.accentInk
  449. : colors.inkMuted,
  450. fontWeight: FontWeight.w600,
  451. ),
  452. ),
  453. ),
  454. ),
  455. ],
  456. ),
  457. );
  458. }
  459. }
  460. class _AccentSelector extends StatelessWidget {
  461. const _AccentSelector({required this.selected, required this.onSelected});
  462. final AccentChoice selected;
  463. final ValueChanged<AccentChoice> onSelected;
  464. @override
  465. Widget build(BuildContext context) {
  466. final colors = context.colors;
  467. final brightness = Theme.of(context).brightness;
  468. return Wrap(
  469. spacing: 10,
  470. runSpacing: 10,
  471. children: [
  472. for (final choice in AccentChoice.values)
  473. Semantics(
  474. label: '${choice.label} accent',
  475. selected: choice == selected,
  476. button: true,
  477. child: Material(
  478. color: Colors.transparent,
  479. shape: const CircleBorder(),
  480. child: InkWell(
  481. customBorder: const CircleBorder(),
  482. onTap: () => onSelected(choice),
  483. child: Padding(
  484. padding: const EdgeInsets.all(3),
  485. child: AnimatedContainer(
  486. duration: const Duration(milliseconds: 180),
  487. width: 30,
  488. height: 30,
  489. decoration: BoxDecoration(
  490. shape: BoxShape.circle,
  491. color: choice.accentFor(brightness),
  492. border: Border.all(
  493. color: choice == selected ? colors.ink : colors.border,
  494. width: choice == selected ? 2 : 1,
  495. ),
  496. ),
  497. alignment: Alignment.center,
  498. child: choice == selected
  499. ? Icon(
  500. Icons.check_rounded,
  501. size: 16,
  502. color: choice.inkFor(brightness),
  503. )
  504. : null,
  505. ),
  506. ),
  507. ),
  508. ),
  509. ),
  510. ],
  511. );
  512. }
  513. }
  514. class _SettingsSwitch extends StatelessWidget {
  515. const _SettingsSwitch({
  516. required this.value,
  517. required this.onChanged,
  518. required this.semanticsLabel,
  519. });
  520. final bool value;
  521. final ValueChanged<bool> onChanged;
  522. final String semanticsLabel;
  523. @override
  524. Widget build(BuildContext context) {
  525. final colors = context.colors;
  526. return Semantics(
  527. label: semanticsLabel,
  528. child: Switch(
  529. value: value,
  530. onChanged: onChanged,
  531. thumbColor: WidgetStateProperty.resolveWith<Color?>(
  532. (states) => states.contains(WidgetState.selected)
  533. ? colors.accentInk
  534. : colors.inkFaint,
  535. ),
  536. trackColor: WidgetStateProperty.resolveWith<Color?>(
  537. (states) => states.contains(WidgetState.selected)
  538. ? colors.accent
  539. : colors.surfaceStrong,
  540. ),
  541. trackOutlineColor: WidgetStatePropertyAll(colors.border),
  542. ),
  543. );
  544. }
  545. }
  546. class _BatteryToggleRow extends StatelessWidget {
  547. const _BatteryToggleRow({required this.value, required this.onChanged});
  548. final bool value;
  549. final ValueChanged<bool> onChanged;
  550. @override
  551. Widget build(BuildContext context) {
  552. final colors = context.colors;
  553. final theme = Theme.of(context);
  554. return Row(
  555. children: [
  556. Container(
  557. width: 38,
  558. height: 38,
  559. decoration: BoxDecoration(
  560. color: colors.surfaceStrong,
  561. borderRadius: BorderRadius.circular(13),
  562. border: Border.all(color: colors.border),
  563. ),
  564. alignment: Alignment.center,
  565. child: AppIcon(
  566. 'assets/icons/battery-charging.svg',
  567. size: 18,
  568. color: colors.inkMuted,
  569. ),
  570. ),
  571. const SizedBox(width: 14),
  572. Expanded(
  573. child: Column(
  574. crossAxisAlignment: CrossAxisAlignment.start,
  575. children: [
  576. Text('Battery & charging', style: theme.textTheme.bodyLarge),
  577. const SizedBox(height: 2),
  578. Text(
  579. 'Show the battery level and charging bolt.',
  580. style: theme.textTheme.bodySmall?.copyWith(
  581. color: colors.inkMuted,
  582. ),
  583. ),
  584. ],
  585. ),
  586. ),
  587. const SizedBox(width: 8),
  588. _SettingsSwitch(
  589. value: value,
  590. semanticsLabel: value
  591. ? 'Hide battery and charging status'
  592. : 'Show battery and charging status',
  593. onChanged: onChanged,
  594. ),
  595. ],
  596. );
  597. }
  598. }
  599. class _ActionRow extends StatelessWidget {
  600. const _ActionRow({
  601. required this.iconAsset,
  602. required this.title,
  603. this.value,
  604. this.trailing,
  605. this.onTap,
  606. });
  607. final String iconAsset;
  608. final String title;
  609. final String? value;
  610. final Widget? trailing;
  611. final VoidCallback? onTap;
  612. @override
  613. Widget build(BuildContext context) {
  614. final colors = context.colors;
  615. final theme = Theme.of(context);
  616. final content = Padding(
  617. padding: const EdgeInsets.symmetric(vertical: 7),
  618. child: Row(
  619. children: [
  620. Container(
  621. width: 38,
  622. height: 38,
  623. decoration: BoxDecoration(
  624. color: colors.surfaceStrong,
  625. borderRadius: BorderRadius.circular(13),
  626. border: Border.all(color: colors.border),
  627. ),
  628. alignment: Alignment.center,
  629. child: AppIcon(iconAsset, size: 18, color: colors.inkMuted),
  630. ),
  631. const SizedBox(width: 14),
  632. Expanded(child: Text(title, style: theme.textTheme.bodyLarge)),
  633. if (value != null)
  634. Text(
  635. value!,
  636. style: theme.textTheme.labelLarge?.copyWith(
  637. color: colors.inkMuted,
  638. ),
  639. ),
  640. if (trailing != null) ...[const SizedBox(width: 8), trailing!],
  641. ],
  642. ),
  643. );
  644. if (onTap == null) return content;
  645. return Material(
  646. color: Colors.transparent,
  647. child: InkWell(
  648. borderRadius: BorderRadius.circular(14),
  649. onTap: onTap,
  650. child: content,
  651. ),
  652. );
  653. }
  654. }
  655. class _ShortcutToggleRow extends StatelessWidget {
  656. const _ShortcutToggleRow({
  657. required this.slot,
  658. required this.launcher,
  659. required this.onPick,
  660. });
  661. final SlotType slot;
  662. final LauncherController launcher;
  663. final VoidCallback onPick;
  664. @override
  665. Widget build(BuildContext context) {
  666. final colors = context.colors;
  667. final theme = Theme.of(context);
  668. final enabled = launcher.isSlotEnabled(slot);
  669. final assignment = launcher.assignmentFor(slot);
  670. return Row(
  671. children: [
  672. Expanded(
  673. child: Material(
  674. color: Colors.transparent,
  675. child: InkWell(
  676. borderRadius: BorderRadius.circular(14),
  677. onTap: onPick,
  678. child: Padding(
  679. padding: const EdgeInsets.symmetric(vertical: 6),
  680. child: Row(
  681. children: [
  682. Opacity(
  683. opacity: enabled ? 1 : 0.45,
  684. child: Container(
  685. width: 38,
  686. height: 38,
  687. decoration: BoxDecoration(
  688. color: colors.surfaceStrong,
  689. borderRadius: BorderRadius.circular(13),
  690. border: Border.all(color: colors.border),
  691. ),
  692. alignment: Alignment.center,
  693. child: AppIcon(
  694. slot.iconAsset,
  695. size: 18,
  696. color: colors.inkMuted,
  697. ),
  698. ),
  699. ),
  700. const SizedBox(width: 14),
  701. Expanded(
  702. child: Column(
  703. crossAxisAlignment: CrossAxisAlignment.start,
  704. children: [
  705. Text(
  706. slot.label,
  707. style: theme.textTheme.bodyLarge?.copyWith(
  708. color: enabled ? colors.ink : colors.inkFaint,
  709. ),
  710. ),
  711. const SizedBox(height: 2),
  712. Text(
  713. assignment?.label ?? 'Select app',
  714. maxLines: 1,
  715. overflow: TextOverflow.ellipsis,
  716. style: theme.textTheme.bodySmall?.copyWith(
  717. color: !enabled
  718. ? colors.inkFaint
  719. : assignment == null
  720. ? colors.accent
  721. : colors.inkMuted,
  722. fontWeight: assignment == null && enabled
  723. ? FontWeight.w600
  724. : FontWeight.w400,
  725. ),
  726. ),
  727. ],
  728. ),
  729. ),
  730. ],
  731. ),
  732. ),
  733. ),
  734. ),
  735. ),
  736. const SizedBox(width: 8),
  737. _SettingsSwitch(
  738. value: enabled,
  739. semanticsLabel: enabled
  740. ? 'Hide ${slot.label} on Home'
  741. : 'Show ${slot.label} on Home',
  742. onChanged: (value) async {
  743. final error = await launcher.setSlotEnabled(slot, value);
  744. if (error != null && context.mounted) {
  745. ScaffoldMessenger.of(context)
  746. .showSnackBar(SnackBar(content: Text(error)));
  747. }
  748. },
  749. ),
  750. ],
  751. );
  752. }
  753. }
  754. class _Chevron extends StatelessWidget {
  755. const _Chevron();
  756. @override
  757. Widget build(BuildContext context) {
  758. return AppIcon(
  759. 'assets/icons/chevron-right.svg',
  760. size: 18,
  761. color: context.colors.inkFaint,
  762. );
  763. }
  764. }
  765. class _SmallButton extends StatelessWidget {
  766. const _SmallButton({required this.label, required this.onTap});
  767. final String label;
  768. final VoidCallback onTap;
  769. @override
  770. Widget build(BuildContext context) {
  771. final colors = context.colors;
  772. return Pressable(
  773. onTap: onTap,
  774. semanticLabel: label,
  775. pressedScale: 0.95,
  776. child: Container(
  777. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
  778. decoration: BoxDecoration(
  779. color: colors.accent,
  780. borderRadius: BorderRadius.circular(999),
  781. ),
  782. child: Text(
  783. label,
  784. style: Theme.of(context).textTheme.labelMedium
  785. ?.copyWith(color: colors.accentInk, fontWeight: FontWeight.w600),
  786. ),
  787. ),
  788. );
  789. }
  790. }
  791. class _HomeRoleRow extends StatelessWidget {
  792. const _HomeRoleRow({required this.launcher});
  793. final LauncherController launcher;
  794. @override
  795. Widget build(BuildContext context) {
  796. final colors = context.colors;
  797. final theme = Theme.of(context);
  798. final isDefault = launcher.isDefaultHome;
  799. return Row(
  800. children: [
  801. Container(
  802. width: 38,
  803. height: 38,
  804. decoration: BoxDecoration(
  805. color: colors.surfaceStrong,
  806. borderRadius: BorderRadius.circular(13),
  807. border: Border.all(color: colors.border),
  808. ),
  809. alignment: Alignment.center,
  810. child: AppIcon(
  811. 'assets/icons/smartphone.svg',
  812. size: 18,
  813. color: colors.inkMuted,
  814. ),
  815. ),
  816. const SizedBox(width: 14),
  817. Expanded(
  818. child: Column(
  819. crossAxisAlignment: CrossAxisAlignment.start,
  820. children: [
  821. Text('Home app', style: theme.textTheme.bodyLarge),
  822. const SizedBox(height: 2),
  823. Text(
  824. launcher.defaultHomeKnown
  825. ? (isDefault ? 'CarDash is your home app' : 'Not set')
  826. : 'Checking…',
  827. style: theme.textTheme.bodySmall?.copyWith(
  828. color: isDefault ? colors.accent : colors.inkMuted,
  829. fontWeight: isDefault ? FontWeight.w600 : FontWeight.w400,
  830. ),
  831. ),
  832. ],
  833. ),
  834. ),
  835. if (launcher.defaultHomeKnown && !isDefault)
  836. _SmallButton(
  837. label: 'Set as default',
  838. onTap: () async {
  839. final launched = await launcher.requestDefaultHome();
  840. if (!launched && context.mounted) {
  841. ScaffoldMessenger.of(context).showSnackBar(
  842. const SnackBar(
  843. content: Text('Could not open the home app settings.'),
  844. ),
  845. );
  846. }
  847. },
  848. )
  849. else if (isDefault)
  850. Icon(Icons.check_circle_rounded, color: colors.accent, size: 22),
  851. ],
  852. );
  853. }
  854. }