settings_screen.dart 40 KB

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