From e45bfadc147e4df5a34a86023aa0d44ba122102f Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 27 May 2026 00:32:18 +0200 Subject: [PATCH] feat(libraries): add nav quick picker close #1144 --- lib/screens/libraries/libraries_screen.dart | 4 +- .../libraries/library_quick_picker_sheet.dart | 167 ++++++++++++++++++ lib/screens/main_screen.dart | 120 +++++++++++-- .../library_quick_picker_sheet_test.dart | 93 ++++++++++ 4 files changed, 369 insertions(+), 15 deletions(-) create mode 100644 lib/screens/libraries/library_quick_picker_sheet.dart create mode 100644 test/screens/libraries/library_quick_picker_sheet_test.dart diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 21e0aa1a..b252993d 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -71,8 +71,9 @@ class ContextMenuItem { class LibrariesScreen extends StatefulWidget { final VoidCallback? onLibraryOrderChanged; + final ValueChanged? onLibrarySelected; - const LibrariesScreen({super.key, this.onLibraryOrderChanged}); + const LibrariesScreen({super.key, this.onLibraryOrderChanged, this.onLibrarySelected}); @override State createState() => _LibrariesScreenState(); @@ -469,6 +470,7 @@ class _LibrariesScreenState extends State // Clear loaded tabs tracking for new library _loadedTabs.clear(); }); + widget.onLibrarySelected?.call(libraryGlobalKey); // The new TabBarView mounts with fresh inner positions at offset 0; // bring the floating header back too. Also covers the case where the diff --git a/lib/screens/libraries/library_quick_picker_sheet.dart b/lib/screens/libraries/library_quick_picker_sheet.dart new file mode 100644 index 00000000..1792539e --- /dev/null +++ b/lib/screens/libraries/library_quick_picker_sheet.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../i18n/strings.g.dart'; +import '../../media/media_library.dart'; +import '../../utils/content_utils.dart'; +import '../../utils/library_grouping.dart'; +import '../../widgets/app_icon.dart'; +import '../../widgets/backend_badge.dart'; +import '../../widgets/focusable_list_tile.dart'; + +class LibraryQuickPickerSheet extends StatelessWidget { + final List libraries; + final String? selectedLibraryKey; + final bool isLoading; + final bool groupByServer; + final String emptyMessage; + final ValueChanged onSelected; + + const LibraryQuickPickerSheet({ + super.key, + required this.libraries, + required this.selectedLibraryKey, + required this.isLoading, + required this.groupByServer, + required this.emptyMessage, + required this.onSelected, + }); + + bool get _showServerHeaders { + final serverIds = libraries.where((library) => library.serverId != null).map((library) => library.serverId).toSet(); + return serverIds.length > 1 && groupByServer; + } + + Set _getNonUniqueLibraryNames() { + final nameCounts = {}; + for (final library in libraries) { + nameCounts[library.title] = (nameCounts[library.title] ?? 0) + 1; + } + return nameCounts.entries.where((entry) => entry.value > 1).map((entry) => entry.key).toSet(); + } + + List _buildLibraryRows(BuildContext context) { + if (!_showServerHeaders) { + final nonUniqueNames = _getNonUniqueLibraryNames(); + return libraries.map((library) { + return _buildLibraryTile( + context, + library, + showServerName: library.serverName != null && nonUniqueNames.contains(library.title), + ); + }).toList(); + } + + final grouped = groupLibrariesByFirstAppearance(libraries); + final rows = []; + for (final serverKey in grouped.serverOrder) { + final bucket = grouped.byServer[serverKey]!; + if (serverKey.isNotEmpty) { + rows.add(_buildServerHeader(context, bucket.first, serverKey)); + } + for (final library in bucket) { + rows.add(_buildLibraryTile(context, library, showServerName: false)); + } + } + return rows; + } + + Widget _buildServerHeader(BuildContext context, MediaLibrary library, String fallbackServerName) { + final theme = Theme.of(context); + final labelStyle = theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: 0.4, + color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.65), + ); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Row( + children: [ + BackendBadge(backend: library.backend, size: 12, color: labelStyle?.color), + const SizedBox(width: 6), + Expanded( + child: Text( + library.serverName ?? fallbackServerName, + style: labelStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Widget _buildServerSubtitle(BuildContext context, MediaLibrary library) { + final style = Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6)); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + BackendBadge(backend: library.backend, size: 10, color: style?.color), + const SizedBox(width: 4), + Flexible( + child: Text(library.serverName!, style: style, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ], + ); + } + + Widget _buildLibraryTile(BuildContext context, MediaLibrary library, {required bool showServerName}) { + final colorScheme = Theme.of(context).colorScheme; + final isSelected = library.globalKey == selectedLibraryKey; + final foregroundColor = isSelected ? colorScheme.primary : null; + + return FocusableListTile( + key: ValueKey('library_quick_picker_${library.globalKey}'), + dense: false, + visualDensity: VisualDensity.standard, + selected: isSelected, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + leading: AppIcon(ContentTypeHelper.getLibraryIcon(library.kind.id), fill: 1, size: 22, color: foregroundColor), + title: Text( + library.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, color: foregroundColor), + ), + subtitle: showServerName ? _buildServerSubtitle(context, library) : null, + trailing: isSelected ? AppIcon(Symbols.check_rounded, fill: 1, color: colorScheme.primary) : null, + onTap: () => onSelected(library.globalKey), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text(t.libraries.selectLibrary, style: theme.textTheme.titleMedium), + ), + ), + if (isLoading && libraries.isEmpty) + const Padding(padding: EdgeInsets.symmetric(vertical: 32), child: CircularProgressIndicator()) + else if (libraries.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), + child: Text(emptyMessage, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium), + ) + else + Flexible( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 8), + children: _buildLibraryRows(context), + ), + ), + ], + ); + } +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 9a0e2c76..e0880b30 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -47,6 +47,7 @@ import '../widgets/side_navigation_rail.dart'; import '../focus/dpad_navigator.dart'; import '../focus/key_event_utils.dart'; import 'discover_screen.dart'; +import 'libraries/library_quick_picker_sheet.dart'; import 'libraries/libraries_screen.dart'; import 'livetv/live_tv_screen.dart'; import 'search_screen.dart'; @@ -890,6 +891,7 @@ class _MainScreenState extends State NavigationTabId.libraries => LibrariesScreen( key: _librariesKey, onLibraryOrderChanged: _onLibraryOrderChanged, + onLibrarySelected: _handleLibrariesScreenSelected, ), NavigationTabId.liveTv => LiveTvScreen(key: _liveTvKey), NavigationTabId.search => SearchScreen(key: _searchKey), @@ -1396,6 +1398,63 @@ class _MainScreenState extends State } } + void _handleLibrariesScreenSelected(String libraryGlobalKey) { + if (_selectedLibraryGlobalKey == libraryGlobalKey) return; + setState(() => _selectedLibraryGlobalKey = libraryGlobalKey); + } + + void _showLibraryQuickPicker(BuildContext context) { + if (_isOffline) return; + + final controller = OverlaySheetController.of(context); + final groupByServer = SettingsService.instanceOrNull?.read(SettingsService.groupLibrariesByServer) ?? false; + final maxHeight = MediaQuery.sizeOf(context).height * 0.62; + + controller + .show( + showDragHandle: true, + constraints: BoxConstraints(maxHeight: maxHeight), + builder: (sheetContext) { + return Consumer2( + builder: (context, librariesProvider, hiddenLibrariesProvider, _) { + if (!hiddenLibrariesProvider.isInitialized) { + return LibraryQuickPickerSheet( + libraries: const [], + selectedLibraryKey: _selectedLibraryGlobalKey, + isLoading: true, + groupByServer: groupByServer, + emptyMessage: t.libraries.noLibrariesFound, + onSelected: (libraryGlobalKey) => controller.close(libraryGlobalKey), + ); + } + + final allLibraries = librariesProvider.libraries; + final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; + final visibleLibraries = allLibraries + .where((library) => !hiddenKeys.contains(library.globalKey)) + .toList(); + final emptyMessage = allLibraries.isEmpty + ? t.libraries.noLibrariesFound + : t.libraries.allLibrariesHidden; + + return LibraryQuickPickerSheet( + libraries: visibleLibraries, + selectedLibraryKey: _selectedLibraryGlobalKey, + isLoading: librariesProvider.isLoading, + groupByServer: groupByServer, + emptyMessage: emptyMessage, + onSelected: (libraryGlobalKey) => controller.close(libraryGlobalKey), + ); + }, + ); + }, + ) + .then((libraryGlobalKey) { + if (!mounted || libraryGlobalKey == null) return; + _selectLibrary(libraryGlobalKey); + }); + } + /// Whether the Live TV tab is currently visible /// Use the synchronized value so screens list and nav bar always agree. /// Updated by _handleLiveTvChanged when the provider notifies. @@ -1418,9 +1477,52 @@ class _MainScreenState extends State }; } - /// Build navigation destinations for bottom navigation bar. - List _buildNavDestinations(bool isOffline) { - return _getVisibleTabs(isOffline).map((tab) => tab.toDestination()).toList(); + Widget _buildBottomNavigationBar(BuildContext context, {required bool hideLabels}) { + final tabs = _getVisibleTabs(_isOffline); + final navigationBar = NavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: (i) { + if (i >= 0 && i < tabs.length) _selectTab(tabs[i].id); + }, + labelBehavior: hideLabels + ? NavigationDestinationLabelBehavior.alwaysHide + : NavigationDestinationLabelBehavior.alwaysShow, + destinations: tabs.map((tab) => tab.toDestination()).toList(), + ); + + final librariesIndex = tabs.indexWhere((tab) => tab.id == NavigationTabId.libraries); + if (librariesIndex < 0 || tabs.isEmpty) return navigationBar; + + return LayoutBuilder( + builder: (context, constraints) { + if (!constraints.hasBoundedWidth) return navigationBar; + + final itemWidth = constraints.maxWidth / tabs.length; + final isRtl = Directionality.of(context) == TextDirection.rtl; + final left = isRtl ? constraints.maxWidth - (itemWidth * (librariesIndex + 1)) : itemWidth * librariesIndex; + + return Stack( + children: [ + navigationBar, + Positioned( + left: left, + top: 0, + bottom: 0, + width: itemWidth, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + excludeFromSemantics: true, + onLongPress: () { + Feedback.forLongPress(context); + _showLibraryQuickPicker(context); + }, + child: const SizedBox.expand(), + ), + ), + ], + ); + }, + ); } @override @@ -1595,17 +1697,7 @@ class _MainScreenState extends State final hideLabels = !showNavBarLabels; return NavigationBarTheme( data: NavigationBarTheme.of(context).copyWith(height: hideLabels ? 56 : null), - child: NavigationBar( - selectedIndex: _currentIndex, - onDestinationSelected: (i) { - final tabs = _getVisibleTabs(_isOffline); - if (i >= 0 && i < tabs.length) _selectTab(tabs[i].id); - }, - labelBehavior: hideLabels - ? NavigationDestinationLabelBehavior.alwaysHide - : NavigationDestinationLabelBehavior.alwaysShow, - destinations: _buildNavDestinations(_isOffline), - ), + child: _buildBottomNavigationBar(context, hideLabels: hideLabels), ); }, ), diff --git a/test/screens/libraries/library_quick_picker_sheet_test.dart b/test/screens/libraries/library_quick_picker_sheet_test.dart new file mode 100644 index 00000000..b8643cb2 --- /dev/null +++ b/test/screens/libraries/library_quick_picker_sheet_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_library.dart'; +import 'package:plezy/screens/libraries/library_quick_picker_sheet.dart'; + +void main() { + testWidgets('groups libraries by server and reports selection', (tester) async { + final libraries = [ + const MediaLibrary( + id: '1', + backend: MediaBackend.plex, + title: 'Movies', + kind: MediaKind.movie, + serverId: 'plex-server', + serverName: 'Plex Server', + ), + const MediaLibrary( + id: '2', + backend: MediaBackend.jellyfin, + title: 'Shows', + kind: MediaKind.show, + serverId: 'jellyfin-server', + serverName: 'Jellyfin Server', + ), + ]; + String? selectedKey; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: LibraryQuickPickerSheet( + libraries: libraries, + selectedLibraryKey: libraries.last.globalKey, + isLoading: false, + groupByServer: true, + emptyMessage: 'No libraries', + onSelected: (key) => selectedKey = key, + ), + ), + ), + ); + + expect(find.text('Plex Server'), findsOneWidget); + expect(find.text('Jellyfin Server'), findsOneWidget); + expect(find.text('Movies'), findsOneWidget); + expect(find.text('Shows'), findsOneWidget); + + await tester.tap(find.text('Movies')); + expect(selectedKey, libraries.first.globalKey); + }); + + testWidgets('shows duplicate library server subtitles without grouping', (tester) async { + final libraries = [ + const MediaLibrary( + id: '1', + backend: MediaBackend.plex, + title: 'Movies', + kind: MediaKind.movie, + serverId: 'plex-server', + serverName: 'Plex Server', + ), + const MediaLibrary( + id: '2', + backend: MediaBackend.jellyfin, + title: 'Movies', + kind: MediaKind.movie, + serverId: 'jellyfin-server', + serverName: 'Jellyfin Server', + ), + ]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: LibraryQuickPickerSheet( + libraries: libraries, + selectedLibraryKey: null, + isLoading: false, + groupByServer: false, + emptyMessage: 'No libraries', + onSelected: (_) {}, + ), + ), + ), + ); + + expect(find.text('Movies'), findsNWidgets(2)); + expect(find.text('Plex Server'), findsOneWidget); + expect(find.text('Jellyfin Server'), findsOneWidget); + }); +}