settings_screen.dart 40 KB

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