settings_screen.dart 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713
  1. import 'package:flutter/material.dart';
  2. import 'package:provider/provider.dart';
  3. import '../../core/theme/app_colors.dart';
  4. import '../../core/theme/theme_controller.dart';
  5. import '../../core/theme/theme_preference.dart';
  6. import '../../core/widgets/app_icon.dart';
  7. import '../../core/widgets/pressable.dart';
  8. import '../../core/widgets/surface_card.dart';
  9. import '../../data/models/slot_type.dart';
  10. import '../apps/app_picker_sheet.dart';
  11. import '../home/launcher_controller.dart';
  12. import '../lock/lock_controller.dart';
  13. import '../lock/pin_setup_screen.dart';
  14. class SettingsScreen extends StatelessWidget {
  15. const SettingsScreen({super.key});
  16. @override
  17. Widget build(BuildContext context) {
  18. final colors = context.colors;
  19. final theme = Theme.of(context);
  20. final themeController = context.watch<ThemeController>();
  21. final launcher = context.watch<LauncherController>();
  22. final lock = context.watch<LockController>();
  23. return Scaffold(
  24. backgroundColor: colors.canvas,
  25. body: Container(
  26. decoration: homeBackground(colors, Theme.of(context).brightness),
  27. child: SafeArea(
  28. child: SingleChildScrollView(
  29. padding: const EdgeInsets.fromLTRB(32, 22, 32, 32),
  30. child: Center(
  31. child: ConstrainedBox(
  32. constraints: const BoxConstraints(maxWidth: 980),
  33. child: Column(
  34. crossAxisAlignment: CrossAxisAlignment.start,
  35. children: [
  36. Row(
  37. children: [
  38. Pressable(
  39. onTap: () => Navigator.of(context).pop(),
  40. semanticLabel: 'Back',
  41. pressedScale: 0.92,
  42. child: Container(
  43. width: 46,
  44. height: 46,
  45. decoration: BoxDecoration(
  46. color: colors.surface,
  47. shape: BoxShape.circle,
  48. border: Border.all(color: colors.border),
  49. ),
  50. alignment: Alignment.center,
  51. child: AppIcon(
  52. 'assets/icons/arrow-left.svg',
  53. size: 20,
  54. color: colors.ink,
  55. ),
  56. ),
  57. ),
  58. const SizedBox(width: 18),
  59. Column(
  60. crossAxisAlignment: CrossAxisAlignment.start,
  61. children: [
  62. Text(
  63. 'Settings',
  64. style: theme.textTheme.headlineLarge,
  65. ),
  66. const SizedBox(height: 2),
  67. Text(
  68. 'Theme, shortcuts and access',
  69. style: theme.textTheme.bodySmall?.copyWith(
  70. color: colors.inkMuted,
  71. ),
  72. ),
  73. ],
  74. ),
  75. ],
  76. ),
  77. const SizedBox(height: 32),
  78. _SectionTitle(
  79. title: 'General',
  80. colors: colors,
  81. theme: theme,
  82. ),
  83. const SizedBox(height: 12),
  84. SurfaceCard(
  85. padding: const EdgeInsets.fromLTRB(26, 22, 26, 18),
  86. child: Column(
  87. crossAxisAlignment: CrossAxisAlignment.start,
  88. children: [
  89. Row(
  90. children: [
  91. Text('Theme', style: theme.textTheme.titleMedium),
  92. const Spacer(),
  93. _ThemeSelector(
  94. selected: themeController.preference,
  95. onSelected: themeController.setPreference,
  96. ),
  97. ],
  98. ),
  99. const SizedBox(height: 8),
  100. Text(
  101. themeController.preference ==
  102. ThemePreference.automatic
  103. ? 'Switches automatically at the times below.'
  104. : themeController.preference ==
  105. ThemePreference.dark
  106. ? 'Always dark.'
  107. : 'Always light.',
  108. style: theme.textTheme.bodySmall?.copyWith(
  109. color: colors.inkMuted,
  110. ),
  111. ),
  112. if (themeController.preference ==
  113. ThemePreference.automatic) ...[
  114. const SizedBox(height: 8),
  115. Divider(color: colors.border, height: 24),
  116. _ActionRow(
  117. iconAsset: 'assets/icons/moon.svg',
  118. title: 'Dark from',
  119. value: _formatMinutes(
  120. context,
  121. themeController.nightStartMinutes,
  122. ),
  123. onTap: () => _pickTime(
  124. context,
  125. themeController,
  126. start: true,
  127. ),
  128. ),
  129. const SizedBox(height: 2),
  130. _ActionRow(
  131. iconAsset: 'assets/icons/sun.svg',
  132. title: 'Light from',
  133. value: _formatMinutes(
  134. context,
  135. themeController.nightEndMinutes,
  136. ),
  137. onTap: () => _pickTime(
  138. context,
  139. themeController,
  140. start: false,
  141. ),
  142. ),
  143. ],
  144. Divider(color: colors.border, height: 30),
  145. Row(
  146. children: [
  147. Text(
  148. 'Home buttons',
  149. style: theme.textTheme.titleMedium,
  150. ),
  151. const Spacer(),
  152. Text(
  153. '${launcher.homeButtonCount} / '
  154. '${LauncherController.maxHomeButtons}',
  155. style: theme.textTheme.labelMedium?.copyWith(
  156. color: colors.inkMuted,
  157. ),
  158. ),
  159. ],
  160. ),
  161. const SizedBox(height: 3),
  162. Text(
  163. 'Turn one off to hide it. Settings and All apps are always shown.',
  164. style: theme.textTheme.bodySmall?.copyWith(
  165. color: colors.inkMuted,
  166. ),
  167. ),
  168. const SizedBox(height: 4),
  169. for (final slot in SlotType.values)
  170. _ShortcutToggleRow(
  171. slot: slot,
  172. launcher: launcher,
  173. onPick: () => showAppPicker(context, slot),
  174. ),
  175. Divider(color: colors.border, height: 30),
  176. _HomeRoleRow(launcher: launcher),
  177. ],
  178. ),
  179. ),
  180. const SizedBox(height: 30),
  181. _SectionTitle(
  182. title: 'Security',
  183. colors: colors,
  184. theme: theme,
  185. ),
  186. const SizedBox(height: 12),
  187. SurfaceCard(
  188. padding: const EdgeInsets.fromLTRB(26, 22, 26, 22),
  189. child: Column(
  190. crossAxisAlignment: CrossAxisAlignment.start,
  191. children: [
  192. Row(
  193. children: [
  194. Container(
  195. width: 42,
  196. height: 42,
  197. decoration: BoxDecoration(
  198. color: colors.accentSoft,
  199. borderRadius: BorderRadius.circular(14),
  200. ),
  201. alignment: Alignment.center,
  202. child: AppIcon(
  203. 'assets/icons/lock.svg',
  204. size: 20,
  205. color: colors.accent,
  206. ),
  207. ),
  208. const SizedBox(width: 14),
  209. Expanded(
  210. child: Column(
  211. crossAxisAlignment: CrossAxisAlignment.start,
  212. children: [
  213. Text(
  214. 'PIN protection',
  215. style: theme.textTheme.titleMedium,
  216. ),
  217. const SizedBox(height: 2),
  218. Text(
  219. lock.pinSet ? 'On' : 'Off',
  220. style: theme.textTheme.bodySmall
  221. ?.copyWith(
  222. color: lock.pinSet
  223. ? colors.accent
  224. : colors.inkMuted,
  225. fontWeight: FontWeight.w600,
  226. ),
  227. ),
  228. ],
  229. ),
  230. ),
  231. if (!lock.pinSet)
  232. _SmallButton(
  233. label: 'Set PIN',
  234. onTap: () =>
  235. _openPinSetup(context, PinSetupMode.set),
  236. ),
  237. ],
  238. ),
  239. if (lock.pinSet) ...[
  240. const SizedBox(height: 10),
  241. Divider(color: colors.border, height: 24),
  242. _ActionRow(
  243. iconAsset: 'assets/icons/lock.svg',
  244. title: 'Change PIN',
  245. trailing: const _Chevron(),
  246. onTap: () =>
  247. _openPinSetup(context, PinSetupMode.change),
  248. ),
  249. _ActionRow(
  250. iconAsset: 'assets/icons/x.svg',
  251. title: 'Remove PIN',
  252. trailing: const _Chevron(),
  253. onTap: () =>
  254. _openPinSetup(context, PinSetupMode.remove),
  255. ),
  256. ],
  257. const SizedBox(height: 16),
  258. Text(
  259. 'The PIN is requested only when CarDash starts. It does not lock '
  260. 'again when you switch apps or turn the screen off. It protects the '
  261. 'launcher, not the device itself.',
  262. style: theme.textTheme.bodySmall?.copyWith(
  263. color: colors.inkMuted,
  264. height: 1.5,
  265. ),
  266. ),
  267. ],
  268. ),
  269. ),
  270. const SizedBox(height: 28),
  271. Center(
  272. child: Text(
  273. 'CarDash 0.2.0 · Android 10+',
  274. style: theme.textTheme.bodySmall?.copyWith(
  275. color: colors.inkFaint,
  276. ),
  277. ),
  278. ),
  279. ],
  280. ),
  281. ),
  282. ),
  283. ),
  284. ),
  285. ),
  286. );
  287. }
  288. Future<void> _openPinSetup(BuildContext context, PinSetupMode mode) async {
  289. final result = await Navigator.of(
  290. context,
  291. ).push<bool>(MaterialPageRoute(builder: (_) => PinSetupScreen(mode: mode)));
  292. if (result != true || !context.mounted) return;
  293. final message = switch (mode) {
  294. PinSetupMode.set =>
  295. 'PIN set. It will be requested the next time CarDash starts.',
  296. PinSetupMode.change => 'PIN changed.',
  297. PinSetupMode.remove => 'PIN removed.',
  298. };
  299. ScaffoldMessenger.of(context)
  300. .showSnackBar(SnackBar(content: Text(message)));
  301. }
  302. Future<void> _pickTime(
  303. BuildContext context,
  304. ThemeController controller, {
  305. required bool start,
  306. }) async {
  307. final minutes = start
  308. ? controller.nightStartMinutes
  309. : controller.nightEndMinutes;
  310. final picked = await showTimePicker(
  311. context: context,
  312. initialTime: TimeOfDay(hour: minutes ~/ 60, minute: minutes % 60),
  313. helpText: start ? 'Dark from' : 'Light from',
  314. builder: (context, child) {
  315. final theme = Theme.of(context);
  316. return Theme(
  317. data: theme.copyWith(
  318. timePickerTheme: TimePickerThemeData(
  319. backgroundColor: theme.colorScheme.surface,
  320. dialBackgroundColor: theme.colorScheme.surfaceContainerHighest,
  321. shape: RoundedRectangleBorder(
  322. borderRadius: BorderRadius.circular(28),
  323. ),
  324. ),
  325. ),
  326. child: child ?? const SizedBox.shrink(),
  327. );
  328. },
  329. );
  330. if (picked == null || !context.mounted) return;
  331. final newValue = picked.hour * 60 + picked.minute;
  332. await controller.setNightWindow(
  333. startMinutes: start ? newValue : controller.nightStartMinutes,
  334. endMinutes: start ? controller.nightEndMinutes : newValue,
  335. );
  336. }
  337. String _formatMinutes(BuildContext context, int minutes) {
  338. return TimeOfDay(hour: minutes ~/ 60, minute: minutes % 60).format(context);
  339. }
  340. }
  341. class _SectionTitle extends StatelessWidget {
  342. const _SectionTitle({
  343. required this.title,
  344. required this.colors,
  345. required this.theme,
  346. });
  347. final String title;
  348. final AppColors colors;
  349. final ThemeData theme;
  350. @override
  351. Widget build(BuildContext context) {
  352. return Padding(
  353. padding: const EdgeInsets.only(left: 4),
  354. child: Text(
  355. title,
  356. style: theme.textTheme.titleLarge?.copyWith(color: colors.inkMuted),
  357. ),
  358. );
  359. }
  360. }
  361. class _ThemeSelector extends StatelessWidget {
  362. const _ThemeSelector({required this.selected, required this.onSelected});
  363. final ThemePreference selected;
  364. final ValueChanged<ThemePreference> onSelected;
  365. @override
  366. Widget build(BuildContext context) {
  367. final colors = context.colors;
  368. return Container(
  369. padding: const EdgeInsets.all(4),
  370. decoration: BoxDecoration(
  371. color: colors.surfaceStrong,
  372. borderRadius: BorderRadius.circular(999),
  373. border: Border.all(color: colors.border),
  374. ),
  375. child: Row(
  376. mainAxisSize: MainAxisSize.min,
  377. children: [
  378. for (final preference in ThemePreference.values)
  379. Pressable(
  380. onTap: () => onSelected(preference),
  381. semanticLabel: 'Theme ${preference.storageValue}',
  382. pressedScale: 0.95,
  383. child: AnimatedContainer(
  384. duration: const Duration(milliseconds: 160),
  385. curve: Curves.easeOutCubic,
  386. padding: const EdgeInsets.symmetric(
  387. horizontal: 18,
  388. vertical: 9,
  389. ),
  390. decoration: BoxDecoration(
  391. color: selected == preference
  392. ? colors.accent
  393. : Colors.transparent,
  394. borderRadius: BorderRadius.circular(999),
  395. ),
  396. child: Text(
  397. switch (preference) {
  398. ThemePreference.light => 'Light',
  399. ThemePreference.dark => 'Dark',
  400. ThemePreference.automatic => 'Auto',
  401. },
  402. style: Theme.of(context).textTheme.labelMedium?.copyWith(
  403. color: selected == preference
  404. ? colors.accentInk
  405. : colors.inkMuted,
  406. fontWeight: FontWeight.w600,
  407. ),
  408. ),
  409. ),
  410. ),
  411. ],
  412. ),
  413. );
  414. }
  415. }
  416. class _ActionRow extends StatelessWidget {
  417. const _ActionRow({
  418. required this.iconAsset,
  419. required this.title,
  420. this.value,
  421. this.trailing,
  422. this.onTap,
  423. });
  424. final String iconAsset;
  425. final String title;
  426. final String? value;
  427. final Widget? trailing;
  428. final VoidCallback? onTap;
  429. @override
  430. Widget build(BuildContext context) {
  431. final colors = context.colors;
  432. final theme = Theme.of(context);
  433. final content = Padding(
  434. padding: const EdgeInsets.symmetric(vertical: 7),
  435. child: Row(
  436. children: [
  437. Container(
  438. width: 38,
  439. height: 38,
  440. decoration: BoxDecoration(
  441. color: colors.surfaceStrong,
  442. borderRadius: BorderRadius.circular(13),
  443. border: Border.all(color: colors.border),
  444. ),
  445. alignment: Alignment.center,
  446. child: AppIcon(iconAsset, size: 18, color: colors.inkMuted),
  447. ),
  448. const SizedBox(width: 14),
  449. Expanded(child: Text(title, style: theme.textTheme.bodyLarge)),
  450. if (value != null)
  451. Text(
  452. value!,
  453. style: theme.textTheme.labelLarge?.copyWith(
  454. color: colors.inkMuted,
  455. ),
  456. ),
  457. if (trailing != null) ...[const SizedBox(width: 8), trailing!],
  458. ],
  459. ),
  460. );
  461. if (onTap == null) return content;
  462. return Material(
  463. color: Colors.transparent,
  464. child: InkWell(
  465. borderRadius: BorderRadius.circular(14),
  466. onTap: onTap,
  467. child: content,
  468. ),
  469. );
  470. }
  471. }
  472. class _ShortcutToggleRow extends StatelessWidget {
  473. const _ShortcutToggleRow({
  474. required this.slot,
  475. required this.launcher,
  476. required this.onPick,
  477. });
  478. final SlotType slot;
  479. final LauncherController launcher;
  480. final VoidCallback onPick;
  481. @override
  482. Widget build(BuildContext context) {
  483. final colors = context.colors;
  484. final theme = Theme.of(context);
  485. final enabled = launcher.isSlotEnabled(slot);
  486. final assignment = launcher.assignmentFor(slot);
  487. return Row(
  488. children: [
  489. Expanded(
  490. child: Material(
  491. color: Colors.transparent,
  492. child: InkWell(
  493. borderRadius: BorderRadius.circular(14),
  494. onTap: onPick,
  495. child: Padding(
  496. padding: const EdgeInsets.symmetric(vertical: 6),
  497. child: Row(
  498. children: [
  499. Opacity(
  500. opacity: enabled ? 1 : 0.45,
  501. child: Container(
  502. width: 38,
  503. height: 38,
  504. decoration: BoxDecoration(
  505. color: colors.surfaceStrong,
  506. borderRadius: BorderRadius.circular(13),
  507. border: Border.all(color: colors.border),
  508. ),
  509. alignment: Alignment.center,
  510. child: AppIcon(
  511. slot.iconAsset,
  512. size: 18,
  513. color: colors.inkMuted,
  514. ),
  515. ),
  516. ),
  517. const SizedBox(width: 14),
  518. Expanded(
  519. child: Column(
  520. crossAxisAlignment: CrossAxisAlignment.start,
  521. children: [
  522. Text(
  523. slot.label,
  524. style: theme.textTheme.bodyLarge?.copyWith(
  525. color: enabled ? colors.ink : colors.inkFaint,
  526. ),
  527. ),
  528. const SizedBox(height: 2),
  529. Text(
  530. assignment?.label ?? 'Select app',
  531. maxLines: 1,
  532. overflow: TextOverflow.ellipsis,
  533. style: theme.textTheme.bodySmall?.copyWith(
  534. color: !enabled
  535. ? colors.inkFaint
  536. : assignment == null
  537. ? colors.accent
  538. : colors.inkMuted,
  539. fontWeight: assignment == null && enabled
  540. ? FontWeight.w600
  541. : FontWeight.w400,
  542. ),
  543. ),
  544. ],
  545. ),
  546. ),
  547. ],
  548. ),
  549. ),
  550. ),
  551. ),
  552. ),
  553. const SizedBox(width: 8),
  554. Semantics(
  555. label: enabled
  556. ? 'Hide ${slot.label} on Home'
  557. : 'Show ${slot.label} on Home',
  558. child: Switch(
  559. value: enabled,
  560. onChanged: (value) async {
  561. final error = await launcher.setSlotEnabled(slot, value);
  562. if (error != null && context.mounted) {
  563. ScaffoldMessenger.of(context)
  564. .showSnackBar(SnackBar(content: Text(error)));
  565. }
  566. },
  567. thumbColor: WidgetStateProperty.resolveWith<Color?>(
  568. (states) => states.contains(WidgetState.selected)
  569. ? colors.accentInk
  570. : colors.inkFaint,
  571. ),
  572. trackColor: WidgetStateProperty.resolveWith<Color?>(
  573. (states) => states.contains(WidgetState.selected)
  574. ? colors.accent
  575. : colors.surfaceStrong,
  576. ),
  577. trackOutlineColor: WidgetStatePropertyAll(colors.border),
  578. ),
  579. ),
  580. ],
  581. );
  582. }
  583. }
  584. class _Chevron extends StatelessWidget {
  585. const _Chevron();
  586. @override
  587. Widget build(BuildContext context) {
  588. return AppIcon(
  589. 'assets/icons/chevron-right.svg',
  590. size: 18,
  591. color: context.colors.inkFaint,
  592. );
  593. }
  594. }
  595. class _SmallButton extends StatelessWidget {
  596. const _SmallButton({required this.label, required this.onTap});
  597. final String label;
  598. final VoidCallback onTap;
  599. @override
  600. Widget build(BuildContext context) {
  601. final colors = context.colors;
  602. return Pressable(
  603. onTap: onTap,
  604. semanticLabel: label,
  605. pressedScale: 0.95,
  606. child: Container(
  607. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
  608. decoration: BoxDecoration(
  609. color: colors.accent,
  610. borderRadius: BorderRadius.circular(999),
  611. ),
  612. child: Text(
  613. label,
  614. style: Theme.of(context).textTheme.labelMedium
  615. ?.copyWith(color: colors.accentInk, fontWeight: FontWeight.w600),
  616. ),
  617. ),
  618. );
  619. }
  620. }
  621. class _HomeRoleRow extends StatelessWidget {
  622. const _HomeRoleRow({required this.launcher});
  623. final LauncherController launcher;
  624. @override
  625. Widget build(BuildContext context) {
  626. final colors = context.colors;
  627. final theme = Theme.of(context);
  628. final isDefault = launcher.isDefaultHome;
  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/smartphone.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('Home app', style: theme.textTheme.bodyLarge),
  652. const SizedBox(height: 2),
  653. Text(
  654. launcher.defaultHomeKnown
  655. ? (isDefault ? 'CarDash is your home app' : 'Not set')
  656. : 'Checking…',
  657. style: theme.textTheme.bodySmall?.copyWith(
  658. color: isDefault ? colors.accent : colors.inkMuted,
  659. fontWeight: isDefault ? FontWeight.w600 : FontWeight.w400,
  660. ),
  661. ),
  662. ],
  663. ),
  664. ),
  665. if (launcher.defaultHomeKnown && !isDefault)
  666. _SmallButton(
  667. label: 'Set as default',
  668. onTap: () async {
  669. final launched = await launcher.requestDefaultHome();
  670. if (!launched && context.mounted) {
  671. ScaffoldMessenger.of(context).showSnackBar(
  672. const SnackBar(
  673. content: Text('Could not open the home app settings.'),
  674. ),
  675. );
  676. }
  677. },
  678. )
  679. else if (isDefault)
  680. Icon(Icons.check_circle_rounded, color: colors.accent, size: 22),
  681. ],
  682. );
  683. }
  684. }