settings_screen.dart 37 KB

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