settings_screen.dart 35 KB

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