Files
SpotiFLAC-Mobile/lib/screens/settings_screen.dart
T
zarzet 794486a200 v2.0.2: Quality display, fallback fix, Open in Spotify fix
- Add actual quality display (bit depth/sample rate) in history and metadata
- Add quality disclaimer in quality picker
- Fix fallback service display showing wrong service
- Fix Open in Spotify not opening app correctly
- Remove romaji conversion feature
- Amazon now reads quality from FLAC file
2026-01-03 07:23:54 +07:00

541 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final themeSettings = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: [
// Theme Section
_buildSectionHeader(context, 'Appearance', colorScheme),
// Theme Mode
ListTile(
leading: Icon(Icons.brightness_6, color: colorScheme.primary),
title: const Text('Theme Mode'),
subtitle: Text(_getThemeModeName(themeSettings.themeMode)),
onTap: () => _showThemeModePicker(context, ref, themeSettings.themeMode),
),
// Dynamic Color Toggle
SwitchListTile(
secondary: Icon(Icons.palette, color: colorScheme.primary),
title: const Text('Dynamic Color'),
subtitle: const Text('Use colors from your wallpaper'),
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
),
// Seed Color Picker (only when dynamic color is disabled)
if (!themeSettings.useDynamicColor)
ListTile(
leading: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Color(themeSettings.seedColorValue),
shape: BoxShape.circle,
border: Border.all(color: colorScheme.outline),
),
),
title: const Text('Accent Color'),
subtitle: const Text('Choose your preferred color'),
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
),
const Divider(),
// Download Section
_buildSectionHeader(context, 'Download', colorScheme),
// Download Service
ListTile(
leading: Icon(Icons.cloud_download, color: colorScheme.primary),
title: const Text('Default Service'),
subtitle: Text(_getServiceName(settings.defaultService)),
onTap: () => _showServicePicker(context, ref, settings.defaultService),
),
// Audio Quality
ListTile(
leading: Icon(Icons.high_quality, color: colorScheme.primary),
title: const Text('Audio Quality'),
subtitle: Text(_getQualityName(settings.audioQuality)),
onTap: () => _showQualityPicker(context, ref, settings.audioQuality),
),
// Filename Format
ListTile(
leading: Icon(Icons.text_fields, color: colorScheme.primary),
title: const Text('Filename Format'),
subtitle: Text(settings.filenameFormat),
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
),
// Download Directory
ListTile(
leading: Icon(Icons.folder, color: colorScheme.primary),
title: const Text('Download Directory'),
subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory),
onTap: () => _pickDirectory(context, ref),
),
const Divider(),
// Options Section
_buildSectionHeader(context, 'Options', colorScheme),
// Auto Fallback
SwitchListTile(
secondary: Icon(Icons.sync, color: colorScheme.primary),
title: const Text('Auto Fallback'),
subtitle: const Text('Try other services if download fails'),
value: settings.autoFallback,
onChanged: (value) => ref.read(settingsProvider.notifier).setAutoFallback(value),
),
// Embed Lyrics
SwitchListTile(
secondary: Icon(Icons.lyrics, color: colorScheme.primary),
title: const Text('Embed Lyrics'),
subtitle: const Text('Embed synced lyrics into FLAC files'),
value: settings.embedLyrics,
onChanged: (value) => ref.read(settingsProvider.notifier).setEmbedLyrics(value),
),
// Max Quality Cover
SwitchListTile(
secondary: Icon(Icons.image, color: colorScheme.primary),
title: const Text('Max Quality Cover'),
subtitle: const Text('Download highest resolution cover art'),
value: settings.maxQualityCover,
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
),
// Concurrent Downloads
ListTile(
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
title: const Text('Concurrent Downloads'),
subtitle: Text(settings.concurrentDownloads == 1
? 'Sequential (1 at a time)'
: '${settings.concurrentDownloads} parallel downloads'),
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
),
// Check for Updates
SwitchListTile(
secondary: Icon(Icons.system_update, color: colorScheme.primary),
title: const Text('Check for Updates'),
subtitle: const Text('Notify when new version is available'),
value: settings.checkForUpdates,
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
),
const Divider(),
// GitHub & Credits Section
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
ListTile(
leading: Icon(Icons.code, color: colorScheme.primary),
title: Text('${AppInfo.appName} Mobile'),
subtitle: Text('github.com/${AppInfo.githubRepo}'),
onTap: () => _launchUrl(AppInfo.githubUrl),
),
ListTile(
leading: Icon(Icons.computer, color: colorScheme.primary),
title: Text('Original ${AppInfo.appName} (Desktop)'),
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const Divider(),
// About
ListTile(
leading: Icon(Icons.info, color: colorScheme.primary),
title: const Text('About'),
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
onTap: () => _showAboutDialog(context),
),
],
),
);
}
void _showAboutDialog(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
const SizedBox(width: 12),
Text(AppInfo.appName),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAboutRow('Version', AppInfo.version, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
const SizedBox(height: 16),
Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
);
}
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
String _getThemeModeName(ThemeMode mode) {
switch (mode) {
case ThemeMode.light: return 'Light';
case ThemeMode.dark: return 'Dark';
case ThemeMode.system: return 'System';
}
}
String _getServiceName(String service) {
switch (service) {
case 'tidal': return 'Tidal';
case 'qobuz': return 'Qobuz';
case 'amazon': return 'Amazon Music';
default: return service;
}
}
String _getQualityName(String quality) {
switch (quality) {
case 'LOSSLESS': return 'FLAC (Lossless)';
case 'HI_RES': return 'Hi-Res FLAC (24-bit)';
default: return quality;
}
}
void _showThemeModePicker(BuildContext context, WidgetRef ref, ThemeMode current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Theme Mode'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildThemeModeOption(context, ref, ThemeMode.system, 'System', Icons.brightness_auto, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.light, 'Light', Icons.light_mode, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.dark, 'Dark', Icons.dark_mode, current, colorScheme),
],
),
),
);
}
Widget _buildThemeModeOption(BuildContext context, WidgetRef ref, ThemeMode mode, String label, IconData icon, ThemeMode current, ColorScheme colorScheme) {
final isSelected = mode == current;
return ListTile(
leading: Icon(icon, color: isSelected ? colorScheme.primary : null),
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(themeProvider.notifier).setThemeMode(mode);
Navigator.pop(context);
},
);
}
void _showColorPicker(BuildContext context, WidgetRef ref, int currentColor) {
final colors = [
const Color(0xFF1DB954), // Spotify Green
const Color(0xFF6750A4), // Purple
const Color(0xFF0061A4), // Blue
const Color(0xFF006E1C), // Green
const Color(0xFFBA1A1A), // Red
const Color(0xFF984061), // Pink
const Color(0xFF7D5260), // Brown
const Color(0xFF006874), // Teal
const Color(0xFFFF6F00), // Orange
];
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Choose Accent Color'),
content: Wrap(
spacing: 12,
runSpacing: 12,
children: colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return GestureDetector(
onTap: () {
ref.read(themeProvider.notifier).setSeedColor(color);
Navigator.pop(context);
},
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: isSelected
? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3)
: null,
),
child: isSelected
? const Icon(Icons.check, color: Colors.white)
: null,
),
);
}).toList(),
),
),
);
}
void _showServicePicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Service'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildServiceOption(context, ref, 'tidal', 'Tidal', current, colorScheme),
_buildServiceOption(context, ref, 'qobuz', 'Qobuz', current, colorScheme),
_buildServiceOption(context, ref, 'amazon', 'Amazon Music', current, colorScheme),
],
),
),
);
}
Widget _buildServiceOption(BuildContext context, WidgetRef ref, String value, String label, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setDefaultService(value);
Navigator.pop(context);
},
);
}
void _showQualityPicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Quality'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Disclaimer
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
],
),
),
);
}
Widget _buildQualityOption(BuildContext context, WidgetRef ref, String value, String title, String subtitle, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAudioQuality(value);
Navigator.pop(context);
},
);
}
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Filename Format'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller,
decoration: const InputDecoration(
hintText: '{artist} - {title}',
),
),
const SizedBox(height: 16),
Text(
'Available placeholders:',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'{title}, {artist}, {album}, {track}, {year}, {disc}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
ref.read(settingsProvider.notifier).setFilenameFormat(controller.text);
Navigator.pop(context);
},
child: const Text('Save'),
),
],
),
);
}
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Concurrent Downloads'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
'Parallel downloads may trigger rate limiting from streaming services.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
),
),
);
}
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
Navigator.pop(context);
},
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}