diff --git a/lib/screens/queue_tab_sections.dart b/lib/screens/queue_tab_sections.dart index 3ede9345..a0bce101 100644 --- a/lib/screens/queue_tab_sections.dart +++ b/lib/screens/queue_tab_sections.dart @@ -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 _showSortSheet(BuildContext context) async { diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index e758a523..e4142cec 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -958,63 +958,39 @@ class _LanguageSelector extends StatelessWidget { void _showLanguagePicker(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - showAppModalBottomSheet( + showAppScrollableBottomSheet( 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); + }, + ); + }, ), ); } diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index b712aa74..a62424a9 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -408,44 +408,46 @@ class _DownloadSettingsPageState extends ConsumerState { String current, ) { final normalizedCurrent = normalizeAutoConvertFormat(current); - showAppBottomSheet( + showAppScrollableBottomSheet( 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 { String current, ) { final normalizedCurrent = normalizeAutoConvertBitrate(current); - showAppBottomSheet( + showAppScrollableBottomSheet( 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 { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showAppModalBottomSheet( + showAppScrollableBottomSheet( 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 { int current, ) { final colorScheme = Theme.of(context).colorScheme; - showAppModalBottomSheet( + showAppScrollableBottomSheet( 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 { 'ZM', 'ZW', ]; - final colorScheme = Theme.of(context).colorScheme; final normalizedCurrent = current.trim().toUpperCase(); - showAppModalBottomSheet( + showAppScrollableBottomSheet( 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 regions; final String selected; final String Function(BuildContext context, String code) countryNameOf; final ValueChanged 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), + ); + }, + ), ), - ), + ], ); } } diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 749f9547..4a141c37 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -1109,11 +1109,11 @@ class _SettingItemState extends State<_SettingItem> { } Future _showOptions() async { - final value = await showAppBottomSheet( + final value = await showAppScrollableBottomSheet( context: context, title: widget.setting.label, - maxHeightFactor: 0.7, - builder: (sheetContext) => SingleChildScrollView( + builder: (sheetContext, controller) => SingleChildScrollView( + controller: controller, child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index c0d2846f..6f53d2e9 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -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( + showAppScrollableBottomSheet( 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( + showAppScrollableBottomSheet( 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), + ], ), ), ); diff --git a/lib/screens/settings/files_settings_page.dart b/lib/screens/settings/files_settings_page.dart index 5a0ee267..96e98339 100644 --- a/lib/screens/settings/files_settings_page.dart +++ b/lib/screens/settings/files_settings_page.dart @@ -519,33 +519,16 @@ class _FilesSettingsPageState extends ConsumerState { final settings = ref.read(settingsProvider); final isSafMode = settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty; - showAppModalBottomSheet( + showAppScrollableBottomSheet( 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 { void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - showAppModalBottomSheet( + showAppScrollableBottomSheet( 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 { String? title, String? description, }) { - final colorScheme = Theme.of(context).colorScheme; final save = onSave ?? ref.read(settingsProvider.notifier).setFilenameFormat; - showAppModalBottomSheet( + showAppScrollableBottomSheet( 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 { WidgetRef ref, String current, ) { - final colorScheme = Theme.of(context).colorScheme; - showAppModalBottomSheet( + showAppScrollableBottomSheet( 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 { WidgetRef ref, String current, ) { - final colorScheme = Theme.of(context).colorScheme; - showAppModalBottomSheet( + showAppScrollableBottomSheet( 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), + ], ), ); } diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index ed20e5fe..73900119 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -336,33 +336,16 @@ class _LibrarySettingsPageState extends ConsumerState { void _showAutoScanPicker(BuildContext context, String current) { final colorScheme = Theme.of(context).colorScheme; - showAppModalBottomSheet( + showAppScrollableBottomSheet( 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 { ('artists', Icons.person, context.l10n.searchArtists), ('playlists', Icons.queue_music, context.l10n.searchPlaylists), ]; - showAppModalBottomSheet( + showAppScrollableBottomSheet( 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 { context.l10n.libraryQualityLabelFileFormat, ), ]; - showAppModalBottomSheet( + showAppScrollableBottomSheet( 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, diff --git a/lib/screens/settings/lyrics_settings_page.dart b/lib/screens/settings/lyrics_settings_page.dart index b923beb0..812ab107 100644 --- a/lib/screens/settings/lyrics_settings_page.dart +++ b/lib/screens/settings/lyrics_settings_page.dart @@ -229,34 +229,16 @@ class LyricsSettingsPage extends ConsumerWidget { WidgetRef ref, String current, ) { - final colorScheme = Theme.of(context).colorScheme; - showAppModalBottomSheet( + showAppScrollableBottomSheet( 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( + showAppScrollableBottomSheet( 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, diff --git a/lib/screens/settings/metadata_settings_page.dart b/lib/screens/settings/metadata_settings_page.dart index 368a2053..25219d07 100644 --- a/lib/screens/settings/metadata_settings_page.dart +++ b/lib/screens/settings/metadata_settings_page.dart @@ -186,59 +186,36 @@ class MetadataSettingsPage extends ConsumerWidget { int currentMaxDimension, ) { const options = [0, 500, 1000, 1500, 2000]; - final colorScheme = Theme.of(context).colorScheme; - showAppModalBottomSheet( + showAppScrollableBottomSheet( 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( + showAppScrollableBottomSheet( 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), + ], ), ), ); diff --git a/lib/widgets/app_bottom_sheet.dart b/lib/widgets/app_bottom_sheet.dart index 698cf927..a308e3ab 100644 --- a/lib/widgets/app_bottom_sheet.dart +++ b/lib/widgets/app_bottom_sheet.dart @@ -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 showAppBottomSheet({ ); } +/// 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 createState() => _AppScrollableSheetState(); +} + +class _AppScrollableSheetState extends State { + 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( + onNotification: (notification) { + if (notification.depth == 0) _scheduleFit(); + return false; + }, + child: NotificationListener( + 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 showAppScrollableBottomSheet({ + required BuildContext context, + required ScrollableWidgetBuilder builder, + required String title, + String? subtitle, +}) => showAppModalBottomSheet( + 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 showAppModalBottomSheet({ required BuildContext context, diff --git a/test/adaptive_overlay_test.dart b/test/adaptive_overlay_test.dart index 33157c07..0719f55e 100644 --- a/test/adaptive_overlay_test.dart +++ b/test/adaptive_overlay_test.dart @@ -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( + context: context, + title: 'Options', + builder: (context, controller) => + ValueListenableBuilder( + 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( + 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 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 { diff --git a/test/album_completeness_query_test.dart b/test/album_completeness_query_test.dart index 078a4486..41512b18 100644 --- a/test/album_completeness_query_test.dart +++ b/test/album_completeness_query_test.dart @@ -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);