|
@@ -0,0 +1,611 @@
|
|
|
|
|
+import 'package:flutter/material.dart';
|
|
|
|
|
+import 'package:provider/provider.dart';
|
|
|
|
|
+
|
|
|
|
|
+import '../../core/theme/app_colors.dart';
|
|
|
|
|
+import '../../core/theme/theme_controller.dart';
|
|
|
|
|
+import '../../core/theme/theme_preference.dart';
|
|
|
|
|
+import '../../core/widgets/app_icon.dart';
|
|
|
|
|
+import '../../core/widgets/pressable.dart';
|
|
|
|
|
+import '../../core/widgets/surface_card.dart';
|
|
|
|
|
+import '../../data/models/slot_type.dart';
|
|
|
|
|
+import '../apps/app_picker_sheet.dart';
|
|
|
|
|
+import '../home/launcher_controller.dart';
|
|
|
|
|
+import '../lock/lock_controller.dart';
|
|
|
|
|
+import '../lock/pin_setup_screen.dart';
|
|
|
|
|
+
|
|
|
|
|
+class SettingsScreen extends StatelessWidget {
|
|
|
|
|
+ const SettingsScreen({super.key});
|
|
|
|
|
+
|
|
|
|
|
+ @override
|
|
|
|
|
+ Widget build(BuildContext context) {
|
|
|
|
|
+ final colors = context.colors;
|
|
|
|
|
+ final theme = Theme.of(context);
|
|
|
|
|
+ final themeController = context.watch<ThemeController>();
|
|
|
|
|
+ final launcher = context.watch<LauncherController>();
|
|
|
|
|
+ final lock = context.watch<LockController>();
|
|
|
|
|
+
|
|
|
|
|
+ return Scaffold(
|
|
|
|
|
+ backgroundColor: colors.canvas,
|
|
|
|
|
+ body: Container(
|
|
|
|
|
+ decoration: homeBackground(colors, Theme.of(context).brightness),
|
|
|
|
|
+ child: SafeArea(
|
|
|
|
|
+ child: SingleChildScrollView(
|
|
|
|
|
+ padding: const EdgeInsets.fromLTRB(32, 22, 32, 32),
|
|
|
|
|
+ child: Center(
|
|
|
|
|
+ child: ConstrainedBox(
|
|
|
|
|
+ constraints: const BoxConstraints(maxWidth: 980),
|
|
|
|
|
+ child: Column(
|
|
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
+ children: [
|
|
|
|
|
+ Row(
|
|
|
|
|
+ children: [
|
|
|
|
|
+ Pressable(
|
|
|
|
|
+ onTap: () => Navigator.of(context).pop(),
|
|
|
|
|
+ semanticLabel: 'Back',
|
|
|
|
|
+ pressedScale: 0.92,
|
|
|
|
|
+ child: Container(
|
|
|
|
|
+ width: 46,
|
|
|
|
|
+ height: 46,
|
|
|
|
|
+ decoration: BoxDecoration(
|
|
|
|
|
+ color: colors.surface,
|
|
|
|
|
+ shape: BoxShape.circle,
|
|
|
|
|
+ border: Border.all(color: colors.border),
|
|
|
|
|
+ ),
|
|
|
|
|
+ alignment: Alignment.center,
|
|
|
|
|
+ child: AppIcon(
|
|
|
|
|
+ 'assets/icons/arrow-left.svg',
|
|
|
|
|
+ size: 20,
|
|
|
|
|
+ color: colors.ink,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(width: 18),
|
|
|
|
|
+ Column(
|
|
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
+ children: [
|
|
|
|
|
+ Text(
|
|
|
|
|
+ 'Settings',
|
|
|
|
|
+ style: theme.textTheme.headlineLarge,
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(height: 2),
|
|
|
|
|
+ Text(
|
|
|
|
|
+ 'Theme, shortcuts and access',
|
|
|
|
|
+ style: theme.textTheme.bodySmall?.copyWith(
|
|
|
|
|
+ color: colors.inkMuted,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(height: 32),
|
|
|
|
|
+ _SectionTitle(
|
|
|
|
|
+ title: 'General',
|
|
|
|
|
+ colors: colors,
|
|
|
|
|
+ theme: theme,
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(height: 12),
|
|
|
|
|
+ SurfaceCard(
|
|
|
|
|
+ padding: const EdgeInsets.fromLTRB(26, 22, 26, 18),
|
|
|
|
|
+ child: Column(
|
|
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
+ children: [
|
|
|
|
|
+ Row(
|
|
|
|
|
+ children: [
|
|
|
|
|
+ Text('Theme', style: theme.textTheme.titleMedium),
|
|
|
|
|
+ const Spacer(),
|
|
|
|
|
+ _ThemeSelector(
|
|
|
|
|
+ selected: themeController.preference,
|
|
|
|
|
+ onSelected: themeController.setPreference,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(height: 8),
|
|
|
|
|
+ Text(
|
|
|
|
|
+ themeController.preference ==
|
|
|
|
|
+ ThemePreference.automatic
|
|
|
|
|
+ ? 'Switches automatically at the times below.'
|
|
|
|
|
+ : themeController.preference ==
|
|
|
|
|
+ ThemePreference.dark
|
|
|
|
|
+ ? 'Always dark.'
|
|
|
|
|
+ : 'Always light.',
|
|
|
|
|
+ style: theme.textTheme.bodySmall?.copyWith(
|
|
|
|
|
+ color: colors.inkMuted,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ if (themeController.preference ==
|
|
|
|
|
+ ThemePreference.automatic) ...[
|
|
|
|
|
+ const SizedBox(height: 8),
|
|
|
|
|
+ Divider(color: colors.border, height: 24),
|
|
|
|
|
+ _ActionRow(
|
|
|
|
|
+ iconAsset: 'assets/icons/moon.svg',
|
|
|
|
|
+ title: 'Dark from',
|
|
|
|
|
+ value: _formatMinutes(
|
|
|
|
|
+ context,
|
|
|
|
|
+ themeController.nightStartMinutes,
|
|
|
|
|
+ ),
|
|
|
|
|
+ onTap: () => _pickTime(
|
|
|
|
|
+ context,
|
|
|
|
|
+ themeController,
|
|
|
|
|
+ start: true,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(height: 2),
|
|
|
|
|
+ _ActionRow(
|
|
|
|
|
+ iconAsset: 'assets/icons/sun.svg',
|
|
|
|
|
+ title: 'Light from',
|
|
|
|
|
+ value: _formatMinutes(
|
|
|
|
|
+ context,
|
|
|
|
|
+ themeController.nightEndMinutes,
|
|
|
|
|
+ ),
|
|
|
|
|
+ onTap: () => _pickTime(
|
|
|
|
|
+ context,
|
|
|
|
|
+ themeController,
|
|
|
|
|
+ start: false,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ Divider(color: colors.border, height: 30),
|
|
|
|
|
+ Text(
|
|
|
|
|
+ 'Home shortcuts',
|
|
|
|
|
+ style: theme.textTheme.titleMedium,
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(height: 4),
|
|
|
|
|
+ for (final slot in SlotType.values)
|
|
|
|
|
+ _ActionRow(
|
|
|
|
|
+ iconAsset: slot.iconAsset,
|
|
|
|
|
+ title: slot.label,
|
|
|
|
|
+ subtitle:
|
|
|
|
|
+ launcher.assignmentFor(slot)?.label ??
|
|
|
|
|
+ 'Select app',
|
|
|
|
|
+ highlightSubtitle:
|
|
|
|
|
+ launcher.assignmentFor(slot) == null,
|
|
|
|
|
+ trailing: const _Chevron(),
|
|
|
|
|
+ onTap: () => showAppPicker(context, slot),
|
|
|
|
|
+ ),
|
|
|
|
|
+ Divider(color: colors.border, height: 30),
|
|
|
|
|
+ _HomeRoleRow(launcher: launcher),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(height: 30),
|
|
|
|
|
+ _SectionTitle(
|
|
|
|
|
+ title: 'Security',
|
|
|
|
|
+ colors: colors,
|
|
|
|
|
+ theme: theme,
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(height: 12),
|
|
|
|
|
+ SurfaceCard(
|
|
|
|
|
+ padding: const EdgeInsets.fromLTRB(26, 22, 26, 22),
|
|
|
|
|
+ child: Column(
|
|
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
+ children: [
|
|
|
|
|
+ Row(
|
|
|
|
|
+ children: [
|
|
|
|
|
+ Container(
|
|
|
|
|
+ width: 42,
|
|
|
|
|
+ height: 42,
|
|
|
|
|
+ decoration: BoxDecoration(
|
|
|
|
|
+ color: colors.accentSoft,
|
|
|
|
|
+ borderRadius: BorderRadius.circular(14),
|
|
|
|
|
+ ),
|
|
|
|
|
+ alignment: Alignment.center,
|
|
|
|
|
+ child: AppIcon(
|
|
|
|
|
+ 'assets/icons/lock.svg',
|
|
|
|
|
+ size: 20,
|
|
|
|
|
+ color: colors.accent,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(width: 14),
|
|
|
|
|
+ Expanded(
|
|
|
|
|
+ child: Column(
|
|
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
+ children: [
|
|
|
|
|
+ Text(
|
|
|
|
|
+ 'PIN protection',
|
|
|
|
|
+ style: theme.textTheme.titleMedium,
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(height: 2),
|
|
|
|
|
+ Text(
|
|
|
|
|
+ lock.pinSet ? 'On' : 'Off',
|
|
|
|
|
+ style: theme.textTheme.bodySmall
|
|
|
|
|
+ ?.copyWith(
|
|
|
|
|
+ color: lock.pinSet
|
|
|
|
|
+ ? colors.accent
|
|
|
|
|
+ : colors.inkMuted,
|
|
|
|
|
+ fontWeight: FontWeight.w600,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ if (!lock.pinSet)
|
|
|
|
|
+ _SmallButton(
|
|
|
|
|
+ label: 'Set PIN',
|
|
|
|
|
+ onTap: () =>
|
|
|
|
|
+ _openPinSetup(context, PinSetupMode.set),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ),
|
|
|
|
|
+ if (lock.pinSet) ...[
|
|
|
|
|
+ const SizedBox(height: 10),
|
|
|
|
|
+ Divider(color: colors.border, height: 24),
|
|
|
|
|
+ _ActionRow(
|
|
|
|
|
+ iconAsset: 'assets/icons/lock.svg',
|
|
|
|
|
+ title: 'Change PIN',
|
|
|
|
|
+ trailing: const _Chevron(),
|
|
|
|
|
+ onTap: () =>
|
|
|
|
|
+ _openPinSetup(context, PinSetupMode.change),
|
|
|
|
|
+ ),
|
|
|
|
|
+ _ActionRow(
|
|
|
|
|
+ iconAsset: 'assets/icons/x.svg',
|
|
|
|
|
+ title: 'Remove PIN',
|
|
|
|
|
+ trailing: const _Chevron(),
|
|
|
|
|
+ onTap: () =>
|
|
|
|
|
+ _openPinSetup(context, PinSetupMode.remove),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ const SizedBox(height: 16),
|
|
|
|
|
+ Text(
|
|
|
|
|
+ 'The PIN is requested only when CarDash starts. It does not lock '
|
|
|
|
|
+ 'again when you switch apps or turn the screen off. It protects the '
|
|
|
|
|
+ 'launcher, not the device itself.',
|
|
|
|
|
+ style: theme.textTheme.bodySmall?.copyWith(
|
|
|
|
|
+ color: colors.inkMuted,
|
|
|
|
|
+ height: 1.5,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(height: 28),
|
|
|
|
|
+ Center(
|
|
|
|
|
+ child: Text(
|
|
|
|
|
+ 'CarDash 0.1.0 · Android 10+',
|
|
|
|
|
+ style: theme.textTheme.bodySmall?.copyWith(
|
|
|
|
|
+ color: colors.inkFaint,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Future<void> _openPinSetup(BuildContext context, PinSetupMode mode) async {
|
|
|
|
|
+ final result = await Navigator.of(
|
|
|
|
|
+ context,
|
|
|
|
|
+ ).push<bool>(MaterialPageRoute(builder: (_) => PinSetupScreen(mode: mode)));
|
|
|
|
|
+ if (result != true || !context.mounted) return;
|
|
|
|
|
+ final message = switch (mode) {
|
|
|
|
|
+ PinSetupMode.set =>
|
|
|
|
|
+ 'PIN set. It will be requested the next time CarDash starts.',
|
|
|
|
|
+ PinSetupMode.change => 'PIN changed.',
|
|
|
|
|
+ PinSetupMode.remove => 'PIN removed.',
|
|
|
|
|
+ };
|
|
|
|
|
+ ScaffoldMessenger.of(context)
|
|
|
|
|
+ .showSnackBar(SnackBar(content: Text(message)));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Future<void> _pickTime(
|
|
|
|
|
+ BuildContext context,
|
|
|
|
|
+ ThemeController controller, {
|
|
|
|
|
+ required bool start,
|
|
|
|
|
+ }) async {
|
|
|
|
|
+ final minutes = start
|
|
|
|
|
+ ? controller.nightStartMinutes
|
|
|
|
|
+ : controller.nightEndMinutes;
|
|
|
|
|
+ final picked = await showTimePicker(
|
|
|
|
|
+ context: context,
|
|
|
|
|
+ initialTime: TimeOfDay(hour: minutes ~/ 60, minute: minutes % 60),
|
|
|
|
|
+ helpText: start ? 'Dark from' : 'Light from',
|
|
|
|
|
+ builder: (context, child) {
|
|
|
|
|
+ final theme = Theme.of(context);
|
|
|
|
|
+ return Theme(
|
|
|
|
|
+ data: theme.copyWith(
|
|
|
|
|
+ timePickerTheme: TimePickerThemeData(
|
|
|
|
|
+ backgroundColor: theme.colorScheme.surface,
|
|
|
|
|
+ dialBackgroundColor: theme.colorScheme.surfaceContainerHighest,
|
|
|
|
|
+ shape: RoundedRectangleBorder(
|
|
|
|
|
+ borderRadius: BorderRadius.circular(28),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ child: child ?? const SizedBox.shrink(),
|
|
|
|
|
+ );
|
|
|
|
|
+ },
|
|
|
|
|
+ );
|
|
|
|
|
+ if (picked == null || !context.mounted) return;
|
|
|
|
|
+ final newValue = picked.hour * 60 + picked.minute;
|
|
|
|
|
+ await controller.setNightWindow(
|
|
|
|
|
+ startMinutes: start ? newValue : controller.nightStartMinutes,
|
|
|
|
|
+ endMinutes: start ? controller.nightEndMinutes : newValue,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String _formatMinutes(BuildContext context, int minutes) {
|
|
|
|
|
+ return TimeOfDay(hour: minutes ~/ 60, minute: minutes % 60).format(context);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+class _SectionTitle extends StatelessWidget {
|
|
|
|
|
+ const _SectionTitle({
|
|
|
|
|
+ required this.title,
|
|
|
|
|
+ required this.colors,
|
|
|
|
|
+ required this.theme,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ final String title;
|
|
|
|
|
+ final AppColors colors;
|
|
|
|
|
+ final ThemeData theme;
|
|
|
|
|
+
|
|
|
|
|
+ @override
|
|
|
|
|
+ Widget build(BuildContext context) {
|
|
|
|
|
+ return Padding(
|
|
|
|
|
+ padding: const EdgeInsets.only(left: 4),
|
|
|
|
|
+ child: Text(
|
|
|
|
|
+ title,
|
|
|
|
|
+ style: theme.textTheme.titleLarge?.copyWith(color: colors.inkMuted),
|
|
|
|
|
+ ),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+class _ThemeSelector extends StatelessWidget {
|
|
|
|
|
+ const _ThemeSelector({required this.selected, required this.onSelected});
|
|
|
|
|
+
|
|
|
|
|
+ final ThemePreference selected;
|
|
|
|
|
+ final ValueChanged<ThemePreference> onSelected;
|
|
|
|
|
+
|
|
|
|
|
+ @override
|
|
|
|
|
+ Widget build(BuildContext context) {
|
|
|
|
|
+ final colors = context.colors;
|
|
|
|
|
+ return Container(
|
|
|
|
|
+ padding: const EdgeInsets.all(4),
|
|
|
|
|
+ decoration: BoxDecoration(
|
|
|
|
|
+ color: colors.surfaceStrong,
|
|
|
|
|
+ borderRadius: BorderRadius.circular(999),
|
|
|
|
|
+ border: Border.all(color: colors.border),
|
|
|
|
|
+ ),
|
|
|
|
|
+ child: Row(
|
|
|
|
|
+ mainAxisSize: MainAxisSize.min,
|
|
|
|
|
+ children: [
|
|
|
|
|
+ for (final preference in ThemePreference.values)
|
|
|
|
|
+ Pressable(
|
|
|
|
|
+ onTap: () => onSelected(preference),
|
|
|
|
|
+ semanticLabel: 'Theme ${preference.storageValue}',
|
|
|
|
|
+ pressedScale: 0.95,
|
|
|
|
|
+ child: AnimatedContainer(
|
|
|
|
|
+ duration: const Duration(milliseconds: 160),
|
|
|
|
|
+ curve: Curves.easeOutCubic,
|
|
|
|
|
+ padding: const EdgeInsets.symmetric(
|
|
|
|
|
+ horizontal: 18,
|
|
|
|
|
+ vertical: 9,
|
|
|
|
|
+ ),
|
|
|
|
|
+ decoration: BoxDecoration(
|
|
|
|
|
+ color: selected == preference
|
|
|
|
|
+ ? colors.accent
|
|
|
|
|
+ : Colors.transparent,
|
|
|
|
|
+ borderRadius: BorderRadius.circular(999),
|
|
|
|
|
+ ),
|
|
|
|
|
+ child: Text(
|
|
|
|
|
+ switch (preference) {
|
|
|
|
|
+ ThemePreference.light => 'Light',
|
|
|
|
|
+ ThemePreference.dark => 'Dark',
|
|
|
|
|
+ ThemePreference.automatic => 'Auto',
|
|
|
|
|
+ },
|
|
|
|
|
+ style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
|
|
|
+ color: selected == preference
|
|
|
|
|
+ ? colors.accentInk
|
|
|
|
|
+ : colors.inkMuted,
|
|
|
|
|
+ fontWeight: FontWeight.w600,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+class _ActionRow extends StatelessWidget {
|
|
|
|
|
+ const _ActionRow({
|
|
|
|
|
+ required this.iconAsset,
|
|
|
|
|
+ required this.title,
|
|
|
|
|
+ this.subtitle,
|
|
|
|
|
+ this.value,
|
|
|
|
|
+ this.trailing,
|
|
|
|
|
+ this.onTap,
|
|
|
|
|
+ this.highlightSubtitle = false,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ final String iconAsset;
|
|
|
|
|
+ final String title;
|
|
|
|
|
+ final String? subtitle;
|
|
|
|
|
+ final String? value;
|
|
|
|
|
+ final Widget? trailing;
|
|
|
|
|
+ final VoidCallback? onTap;
|
|
|
|
|
+ final bool highlightSubtitle;
|
|
|
|
|
+
|
|
|
|
|
+ @override
|
|
|
|
|
+ Widget build(BuildContext context) {
|
|
|
|
|
+ final colors = context.colors;
|
|
|
|
|
+ final theme = Theme.of(context);
|
|
|
|
|
+ final content = Padding(
|
|
|
|
|
+ padding: const EdgeInsets.symmetric(vertical: 7),
|
|
|
|
|
+ child: Row(
|
|
|
|
|
+ children: [
|
|
|
|
|
+ Container(
|
|
|
|
|
+ width: 38,
|
|
|
|
|
+ height: 38,
|
|
|
|
|
+ decoration: BoxDecoration(
|
|
|
|
|
+ color: colors.surfaceStrong,
|
|
|
|
|
+ borderRadius: BorderRadius.circular(13),
|
|
|
|
|
+ border: Border.all(color: colors.border),
|
|
|
|
|
+ ),
|
|
|
|
|
+ alignment: Alignment.center,
|
|
|
|
|
+ child: AppIcon(iconAsset, size: 18, color: colors.inkMuted),
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(width: 14),
|
|
|
|
|
+ Expanded(
|
|
|
|
|
+ child: Column(
|
|
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
+ children: [
|
|
|
|
|
+ Text(title, style: theme.textTheme.bodyLarge),
|
|
|
|
|
+ if (subtitle != null) ...[
|
|
|
|
|
+ const SizedBox(height: 2),
|
|
|
|
|
+ Text(
|
|
|
|
|
+ subtitle!,
|
|
|
|
|
+ maxLines: 1,
|
|
|
|
|
+ overflow: TextOverflow.ellipsis,
|
|
|
|
|
+ style: theme.textTheme.bodySmall?.copyWith(
|
|
|
|
|
+ color: highlightSubtitle
|
|
|
|
|
+ ? colors.accent
|
|
|
|
|
+ : colors.inkMuted,
|
|
|
|
|
+ fontWeight: highlightSubtitle
|
|
|
|
|
+ ? FontWeight.w600
|
|
|
|
|
+ : FontWeight.w400,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ if (value != null)
|
|
|
|
|
+ Text(
|
|
|
|
|
+ value!,
|
|
|
|
|
+ style: theme.textTheme.labelLarge?.copyWith(
|
|
|
|
|
+ color: colors.inkMuted,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ if (trailing != null) ...[const SizedBox(width: 8), trailing!],
|
|
|
|
|
+ ],
|
|
|
|
|
+ ),
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (onTap == null) return content;
|
|
|
|
|
+ return Material(
|
|
|
|
|
+ color: Colors.transparent,
|
|
|
|
|
+ child: InkWell(
|
|
|
|
|
+ borderRadius: BorderRadius.circular(14),
|
|
|
|
|
+ onTap: onTap,
|
|
|
|
|
+ child: content,
|
|
|
|
|
+ ),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+class _Chevron extends StatelessWidget {
|
|
|
|
|
+ const _Chevron();
|
|
|
|
|
+
|
|
|
|
|
+ @override
|
|
|
|
|
+ Widget build(BuildContext context) {
|
|
|
|
|
+ return AppIcon(
|
|
|
|
|
+ 'assets/icons/chevron-right.svg',
|
|
|
|
|
+ size: 18,
|
|
|
|
|
+ color: context.colors.inkFaint,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+class _SmallButton extends StatelessWidget {
|
|
|
|
|
+ const _SmallButton({required this.label, required this.onTap});
|
|
|
|
|
+
|
|
|
|
|
+ final String label;
|
|
|
|
|
+ final VoidCallback onTap;
|
|
|
|
|
+
|
|
|
|
|
+ @override
|
|
|
|
|
+ Widget build(BuildContext context) {
|
|
|
|
|
+ final colors = context.colors;
|
|
|
|
|
+ return Pressable(
|
|
|
|
|
+ onTap: onTap,
|
|
|
|
|
+ semanticLabel: label,
|
|
|
|
|
+ pressedScale: 0.95,
|
|
|
|
|
+ child: Container(
|
|
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
|
|
|
+ decoration: BoxDecoration(
|
|
|
|
|
+ color: colors.accent,
|
|
|
|
|
+ borderRadius: BorderRadius.circular(999),
|
|
|
|
|
+ ),
|
|
|
|
|
+ child: Text(
|
|
|
|
|
+ label,
|
|
|
|
|
+ style: Theme.of(context).textTheme.labelMedium
|
|
|
|
|
+ ?.copyWith(color: colors.accentInk, fontWeight: FontWeight.w600),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+class _HomeRoleRow extends StatelessWidget {
|
|
|
|
|
+ const _HomeRoleRow({required this.launcher});
|
|
|
|
|
+
|
|
|
|
|
+ final LauncherController launcher;
|
|
|
|
|
+
|
|
|
|
|
+ @override
|
|
|
|
|
+ Widget build(BuildContext context) {
|
|
|
|
|
+ final colors = context.colors;
|
|
|
|
|
+ final theme = Theme.of(context);
|
|
|
|
|
+ final isDefault = launcher.isDefaultHome;
|
|
|
|
|
+ return Row(
|
|
|
|
|
+ children: [
|
|
|
|
|
+ Container(
|
|
|
|
|
+ width: 38,
|
|
|
|
|
+ height: 38,
|
|
|
|
|
+ decoration: BoxDecoration(
|
|
|
|
|
+ color: colors.surfaceStrong,
|
|
|
|
|
+ borderRadius: BorderRadius.circular(13),
|
|
|
|
|
+ border: Border.all(color: colors.border),
|
|
|
|
|
+ ),
|
|
|
|
|
+ alignment: Alignment.center,
|
|
|
|
|
+ child: AppIcon(
|
|
|
|
|
+ 'assets/icons/smartphone.svg',
|
|
|
|
|
+ size: 18,
|
|
|
|
|
+ color: colors.inkMuted,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(width: 14),
|
|
|
|
|
+ Expanded(
|
|
|
|
|
+ child: Column(
|
|
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
+ children: [
|
|
|
|
|
+ Text('Home app', style: theme.textTheme.bodyLarge),
|
|
|
|
|
+ const SizedBox(height: 2),
|
|
|
|
|
+ Text(
|
|
|
|
|
+ launcher.defaultHomeKnown
|
|
|
|
|
+ ? (isDefault ? 'CarDash is your home app' : 'Not set')
|
|
|
|
|
+ : 'Checking…',
|
|
|
|
|
+ style: theme.textTheme.bodySmall?.copyWith(
|
|
|
|
|
+ color: isDefault ? colors.accent : colors.inkMuted,
|
|
|
|
|
+ fontWeight: isDefault ? FontWeight.w600 : FontWeight.w400,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ if (launcher.defaultHomeKnown && !isDefault)
|
|
|
|
|
+ _SmallButton(
|
|
|
|
|
+ label: 'Set as default',
|
|
|
|
|
+ onTap: () async {
|
|
|
|
|
+ final launched = await launcher.requestDefaultHome();
|
|
|
|
|
+ if (!launched && context.mounted) {
|
|
|
|
|
+ ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
+ const SnackBar(
|
|
|
|
|
+ content: Text('Could not open the home app settings.'),
|
|
|
|
|
+ ),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ else if (isDefault)
|
|
|
|
|
+ Icon(Icons.check_circle_rounded, color: colors.accent, size: 22),
|
|
|
|
|
+ ],
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+}
|