mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-09 22:18:45 +02:00
1177 lines
43 KiB
Dart
1177 lines
43 KiB
Dart
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:device_info_plus/device_info_plus.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/models/settings.dart';
|
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
|
import 'package:spotiflac_android/utils/file_access.dart';
|
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
|
|
|
class FilesSettingsPage extends ConsumerStatefulWidget {
|
|
const FilesSettingsPage({super.key});
|
|
|
|
@override
|
|
ConsumerState<FilesSettingsPage> createState() => _FilesSettingsPageState();
|
|
}
|
|
|
|
class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
|
int _androidSdkVersion = 0;
|
|
bool _hasAllFilesAccess = false;
|
|
bool _artistFolderFiltersExpanded = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initDeviceInfo();
|
|
}
|
|
|
|
Future<void> _initDeviceInfo() async {
|
|
if (Platform.isAndroid) {
|
|
final deviceInfo = DeviceInfoPlugin();
|
|
final androidInfo = await deviceInfo.androidInfo;
|
|
final sdkVersion = androidInfo.version.sdkInt;
|
|
final hasAccess = await Permission.manageExternalStorage.isGranted;
|
|
if (mounted) {
|
|
setState(() {
|
|
_androidSdkVersion = sdkVersion;
|
|
_hasAllFilesAccess = hasAccess;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _requestAllFilesAccess() async {
|
|
final status = await Permission.manageExternalStorage.request();
|
|
if (status.isGranted) {
|
|
ref.read(settingsProvider.notifier).setUseAllFilesAccess(true);
|
|
if (mounted) setState(() => _hasAllFilesAccess = true);
|
|
} else if (status.isPermanentlyDenied) {
|
|
if (mounted) {
|
|
final shouldOpen = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(context.l10n.setupStorageAccessRequired),
|
|
content: Text(context.l10n.allFilesAccessDeniedMessage),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: Text(context.l10n.setupOpenSettings),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (shouldOpen == true) await openAppSettings();
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _disableAllFilesAccess() async {
|
|
ref.read(settingsProvider.notifier).setUseAllFilesAccess(false);
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.allFilesAccessDisabledMessage)),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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.settingsFiles,
|
|
style: TextStyle(
|
|
fontSize: 20 + (8 * expandRatio),
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(
|
|
title: context.l10n.setupDownloadLocationTitle,
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: [
|
|
SettingsItem(
|
|
icon: Icons.folder_outlined,
|
|
title: context.l10n.downloadDirectory,
|
|
subtitle: settings.downloadDirectory.isEmpty
|
|
? (Platform.isIOS
|
|
? context.l10n.setupAppDocumentsFolder
|
|
: 'Music/SpotiFLAC')
|
|
: settings.downloadDirectory,
|
|
onTap: () => _pickDirectory(context, ref),
|
|
showDivider: false,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(
|
|
title: context.l10n.sectionFileSettings,
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: [
|
|
SettingsItem(
|
|
icon: Icons.text_fields,
|
|
title: context.l10n.downloadFilenameFormat,
|
|
subtitle: settings.filenameFormat,
|
|
onTap: () => _showFormatEditor(
|
|
context,
|
|
ref,
|
|
settings.filenameFormat,
|
|
),
|
|
),
|
|
SettingsItem(
|
|
icon: Icons.music_note_outlined,
|
|
title: context.l10n.downloadSingleFilenameFormat,
|
|
subtitle: settings.singleFilenameFormat,
|
|
onTap: () => _showFormatEditor(
|
|
context,
|
|
ref,
|
|
settings.singleFilenameFormat,
|
|
onSave: ref
|
|
.read(settingsProvider.notifier)
|
|
.setSingleFilenameFormat,
|
|
title: context.l10n.downloadSingleFilenameFormat,
|
|
description:
|
|
context.l10n.downloadSingleFilenameFormatDescription,
|
|
),
|
|
showDivider: false,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(
|
|
title: context.l10n.downloadFolderOrganization,
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: [
|
|
SettingsSwitchItem(
|
|
icon: Icons.library_music_outlined,
|
|
title: context.l10n.downloadSeparateSinglesFolder,
|
|
subtitle: settings.separateSingles
|
|
? context.l10n.downloadSeparateSinglesEnabled
|
|
: context.l10n.downloadSeparateSinglesDisabled,
|
|
value: settings.separateSingles,
|
|
onChanged: (value) => ref
|
|
.read(settingsProvider.notifier)
|
|
.setSeparateSingles(value),
|
|
),
|
|
if (settings.separateSingles)
|
|
SettingsItem(
|
|
icon: Icons.folder_outlined,
|
|
title: context.l10n.downloadAlbumFolderStructure,
|
|
subtitle: _getAlbumFolderStructureLabel(
|
|
context,
|
|
settings.albumFolderStructure,
|
|
),
|
|
onTap: () => _showAlbumFolderStructurePicker(
|
|
context,
|
|
ref,
|
|
settings.albumFolderStructure,
|
|
),
|
|
),
|
|
if (!settings.separateSingles)
|
|
SettingsItem(
|
|
icon: Icons.create_new_folder_outlined,
|
|
title: context.l10n.downloadFolderOrganization,
|
|
subtitle: _getFolderOrganizationLabel(
|
|
context,
|
|
settings.folderOrganization,
|
|
),
|
|
onTap: () => _showFolderOrganizationPicker(
|
|
context,
|
|
ref,
|
|
settings.folderOrganization,
|
|
),
|
|
),
|
|
SettingsSwitchItem(
|
|
icon: Icons.playlist_play_outlined,
|
|
title: context.l10n.downloadCreatePlaylistSourceFolder,
|
|
subtitle: _getPlaylistFolderSubtitle(settings),
|
|
value: settings.createPlaylistFolder,
|
|
onChanged: (value) => ref
|
|
.read(settingsProvider.notifier)
|
|
.setCreatePlaylistFolder(value),
|
|
),
|
|
SettingsSwitchItem(
|
|
icon: Icons.person_search_outlined,
|
|
title: context.l10n.downloadUseAlbumArtistForFolders,
|
|
subtitle: settings.useAlbumArtistForFolders
|
|
? context
|
|
.l10n
|
|
.downloadUseAlbumArtistForFoldersAlbumSubtitle
|
|
: context
|
|
.l10n
|
|
.downloadUseAlbumArtistForFoldersTrackSubtitle,
|
|
value: settings.useAlbumArtistForFolders,
|
|
onChanged: (value) => ref
|
|
.read(settingsProvider.notifier)
|
|
.setUseAlbumArtistForFolders(value),
|
|
),
|
|
SettingsItem(
|
|
icon: Icons.filter_alt_outlined,
|
|
title: context.l10n.downloadArtistNameFilters,
|
|
subtitle: _getArtistFolderFilterSubtitle(
|
|
context,
|
|
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
|
filterAlbumArtistContributors:
|
|
settings.filterContributingArtistsInAlbumArtist,
|
|
),
|
|
trailing: Icon(
|
|
_artistFolderFiltersExpanded
|
|
? Icons.expand_less
|
|
: Icons.expand_more,
|
|
),
|
|
onTap: () => setState(() {
|
|
_artistFolderFiltersExpanded =
|
|
!_artistFolderFiltersExpanded;
|
|
}),
|
|
showDivider: !_artistFolderFiltersExpanded,
|
|
),
|
|
if (_artistFolderFiltersExpanded) ...[
|
|
SettingsSwitchItem(
|
|
icon: Icons.person_outline,
|
|
title: context.l10n.downloadUsePrimaryArtistOnly,
|
|
subtitle: settings.usePrimaryArtistOnly
|
|
? context.l10n.downloadUsePrimaryArtistOnlyEnabled
|
|
: context.l10n.downloadUsePrimaryArtistOnlyDisabled,
|
|
value: settings.usePrimaryArtistOnly,
|
|
onChanged: (value) => ref
|
|
.read(settingsProvider.notifier)
|
|
.setUsePrimaryArtistOnly(value),
|
|
),
|
|
SettingsSwitchItem(
|
|
icon: Icons.group_remove_outlined,
|
|
title: context.l10n.downloadFilterContributing,
|
|
subtitle: settings.filterContributingArtistsInAlbumArtist
|
|
? context.l10n.downloadFilterContributingEnabled
|
|
: context.l10n.downloadFilterContributingDisabled,
|
|
value: settings.filterContributingArtistsInAlbumArtist,
|
|
onChanged: (value) => ref
|
|
.read(settingsProvider.notifier)
|
|
.setFilterContributingArtistsInAlbumArtist(value),
|
|
showDivider: false,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
|
|
if (Platform.isAndroid && _androidSdkVersion >= 33) ...[
|
|
SliverToBoxAdapter(
|
|
child: SettingsSectionHeader(
|
|
title: context.l10n.sectionStorageAccess,
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: SettingsGroup(
|
|
children: [
|
|
SettingsSwitchItem(
|
|
icon: Icons.folder_special_outlined,
|
|
title: context.l10n.allFilesAccess,
|
|
subtitle: _hasAllFilesAccess
|
|
? context.l10n.allFilesAccessEnabledSubtitle
|
|
: context.l10n.allFilesAccessDisabledSubtitle,
|
|
value: _hasAllFilesAccess && settings.useAllFilesAccess,
|
|
onChanged: (value) {
|
|
if (value) {
|
|
_requestAllFilesAccess();
|
|
} else {
|
|
_disableAllFilesAccess();
|
|
}
|
|
},
|
|
showDivider: false,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(
|
|
Icons.info_outline,
|
|
size: 16,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
context.l10n.allFilesAccessDescription,
|
|
style: Theme.of(context).textTheme.bodySmall
|
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
|
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _getAlbumFolderStructureLabel(BuildContext context, String structure) {
|
|
switch (structure) {
|
|
case 'album_only':
|
|
return context.l10n.albumFolderAlbumOnlySubtitle;
|
|
case 'artist_year_album':
|
|
return context.l10n.albumFolderArtistYearAlbumSubtitle;
|
|
case 'year_album':
|
|
return context.l10n.albumFolderYearAlbumSubtitle;
|
|
case 'artist_album_singles':
|
|
return context.l10n.albumFolderArtistAlbumSinglesSubtitle;
|
|
case 'artist_album_flat':
|
|
return context.l10n.albumFolderArtistAlbumFlatSubtitle;
|
|
default:
|
|
return context.l10n.albumFolderArtistAlbumSubtitle;
|
|
}
|
|
}
|
|
|
|
String _getFolderOrganizationLabel(BuildContext context, String value) {
|
|
switch (value) {
|
|
case 'playlist':
|
|
return context.l10n.folderOrganizationByPlaylist;
|
|
case 'artist':
|
|
return context.l10n.folderOrganizationByArtist;
|
|
case 'album':
|
|
return context.l10n.folderOrganizationByAlbum;
|
|
case 'artist_album':
|
|
return context.l10n.folderOrganizationByArtistAlbum;
|
|
default:
|
|
return context.l10n.folderOrganizationNone;
|
|
}
|
|
}
|
|
|
|
String _getPlaylistFolderSubtitle(AppSettings settings) {
|
|
if (settings.folderOrganization == 'playlist') {
|
|
return context.l10n.downloadCreatePlaylistSourceFolderRedundant;
|
|
}
|
|
if (settings.createPlaylistFolder) {
|
|
return context.l10n.downloadCreatePlaylistSourceFolderEnabled;
|
|
}
|
|
return context.l10n.downloadCreatePlaylistSourceFolderDisabled;
|
|
}
|
|
|
|
String _getArtistFolderFilterSubtitle(
|
|
BuildContext context, {
|
|
required bool usePrimaryArtistOnly,
|
|
required bool filterAlbumArtistContributors,
|
|
}) {
|
|
final statuses = <String>[
|
|
usePrimaryArtistOnly ? 'Primary only: On' : 'Primary only: Off',
|
|
filterAlbumArtistContributors
|
|
? 'Album Artist metadata: Primary only'
|
|
: 'Album Artist metadata: Full',
|
|
];
|
|
return statuses.join(' | ');
|
|
}
|
|
|
|
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
|
|
if (Platform.isIOS) {
|
|
_showIOSDirectoryOptions(context, ref);
|
|
} else if (Platform.isAndroid) {
|
|
_showAndroidDirectoryOptions(context, ref);
|
|
}
|
|
}
|
|
|
|
Future<String> _getDefaultAndroidDirectory() async {
|
|
const directMusicPath = '/storage/emulated/0/Music/SpotiFLAC';
|
|
try {
|
|
final musicDir = Directory(directMusicPath);
|
|
if (!await musicDir.exists()) await musicDir.create(recursive: true);
|
|
return musicDir.path;
|
|
} catch (_) {}
|
|
try {
|
|
final externalDir = await getExternalStorageDirectory();
|
|
if (externalDir != null) {
|
|
final musicDir = Directory(
|
|
'${externalDir.parent.parent.parent.parent.path}/Music/SpotiFLAC',
|
|
);
|
|
if (!await musicDir.exists()) await musicDir.create(recursive: true);
|
|
return musicDir.path;
|
|
}
|
|
} catch (_) {}
|
|
final appDir = await getApplicationDocumentsDirectory();
|
|
final fallbackDir = Directory('${appDir.path}/SpotiFLAC');
|
|
if (!await fallbackDir.exists()) await fallbackDir.create(recursive: true);
|
|
return fallbackDir.path;
|
|
}
|
|
|
|
void _showAndroidDirectoryOptions(BuildContext context, WidgetRef ref) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final settings = ref.read(settingsProvider);
|
|
final isSafMode =
|
|
settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty;
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
useRootNavigator: true,
|
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
|
),
|
|
builder: (ctx) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
|
child: Text(
|
|
context.l10n.setupDownloadLocationTitle,
|
|
style: Theme.of(
|
|
ctx,
|
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
|
child: Text(
|
|
context.l10n.downloadLocationSubtitle,
|
|
style: Theme.of(ctx).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
|
title: Text(context.l10n.storageModeAppFolder),
|
|
subtitle: Text(context.l10n.storageModeAppFolderSubtitle),
|
|
trailing: !isSafMode ? const Icon(Icons.check) : null,
|
|
onTap: () async {
|
|
Navigator.pop(ctx);
|
|
final defaultDir = await _getDefaultAndroidDirectory();
|
|
final notifier = ref.read(settingsProvider.notifier);
|
|
notifier.setStorageMode('app');
|
|
notifier.setDownloadDirectory(defaultDir);
|
|
notifier.setDownloadTreeUri('');
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: Icon(Icons.folder_open, color: colorScheme.primary),
|
|
title: Text(context.l10n.storageModeSaf),
|
|
subtitle: Text(context.l10n.storageModeSafSubtitle),
|
|
trailing: isSafMode ? const Icon(Icons.check) : null,
|
|
onTap: () async {
|
|
Navigator.pop(ctx);
|
|
final result = await PlatformBridge.pickSafTree();
|
|
if (result != null) {
|
|
final treeUri = result['tree_uri'] as String? ?? '';
|
|
final displayName = result['display_name'] as String? ?? '';
|
|
if (treeUri.isNotEmpty) {
|
|
ref.read(settingsProvider.notifier).setStorageMode('saf');
|
|
ref
|
|
.read(settingsProvider.notifier)
|
|
.setDownloadTreeUri(
|
|
treeUri,
|
|
displayName: displayName.isNotEmpty
|
|
? displayName
|
|
: treeUri,
|
|
);
|
|
}
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) {
|
|
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: (ctx) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
|
child: Text(
|
|
context.l10n.setupDownloadLocationTitle,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
|
child: Text(
|
|
context.l10n.setupDownloadLocationIosMessage,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
|
title: Text(context.l10n.setupAppDocumentsFolder),
|
|
subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle),
|
|
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
|
onTap: () async {
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
ref
|
|
.read(settingsProvider.notifier)
|
|
.setDownloadDirectory(dir.path);
|
|
if (ctx.mounted) Navigator.pop(ctx);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
|
title: Text(context.l10n.setupChooseFromFiles),
|
|
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
|
onTap: () async {
|
|
Navigator.pop(ctx);
|
|
if (Platform.isIOS) {
|
|
await Future<void>.delayed(const Duration(milliseconds: 250));
|
|
}
|
|
String? result;
|
|
try {
|
|
result = await FilePicker.getDirectoryPath();
|
|
} catch (e) {
|
|
if (ctx.mounted) {
|
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
ctx.l10n.snackbarFolderPickerFailed(e.toString()),
|
|
),
|
|
backgroundColor: Theme.of(ctx).colorScheme.error,
|
|
duration: const Duration(seconds: 4),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (result != null) {
|
|
if (Platform.isIOS) {
|
|
final validation = validateIosPath(result);
|
|
if (!validation.isValid) {
|
|
if (ctx.mounted) {
|
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
validation.errorReason ??
|
|
context.l10n.setupIcloudNotSupported,
|
|
),
|
|
backgroundColor: Theme.of(ctx).colorScheme.error,
|
|
duration: const Duration(seconds: 4),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
final bookmark =
|
|
await PlatformBridge.createIosBookmarkFromPath(result);
|
|
if (bookmark == null || bookmark.isEmpty) {
|
|
if (ctx.mounted) {
|
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
ctx.l10n.snackbarFolderPickerFailed(
|
|
ctx.l10n.errorCouldNotKeepFolderAccess,
|
|
),
|
|
),
|
|
backgroundColor: Theme.of(ctx).colorScheme.error,
|
|
duration: const Duration(seconds: 4),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
ref
|
|
.read(settingsProvider.notifier)
|
|
.setDownloadDirectory(result, iosBookmark: bookmark);
|
|
return;
|
|
}
|
|
ref
|
|
.read(settingsProvider.notifier)
|
|
.setDownloadDirectory(result);
|
|
}
|
|
},
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.info_outline,
|
|
size: 20,
|
|
color: colorScheme.tertiary,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
context.l10n.setupIosEmptyFolderWarning,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onTertiaryContainer,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showFormatEditor(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
String current, {
|
|
void Function(String)? onSave,
|
|
String? title,
|
|
String? description,
|
|
}) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final save =
|
|
onSave ?? ref.read(settingsProvider.notifier).setFilenameFormat;
|
|
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
backgroundColor: colorScheme.surface,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
|
),
|
|
builder: (context) => _FilenameFormatEditorSheet(
|
|
initialText: current,
|
|
onSave: save,
|
|
title: title,
|
|
description: description,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showAlbumFolderStructurePicker(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
String current,
|
|
) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
useRootNavigator: true,
|
|
builder: (context) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (final option in [
|
|
(
|
|
'artist_album',
|
|
context.l10n.albumFolderArtistAlbum,
|
|
context.l10n.albumFolderArtistAlbumSubtitle,
|
|
Icons.folder_outlined,
|
|
),
|
|
(
|
|
'artist_year_album',
|
|
context.l10n.albumFolderArtistYearAlbum,
|
|
context.l10n.albumFolderArtistYearAlbumSubtitle,
|
|
Icons.calendar_today_outlined,
|
|
),
|
|
(
|
|
'album_only',
|
|
context.l10n.albumFolderAlbumOnly,
|
|
context.l10n.albumFolderAlbumOnlySubtitle,
|
|
Icons.album_outlined,
|
|
),
|
|
(
|
|
'year_album',
|
|
context.l10n.albumFolderYearAlbum,
|
|
context.l10n.albumFolderYearAlbumSubtitle,
|
|
Icons.event_outlined,
|
|
),
|
|
(
|
|
'artist_album_singles',
|
|
context.l10n.albumFolderArtistAlbumSingles,
|
|
context.l10n.albumFolderArtistAlbumSinglesSubtitle,
|
|
Icons.person_outlined,
|
|
),
|
|
(
|
|
'artist_album_flat',
|
|
context.l10n.albumFolderArtistAlbumFlat,
|
|
context.l10n.albumFolderArtistAlbumFlatSubtitle,
|
|
Icons.person_outline_outlined,
|
|
),
|
|
])
|
|
ListTile(
|
|
leading: Icon(option.$4),
|
|
title: Text(option.$2),
|
|
subtitle: Text(option.$3),
|
|
trailing: current == option.$1 ? const Icon(Icons.check) : null,
|
|
onTap: () {
|
|
ref
|
|
.read(settingsProvider.notifier)
|
|
.setAlbumFolderStructure(option.$1);
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showFolderOrganizationPicker(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
String current,
|
|
) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
useRootNavigator: true,
|
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
|
isScrollControlled: true,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
|
),
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(context).size.height * 0.7,
|
|
),
|
|
builder: (context) => SafeArea(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
|
child: Text(
|
|
context.l10n.downloadFolderOrganization,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
|
child: Text(
|
|
context.l10n.folderOrganizationDescription,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
for (final option in [
|
|
(
|
|
'none',
|
|
context.l10n.folderOrganizationNone,
|
|
context.l10n.folderOrganizationNoneSubtitle,
|
|
'SpotiFLAC/Track.flac',
|
|
),
|
|
(
|
|
'playlist',
|
|
context.l10n.folderOrganizationByPlaylist,
|
|
context.l10n.folderOrganizationByPlaylistSubtitle,
|
|
'SpotiFLAC/Playlist Name/Track.flac',
|
|
),
|
|
(
|
|
'artist',
|
|
context.l10n.folderOrganizationByArtist,
|
|
context.l10n.folderOrganizationByArtistSubtitle,
|
|
'SpotiFLAC/Artist Name/Track.flac',
|
|
),
|
|
(
|
|
'album',
|
|
context.l10n.folderOrganizationByAlbum,
|
|
context.l10n.folderOrganizationByAlbumSubtitle,
|
|
'SpotiFLAC/Album Name/Track.flac',
|
|
),
|
|
(
|
|
'artist_album',
|
|
context.l10n.folderOrganizationByArtistAlbum,
|
|
context.l10n.folderOrganizationByArtistAlbumSubtitle,
|
|
'SpotiFLAC/Artist/Album/Track.flac',
|
|
),
|
|
])
|
|
_FolderOption(
|
|
title: option.$2,
|
|
subtitle: option.$3,
|
|
example: option.$4,
|
|
isSelected: current == option.$1,
|
|
onTap: () {
|
|
ref
|
|
.read(settingsProvider.notifier)
|
|
.setFolderOrganization(option.$1);
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FolderOption extends StatelessWidget {
|
|
final String title;
|
|
final String subtitle;
|
|
final String example;
|
|
final bool isSelected;
|
|
final VoidCallback onTap;
|
|
const _FolderOption({
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.example,
|
|
required this.isSelected,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return ListTile(
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
|
title: Text(title),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(subtitle),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
example,
|
|
style: TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 11,
|
|
color: colorScheme.primary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
trailing: isSelected
|
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
|
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
|
onTap: onTap,
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/// Bottom sheet for editing a filename format. Owns its controller and disposes
|
|
/// it in [dispose] to avoid use-after-dispose during the close animation.
|
|
class _FilenameFormatEditorSheet extends StatefulWidget {
|
|
final String initialText;
|
|
final void Function(String) onSave;
|
|
final String? title;
|
|
final String? description;
|
|
|
|
const _FilenameFormatEditorSheet({
|
|
required this.initialText,
|
|
required this.onSave,
|
|
this.title,
|
|
this.description,
|
|
});
|
|
|
|
@override
|
|
State<_FilenameFormatEditorSheet> createState() =>
|
|
_FilenameFormatEditorSheetState();
|
|
}
|
|
|
|
class _FilenameFormatEditorSheetState
|
|
extends State<_FilenameFormatEditorSheet> {
|
|
static const _basicTags = [
|
|
'{artist}',
|
|
'{title}',
|
|
'{album}',
|
|
'{track}',
|
|
'{year}',
|
|
'{date}',
|
|
'{disc}',
|
|
];
|
|
static const _advancedTags = [
|
|
'{track_raw}',
|
|
'{track:02}',
|
|
'{track:1}',
|
|
'{date:%Y}',
|
|
'{date:%Y-%m-%d}',
|
|
'{disc_raw}',
|
|
'{disc:02}',
|
|
];
|
|
|
|
late final TextEditingController _controller;
|
|
late bool _showAdvancedTags;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = TextEditingController(text: widget.initialText);
|
|
_showAdvancedTags = RegExp(
|
|
r'\{(?:track_raw|disc_raw|track:\d+|disc:\d+|date:[^}]+)\}',
|
|
caseSensitive: false,
|
|
).hasMatch(widget.initialText);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _insertTag(String tag) {
|
|
final text = _controller.text;
|
|
final selection = _controller.selection;
|
|
final start = selection.start >= 0 ? selection.start : text.length;
|
|
final end = selection.end >= 0 ? selection.end : text.length;
|
|
String insertion = tag;
|
|
if (start > 0) {
|
|
final before = text.substring(0, start);
|
|
if (!before.trim().endsWith('-')) {
|
|
insertion = ' - $tag';
|
|
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
|
|
insertion = ' $tag';
|
|
}
|
|
}
|
|
final newText = text.replaceRange(start, end, insertion);
|
|
_controller.value = TextEditingValue(
|
|
text: newText,
|
|
selection: TextSelection.collapsed(offset: start + insertion.length),
|
|
);
|
|
}
|
|
|
|
Widget _tagChip(ColorScheme colorScheme, String tag) {
|
|
return ActionChip(
|
|
label: Text(tag),
|
|
onPressed: () => _insertTag(tag),
|
|
backgroundColor: colorScheme.surfaceContainerHighest.withValues(
|
|
alpha: 0.5,
|
|
),
|
|
side: BorderSide.none,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
labelStyle: TextStyle(
|
|
color: colorScheme.onSurface,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
|
child: SingleChildScrollView(
|
|
child: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
width: 32,
|
|
height: 4,
|
|
margin: const EdgeInsets.only(bottom: 24),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.outlineVariant,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
widget.title ?? context.l10n.filenameFormat,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
widget.description ??
|
|
context.l10n.downloadFilenameDescription(
|
|
'{album}',
|
|
'{artist}',
|
|
'{date}',
|
|
'{disc}',
|
|
'{title}',
|
|
'{track}',
|
|
'{year}',
|
|
),
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 24),
|
|
TextField(
|
|
controller: _controller,
|
|
decoration: InputDecoration(
|
|
hintText: '{artist} - {title}',
|
|
filled: true,
|
|
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
|
alpha: 0.3,
|
|
),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
),
|
|
autofocus: true,
|
|
),
|
|
const SizedBox(height: 24),
|
|
Text(
|
|
context.l10n.downloadFilenameInsertTag,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: _basicTags
|
|
.map((tag) => _tagChip(colorScheme, tag))
|
|
.toList(),
|
|
),
|
|
const SizedBox(height: 12),
|
|
SwitchListTile(
|
|
value: _showAdvancedTags,
|
|
onChanged: (value) =>
|
|
setState(() => _showAdvancedTags = value),
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(context.l10n.filenameShowAdvancedTags),
|
|
subtitle: Text(
|
|
context.l10n.filenameShowAdvancedTagsDescription,
|
|
),
|
|
),
|
|
if (_showAdvancedTags) ...[
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: _advancedTags
|
|
.map((tag) => _tagChip(colorScheme, tag))
|
|
.toList(),
|
|
),
|
|
],
|
|
const SizedBox(height: 32),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
style: TextButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
flex: 2,
|
|
child: FilledButton(
|
|
onPressed: () {
|
|
widget.onSave(_controller.text);
|
|
Navigator.pop(context);
|
|
},
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
),
|
|
child: Text(context.l10n.dialogSave),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|