From eb8796fa265fafdcd221b6db81d91184d25e630f Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:36:23 +0200 Subject: [PATCH] feat(library): indicate active filters in browse view Badge the mobile Library options icon with a dot while filters are active, and replace the "This library is empty" state with a "No items match the active filters" message plus a reset button. close #1470 --- lib/i18n/en.i18n.json | 2 + lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_en.g.dart | 12 +++- .../libraries/content_state_builder.dart | 22 ++++++- lib/screens/libraries/libraries_screen.dart | 36 +++++++++++ .../libraries/tabs/library_browse_tab.dart | 61 +++++++++++++++---- 6 files changed, 117 insertions(+), 18 deletions(-) diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 7b0a6f71..4d63ead4 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -736,6 +736,8 @@ "allLibrariesHidden": "All libraries are hidden", "hiddenLibrariesCount": "Hidden libraries (${count})", "thisLibraryIsEmpty": "This library is empty", + "noItemsMatchFilters": "No items match the active filters", + "resetFilters": "Reset filters", "all": "All", "clearAll": "Clear All", "scanLibraryConfirm": "Are you sure you want to scan \"${title}\"?", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 35572abf..10be4474 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 20716 (1294 per locale) +/// Strings: 20718 (1294 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 2f3caede..075572f2 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -2269,6 +2269,12 @@ class TranslationsLibrariesEn { /// en: 'This library is empty' String get thisLibraryIsEmpty => 'This library is empty'; + /// en: 'No items match the active filters' + String get noItemsMatchFilters => 'No items match the active filters'; + + /// en: 'Reset filters' + String get resetFilters => 'Reset filters'; + /// en: 'All' String get all => 'All'; @@ -5261,6 +5267,8 @@ extension on Translations { 'libraries.allLibrariesHidden' => 'All libraries are hidden', 'libraries.hiddenLibrariesCount' => ({required Object count}) => 'Hidden libraries (${count})', 'libraries.thisLibraryIsEmpty' => 'This library is empty', + 'libraries.noItemsMatchFilters' => 'No items match the active filters', + 'libraries.resetFilters' => 'Reset filters', 'libraries.all' => 'All', 'libraries.clearAll' => 'Clear All', 'libraries.scanLibraryConfirm' => ({required Object title}) => 'Are you sure you want to scan "${title}"?', @@ -5597,10 +5605,10 @@ extension on Translations { 'shaders.shaderImportFailed' => 'Failed to import shader', 'shaders.deleteShader' => 'Delete Shader', 'shaders.deleteShaderConfirm' => ({required Object name}) => 'Delete "${name}"?', - 'companionRemote.title' => 'Companion Remote', - 'companionRemote.connectedTo' => ({required Object name}) => 'Connected to ${name}', _ => null, } ?? switch (path) { + 'companionRemote.title' => 'Companion Remote', + 'companionRemote.connectedTo' => ({required Object name}) => 'Connected to ${name}', 'companionRemote.unknownDevice' => 'Unknown Device', 'companionRemote.session.startingServer' => 'Starting remote server...', 'companionRemote.session.failedToCreate' => 'Failed to start remote server:', diff --git a/lib/screens/libraries/content_state_builder.dart b/lib/screens/libraries/content_state_builder.dart index cfa241d9..c194ac02 100644 --- a/lib/screens/libraries/content_state_builder.dart +++ b/lib/screens/libraries/content_state_builder.dart @@ -27,12 +27,30 @@ class SliverEmptyState extends StatelessWidget { final String message; final IconData? icon; final String? subtitle; + final VoidCallback? onAction; + final String? actionLabel; + final IconData? actionIcon; - const SliverEmptyState({super.key, required this.message, required this.icon, this.subtitle}); + const SliverEmptyState({ + super.key, + required this.message, + required this.icon, + this.subtitle, + this.onAction, + this.actionLabel, + this.actionIcon, + }); @override Widget build(BuildContext context) => SliverFillRemaining( - child: EmptyStateWidget(message: message, icon: icon, subtitle: subtitle), + child: EmptyStateWidget( + message: message, + icon: icon, + subtitle: subtitle, + onAction: onAction, + actionLabel: actionLabel, + actionIcon: actionIcon, + ), ); } diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 6bfcfb36..3e6bb8b9 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -107,6 +107,9 @@ class _LibrariesScreenState extends State /// Track which tabs have loaded data (used to trigger focus after tab restore) final Set _loadedTabs = {}; + /// Whether the browse tab has active filters (badges the Library options icon) + bool _browseFiltersActive = false; + /// Key for the library dropdown menu button. final _libraryDropdownKey = GlobalKey>(); @@ -324,6 +327,12 @@ class _LibrariesScreenState extends State (tabState as dynamic).showBrowseOptionsSheet(); } + /// Handle when the browse tab's active-filter state changes + void _handleBrowseFiltersActiveChanged(bool active) { + if (_browseFiltersActive == active) return; + setState(() => _browseFiltersActive = active); + } + /// Handle when a tab's data has finished loading void _handleTabDataLoaded(int tabIndex) { // Track that this tab has loaded @@ -426,6 +435,7 @@ class _LibrariesScreenState extends State onDataLoaded: () => _handleTabDataLoaded(tabIndex), onBack: focusTabBar, onResetScroll: _resetOuterScroll, + onFiltersActiveChanged: _handleBrowseFiltersActiveChanged, ), LibraryTabType.collections => LibraryCollectionsTab( key: _collectionsTabKey, @@ -1031,6 +1041,32 @@ class _LibrariesScreenState extends State icon: Symbols.tune_rounded, tooltip: t.libraries.libraryOptions, onPressed: _showBrowseOptionsForCurrentTab, + // Badge the icon with a dot while the browse tab has active filters + // (issue #1470). A null child keeps the default rendering. + child: _browseFiltersActive + ? IconButton( + tooltip: t.libraries.libraryOptions, + onPressed: _showBrowseOptionsForCurrentTab, + icon: Stack( + clipBehavior: Clip.none, + children: [ + const AppIcon(Symbols.tune_rounded, fill: 1), + Positioned( + top: -2, + right: -2, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ) + : null, ), FocusableAction(icon: Symbols.refresh_rounded, tooltip: t.common.refresh, onPressed: _refreshSelectedLibraryTabs), ]; diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 5f097ad1..1ac78603 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -73,6 +73,10 @@ class LibraryBrowseTab extends BaseLibraryTab { /// (filter/sort change, library reload, etc.). Lets the parent resync the /// outer floating header — see `_resetOuterScroll` in libraries_screen. final VoidCallback? onResetScroll; + + /// Notifies the parent when the active-filter state changes so the app + /// bar can badge the Library options action on mobile. + final ValueChanged? onFiltersActiveChanged; final bool canGroupByFolders; const LibraryBrowseTab({ @@ -86,6 +90,7 @@ class LibraryBrowseTab extends BaseLibraryTab { super.suppressAutoFocus, super.onBack, this.onResetScroll, + this.onFiltersActiveChanged, }); @override @@ -566,6 +571,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _applyFilters(Map filters) async { + setState(() { + _selectedFilters.clear(); + _selectedFilters.addAll(filters); + }); + _notifyFiltersActive(); + + // Save filters to storage + final storage = await StorageService.getInstance(); + await storage.saveLibraryFilters(filters, sectionId: widget.library.globalKey); + + unawaited(_loadItems()); + unawaited(_loadFirstCharacters()); + } + + void _resetFilters() => unawaited(_applyFilters(const {})); + Future> _loadFilterValues(MediaFilter filter) async { if (!mounted) return const []; @@ -1659,6 +1683,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState