634 lines
20 KiB
Dart
634 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../../../core/constants/enums.dart';
|
|
import '../../../../core/theme/branding_config.dart';
|
|
import '../../../../core/theme/theme_provider.dart';
|
|
import '../../../../shared/widgets/app_button.dart';
|
|
import '../providers/settings_provider.dart';
|
|
import '../widgets/settings_widgets.dart';
|
|
import '../../../../shared/widgets/app_toast.dart';
|
|
|
|
class _BrandThemePreset {
|
|
const _BrandThemePreset({
|
|
required this.name,
|
|
required this.primary,
|
|
required this.secondary,
|
|
});
|
|
|
|
final String name;
|
|
final int primary;
|
|
final int secondary;
|
|
|
|
bool matches(BrandingConfig branding) =>
|
|
branding.primaryColorValue == primary &&
|
|
branding.secondaryColorValue == secondary;
|
|
}
|
|
|
|
const _brandThemePresets = <_BrandThemePreset>[
|
|
_BrandThemePreset(
|
|
name: 'Ocean Blue',
|
|
primary: 0xFF2563EB,
|
|
secondary: 0xFF0891B2,
|
|
),
|
|
_BrandThemePreset(
|
|
name: 'Royal Purple',
|
|
primary: 0xFF6D28D9,
|
|
secondary: 0xFF0F766E,
|
|
),
|
|
_BrandThemePreset(
|
|
name: 'Emerald Green',
|
|
primary: 0xFF15803D,
|
|
secondary: 0xFF0F766E,
|
|
),
|
|
_BrandThemePreset(
|
|
name: 'Sunset Orange',
|
|
primary: 0xFFEA580C,
|
|
secondary: 0xFFD97706,
|
|
),
|
|
_BrandThemePreset(
|
|
name: 'Crimson Red',
|
|
primary: 0xFFDC2626,
|
|
secondary: 0xFFB45309,
|
|
),
|
|
_BrandThemePreset(
|
|
name: 'Indigo Sky',
|
|
primary: 0xFF4F46E5,
|
|
secondary: 0xFF0284C7,
|
|
),
|
|
_BrandThemePreset(
|
|
name: 'Slate Blue',
|
|
primary: 0xFF334155,
|
|
secondary: 0xFF2563EB,
|
|
),
|
|
_BrandThemePreset(
|
|
name: 'Teal Mint',
|
|
primary: 0xFF0F766E,
|
|
secondary: 0xFF059669,
|
|
),
|
|
_BrandThemePreset(
|
|
name: 'Rose Pink',
|
|
primary: 0xFFDB2777,
|
|
secondary: 0xFF7C3AED,
|
|
),
|
|
_BrandThemePreset(
|
|
name: 'Charcoal Gold',
|
|
primary: 0xFF374151,
|
|
secondary: 0xFFD97706,
|
|
),
|
|
];
|
|
|
|
class AppearanceSettingsScreen extends ConsumerWidget {
|
|
const AppearanceSettingsScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final themeMode = ref.watch(themeModeProvider);
|
|
final branding = ref.watch(brandingProvider);
|
|
final uiPrefs = ref.watch(appSettingsProvider).uiPreferences;
|
|
final selectedPreset = _brandThemePresets
|
|
.where((preset) => preset.matches(branding))
|
|
.firstOrNull;
|
|
|
|
return SettingsPageLayout(
|
|
title: 'Appearance',
|
|
subtitle: 'Theme, branding, and UI preferences',
|
|
child: Column(
|
|
children: [
|
|
SettingsFormCard(
|
|
title: 'Theme',
|
|
children: [
|
|
...ThemeModeOption.values.map(
|
|
(mode) => RadioListTile<ThemeModeOption>(
|
|
title: Text(_themeLabel(mode)),
|
|
value: mode,
|
|
groupValue: themeMode,
|
|
onChanged: (v) {
|
|
if (v != null) {
|
|
ref.read(themeModeProvider.notifier).setThemeMode(v);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
SettingsFormCard(
|
|
title: 'Branding',
|
|
subtitle:
|
|
'Professionally curated primary and secondary color pairs',
|
|
children: [
|
|
_BrandingPreviewCard(
|
|
primary: branding.primaryColor,
|
|
secondary: branding.secondaryColor,
|
|
themeName: selectedPreset?.name ?? 'Custom',
|
|
primaryHex: _hex(branding.primaryColorValue),
|
|
secondaryHex: _hex(branding.secondaryColorValue),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
'Color themes',
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Choose a cohesive pair optimized for light and dark modes.',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final width = constraints.maxWidth;
|
|
final crossAxisCount = width >= 720
|
|
? 3
|
|
: width >= 480
|
|
? 2
|
|
: 1;
|
|
final spacing = 12.0;
|
|
final itemWidth =
|
|
(width - spacing * (crossAxisCount - 1)) / crossAxisCount;
|
|
|
|
return Wrap(
|
|
spacing: spacing,
|
|
runSpacing: spacing,
|
|
children: [
|
|
for (final preset in _brandThemePresets)
|
|
SizedBox(
|
|
width: itemWidth,
|
|
child: _ThemePresetCard(
|
|
preset: preset,
|
|
selected: preset.matches(branding),
|
|
onTap: () {
|
|
ref.read(brandingProvider.notifier).updateBranding(
|
|
branding.copyWith(
|
|
primaryColorValue: preset.primary,
|
|
secondaryColorValue: preset.secondary,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
SettingsFormCard(
|
|
title: 'Navigation Layout',
|
|
subtitle: 'Choose how the main menu is displayed',
|
|
children: [
|
|
...NavigationLayout.values.map(
|
|
(layout) => RadioListTile<NavigationLayout>(
|
|
title: Text(layout.label),
|
|
subtitle: Text(
|
|
layout == NavigationLayout.sidebar
|
|
? 'Vertical sidebar on the left (default)'
|
|
: 'Horizontal menu bar at the top',
|
|
),
|
|
value: layout,
|
|
groupValue:
|
|
NavigationLayout.fromValue(uiPrefs.navigationLayout),
|
|
onChanged: (v) {
|
|
if (v != null) {
|
|
ref
|
|
.read(appSettingsProvider.notifier)
|
|
.updateUiPreferences(
|
|
uiPrefs.copyWith(navigationLayout: v.value),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
SettingsFormCard(
|
|
title: 'UI Preferences',
|
|
children: [
|
|
SettingsSwitchTile(
|
|
title: 'Sidebar Expanded By Default',
|
|
subtitle: 'Show full sidebar labels on login',
|
|
value: uiPrefs.sidebarExpanded,
|
|
onChanged: (v) => ref
|
|
.read(appSettingsProvider.notifier)
|
|
.updateUiPreferences(uiPrefs.copyWith(sidebarExpanded: v)),
|
|
),
|
|
SettingsDropdownField<String>(
|
|
label: 'Dashboard Layout',
|
|
value: uiPrefs.dashboardLayout,
|
|
items: const ['default', 'compact', 'analytics'],
|
|
itemLabel: (v) => v[0].toUpperCase() + v.substring(1),
|
|
onChanged: (v) {
|
|
if (v != null) {
|
|
ref.read(appSettingsProvider.notifier).updateUiPreferences(
|
|
uiPrefs.copyWith(dashboardLayout: v),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
SettingsDropdownField<String>(
|
|
label: 'Table Density',
|
|
value: uiPrefs.tableDensity,
|
|
items: const ['compact', 'comfortable', 'spacious'],
|
|
itemLabel: (v) => v[0].toUpperCase() + v.substring(1),
|
|
onChanged: (v) {
|
|
if (v != null) {
|
|
ref.read(appSettingsProvider.notifier).updateUiPreferences(
|
|
uiPrefs.copyWith(tableDensity: v),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
SettingsDropdownField<int>(
|
|
label: 'Pagination Size',
|
|
value: uiPrefs.paginationSize,
|
|
items: const [10, 20, 50, 100],
|
|
itemLabel: (v) => '$v rows',
|
|
onChanged: (v) {
|
|
if (v != null) {
|
|
ref.read(appSettingsProvider.notifier).updateUiPreferences(
|
|
uiPrefs.copyWith(paginationSize: v),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
AppButton(
|
|
label: 'Settings Auto-Saved',
|
|
onPressed: () {
|
|
showAppToastFromSnackBar(
|
|
context,
|
|
const SnackBar(
|
|
content: Text('Appearance settings are saved automatically'),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _themeLabel(ThemeModeOption mode) => switch (mode) {
|
|
ThemeModeOption.light => 'Light Theme',
|
|
ThemeModeOption.dark => 'Dark Theme',
|
|
ThemeModeOption.system => 'System Theme',
|
|
};
|
|
|
|
static String _hex(int value) =>
|
|
'#${value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}';
|
|
}
|
|
|
|
class _BrandingPreviewCard extends StatelessWidget {
|
|
const _BrandingPreviewCard({
|
|
required this.primary,
|
|
required this.secondary,
|
|
required this.themeName,
|
|
required this.primaryHex,
|
|
required this.secondaryHex,
|
|
});
|
|
|
|
final Color primary;
|
|
final Color secondary;
|
|
final String themeName;
|
|
final String primaryHex;
|
|
final String secondaryHex;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final onPrimary =
|
|
ThemeData.estimateBrightnessForColor(primary) == Brightness.dark
|
|
? Colors.white
|
|
: Colors.black87;
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: theme.colorScheme.outline.withValues(alpha: 0.2),
|
|
),
|
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.35),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
color: primary,
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.dashboard_outlined, color: onPrimary, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Bharat ERP · $themeName',
|
|
style: theme.textTheme.labelLarge?.copyWith(
|
|
color: onPrimary,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: secondary,
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Text(
|
|
'Accent',
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: ThemeData.estimateBrightnessForColor(secondary) ==
|
|
Brightness.dark
|
|
? Colors.white
|
|
: Colors.black87,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Live preview',
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'How headers, primary actions, and secondary accents will look.',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: FilledButton.icon(
|
|
onPressed: () {},
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: primary,
|
|
foregroundColor: onPrimary,
|
|
),
|
|
icon: const Icon(Icons.add, size: 18),
|
|
label: const Text('Add'),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: () {},
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: secondary,
|
|
side: BorderSide(
|
|
color: secondary.withValues(alpha: 0.55),
|
|
),
|
|
),
|
|
child: const Text('Export'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
children: [
|
|
_ColorSwatchLabel(
|
|
color: primary,
|
|
label: 'Primary',
|
|
hex: primaryHex,
|
|
),
|
|
const SizedBox(width: 16),
|
|
_ColorSwatchLabel(
|
|
color: secondary,
|
|
label: 'Secondary',
|
|
hex: secondaryHex,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ColorSwatchLabel extends StatelessWidget {
|
|
const _ColorSwatchLabel({
|
|
required this.color,
|
|
required this.label,
|
|
required this.hex,
|
|
});
|
|
|
|
final Color color;
|
|
final String label;
|
|
final String hex;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Row(
|
|
children: [
|
|
Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: theme.colorScheme.outline.withValues(alpha: 0.25),
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: color.withValues(alpha: 0.28),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: theme.textTheme.labelLarge?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
Text(
|
|
hex,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ThemePresetCard extends StatelessWidget {
|
|
const _ThemePresetCard({
|
|
required this.preset,
|
|
required this.selected,
|
|
required this.onTap,
|
|
});
|
|
|
|
final _BrandThemePreset preset;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final primary = Color(preset.primary);
|
|
final secondary = Color(preset.secondary);
|
|
final borderColor = selected
|
|
? primary
|
|
: theme.colorScheme.outline.withValues(alpha: 0.28);
|
|
|
|
return Material(
|
|
color: selected
|
|
? primary.withValues(alpha: 0.06)
|
|
: theme.colorScheme.surface,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOut,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: borderColor,
|
|
width: selected ? 2 : 1,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
_StackedColorCircles(
|
|
primary: primary,
|
|
secondary: secondary,
|
|
),
|
|
const Spacer(),
|
|
if (selected)
|
|
Container(
|
|
width: 22,
|
|
height: 22,
|
|
decoration: BoxDecoration(
|
|
color: primary,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
Icons.check,
|
|
size: 14,
|
|
color: ThemeData.estimateBrightnessForColor(primary) ==
|
|
Brightness.dark
|
|
? Colors.white
|
|
: Colors.black87,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
preset.name,
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'${AppearanceSettingsScreen._hex(preset.primary)} · '
|
|
'${AppearanceSettingsScreen._hex(preset.secondary)}',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StackedColorCircles extends StatelessWidget {
|
|
const _StackedColorCircles({
|
|
required this.primary,
|
|
required this.secondary,
|
|
});
|
|
|
|
final Color primary;
|
|
final Color secondary;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final outline = Theme.of(context)
|
|
.colorScheme
|
|
.outline
|
|
.withValues(alpha: 0.2);
|
|
|
|
return SizedBox(
|
|
width: 56,
|
|
height: 32,
|
|
child: Stack(
|
|
children: [
|
|
Positioned(
|
|
left: 0,
|
|
child: Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: primary,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: outline, width: 2),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 22,
|
|
child: Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: secondary,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: outline, width: 2),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|