settings_screen.dart 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  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. Text(
  146. 'Home shortcuts',
  147. style: theme.textTheme.titleMedium,
  148. ),
  149. const SizedBox(height: 4),
  150. for (final slot in SlotType.values)
  151. _ActionRow(
  152. iconAsset: slot.iconAsset,
  153. title: slot.label,
  154. subtitle:
  155. launcher.assignmentFor(slot)?.label ??
  156. 'Select app',
  157. highlightSubtitle:
  158. launcher.assignmentFor(slot) == null,
  159. trailing: const _Chevron(),
  160. onTap: () => showAppPicker(context, slot),
  161. ),
  162. Divider(color: colors.border, height: 30),
  163. _HomeRoleRow(launcher: launcher),
  164. ],
  165. ),
  166. ),
  167. const SizedBox(height: 30),
  168. _SectionTitle(
  169. title: 'Security',
  170. colors: colors,
  171. theme: theme,
  172. ),
  173. const SizedBox(height: 12),
  174. SurfaceCard(
  175. padding: const EdgeInsets.fromLTRB(26, 22, 26, 22),
  176. child: Column(
  177. crossAxisAlignment: CrossAxisAlignment.start,
  178. children: [
  179. Row(
  180. children: [
  181. Container(
  182. width: 42,
  183. height: 42,
  184. decoration: BoxDecoration(
  185. color: colors.accentSoft,
  186. borderRadius: BorderRadius.circular(14),
  187. ),
  188. alignment: Alignment.center,
  189. child: AppIcon(
  190. 'assets/icons/lock.svg',
  191. size: 20,
  192. color: colors.accent,
  193. ),
  194. ),
  195. const SizedBox(width: 14),
  196. Expanded(
  197. child: Column(
  198. crossAxisAlignment: CrossAxisAlignment.start,
  199. children: [
  200. Text(
  201. 'PIN protection',
  202. style: theme.textTheme.titleMedium,
  203. ),
  204. const SizedBox(height: 2),
  205. Text(
  206. lock.pinSet ? 'On' : 'Off',
  207. style: theme.textTheme.bodySmall
  208. ?.copyWith(
  209. color: lock.pinSet
  210. ? colors.accent
  211. : colors.inkMuted,
  212. fontWeight: FontWeight.w600,
  213. ),
  214. ),
  215. ],
  216. ),
  217. ),
  218. if (!lock.pinSet)
  219. _SmallButton(
  220. label: 'Set PIN',
  221. onTap: () =>
  222. _openPinSetup(context, PinSetupMode.set),
  223. ),
  224. ],
  225. ),
  226. if (lock.pinSet) ...[
  227. const SizedBox(height: 10),
  228. Divider(color: colors.border, height: 24),
  229. _ActionRow(
  230. iconAsset: 'assets/icons/lock.svg',
  231. title: 'Change PIN',
  232. trailing: const _Chevron(),
  233. onTap: () =>
  234. _openPinSetup(context, PinSetupMode.change),
  235. ),
  236. _ActionRow(
  237. iconAsset: 'assets/icons/x.svg',
  238. title: 'Remove PIN',
  239. trailing: const _Chevron(),
  240. onTap: () =>
  241. _openPinSetup(context, PinSetupMode.remove),
  242. ),
  243. ],
  244. const SizedBox(height: 16),
  245. Text(
  246. 'The PIN is requested only when CarDash starts. It does not lock '
  247. 'again when you switch apps or turn the screen off. It protects the '
  248. 'launcher, not the device itself.',
  249. style: theme.textTheme.bodySmall?.copyWith(
  250. color: colors.inkMuted,
  251. height: 1.5,
  252. ),
  253. ),
  254. ],
  255. ),
  256. ),
  257. const SizedBox(height: 28),
  258. Center(
  259. child: Text(
  260. 'CarDash 0.1.0 · Android 10+',
  261. style: theme.textTheme.bodySmall?.copyWith(
  262. color: colors.inkFaint,
  263. ),
  264. ),
  265. ),
  266. ],
  267. ),
  268. ),
  269. ),
  270. ),
  271. ),
  272. ),
  273. );
  274. }
  275. Future<void> _openPinSetup(BuildContext context, PinSetupMode mode) async {
  276. final result = await Navigator.of(
  277. context,
  278. ).push<bool>(MaterialPageRoute(builder: (_) => PinSetupScreen(mode: mode)));
  279. if (result != true || !context.mounted) return;
  280. final message = switch (mode) {
  281. PinSetupMode.set =>
  282. 'PIN set. It will be requested the next time CarDash starts.',
  283. PinSetupMode.change => 'PIN changed.',
  284. PinSetupMode.remove => 'PIN removed.',
  285. };
  286. ScaffoldMessenger.of(context)
  287. .showSnackBar(SnackBar(content: Text(message)));
  288. }
  289. Future<void> _pickTime(
  290. BuildContext context,
  291. ThemeController controller, {
  292. required bool start,
  293. }) async {
  294. final minutes = start
  295. ? controller.nightStartMinutes
  296. : controller.nightEndMinutes;
  297. final picked = await showTimePicker(
  298. context: context,
  299. initialTime: TimeOfDay(hour: minutes ~/ 60, minute: minutes % 60),
  300. helpText: start ? 'Dark from' : 'Light from',
  301. builder: (context, child) {
  302. final theme = Theme.of(context);
  303. return Theme(
  304. data: theme.copyWith(
  305. timePickerTheme: TimePickerThemeData(
  306. backgroundColor: theme.colorScheme.surface,
  307. dialBackgroundColor: theme.colorScheme.surfaceContainerHighest,
  308. shape: RoundedRectangleBorder(
  309. borderRadius: BorderRadius.circular(28),
  310. ),
  311. ),
  312. ),
  313. child: child ?? const SizedBox.shrink(),
  314. );
  315. },
  316. );
  317. if (picked == null || !context.mounted) return;
  318. final newValue = picked.hour * 60 + picked.minute;
  319. await controller.setNightWindow(
  320. startMinutes: start ? newValue : controller.nightStartMinutes,
  321. endMinutes: start ? controller.nightEndMinutes : newValue,
  322. );
  323. }
  324. String _formatMinutes(BuildContext context, int minutes) {
  325. return TimeOfDay(hour: minutes ~/ 60, minute: minutes % 60).format(context);
  326. }
  327. }
  328. class _SectionTitle extends StatelessWidget {
  329. const _SectionTitle({
  330. required this.title,
  331. required this.colors,
  332. required this.theme,
  333. });
  334. final String title;
  335. final AppColors colors;
  336. final ThemeData theme;
  337. @override
  338. Widget build(BuildContext context) {
  339. return Padding(
  340. padding: const EdgeInsets.only(left: 4),
  341. child: Text(
  342. title,
  343. style: theme.textTheme.titleLarge?.copyWith(color: colors.inkMuted),
  344. ),
  345. );
  346. }
  347. }
  348. class _ThemeSelector extends StatelessWidget {
  349. const _ThemeSelector({required this.selected, required this.onSelected});
  350. final ThemePreference selected;
  351. final ValueChanged<ThemePreference> onSelected;
  352. @override
  353. Widget build(BuildContext context) {
  354. final colors = context.colors;
  355. return Container(
  356. padding: const EdgeInsets.all(4),
  357. decoration: BoxDecoration(
  358. color: colors.surfaceStrong,
  359. borderRadius: BorderRadius.circular(999),
  360. border: Border.all(color: colors.border),
  361. ),
  362. child: Row(
  363. mainAxisSize: MainAxisSize.min,
  364. children: [
  365. for (final preference in ThemePreference.values)
  366. Pressable(
  367. onTap: () => onSelected(preference),
  368. semanticLabel: 'Theme ${preference.storageValue}',
  369. pressedScale: 0.95,
  370. child: AnimatedContainer(
  371. duration: const Duration(milliseconds: 160),
  372. curve: Curves.easeOutCubic,
  373. padding: const EdgeInsets.symmetric(
  374. horizontal: 18,
  375. vertical: 9,
  376. ),
  377. decoration: BoxDecoration(
  378. color: selected == preference
  379. ? colors.accent
  380. : Colors.transparent,
  381. borderRadius: BorderRadius.circular(999),
  382. ),
  383. child: Text(
  384. switch (preference) {
  385. ThemePreference.light => 'Light',
  386. ThemePreference.dark => 'Dark',
  387. ThemePreference.automatic => 'Auto',
  388. },
  389. style: Theme.of(context).textTheme.labelMedium?.copyWith(
  390. color: selected == preference
  391. ? colors.accentInk
  392. : colors.inkMuted,
  393. fontWeight: FontWeight.w600,
  394. ),
  395. ),
  396. ),
  397. ),
  398. ],
  399. ),
  400. );
  401. }
  402. }
  403. class _ActionRow extends StatelessWidget {
  404. const _ActionRow({
  405. required this.iconAsset,
  406. required this.title,
  407. this.subtitle,
  408. this.value,
  409. this.trailing,
  410. this.onTap,
  411. this.highlightSubtitle = false,
  412. });
  413. final String iconAsset;
  414. final String title;
  415. final String? subtitle;
  416. final String? value;
  417. final Widget? trailing;
  418. final VoidCallback? onTap;
  419. final bool highlightSubtitle;
  420. @override
  421. Widget build(BuildContext context) {
  422. final colors = context.colors;
  423. final theme = Theme.of(context);
  424. final content = Padding(
  425. padding: const EdgeInsets.symmetric(vertical: 7),
  426. child: Row(
  427. children: [
  428. Container(
  429. width: 38,
  430. height: 38,
  431. decoration: BoxDecoration(
  432. color: colors.surfaceStrong,
  433. borderRadius: BorderRadius.circular(13),
  434. border: Border.all(color: colors.border),
  435. ),
  436. alignment: Alignment.center,
  437. child: AppIcon(iconAsset, size: 18, color: colors.inkMuted),
  438. ),
  439. const SizedBox(width: 14),
  440. Expanded(
  441. child: Column(
  442. crossAxisAlignment: CrossAxisAlignment.start,
  443. children: [
  444. Text(title, style: theme.textTheme.bodyLarge),
  445. if (subtitle != null) ...[
  446. const SizedBox(height: 2),
  447. Text(
  448. subtitle!,
  449. maxLines: 1,
  450. overflow: TextOverflow.ellipsis,
  451. style: theme.textTheme.bodySmall?.copyWith(
  452. color: highlightSubtitle
  453. ? colors.accent
  454. : colors.inkMuted,
  455. fontWeight: highlightSubtitle
  456. ? FontWeight.w600
  457. : FontWeight.w400,
  458. ),
  459. ),
  460. ],
  461. ],
  462. ),
  463. ),
  464. if (value != null)
  465. Text(
  466. value!,
  467. style: theme.textTheme.labelLarge?.copyWith(
  468. color: colors.inkMuted,
  469. ),
  470. ),
  471. if (trailing != null) ...[const SizedBox(width: 8), trailing!],
  472. ],
  473. ),
  474. );
  475. if (onTap == null) return content;
  476. return Material(
  477. color: Colors.transparent,
  478. child: InkWell(
  479. borderRadius: BorderRadius.circular(14),
  480. onTap: onTap,
  481. child: content,
  482. ),
  483. );
  484. }
  485. }
  486. class _Chevron extends StatelessWidget {
  487. const _Chevron();
  488. @override
  489. Widget build(BuildContext context) {
  490. return AppIcon(
  491. 'assets/icons/chevron-right.svg',
  492. size: 18,
  493. color: context.colors.inkFaint,
  494. );
  495. }
  496. }
  497. class _SmallButton extends StatelessWidget {
  498. const _SmallButton({required this.label, required this.onTap});
  499. final String label;
  500. final VoidCallback onTap;
  501. @override
  502. Widget build(BuildContext context) {
  503. final colors = context.colors;
  504. return Pressable(
  505. onTap: onTap,
  506. semanticLabel: label,
  507. pressedScale: 0.95,
  508. child: Container(
  509. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
  510. decoration: BoxDecoration(
  511. color: colors.accent,
  512. borderRadius: BorderRadius.circular(999),
  513. ),
  514. child: Text(
  515. label,
  516. style: Theme.of(context).textTheme.labelMedium
  517. ?.copyWith(color: colors.accentInk, fontWeight: FontWeight.w600),
  518. ),
  519. ),
  520. );
  521. }
  522. }
  523. class _HomeRoleRow extends StatelessWidget {
  524. const _HomeRoleRow({required this.launcher});
  525. final LauncherController launcher;
  526. @override
  527. Widget build(BuildContext context) {
  528. final colors = context.colors;
  529. final theme = Theme.of(context);
  530. final isDefault = launcher.isDefaultHome;
  531. return Row(
  532. children: [
  533. Container(
  534. width: 38,
  535. height: 38,
  536. decoration: BoxDecoration(
  537. color: colors.surfaceStrong,
  538. borderRadius: BorderRadius.circular(13),
  539. border: Border.all(color: colors.border),
  540. ),
  541. alignment: Alignment.center,
  542. child: AppIcon(
  543. 'assets/icons/smartphone.svg',
  544. size: 18,
  545. color: colors.inkMuted,
  546. ),
  547. ),
  548. const SizedBox(width: 14),
  549. Expanded(
  550. child: Column(
  551. crossAxisAlignment: CrossAxisAlignment.start,
  552. children: [
  553. Text('Home app', style: theme.textTheme.bodyLarge),
  554. const SizedBox(height: 2),
  555. Text(
  556. launcher.defaultHomeKnown
  557. ? (isDefault ? 'CarDash is your home app' : 'Not set')
  558. : 'Checking…',
  559. style: theme.textTheme.bodySmall?.copyWith(
  560. color: isDefault ? colors.accent : colors.inkMuted,
  561. fontWeight: isDefault ? FontWeight.w600 : FontWeight.w400,
  562. ),
  563. ),
  564. ],
  565. ),
  566. ),
  567. if (launcher.defaultHomeKnown && !isDefault)
  568. _SmallButton(
  569. label: 'Set as default',
  570. onTap: () async {
  571. final launched = await launcher.requestDefaultHome();
  572. if (!launched && context.mounted) {
  573. ScaffoldMessenger.of(context).showSnackBar(
  574. const SnackBar(
  575. content: Text('Could not open the home app settings.'),
  576. ),
  577. );
  578. }
  579. },
  580. )
  581. else if (isDefault)
  582. Icon(Icons.check_circle_rounded, color: colors.accent, size: 22),
  583. ],
  584. );
  585. }
  586. }