mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-17 15:22:34 +02:00
Use one non-generated language catalog in setup and appearance settings. Filter supported locale codes once and retain the resulting immutable picker list.
641 lines
22 KiB
Dart
641 lines
22 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/constants/language_choices.dart';
|
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
|
import 'package:spotiflac_android/providers/theme_provider.dart';
|
|
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
|
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
|
|
|
|
class AppearanceSettingsPage extends ConsumerWidget {
|
|
const AppearanceSettingsPage({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final themeSettings = ref.watch(themeProvider);
|
|
final settings = ref.watch(settingsProvider);
|
|
|
|
return PopScope(
|
|
canPop: true,
|
|
child: Scaffold(
|
|
body: CustomScrollView(
|
|
slivers: [
|
|
AppSliverHeader.page(title: context.l10n.appearanceTitle),
|
|
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: 16 + wideListInset(context),
|
|
vertical: 8,
|
|
),
|
|
child: _ThemePreviewCard(),
|
|
),
|
|
),
|
|
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(title: context.l10n.sectionColor),
|
|
),
|
|
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: [
|
|
SettingsSwitchItem(
|
|
icon: Icons.wallpaper,
|
|
title: context.l10n.appearanceDynamicColor,
|
|
subtitle: context.l10n.appearanceDynamicColorSubtitle,
|
|
value: themeSettings.useDynamicColor,
|
|
onChanged: (value) => ref
|
|
.read(themeProvider.notifier)
|
|
.setUseDynamicColor(value),
|
|
showDivider: false,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (!themeSettings.useDynamicColor)
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
|
child: _ColorPalettePicker(
|
|
currentColor: themeSettings.seedColorValue,
|
|
onColorSelected: (color) =>
|
|
ref.read(themeProvider.notifier).setSeedColor(color),
|
|
),
|
|
),
|
|
),
|
|
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(title: context.l10n.sectionTheme),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: [
|
|
_ThemeModeSelector(
|
|
currentMode: themeSettings.themeMode,
|
|
onChanged: (mode) =>
|
|
ref.read(themeProvider.notifier).setThemeMode(mode),
|
|
),
|
|
if (Theme.of(context).brightness == Brightness.dark)
|
|
SettingsSwitchItem(
|
|
icon: Icons.brightness_2,
|
|
title: context.l10n.appearanceAmoledDark,
|
|
subtitle: context.l10n.appearanceAmoledDarkSubtitle,
|
|
value: themeSettings.useAmoled,
|
|
onChanged: (value) =>
|
|
ref.read(themeProvider.notifier).setUseAmoled(value),
|
|
showDivider: false,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: [
|
|
SettingsSwitchItem(
|
|
icon: Icons.animation,
|
|
title: context.l10n.appearanceHeroAnimations,
|
|
subtitle: context.l10n.appearanceHeroAnimationsSubtitle,
|
|
value: settings.heroAnimationsEnabled,
|
|
onChanged: (value) => ref
|
|
.read(settingsProvider.notifier)
|
|
.setHeroAnimationsEnabled(value),
|
|
),
|
|
SettingsSwitchItem(
|
|
icon: Icons.blur_on,
|
|
title: context.l10n.appearanceForceBlur,
|
|
subtitle: context.l10n.appearanceForceBlurSubtitle,
|
|
value: settings.forceBackdropBlur,
|
|
onChanged: (value) => ref
|
|
.read(settingsProvider.notifier)
|
|
.setForceBackdropBlur(value),
|
|
showDivider: false,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(title: context.l10n.sectionLanguage),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: [
|
|
_LanguageSelector(
|
|
currentLocale: settings.locale,
|
|
onChanged: (locale) =>
|
|
ref.read(settingsProvider.notifier).setLocale(locale),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(title: context.l10n.sectionLayout),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: [
|
|
_HistoryViewSelector(
|
|
currentMode: settings.historyViewMode,
|
|
onChanged: (mode) => ref
|
|
.read(settingsProvider.notifier)
|
|
.setHistoryViewMode(mode),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SliverFillRemaining(
|
|
hasScrollBody: false,
|
|
child: SizedBox(height: 32),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ThemePreviewCard extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
return RepaintBoundary(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final cardWidth = constraints.maxWidth;
|
|
final previewHeight = (cardWidth * 0.56).clamp(170.0, 220.0);
|
|
final innerWidth = (cardWidth - 48).clamp(220.0, 320.0);
|
|
final innerHeight = (previewHeight * 0.70).clamp(120.0, 160.0);
|
|
final innerPadding = (innerHeight * 0.11).clamp(12.0, 18.0);
|
|
final artworkSize = (innerHeight - (innerPadding * 2)).clamp(
|
|
80.0,
|
|
120.0,
|
|
);
|
|
|
|
return Container(
|
|
constraints: BoxConstraints(minHeight: previewHeight),
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(28),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Stack(
|
|
children: [
|
|
Positioned(
|
|
top: -(previewHeight * 0.25),
|
|
right: -(previewHeight * 0.25),
|
|
child: Container(
|
|
width: previewHeight,
|
|
height: previewHeight,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: colorScheme.primaryContainer.withValues(
|
|
alpha: 0.5,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: -(previewHeight * 0.15),
|
|
left: -(previewHeight * 0.15),
|
|
child: Container(
|
|
width: previewHeight * 0.75,
|
|
height: previewHeight * 0.75,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: colorScheme.tertiaryContainer.withValues(
|
|
alpha: 0.5,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Center(
|
|
child: Container(
|
|
width: innerWidth,
|
|
height: innerHeight,
|
|
padding: EdgeInsets.all(innerPadding),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surface,
|
|
borderRadius: BorderRadius.circular(20),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.1),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: artworkSize,
|
|
height: artworkSize,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primary,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Icon(
|
|
Icons.music_note,
|
|
color: colorScheme.onPrimary,
|
|
size: artworkSize * 0.44,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Container(
|
|
width: double.infinity,
|
|
height: 14,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.onSurface,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
width: 80,
|
|
height: 10,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primary,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.skip_previous,
|
|
size: 24,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Icon(
|
|
Icons.play_circle_fill,
|
|
size: 32,
|
|
color: colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Icon(
|
|
Icons.skip_next,
|
|
size: 24,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 12,
|
|
right: 12,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.6),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Text(
|
|
isDark
|
|
? context.l10n.appearanceThemeDark
|
|
: context.l10n.appearanceThemeLight,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ColorPalettePicker extends StatelessWidget {
|
|
final int currentColor;
|
|
final ValueChanged<Color> onColorSelected;
|
|
const _ColorPalettePicker({
|
|
required this.currentColor,
|
|
required this.onColorSelected,
|
|
});
|
|
|
|
static const _colors = [
|
|
Color(0xFF1DB954),
|
|
Color(0xFF6750A4),
|
|
Color(0xFF0061A4),
|
|
Color(0xFF006E1C),
|
|
Color(0xFFBA1A1A),
|
|
Color(0xFF984061),
|
|
Color(0xFF7D5260),
|
|
Color(0xFF006874),
|
|
];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: _colors.map((color) {
|
|
final isSelected = color.toARGB32() == currentColor;
|
|
final colorHex = color
|
|
.toARGB32()
|
|
.toRadixString(16)
|
|
.padLeft(8, '0')
|
|
.toUpperCase();
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 12),
|
|
child: Semantics(
|
|
button: true,
|
|
selected: isSelected,
|
|
label: context.l10n.appearanceSelectAccentColor(colorHex),
|
|
child: GestureDetector(
|
|
onTap: () => onColorSelected(color),
|
|
child: _ColorPaletteItem(color: color, isSelected: isSelected),
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ColorPaletteItem extends StatelessWidget {
|
|
final Color color;
|
|
final bool isSelected;
|
|
|
|
const _ColorPaletteItem({required this.color, required this.isSelected});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = ColorScheme.fromSeed(
|
|
seedColor: color,
|
|
brightness: Theme.of(context).brightness,
|
|
);
|
|
final size = 64.0;
|
|
|
|
return Stack(
|
|
children: [
|
|
Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: Row(
|
|
children: [
|
|
Expanded(child: Container(color: scheme.primaryContainer)),
|
|
Expanded(child: Container(color: scheme.tertiaryContainer)),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Container(color: scheme.secondaryContainer),
|
|
),
|
|
Expanded(child: Container(color: scheme.surfaceContainer)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (isSelected)
|
|
Positioned.fill(
|
|
child: Center(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(Icons.check, size: 16, color: scheme.primary),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ThemeModeSelector extends StatelessWidget {
|
|
final ThemeMode currentMode;
|
|
final ValueChanged<ThemeMode> onChanged;
|
|
const _ThemeModeSelector({
|
|
required this.currentMode,
|
|
required this.onChanged,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Row(
|
|
children: [
|
|
SettingsChoiceChip(
|
|
expand: true,
|
|
layout: SettingsChipLayout.column,
|
|
icon: Icons.brightness_auto,
|
|
label: context.l10n.appearanceThemeSystem,
|
|
isSelected: currentMode == ThemeMode.system,
|
|
onTap: () => onChanged(ThemeMode.system),
|
|
),
|
|
const SizedBox(width: 8),
|
|
SettingsChoiceChip(
|
|
expand: true,
|
|
layout: SettingsChipLayout.column,
|
|
icon: Icons.light_mode,
|
|
label: context.l10n.appearanceThemeLight,
|
|
isSelected: currentMode == ThemeMode.light,
|
|
onTap: () => onChanged(ThemeMode.light),
|
|
),
|
|
const SizedBox(width: 8),
|
|
SettingsChoiceChip(
|
|
expand: true,
|
|
layout: SettingsChipLayout.column,
|
|
icon: Icons.dark_mode,
|
|
label: context.l10n.appearanceThemeDark,
|
|
isSelected: currentMode == ThemeMode.dark,
|
|
onTap: () => onChanged(ThemeMode.dark),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HistoryViewSelector extends StatelessWidget {
|
|
final String currentMode;
|
|
final ValueChanged<String> onChanged;
|
|
const _HistoryViewSelector({
|
|
required this.currentMode,
|
|
required this.onChanged,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final content = Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
|
child: Text(
|
|
context.l10n.appearanceHistoryView,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
Row(
|
|
children: [
|
|
SettingsChoiceChip(
|
|
expand: true,
|
|
layout: SettingsChipLayout.column,
|
|
icon: Icons.view_list,
|
|
label: context.l10n.appearanceHistoryViewList,
|
|
isSelected: currentMode == 'list',
|
|
onTap: () => onChanged('list'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
SettingsChoiceChip(
|
|
expand: true,
|
|
layout: SettingsChipLayout.column,
|
|
icon: Icons.grid_view,
|
|
label: context.l10n.appearanceHistoryViewGrid,
|
|
isSelected: currentMode == 'grid',
|
|
onTap: () => onChanged('grid'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return SettingsSearchTarget(
|
|
label: context.l10n.appearanceHistoryView,
|
|
child: content,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LanguageSelector extends StatelessWidget {
|
|
final String currentLocale;
|
|
final ValueChanged<String> onChanged;
|
|
const _LanguageSelector({
|
|
required this.currentLocale,
|
|
required this.onChanged,
|
|
});
|
|
|
|
String _getLanguageName(String code) {
|
|
for (final lang in appLanguageChoices) {
|
|
if (lang.$1 == code) return lang.$2;
|
|
}
|
|
return code;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final content = ListTile(
|
|
leading: Icon(Icons.language, color: colorScheme.onSurfaceVariant),
|
|
title: Text(context.l10n.appearanceLanguage),
|
|
subtitle: Text(_getLanguageName(currentLocale)),
|
|
trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
|
onTap: () => _showLanguagePicker(context),
|
|
);
|
|
return SettingsSearchTarget(
|
|
label: context.l10n.appearanceLanguage,
|
|
child: content,
|
|
);
|
|
}
|
|
|
|
void _showLanguagePicker(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
useRootNavigator: true,
|
|
backgroundColor: colorScheme.surface,
|
|
builder: (context) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Text(
|
|
context.l10n.appearanceLanguage,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
Flexible(
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
itemCount: supportedLanguageChoices.length,
|
|
itemBuilder: (context, index) {
|
|
final lang = supportedLanguageChoices[index];
|
|
final isSelected = currentLocale == lang.$1;
|
|
return ListTile(
|
|
leading: Icon(
|
|
lang.$3,
|
|
color: isSelected
|
|
? colorScheme.primary
|
|
: colorScheme.onSurfaceVariant,
|
|
),
|
|
title: Text(
|
|
lang.$2,
|
|
style: TextStyle(
|
|
color: isSelected
|
|
? colorScheme.primary
|
|
: colorScheme.onSurface,
|
|
fontWeight: isSelected
|
|
? FontWeight.w600
|
|
: FontWeight.normal,
|
|
),
|
|
),
|
|
trailing: isSelected
|
|
? Icon(Icons.check, color: colorScheme.primary)
|
|
: null,
|
|
onTap: () {
|
|
onChanged(lang.$1);
|
|
Navigator.pop(context);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|