mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-13 12:34:59 +02:00
refactor(settings): split download/options into focused pages
- Extract files, metadata, lyrics into dedicated pages - Move search source + fallback into download page - Move app/update/debug settings into new app_settings_page - Replace options_settings_page with app_settings_page - Reorganize settings_tab into 3 logical groups
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class AppSettingsPage extends ConsumerWidget {
|
||||
const AppSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.settingsApp,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// ── Updates ────────────────────────────────────────────────
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionApp),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.extension,
|
||||
title: context.l10n.optionsExtensionStore,
|
||||
subtitle: context.l10n.optionsExtensionStoreSubtitle,
|
||||
value: settings.showExtensionStore,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setShowExtensionStore(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.system_update,
|
||||
title: context.l10n.optionsCheckUpdates,
|
||||
subtitle: context.l10n.optionsCheckUpdatesSubtitle,
|
||||
value: settings.checkForUpdates,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setCheckForUpdates(v),
|
||||
showDivider: settings.checkForUpdates,
|
||||
),
|
||||
if (settings.checkForUpdates)
|
||||
_UpdateChannelSelector(
|
||||
currentChannel: settings.updateChannel,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setUpdateChannel(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Data ───────────────────────────────────────────────────
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionData),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
title: context.l10n.cleanupOrphanedDownloads,
|
||||
subtitle: context.l10n.cleanupOrphanedDownloadsSubtitle,
|
||||
onTap: () => _cleanupOrphanedDownloads(context, ref),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.delete_forever,
|
||||
title: context.l10n.optionsClearHistory,
|
||||
subtitle: context.l10n.optionsClearHistorySubtitle,
|
||||
onTap: () =>
|
||||
_showClearHistoryDialog(context, ref, colorScheme),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Debug ──────────────────────────────────────────────────
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionDebug),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.bug_report,
|
||||
title: context.l10n.optionsDetailedLogging,
|
||||
subtitle: settings.enableLogging
|
||||
? context.l10n.optionsDetailedLoggingOn
|
||||
: context.l10n.optionsDetailedLoggingOff,
|
||||
value: settings.enableLogging,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setEnableLogging(v),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showClearHistoryDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.dialogClearHistoryTitle),
|
||||
content: Text(context.l10n.dialogClearHistoryMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(downloadHistoryProvider.notifier).clearHistory();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarHistoryCleared)),
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
context.l10n.dialogClear,
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _cleanupOrphanedDownloads(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
) async {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
content: Row(
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(width: 16),
|
||||
Text(context.l10n.cleanupOrphanedDownloads),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
try {
|
||||
final removed = await ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.cleanupOrphanedDownloads();
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
removed > 0
|
||||
? context.l10n.cleanupOrphanedDownloadsResult(removed)
|
||||
: context.l10n.cleanupOrphanedDownloadsNone,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _UpdateChannelSelector extends StatelessWidget {
|
||||
final String currentChannel;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _UpdateChannelSelector({
|
||||
required this.currentChannel,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.new_releases,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.optionsUpdateChannel,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
currentChannel == 'preview'
|
||||
? context.l10n.optionsUpdateChannelPreview
|
||||
: context.l10n.optionsUpdateChannelStable,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
_ChannelChip(
|
||||
label: context.l10n.channelStable,
|
||||
isSelected: currentChannel == 'stable',
|
||||
onTap: () => onChanged('stable'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ChannelChip(
|
||||
label: context.l10n.channelPreview,
|
||||
isSelected: currentChannel == 'preview',
|
||||
onTap: () => onChanged('preview'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.optionsUpdateChannelWarning,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _ChannelChip({
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,373 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/screens/settings/lyrics_provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class LyricsSettingsPage extends ConsumerWidget {
|
||||
const LyricsSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.settingsLyrics,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// ── Lyrics Embedding ───────────────────────────────────────
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionLyrics),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.subtitles_outlined,
|
||||
title: context.l10n.optionsEmbedLyrics,
|
||||
subtitle: settings.embedMetadata
|
||||
? context.l10n.optionsEmbedLyricsSubtitle
|
||||
: context.l10n.downloadEmbedLyricsDisabled,
|
||||
value: settings.embedLyrics,
|
||||
enabled: settings.embedMetadata,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setEmbedLyrics(value),
|
||||
showDivider:
|
||||
settings.embedMetadata && settings.embedLyrics,
|
||||
),
|
||||
if (settings.embedMetadata && settings.embedLyrics) ...[
|
||||
SettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: context.l10n.lyricsMode,
|
||||
subtitle: _getLyricsModeLabel(
|
||||
context,
|
||||
settings.lyricsMode,
|
||||
),
|
||||
onTap: () =>
|
||||
_showLyricsModePicker(context, ref, settings.lyricsMode),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.source_outlined,
|
||||
title: context.l10n.lyricsProvidersTitle,
|
||||
subtitle: _getLyricsProvidersSubtitle(
|
||||
context,
|
||||
settings.lyricsProviders,
|
||||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const LyricsProviderPriorityPage(),
|
||||
),
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Provider Options ───────────────────────────────────────
|
||||
if (settings.embedMetadata && settings.embedLyrics) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.sectionLyricsProviderOptions,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.translate_outlined,
|
||||
title: context.l10n.downloadNeteaseIncludeTranslation,
|
||||
subtitle: settings.lyricsIncludeTranslationNetease
|
||||
? context.l10n.downloadNeteaseIncludeTranslationEnabled
|
||||
: context.l10n.downloadNeteaseIncludeTranslationDisabled,
|
||||
value: settings.lyricsIncludeTranslationNetease,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsIncludeTranslationNetease(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.text_fields_outlined,
|
||||
title: context.l10n.downloadNeteaseIncludeRomanization,
|
||||
subtitle: settings.lyricsIncludeRomanizationNetease
|
||||
? context
|
||||
.l10n
|
||||
.downloadNeteaseIncludeRomanizationEnabled
|
||||
: context
|
||||
.l10n
|
||||
.downloadNeteaseIncludeRomanizationDisabled,
|
||||
value: settings.lyricsIncludeRomanizationNetease,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsIncludeRomanizationNetease(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.record_voice_over_outlined,
|
||||
title: context.l10n.downloadAppleQqMultiPerson,
|
||||
subtitle: settings.lyricsMultiPersonWordByWord
|
||||
? context.l10n.downloadAppleQqMultiPersonEnabled
|
||||
: context.l10n.downloadAppleQqMultiPersonDisabled,
|
||||
value: settings.lyricsMultiPersonWordByWord,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLyricsMultiPersonWordByWord(value),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.language_outlined,
|
||||
title: context.l10n.downloadMusixmatchLanguage,
|
||||
subtitle: settings.musixmatchLanguage.isEmpty
|
||||
? context.l10n.downloadMusixmatchLanguageAuto
|
||||
: settings.musixmatchLanguage.toUpperCase(),
|
||||
onTap: () => _showMusixmatchLanguagePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.musixmatchLanguage,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getLyricsModeLabel(BuildContext context, String mode) {
|
||||
switch (mode) {
|
||||
case 'external':
|
||||
return context.l10n.lyricsModeExternal;
|
||||
case 'both':
|
||||
return context.l10n.lyricsModeBoth;
|
||||
default:
|
||||
return context.l10n.lyricsModeEmbed;
|
||||
}
|
||||
}
|
||||
|
||||
static const _providerDisplayNames = <String, String>{
|
||||
'lrclib': 'LRCLIB',
|
||||
'netease': 'Netease',
|
||||
'musixmatch': 'Musixmatch',
|
||||
'apple_music': 'Apple Music',
|
||||
'qqmusic': 'QQ Music',
|
||||
};
|
||||
|
||||
String _getLyricsProvidersSubtitle(
|
||||
BuildContext context,
|
||||
List<String> providers,
|
||||
) {
|
||||
if (providers.isEmpty) return context.l10n.downloadProvidersNoneEnabled;
|
||||
return providers
|
||||
.map((p) => _providerDisplayNames[p] ?? p)
|
||||
.join(' > ');
|
||||
}
|
||||
|
||||
void _showLyricsModePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.lyricsMode,
|
||||
style: Theme.of(context).textTheme.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
context.l10n.lyricsModeDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.audiotrack),
|
||||
title: Text(context.l10n.lyricsModeEmbed),
|
||||
subtitle: Text(context.l10n.lyricsModeEmbedSubtitle),
|
||||
trailing: current == 'embed' ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLyricsMode('embed');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.insert_drive_file_outlined),
|
||||
title: Text(context.l10n.lyricsModeExternal),
|
||||
subtitle: Text(context.l10n.lyricsModeExternalSubtitle),
|
||||
trailing: current == 'external' ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLyricsMode('external');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.library_music_outlined),
|
||||
title: Text(context.l10n.lyricsModeBoth),
|
||||
subtitle: Text(context.l10n.lyricsModeBothSubtitle),
|
||||
trailing: current == 'both' ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLyricsMode('both');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMusixmatchLanguagePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String currentLanguage,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final controller = TextEditingController(text: currentLanguage);
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 24,
|
||||
right: 24,
|
||||
top: 24,
|
||||
bottom: 24 + MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.downloadMusixmatchLanguage,
|
||||
style: Theme.of(context).textTheme.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.downloadMusixmatchLanguageDesc,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.downloadMusixmatchLanguageCode,
|
||||
hintText: context.l10n.downloadMusixmatchLanguageHint,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setMusixmatchLanguage('');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.downloadMusixmatchAuto),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final normalized = controller.text
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r'[^a-z0-9\-_]'), '');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setMusixmatchLanguage(normalized);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class MetadataSettingsPage extends ConsumerWidget {
|
||||
const MetadataSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
bottom: 16,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.settingsMetadata,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// ── Embedding ──────────────────────────────────────────────
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.sell_outlined,
|
||||
title: 'Embed Metadata',
|
||||
subtitle: settings.embedMetadata
|
||||
? 'Write metadata, cover art, and lyrics to files'
|
||||
: 'Disabled (advanced): skip all metadata embedding',
|
||||
value: settings.embedMetadata,
|
||||
onChanged: (v) =>
|
||||
ref.read(settingsProvider.notifier).setEmbedMetadata(v),
|
||||
showDivider: settings.embedMetadata,
|
||||
),
|
||||
if (settings.embedMetadata) ...[
|
||||
SettingsItem(
|
||||
icon: Icons.people_alt_outlined,
|
||||
title: context.l10n.optionsArtistTagMode,
|
||||
subtitle: _getArtistTagModeLabel(
|
||||
context,
|
||||
settings.artistTagMode,
|
||||
),
|
||||
onTap: () =>
|
||||
_showArtistTagModePicker(context, ref, settings.artistTagMode),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.image,
|
||||
title: context.l10n.optionsMaxQualityCover,
|
||||
subtitle: context.l10n.optionsMaxQualityCoverSubtitle,
|
||||
value: settings.maxQualityCover,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setMaxQualityCover(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.graphic_eq,
|
||||
title: context.l10n.optionsReplayGain,
|
||||
subtitle: settings.embedReplayGain
|
||||
? context.l10n.optionsReplayGainSubtitleOn
|
||||
: context.l10n.optionsReplayGainSubtitleOff,
|
||||
value: settings.embedReplayGain,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setEmbedReplayGain(v),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Providers ─────────────────────────────────────────────
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.sectionMetadataProviders,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.source_outlined,
|
||||
title: context.l10n.metadataProvidersTitle,
|
||||
subtitle: context.l10n.metadataProvidersSubtitle,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const MetadataProviderPriorityPage(),
|
||||
),
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Deduplication ──────────────────────────────────────────
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.sectionDuplicates,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.filter_list_outlined,
|
||||
title: context.l10n.downloadDeduplication,
|
||||
subtitle: settings.deduplicateDownloads
|
||||
? context.l10n.downloadDeduplicationEnabled
|
||||
: context.l10n.downloadDeduplicationDisabled,
|
||||
value: settings.deduplicateDownloads,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setDeduplicateDownloads(value),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getArtistTagModeLabel(BuildContext context, String mode) {
|
||||
switch (mode) {
|
||||
case artistTagModeSplitVorbis:
|
||||
return context.l10n.optionsArtistTagModeSplitVorbis;
|
||||
default:
|
||||
return context.l10n.optionsArtistTagModeJoined;
|
||||
}
|
||||
}
|
||||
|
||||
void _showArtistTagModePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String currentMode,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.optionsArtistTagMode,
|
||||
style: Theme.of(context).textTheme.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
context.l10n.optionsArtistTagModeDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.segment_outlined),
|
||||
title: Text(context.l10n.optionsArtistTagModeJoined),
|
||||
subtitle: Text(context.l10n.optionsArtistTagModeJoinedSubtitle),
|
||||
trailing: currentMode == artistTagModeJoined
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setArtistTagMode(artistTagModeJoined);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.library_music_outlined),
|
||||
title: Text(context.l10n.optionsArtistTagModeSplitVorbis),
|
||||
subtitle: Text(context.l10n.optionsArtistTagModeSplitVorbisSubtitle),
|
||||
trailing: currentMode == artistTagModeSplitVorbis
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setArtistTagMode(artistTagModeSplitVorbis);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,12 @@ import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/files_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/lyrics_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/metadata_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/extensions_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/library_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/app_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/cache_management_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/donate_page.dart';
|
||||
@@ -48,7 +51,7 @@ class SettingsTab extends ConsumerWidget {
|
||||
title: Text(
|
||||
context.l10n.settingsTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
||||
fontSize: 20 + (14 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
@@ -58,6 +61,7 @@ class SettingsTab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// ── Group 1: Appearance & Content ──────────────────────────────
|
||||
SliverToBoxAdapter(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
@@ -72,6 +76,34 @@ class SettingsTab extends ConsumerWidget {
|
||||
onTap: () =>
|
||||
_navigateTo(context, const AppearanceSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.library_music_outlined,
|
||||
title: l10n.settingsLocalLibrary,
|
||||
subtitle: l10n.settingsLocalLibrarySubtitle,
|
||||
onTap: () =>
|
||||
_navigateTo(context, const LibrarySettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.extension_outlined,
|
||||
title: l10n.settingsExtensions,
|
||||
subtitle: l10n.settingsExtensionsSubtitle,
|
||||
onTap: () => _navigateTo(context, const ExtensionsPage()),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// ── Group 2: Download ──────────────────────────────────────────
|
||||
SliverToBoxAdapter(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final l10n = context.l10n;
|
||||
return SettingsGroup(
|
||||
margin: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.download_outlined,
|
||||
title: l10n.settingsDownload,
|
||||
@@ -80,12 +112,41 @@ class SettingsTab extends ConsumerWidget {
|
||||
_navigateTo(context, const DownloadSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.library_music_outlined,
|
||||
title: l10n.settingsLocalLibrary,
|
||||
subtitle: l10n.settingsLocalLibrarySubtitle,
|
||||
icon: Icons.folder_outlined,
|
||||
title: l10n.settingsFiles,
|
||||
subtitle: l10n.settingsFilesSubtitle,
|
||||
onTap: () =>
|
||||
_navigateTo(context, const LibrarySettingsPage()),
|
||||
_navigateTo(context, const FilesSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.sell_outlined,
|
||||
title: l10n.settingsMetadata,
|
||||
subtitle: l10n.settingsMetadataSubtitle,
|
||||
onTap: () =>
|
||||
_navigateTo(context, const MetadataSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.lyrics_outlined,
|
||||
title: l10n.settingsLyrics,
|
||||
subtitle: l10n.settingsLyricsSubtitle,
|
||||
onTap: () =>
|
||||
_navigateTo(context, const LyricsSettingsPage()),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// ── Group 3: App ───────────────────────────────────────────────
|
||||
SliverToBoxAdapter(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final l10n = context.l10n;
|
||||
return SettingsGroup(
|
||||
margin: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.storage_outlined,
|
||||
title: l10n.settingsCache,
|
||||
@@ -95,41 +156,22 @@ class SettingsTab extends ConsumerWidget {
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.tune_outlined,
|
||||
title: l10n.settingsOptions,
|
||||
subtitle: l10n.settingsOptionsSubtitle,
|
||||
title: l10n.settingsApp,
|
||||
subtitle: l10n.settingsAppSubtitle,
|
||||
onTap: () =>
|
||||
_navigateTo(context, const OptionsSettingsPage()),
|
||||
_navigateTo(context, const AppSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.extension_outlined,
|
||||
title: l10n.settingsExtensions,
|
||||
subtitle: l10n.settingsExtensionsSubtitle,
|
||||
onTap: () => _navigateTo(context, const ExtensionsPage()),
|
||||
icon: Icons.article_outlined,
|
||||
title: l10n.logTitle,
|
||||
subtitle: l10n.settingsLogsSubtitle,
|
||||
onTap: () => _navigateTo(context, const LogScreen()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.favorite_outline,
|
||||
title: l10n.settingsDonate,
|
||||
subtitle: l10n.settingsDonateSubtitle,
|
||||
onTap: () => _navigateTo(context, const DonatePage()),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final l10n = context.l10n;
|
||||
return SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.article_outlined,
|
||||
title: l10n.logTitle,
|
||||
subtitle: l10n.settingsLogsSubtitle,
|
||||
onTap: () => _navigateTo(context, const LogScreen()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.info_outline,
|
||||
|
||||
Reference in New Issue
Block a user