settings_screen.dart 40 KB

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