settings_screen.dart 41 KB

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