mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-08 05:37:59 +02:00
d882fc292c
Wire remaining hardcoded strings in about, download region picker, file organization settings, extension repo snackbars, release type badges, metadata queue actions, online cover labels, and update changelog fallback.
1214 lines
44 KiB
Dart
1214 lines
44 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 l10n = context.l10n;
|
|
final statuses = <String>[
|
|
usePrimaryArtistOnly
|
|
? l10n.downloadPrimaryArtistOnlyOn
|
|
: l10n.downloadPrimaryArtistOnlyOff,
|
|
filterAlbumArtistContributors
|
|
? l10n.downloadAlbumArtistMetadataPrimaryOnly
|
|
: l10n.downloadAlbumArtistMetadataFull,
|
|
];
|
|
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,
|
|
) {
|
|
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.downloadAlbumFolderStructure,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
|
child: Text(
|
|
context.l10n.albumFolderStructureDescription,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
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}',
|
|
'{playlist_position}',
|
|
];
|
|
static const _advancedTags = [
|
|
'{track_raw}',
|
|
'{track:02}',
|
|
'{track:1}',
|
|
'{playlist_position_raw}',
|
|
'{playlist_position:02}',
|
|
'{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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|