fix(settings): unify adaptive sheet layout and drag behavior

This commit is contained in:
zarzet committed 2026-10-15 01:14:54 +07:00
1 parent c42b314042
commit 1cc233bdc1
12 files changed
+880 -944

No files matched your search

+1 -9
View File
@@ -30,7 +30,6 @@ const _alphabeticalSorts = {'a-z', 'z-a'};
const _artistSorts = {'artist-asc', 'artist-desc'};
const _albumSorts = {'album-asc', 'album-desc'};
const _dateSorts = {'latest', 'oldest'};
const _libraryOptionsSheetHeightFactor = 0.7;
/// Sort choices offered by the Library sort sheet, in display order.
const _librarySortModes = [
@@ -207,14 +206,7 @@ extension _QueueTabSections on _QueueTabState {
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => DraggableScrollableSheet(
expand: false,
initialChildSize: _libraryOptionsSheetHeightFactor,
maxChildSize: _libraryOptionsSheetHeightFactor,
minChildSize: 0.3,
snap: true,
builder: builder,
),
builder: (context) => AppScrollableSheet(builder: builder),
);
Future<void> _showSortSheet(BuildContext context) async {
@@ -958,63 +958,39 @@ class _LanguageSelector extends StatelessWidget {
void _showLanguagePicker(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showAppModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surface,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
context.l10n.appearanceLanguage,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
title: context.l10n.appearanceLanguage,
builder: (context, controller) => ListView.builder(
controller: controller,
shrinkWrap: true,
itemCount: supportedLanguageChoices.length,
itemBuilder: (context, index) {
final lang = supportedLanguageChoices[index];
final isSelected = currentLocale == lang.$1;
return AppSheetOption(
leading: AppIcon(
lang.$3,
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
title: Text(
lang.$2,
style: TextStyle(
color: isSelected ? colorScheme.primary : colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
const Divider(height: 1),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: supportedLanguageChoices.length,
itemBuilder: (context, index) {
final lang = supportedLanguageChoices[index];
final isSelected = currentLocale == lang.$1;
return AppSheetOption(
leading: AppIcon(
lang.$3,
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
title: Text(
lang.$2,
style: TextStyle(
color: isSelected
? colorScheme.primary
: colorScheme.onSurface,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
),
trailing: isSelected
? AppIcon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
onChanged(lang.$1);
Navigator.pop(context);
},
);
},
),
),
const SizedBox(height: 8),
],
),
trailing: isSelected
? AppIcon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
onChanged(lang.$1);
Navigator.pop(context);
},
);
},
),
);
}
+122 -164
View File
@@ -408,44 +408,46 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
String current,
) {
final normalizedCurrent = normalizeAutoConvertFormat(current);
showAppBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
title: context.l10n.downloadAutoConvertFormat,
subtitle: context.l10n.downloadAutoConvertFormatSubtitle,
builder: (sheetContext) => Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final format in autoConvertFormats)
AppSheetOption(
leading: AppIcon(
format == 'opus'
? Icons.graphic_eq
: format == 'aac'
? Icons.album_outlined
: Icons.audiotrack,
builder: (sheetContext, controller) => SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final format in autoConvertFormats)
AppSheetOption(
leading: AppIcon(
format == 'opus'
? Icons.graphic_eq
: format == 'aac'
? Icons.album_outlined
: Icons.audiotrack,
),
title: Text(autoConvertFormatLabel(format)),
subtitle: Text(switch (format) {
'aac' => context.l10n.downloadAutoConvertM4aSubtitle,
'opus' => context.l10n.downloadAutoConvertOpusSubtitle,
_ => context.l10n.downloadAutoConvertMp3Subtitle,
}),
trailing: normalizedCurrent == format
? AppIcon(
Icons.check,
color: Theme.of(sheetContext).colorScheme.primary,
)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setAutoConvertFormat(format);
Navigator.pop(sheetContext);
},
),
title: Text(autoConvertFormatLabel(format)),
subtitle: Text(switch (format) {
'aac' => context.l10n.downloadAutoConvertM4aSubtitle,
'opus' => context.l10n.downloadAutoConvertOpusSubtitle,
_ => context.l10n.downloadAutoConvertMp3Subtitle,
}),
trailing: normalizedCurrent == format
? AppIcon(
Icons.check,
color: Theme.of(sheetContext).colorScheme.primary,
)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setAutoConvertFormat(format);
Navigator.pop(sheetContext);
},
),
const SizedBox(height: 8),
],
const SizedBox(height: 8),
],
),
),
);
}
@@ -456,33 +458,35 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
String current,
) {
final normalizedCurrent = normalizeAutoConvertBitrate(current);
showAppBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
title: context.l10n.downloadAutoConvertBitrate,
subtitle: context.l10n.downloadAutoConvertBitrateSubtitle,
builder: (sheetContext) => Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final bitrate in autoConvertBitrates)
AppSheetOption(
leading: const AppIcon(Icons.speed_outlined),
title: Text(bitrate.replaceAll('k', ' kbps')),
trailing: normalizedCurrent == bitrate
? AppIcon(
Icons.check,
color: Theme.of(sheetContext).colorScheme.primary,
)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setAutoConvertBitrate(bitrate);
Navigator.pop(sheetContext);
},
),
const SizedBox(height: 8),
],
builder: (sheetContext, controller) => SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final bitrate in autoConvertBitrates)
AppSheetOption(
leading: const AppIcon(Icons.speed_outlined),
title: Text(bitrate.replaceAll('k', ' kbps')),
trailing: normalizedCurrent == bitrate
? AppIcon(
Icons.check,
color: Theme.of(sheetContext).colorScheme.primary,
)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setAutoConvertBitrate(bitrate);
Navigator.pop(sheetContext);
},
),
const SizedBox(height: 8),
],
),
),
);
}
@@ -493,33 +497,16 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showAppModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (context) => SafeArea(
title: context.l10n.settingsDownloadNetwork,
subtitle: context.l10n.settingsDownloadNetworkSubtitle,
builder: (context, controller) => SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.settingsDownloadNetwork,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.settingsDownloadNetworkSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
AppSheetOption(
leading: const AppIcon(Icons.signal_cellular_alt),
title: Text(context.l10n.settingsDownloadNetworkAny),
@@ -561,33 +548,16 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
int current,
) {
final colorScheme = Theme.of(context).colorScheme;
showAppModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (context) => SafeArea(
title: context.l10n.settingsConcurrentDownloads,
subtitle: context.l10n.settingsConcurrentDownloadsSubtitle,
builder: (context, controller) => SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.settingsConcurrentDownloads,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.settingsConcurrentDownloadsSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
for (final count in const [1, 2, 3])
AppSheetOption(
leading: AppIcon(
@@ -809,14 +779,13 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
'ZM',
'ZW',
];
final colorScheme = Theme.of(context).colorScheme;
final normalizedCurrent = current.trim().toUpperCase();
showAppModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
isScrollControlled: true,
builder: (context) => _RegionPickerSheet(
title: context.l10n.downloadSongLinkRegion,
subtitle: context.l10n.downloadSongLinkRegionDesc,
builder: (context, controller) => _RegionPickerSheet(
scrollController: controller,
regions: regions,
selected: normalizedCurrent,
countryNameOf: (context, code) => _regionCountryName(context, code),
@@ -836,12 +805,14 @@ class _RegionPickerSheet extends StatefulWidget {
required this.selected,
required this.countryNameOf,
required this.onSelected,
required this.scrollController,
});
final List<String> regions;
final String selected;
final String Function(BuildContext context, String code) countryNameOf;
final ValueChanged<String> onSelected;
final ScrollController scrollController;
@override
State<_RegionPickerSheet> createState() => _RegionPickerSheetState();
@@ -871,70 +842,57 @@ class _RegionPickerSheetState extends State<_RegionPickerSheet> {
.contains(query);
}).toList();
return SafeArea(
child: SizedBox(
height: MediaQuery.sizeOf(context).height * 0.7,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.downloadSongLinkRegion,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
context.l10n.downloadSongLinkRegionDesc,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: AppSearchField(
controller: _searchController,
onChanged: (value) => setState(() => _query = value),
hintText: MaterialLocalizations.of(context).searchFieldLabel,
clearTooltip: context.l10n.dialogClear,
onClear: () => setState(() => _query = ''),
),
),
Expanded(
child: matches.isEmpty
? Center(
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: AppSearchField(
controller: _searchController,
onChanged: (value) => setState(() => _query = value),
hintText: MaterialLocalizations.of(context).searchFieldLabel,
clearTooltip: context.l10n.dialogClear,
onClear: () => setState(() => _query = ''),
),
),
Flexible(
child: matches.isEmpty
? ListView(
controller: widget.scrollController,
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.all(24),
child: Text(
context.l10n.searchEmptyResultSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
)
: ListView.builder(
itemCount: matches.length,
itemBuilder: (context, index) {
final code = matches[index];
final isSelected = code == widget.selected;
final countryName = widget.countryNameOf(context, code);
return AppSheetOption(
title: Text(countryName != code ? countryName : code),
subtitle: countryName != code ? Text(code) : null,
trailing: isSelected
? AppIcon(Icons.check, color: colorScheme.primary)
: null,
onTap: () => widget.onSelected(code),
);
},
),
),
],
],
)
: ListView.builder(
controller: widget.scrollController,
shrinkWrap: true,
itemCount: matches.length,
itemBuilder: (context, index) {
final code = matches[index];
final isSelected = code == widget.selected;
final countryName = widget.countryNameOf(context, code);
return AppSheetOption(
title: Text(countryName != code ? countryName : code),
subtitle: countryName != code ? Text(code) : null,
trailing: isSelected
? AppIcon(Icons.check, color: colorScheme.primary)
: null,
onTap: () => widget.onSelected(code),
);
},
),
),
),
],
);
}
}
@@ -1109,11 +1109,11 @@ class _SettingItemState extends State<_SettingItem> {
}
Future<void> _showOptions() async {
final value = await showAppBottomSheet<String>(
final value = await showAppScrollableBottomSheet<String>(
context: context,
title: widget.setting.label,
maxHeightFactor: 0.7,
builder: (sheetContext) => SingleChildScrollView(
builder: (sheetContext, controller) => SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
+87 -143
View File
@@ -18,6 +18,7 @@ import 'package:spotiflac_android/screens/settings/metadata_provider_priority_pa
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_action_button.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/app_loading_indicator.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
@@ -822,62 +823,38 @@ class _SearchProviderSelector extends ConsumerWidget {
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (ctx) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
ctx.l10n.extensionsSearchProvider,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
title: context.l10n.extensionsSearchProvider,
subtitle: context.l10n.extensionsSearchProviderDescription,
builder: (ctx, controller) => SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...searchProviders.map(
(ext) => AppSheetOption(
leading: AppIcon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
subtitle: Text(
ext.searchBehavior?.placeholder ??
ctx.l10n.extensionsCustomSearch,
),
trailing: settings.searchProvider == ext.id
? AppIcon(Icons.check_circle, color: colorScheme.primary)
: AppIcon(
Icons.circle_outlined,
color: colorScheme.outline,
),
onTap: () {
ref.read(settingsProvider.notifier).setSearchProvider(ext.id);
Navigator.pop(ctx);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
ctx.l10n.extensionsSearchProviderDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
...searchProviders.map(
(ext) => ListTile(
leading: AppIcon(
Icons.extension,
color: colorScheme.secondary,
),
title: Text(ext.displayName),
subtitle: Text(
ext.searchBehavior?.placeholder ??
ctx.l10n.extensionsCustomSearch,
),
trailing: settings.searchProvider == ext.id
? AppIcon(Icons.check_circle, color: colorScheme.primary)
: AppIcon(
Icons.circle_outlined,
color: colorScheme.outline,
),
onTap: () {
ref
.read(settingsProvider.notifier)
.setSearchProvider(ext.id);
Navigator.pop(ctx);
},
),
),
const SizedBox(height: 16),
],
),
),
const SizedBox(height: 16),
],
),
),
);
@@ -992,61 +969,62 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (ctx) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
ctx.l10n.extensionsHomeFeedProvider,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
ctx.l10n.extensionsHomeFeedDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
title: context.l10n.extensionsHomeFeedProvider,
subtitle: context.l10n.extensionsHomeFeedDescription,
builder: (ctx, controller) => SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppSheetOption(
leading: AppIcon(Icons.auto_awesome, color: colorScheme.primary),
title: Text(ctx.l10n.extensionsHomeFeedAuto),
subtitle: Text(ctx.l10n.extensionsHomeFeedAutoSubtitle),
trailing:
(settings.homeFeedProvider == null ||
settings.homeFeedProvider!.isEmpty)
? AppIcon(Icons.check_circle, color: colorScheme.primary)
: AppIcon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
ref.read(settingsProvider.notifier).setHomeFeedProvider(null);
ref.read(exploreProvider.notifier).refresh();
Navigator.pop(ctx);
},
),
AppSheetOption(
leading: AppIcon(Icons.block, color: colorScheme.error),
title: Text(ctx.l10n.extensionsHomeFeedOff),
subtitle: Text(ctx.l10n.extensionsHomeFeedOffSubtitle),
trailing:
settings.homeFeedProvider == AppSettings.homeFeedProviderOff
? AppIcon(Icons.check_circle, color: colorScheme.primary)
: AppIcon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
ref
.read(settingsProvider.notifier)
.setHomeFeedProvider(AppSettings.homeFeedProviderOff);
ref.read(exploreProvider.notifier).clear();
Navigator.pop(ctx);
},
),
if (homeFeedProviders.isEmpty)
AppSheetOption(
enabled: false,
leading: AppIcon(
Icons.auto_awesome,
color: colorScheme.primary,
Icons.extension_off,
color: colorScheme.outline,
),
title: Text(ctx.l10n.extensionsHomeFeedAuto),
subtitle: Text(ctx.l10n.extensionsHomeFeedAutoSubtitle),
trailing:
(settings.homeFeedProvider == null ||
settings.homeFeedProvider!.isEmpty)
? AppIcon(Icons.check_circle, color: colorScheme.primary)
: AppIcon(
Icons.circle_outlined,
color: colorScheme.outline,
),
onTap: () {
ref.read(settingsProvider.notifier).setHomeFeedProvider(null);
ref.read(exploreProvider.notifier).refresh();
Navigator.pop(ctx);
},
title: Text(ctx.l10n.extensionsNoHomeFeedExtensions),
),
ListTile(
leading: AppIcon(Icons.block, color: colorScheme.error),
title: Text(ctx.l10n.extensionsHomeFeedOff),
subtitle: Text(ctx.l10n.extensionsHomeFeedOffSubtitle),
trailing:
settings.homeFeedProvider == AppSettings.homeFeedProviderOff
...homeFeedProviders.map(
(ext) => AppSheetOption(
leading: AppIcon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
subtitle: Text(ctx.l10n.extensionsHomeFeedUse(ext.displayName)),
trailing: settings.homeFeedProvider == ext.id
? AppIcon(Icons.check_circle, color: colorScheme.primary)
: AppIcon(
Icons.circle_outlined,
@@ -1055,48 +1033,14 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
onTap: () {
ref
.read(settingsProvider.notifier)
.setHomeFeedProvider(AppSettings.homeFeedProviderOff);
ref.read(exploreProvider.notifier).clear();
.setHomeFeedProvider(ext.id);
ref.read(exploreProvider.notifier).refresh();
Navigator.pop(ctx);
},
),
if (homeFeedProviders.isEmpty)
ListTile(
enabled: false,
leading: AppIcon(
Icons.extension_off,
color: colorScheme.outline,
),
title: Text(ctx.l10n.extensionsNoHomeFeedExtensions),
),
...homeFeedProviders.map(
(ext) => ListTile(
leading: AppIcon(
Icons.extension,
color: colorScheme.secondary,
),
title: Text(ext.displayName),
subtitle: Text(
ctx.l10n.extensionsHomeFeedUse(ext.displayName),
),
trailing: settings.homeFeedProvider == ext.id
? AppIcon(Icons.check_circle, color: colorScheme.primary)
: AppIcon(
Icons.circle_outlined,
color: colorScheme.outline,
),
onTap: () {
ref
.read(settingsProvider.notifier)
.setHomeFeedProvider(ext.id);
ref.read(exploreProvider.notifier).refresh();
Navigator.pop(ctx);
},
),
),
const SizedBox(height: 16),
],
),
),
const SizedBox(height: 16),
],
),
),
);
+229 -341
View File
@@ -519,33 +519,16 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
final settings = ref.read(settingsProvider);
final isSafMode =
settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty;
showAppModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (ctx) => SafeArea(
title: context.l10n.setupDownloadLocationTitle,
subtitle: context.l10n.downloadLocationSubtitle,
builder: (ctx, controller) => SingleChildScrollView(
controller: controller,
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,
),
),
),
AppSheetOption(
leading: AppIcon(
Icons.folder_special,
@@ -583,33 +566,16 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showAppModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (ctx) => SafeArea(
title: context.l10n.setupDownloadLocationTitle,
subtitle: context.l10n.setupDownloadLocationIosMessage,
builder: (ctx, controller) => SingleChildScrollView(
controller: controller,
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,
),
),
),
AppSheetOption(
leading: AppIcon(
Icons.folder_special,
@@ -708,20 +674,27 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
String? title,
String? description,
}) {
final colorScheme = Theme.of(context).colorScheme;
final save =
onSave ?? ref.read(settingsProvider.notifier).setFilenameFormat;
showAppModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: colorScheme.surface,
builder: (context) => _FilenameFormatEditorSheet(
title: title ?? context.l10n.filenameFormat,
subtitle:
description ??
context.l10n.downloadFilenameDescription(
'{album}',
'{artist}',
'{date}',
'{disc}',
'{title}',
'{track}',
'{year}',
),
builder: (context, controller) => _FilenameFormatEditorSheet(
initialText: current,
onSave: save,
title: title,
description: description,
scrollController: controller,
),
);
}
@@ -731,93 +704,69 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
WidgetRef ref,
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showAppModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
isScrollControlled: true,
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).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),
),
title: context.l10n.downloadAlbumFolderStructure,
subtitle: context.l10n.albumFolderStructureDescription,
builder: (context, controller) => SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final option in [
(
'artist_album',
context.l10n.albumFolderArtistAlbum,
context.l10n.albumFolderArtistAlbumSubtitle,
Icons.folder_outlined,
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.albumFolderStructureDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
(
'artist_year_album',
context.l10n.albumFolderArtistYearAlbum,
context.l10n.albumFolderArtistYearAlbumSubtitle,
Icons.calendar_today_outlined,
),
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,
),
])
AppSheetOption(
leading: AppIcon(option.$4),
title: Text(option.$2),
subtitle: Text(option.$3),
trailing: current == option.$1
? const AppIcon(Icons.check)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setAlbumFolderStructure(option.$1);
Navigator.pop(context);
},
),
],
),
(
'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,
),
])
AppSheetOption(
leading: AppIcon(option.$4),
title: Text(option.$2),
subtitle: Text(option.$3),
trailing: current == option.$1
? const AppIcon(Icons.check)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setAlbumFolderStructure(option.$1);
Navigator.pop(context);
},
),
],
),
),
);
@@ -828,85 +777,61 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
WidgetRef ref,
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showAppModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
isScrollControlled: true,
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).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),
),
title: context.l10n.downloadFolderOrganization,
subtitle: context.l10n.folderOrganizationDescription,
builder: (context, controller) => SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final option in [
(
'none',
context.l10n.folderOrganizationNone,
context.l10n.folderOrganizationNoneSubtitle,
'SpotiFLAC/Track.flac',
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.folderOrganizationDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
(
'playlist',
context.l10n.folderOrganizationByPlaylist,
context.l10n.folderOrganizationByPlaylistSubtitle,
'SpotiFLAC/Playlist Name/Track.flac',
),
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);
},
),
],
),
(
'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);
},
),
],
),
),
);
@@ -961,14 +886,12 @@ class _FolderOption extends StatelessWidget {
class _FilenameFormatEditorSheet extends StatefulWidget {
final String initialText;
final void Function(String) onSave;
final String? title;
final String? description;
final ScrollController scrollController;
const _FilenameFormatEditorSheet({
required this.initialText,
required this.onSave,
this.title,
this.description,
required this.scrollController,
});
@override
@@ -1069,133 +992,98 @@ class _FilenameFormatEditorSheetState
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SingleChildScrollView(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AppSheetHandle(),
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),
AppSwitchListTile(
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: AppDialogAction(
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: AppDialogAction(
filled: true,
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),
],
return SingleChildScrollView(
controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
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),
AppSwitchListTile(
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: AppDialogAction(
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: AppDialogAction(
filled: true,
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),
],
),
);
}
+13 -48
View File
@@ -336,33 +336,16 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
void _showAutoScanPicker(BuildContext context, String current) {
final colorScheme = Theme.of(context).colorScheme;
showAppModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (context) => SafeArea(
title: context.l10n.libraryAutoScan,
subtitle: context.l10n.libraryAutoScanSubtitle,
builder: (context, controller) => SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.libraryAutoScan,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.libraryAutoScanSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
_AutoScanOption(
icon: Icons.block,
title: context.l10n.libraryAutoScanOff,
@@ -445,24 +428,15 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
('artists', Icons.person, context.l10n.searchArtists),
('playlists', Icons.queue_music, context.l10n.searchPlaylists),
];
showAppModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (context) => SafeArea(
title: context.l10n.libraryDefaultView,
builder: (context, controller) => SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
child: Text(
context.l10n.libraryDefaultView,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
for (final (value, icon, label) in options)
_AutoScanOption(
icon: icon,
@@ -532,24 +506,15 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
context.l10n.libraryQualityLabelFileFormat,
),
];
showAppModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (context) => SafeArea(
title: context.l10n.trackAudioQuality,
builder: (context, controller) => SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
child: Text(
context.l10n.trackAudioQuality,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
for (final (value, icon, label) in options)
_AutoScanOption(
icon: icon,
+11 -49
View File
@@ -229,34 +229,16 @@ class LyricsSettingsPage extends ConsumerWidget {
WidgetRef ref,
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showAppModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
builder: (context) => SafeArea(
title: context.l10n.lyricsMode,
subtitle: context.l10n.lyricsModeDescription,
builder: (context, controller) => SingleChildScrollView(
controller: controller,
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,
),
),
),
AppSheetOption(
leading: const AppIcon(Icons.audiotrack),
title: Text(context.l10n.lyricsModeEmbed),
@@ -301,39 +283,19 @@ class LyricsSettingsPage extends ConsumerWidget {
WidgetRef ref,
String currentLanguage,
) {
final colorScheme = Theme.of(context).colorScheme;
final controller = TextEditingController(text: currentLanguage);
showAppModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
isScrollControlled: true,
builder: (context) => Padding(
padding: EdgeInsets.only(
left: 24,
right: 24,
top: 24,
bottom: 24 + MediaQuery.viewInsetsOf(context).bottom,
),
title: context.l10n.downloadMusixmatchLanguage,
subtitle: context.l10n.downloadMusixmatchLanguageDesc,
builder: (context, scrollController) => SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
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,
+81 -129
View File
@@ -186,59 +186,36 @@ class MetadataSettingsPage extends ConsumerWidget {
int currentMaxDimension,
) {
const options = [0, 500, 1000, 1500, 2000];
final colorScheme = Theme.of(context).colorScheme;
showAppModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerHigh,
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.optionsEmbeddedCoverSize,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
title: context.l10n.optionsEmbeddedCoverSize,
subtitle: context.l10n.optionsEmbeddedCoverSizeDescription,
builder: (context, controller) => SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final maxDimension in options)
AppSheetOption(
leading: AppIcon(
maxDimension == 0
? Icons.image_outlined
: Icons.compress_outlined,
),
title: Text(_getEmbeddedCoverSizeLabel(context, maxDimension)),
trailing: currentMaxDimension == maxDimension
? const AppIcon(Icons.check)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setEmbeddedCoverMaxDimension(maxDimension);
Navigator.pop(context);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
context.l10n.optionsEmbeddedCoverSizeDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
for (final maxDimension in options)
AppSheetOption(
leading: AppIcon(
maxDimension == 0
? Icons.image_outlined
: Icons.compress_outlined,
),
title: Text(
_getEmbeddedCoverSizeLabel(context, maxDimension),
),
trailing: currentMaxDimension == maxDimension
? const AppIcon(Icons.check)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setEmbeddedCoverMaxDimension(maxDimension);
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
const SizedBox(height: 16),
],
),
),
);
@@ -249,87 +226,62 @@ class MetadataSettingsPage extends ConsumerWidget {
WidgetRef ref,
String currentMode,
) {
final colorScheme = Theme.of(context).colorScheme;
showAppModalBottomSheet<void>(
showAppScrollableBottomSheet<void>(
context: context,
useRootNavigator: true,
// Three described options can exceed the default sheet height on
// small or landscape screens, as the cover-size picker above does.
isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerHigh,
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.optionsArtistTagMode,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
title: context.l10n.optionsArtistTagMode,
subtitle: context.l10n.optionsArtistTagModeDescription,
builder: (context, controller) => SingleChildScrollView(
controller: controller,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppSheetOption(
leading: const AppIcon(Icons.segment_outlined),
title: Text(context.l10n.optionsArtistTagModeJoined),
subtitle: Text(context.l10n.optionsArtistTagModeJoinedSubtitle),
trailing: currentMode == artistTagModeJoined
? const AppIcon(Icons.check)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setArtistTagMode(artistTagModeJoined);
Navigator.pop(context);
},
),
AppSheetOption(
leading: const AppIcon(Icons.library_music_outlined),
title: Text(context.l10n.optionsArtistTagModeSplitVorbis),
subtitle: Text(
context.l10n.optionsArtistTagModeSplitVorbisSubtitle,
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.optionsArtistTagModeDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
AppSheetOption(
leading: const AppIcon(Icons.segment_outlined),
title: Text(context.l10n.optionsArtistTagModeJoined),
subtitle: Text(context.l10n.optionsArtistTagModeJoinedSubtitle),
trailing: currentMode == artistTagModeJoined
? const AppIcon(Icons.check)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setArtistTagMode(artistTagModeJoined);
Navigator.pop(context);
},
),
AppSheetOption(
leading: const AppIcon(Icons.library_music_outlined),
title: Text(context.l10n.optionsArtistTagModeSplitVorbis),
subtitle: Text(
context.l10n.optionsArtistTagModeSplitVorbisSubtitle,
),
trailing: currentMode == artistTagModeSplitVorbis
? const AppIcon(Icons.check)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setArtistTagMode(artistTagModeSplitVorbis);
Navigator.pop(context);
},
),
AppSheetOption(
leading: const AppIcon(Icons.person_outline),
title: Text(context.l10n.optionsArtistTagModePrimary),
subtitle: Text(
context.l10n.optionsArtistTagModePrimarySubtitle,
),
trailing: currentMode == artistTagModePrimary
? const AppIcon(Icons.check)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setArtistTagMode(artistTagModePrimary);
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
trailing: currentMode == artistTagModeSplitVorbis
? const AppIcon(Icons.check)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setArtistTagMode(artistTagModeSplitVorbis);
Navigator.pop(context);
},
),
AppSheetOption(
leading: const AppIcon(Icons.person_outline),
title: Text(context.l10n.optionsArtistTagModePrimary),
subtitle: Text(context.l10n.optionsArtistTagModePrimarySubtitle),
trailing: currentMode == artistTagModePrimary
? const AppIcon(Icons.check)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setArtistTagMode(artistTagModePrimary);
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
+125 -1
View File
@@ -119,7 +119,7 @@ class AppBottomSheet extends StatelessWidget {
),
child: Text(
subtitle!,
maxLines: context.isMornye ? 3 : 1,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
@@ -184,6 +184,130 @@ Future<T?> showAppBottomSheet<T>({
);
}
/// Fits short content and coordinates scrolling/dragging for longer sheets.
class AppScrollableSheet extends StatefulWidget {
const AppScrollableSheet({super.key, required this.builder});
final ScrollableWidgetBuilder builder;
@override
State<AppScrollableSheet> createState() => _AppScrollableSheetState();
}
class _AppScrollableSheetState extends State<AppScrollableSheet> {
final _contentKey = GlobalKey();
final _sheetController = DraggableScrollableController();
ScrollController? _scrollController;
double _availableHeight = 0;
double _initialSize = 0.85;
double _maxSize = 0.95;
bool _fitScheduled = false;
void _scheduleFit() {
if (_fitScheduled) return;
_fitScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_fitScheduled = false;
if (!mounted || _availableHeight <= 0) return;
final box = _contentKey.currentContext?.findRenderObject();
final scroll = _scrollController;
if (box is! RenderBox || !box.hasSize || scroll == null) return;
final overflow = scroll.hasClients && scroll.position.hasContentDimensions
? (scroll.position.maxScrollExtent - scroll.position.minScrollExtent)
.clamp(0.0, double.infinity)
: 0.0;
// Align lets the header/body shrink below the allocated sheet height.
// Adding the scroll overflow recovers the full content height without
// an intrinsic-layout pass or a second hidden copy of the form.
final contentFraction = (box.size.height + overflow) / _availableHeight;
final initial = contentFraction.clamp(0.01, 0.85);
final maximum = contentFraction.clamp(initial, 0.95);
if ((initial - _initialSize).abs() * _availableHeight < 1 &&
(maximum - _maxSize).abs() * _availableHeight < 1) {
return;
}
final resize = (initial - _initialSize).abs() * _availableHeight >= 1;
setState(() {
_initialSize = initial;
_maxSize = maximum;
});
// A field or search result can change after the user has dragged. The
// framework otherwise preserves that old extent when content grows.
if (resize) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _sheetController.isAttached) {
_sheetController.jumpTo(_initialSize);
}
});
}
});
}
@override
void dispose() {
_sheetController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
_availableHeight = constraints.maxHeight;
_scheduleFit();
return NotificationListener<ScrollMetricsNotification>(
onNotification: (notification) {
if (notification.depth == 0) _scheduleFit();
return false;
},
child: NotificationListener<SizeChangedLayoutNotification>(
onNotification: (_) {
_scheduleFit();
return false;
},
child: DraggableScrollableSheet(
controller: _sheetController,
expand: false,
initialChildSize: _initialSize,
minChildSize: (_initialSize * 0.6).clamp(0.0, 0.5),
maxChildSize: _maxSize,
snap: true,
snapSizes: [_initialSize],
builder: (context, controller) {
_scrollController = controller;
return Align(
alignment: Alignment.bottomCenter,
child: SizeChangedLayoutNotifier(
key: _contentKey,
child: widget.builder(context, controller),
),
);
},
),
),
);
},
);
}
/// A fixed handle/header above a body that scrolls and drags the same sheet.
Future<T?> showAppScrollableBottomSheet<T>({
required BuildContext context,
required ScrollableWidgetBuilder builder,
required String title,
String? subtitle,
}) => showAppModalBottomSheet<T>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => AppScrollableSheet(
builder: (context, controller) => AppBottomSheet(
title: title,
subtitle: subtitle,
child: builder(context, controller),
),
),
);
/// Adaptive surface for sheets which already supply their own header/layout.
Future<T?> showAppModalBottomSheet<T>({
required BuildContext context,
+161
View File
@@ -30,6 +30,167 @@ void main() {
: ThemeData(brightness: brightness);
final variant = 'Mornye: $mornye, $brightness';
testWidgets(
'short sheets fit their content and resize with options ($variant)',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final count = ValueNotifier(2);
addTearDown(count.dispose);
await tester.pumpWidget(
_app(
theme,
Builder(
builder: (context) => Scaffold(
body: TextButton(
child: const Text('Open options'),
onPressed: () => showAppScrollableBottomSheet<void>(
context: context,
title: 'Options',
builder: (context, controller) =>
ValueListenableBuilder<int>(
valueListenable: count,
builder: (context, count, _) =>
SingleChildScrollView(
controller: controller,
child: Column(
children: [
for (var i = 0; i < count; i++)
AppSheetOption(
title: Text('Option $i'),
onTap: () {},
),
],
),
),
),
),
),
),
),
),
);
await tester.tap(find.text('Open options'));
await tester.pumpAndSettle();
final sheet = find.byType(AppBottomSheet);
final shortHeight = tester.getSize(sheet).height;
expect(shortHeight, lessThan(844 * 0.5));
expect(
tester.getRect(sheet).bottom -
tester.getRect(find.text('Option 1')).bottom,
lessThan(40),
);
final scroll = find.descendant(
of: sheet,
matching: find.byType(SingleChildScrollView),
);
await tester.drag(scroll, const Offset(0, -250));
await tester.pumpAndSettle();
expect(tester.getSize(sheet).height, closeTo(shortHeight, 1));
count.value = 4;
await tester.pumpAndSettle();
expect(tester.getSize(sheet).height, greaterThan(shortHeight));
expect(tester.getSize(sheet).height, lessThan(844 * 0.85));
count.value = 40;
await tester.pumpAndSettle();
expect(tester.getSize(sheet).height, closeTo(844 * 0.85, 1));
await tester.drag(scroll, const Offset(0, -400));
await tester.pumpAndSettle();
count.value = 1;
await tester.pumpAndSettle();
expect(tester.getSize(sheet).height, lessThan(shortHeight));
expect(find.text('Option 0').hitTestable(), findsOneWidget);
await tester.fling(scroll, const Offset(0, 200), 1200);
await tester.pumpAndSettle();
expect(sheet, findsNothing);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'scrollable sheet keeps its header and dismisses from body or handle ($variant)',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
int? selected;
await tester.pumpWidget(
_app(
theme,
Builder(
builder: (context) => Scaffold(
body: TextButton(
child: const Text('Open options'),
onPressed: () async {
selected = await showAppScrollableBottomSheet<int>(
context: context,
title: 'Options',
builder: (context, controller) => SingleChildScrollView(
controller: controller,
child: Column(
children: [
for (var i = 0; i < 40; i++)
AppSheetOption(
title: Text('Option $i'),
onTap: () => Navigator.pop(context, i),
),
],
),
),
);
},
),
),
),
),
);
Future<void> open() async {
await tester.tap(find.text('Open options'));
await tester.pumpAndSettle();
expect(
tester.getSize(find.byType(AppBottomSheet)).height,
closeTo(844 * 0.85, 1),
);
expect(find.byType(AppSheetHandle).hitTestable(), findsOneWidget);
}
await open();
final scroll = find.descendant(
of: find.byType(AppBottomSheet),
matching: find.byType(SingleChildScrollView),
);
await tester.drag(scroll, const Offset(0, -400));
await tester.pumpAndSettle();
expect(
tester.getSize(find.byType(AppBottomSheet)).height,
closeTo(844 * 0.95, 1),
);
expect(find.text('Options').hitTestable(), findsOneWidget);
expect(find.byType(AppSheetHandle).hitTestable(), findsOneWidget);
await tester.ensureVisible(find.text('Option 39'));
await tester.pumpAndSettle();
await tester.tap(find.text('Option 39'));
await tester.pumpAndSettle();
expect(selected, 39);
await open();
await tester.fling(scroll, const Offset(0, 400), 1200);
await tester.pumpAndSettle();
expect(find.byType(AppBottomSheet), findsNothing);
await open();
await tester.drag(scroll, const Offset(0, -400));
await tester.pumpAndSettle();
await tester.fling(
find.byType(AppSheetHandle),
const Offset(0, 300),
1200,
);
await tester.pumpAndSettle();
expect(find.byType(AppBottomSheet), findsNothing);
expect(tester.takeException(), isNull);
},
);
testWidgets('sheet selects, disables and returns values ($variant)', (
tester,
) async {
+17 -3
View File
@@ -343,7 +343,8 @@ void main() {
expect(find.text('Shell navigation').hitTestable(), findsNothing);
final sortSheet = find.byType(AppBottomSheet);
final sheetHeight = tester.getSize(sortSheet).height;
expect(sheetHeight, lessThan(915 * 0.75));
expect(sheetHeight, lessThan(915 * 0.85));
expect(find.text('Genre (Z-A)').hitTestable(), findsOneWidget);
final sheetScroll = find.descendant(
of: sortSheet,
matching: find.byType(SingleChildScrollView),
@@ -365,7 +366,16 @@ void main() {
await tester.tap(find.text('Filters').hitTestable());
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(tester.getSize(sortSheet).height, closeTo(sheetHeight, 1));
expect(tester.getSize(sortSheet).height, closeTo(915 * 0.85, 1));
expect(tester.getSize(sortSheet).height, greaterThan(sheetHeight));
await tester.drag(sheetScroll, const Offset(0, -200));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(tester.getSize(sortSheet).height, closeTo(915 * 0.95, 1));
// At full height, return the body to its top before swiping to dismiss.
await tester.drag(sheetScroll, const Offset(0, 200));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
await tester.drag(sheetScroll, const Offset(0, 450));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
@@ -378,7 +388,11 @@ void main() {
await tester.pump(const Duration(milliseconds: 400));
expect(find.byType(AppSheetHandle).hitTestable(), findsOneWidget);
expect(find.text('Reset').hitTestable(), findsOneWidget);
await tester.drag(find.byType(AppSheetHandle), const Offset(0, 450));
await tester.fling(
find.byType(AppSheetHandle),
const Offset(0, 300),
1000,
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(sortSheet, findsNothing);