diff --git a/lib/main.dart b/lib/main.dart index b1facf24..28ff3b1e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'providers/user_profile_provider.dart'; import 'providers/plex_client_provider.dart'; import 'providers/theme_provider.dart'; import 'providers/settings_provider.dart'; +import 'providers/hidden_libraries_provider.dart'; import 'utils/language_codes.dart'; import 'utils/app_logger.dart'; import 'utils/provider_extensions.dart'; @@ -68,6 +69,7 @@ class MainApp extends StatelessWidget { ), ChangeNotifierProvider(create: (context) => ThemeProvider()), ChangeNotifierProvider(create: (context) => SettingsProvider()), + ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider()), ], child: Consumer( builder: (context, themeProvider, child) { diff --git a/lib/providers/hidden_libraries_provider.dart b/lib/providers/hidden_libraries_provider.dart new file mode 100644 index 00000000..9f3eca99 --- /dev/null +++ b/lib/providers/hidden_libraries_provider.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart'; +import '../services/storage_service.dart'; + +/// Provider for managing hidden library state across the app. +/// This ensures that when a library is hidden/unhidden in one screen, +/// all other screens are automatically updated. +class HiddenLibrariesProvider extends ChangeNotifier { + late StorageService _storageService; + Set _hiddenLibraryKeys = {}; + bool _isInitialized = false; + + /// Get an unmodifiable copy of hidden library keys + Set get hiddenLibraryKeys => Set.unmodifiable(_hiddenLibraryKeys); + + /// Check if the provider has completed initialization + bool get isInitialized => _isInitialized; + + HiddenLibrariesProvider() { + _initialize(); + } + + /// Initialize the provider by loading hidden libraries from storage + Future _initialize() async { + _storageService = await StorageService.getInstance(); + _hiddenLibraryKeys = _storageService.getHiddenLibraries(); + _isInitialized = true; + notifyListeners(); + } + + /// Hide a library by its key + /// Updates both in-memory state and persistent storage + Future hideLibrary(String libraryKey) async { + if (!_hiddenLibraryKeys.contains(libraryKey)) { + _hiddenLibraryKeys = Set.from(_hiddenLibraryKeys)..add(libraryKey); + await _storageService.saveHiddenLibraries(_hiddenLibraryKeys); + notifyListeners(); + } + } + + /// Unhide a library by its key + /// Updates both in-memory state and persistent storage + Future unhideLibrary(String libraryKey) async { + if (_hiddenLibraryKeys.contains(libraryKey)) { + _hiddenLibraryKeys = Set.from(_hiddenLibraryKeys)..remove(libraryKey); + await _storageService.saveHiddenLibraries(_hiddenLibraryKeys); + notifyListeners(); + } + } + + /// Check if a specific library is hidden + bool isLibraryHidden(String libraryKey) { + return _hiddenLibraryKeys.contains(libraryKey); + } + + /// Refresh hidden libraries from storage + /// Useful if storage was modified outside the provider + Future refresh() async { + _hiddenLibraryKeys = _storageService.getHiddenLibraries(); + notifyListeners(); + } +} diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index f871ec52..d2b2b35b 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -7,10 +7,12 @@ import '../models/plex_filter.dart'; import '../models/plex_sort.dart'; import '../providers/plex_client_provider.dart'; import '../providers/settings_provider.dart'; +import '../providers/hidden_libraries_provider.dart'; import '../utils/provider_extensions.dart'; import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/app_bar_back_button.dart'; +import '../widgets/context_menu_wrapper.dart'; import '../services/storage_service.dart'; import '../services/settings_service.dart'; import '../mixins/refreshable.dart'; @@ -29,14 +31,14 @@ class _LibrariesScreenState extends State @override PlexClient get client => context.clientSafe; - List _libraries = []; + List _allLibraries = []; // All libraries from API (unfiltered) List _items = []; List _filters = []; List _sortOptions = []; bool _isLoadingLibraries = true; bool _isLoadingItems = false; String? _errorMessage; - int _selectedLibraryIndex = 0; + String? _selectedLibraryKey; Map _selectedFilters = {}; PlexSort? _selectedSort; bool _isSortDescending = false; @@ -49,45 +51,70 @@ class _LibrariesScreenState extends State } Future _loadLibraries() async { + // Extract context dependencies before async gap + final clientProvider = Provider.of( + context, + listen: false, + ); + final hiddenLibrariesProvider = Provider.of( + context, + listen: false, + ); + setState(() { _isLoadingLibraries = true; _errorMessage = null; }); try { - final clientProvider = Provider.of( - context, - listen: false, - ); final client = clientProvider.client; if (client == null) { throw Exception('No client available'); } - final libraries = await client.getLibraries(); + final storage = await StorageService.getInstance(); + final allLibraries = await client.getLibraries(); + setState(() { - _libraries = libraries; + _allLibraries = allLibraries; // Store all libraries without filtering _isLoadingLibraries = false; }); - if (libraries.isNotEmpty) { + if (allLibraries.isNotEmpty) { + // Compute visible libraries for initial load + final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; + final visibleLibraries = allLibraries + .where((lib) => !hiddenKeys.contains(lib.key)) + .toList(); + // Load saved preferences - final storage = await StorageService.getInstance(); - final savedIndex = storage.getSelectedLibraryIndex(); + final savedLibraryKey = storage.getSelectedLibraryKey(); final savedFilters = storage.getLibraryFilters(); - // Use saved index if valid, otherwise default to 0 - final indexToLoad = - (savedIndex != null && savedIndex < libraries.length) - ? savedIndex - : 0; + // Find the library by key in visible libraries + String? libraryKeyToLoad; + if (savedLibraryKey != null) { + // Check if saved library exists and is visible + final libraryExists = visibleLibraries + .any((lib) => lib.key == savedLibraryKey); + if (libraryExists) { + libraryKeyToLoad = savedLibraryKey; + } + } + + // Fallback to first visible library if saved key not found + if (libraryKeyToLoad == null && visibleLibraries.isNotEmpty) { + libraryKeyToLoad = visibleLibraries.first.key; + } // Restore filters BEFORE loading content if (savedFilters.isNotEmpty) { _selectedFilters = Map.from(savedFilters); } - _loadLibraryContent(indexToLoad); + if (libraryKeyToLoad != null) { + _loadLibraryContent(libraryKeyToLoad); + } } } catch (e) { setState(() { @@ -97,10 +124,22 @@ class _LibrariesScreenState extends State } } - Future _loadLibraryContent(int index) async { - if (index < 0 || index >= _libraries.length) return; + Future _loadLibraryContent(String libraryKey) async { + // Compute visible libraries based on current provider state + final hiddenLibrariesProvider = Provider.of( + context, + listen: false, + ); + final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; + final visibleLibraries = _allLibraries + .where((lib) => !hiddenKeys.contains(lib.key)) + .toList(); - final isChangingLibrary = !_isInitialLoad && _selectedLibraryIndex != index; + // Find the library by key + final libraryIndex = visibleLibraries.indexWhere((lib) => lib.key == libraryKey); + if (libraryIndex == -1) return; // Library not found or hidden + + final isChangingLibrary = !_isInitialLoad && _selectedLibraryKey != libraryKey; // Extract context dependencies before async operations final clientProvider = context.plexClient; @@ -114,7 +153,7 @@ class _LibrariesScreenState extends State } setState(() { - _selectedLibraryIndex = index; + _selectedLibraryKey = libraryKey; _isLoadingItems = true; _errorMessage = null; // Only clear filters when explicitly changing library (not on initial load) @@ -128,9 +167,9 @@ class _LibrariesScreenState extends State _isInitialLoad = false; } - // Save selected library index + // Save selected library key final storage = await StorageService.getInstance(); - await storage.saveSelectedLibraryIndex(index); + await storage.saveSelectedLibraryKey(libraryKey); // Clear filters in storage when changing library if (isChangingLibrary) { @@ -139,19 +178,20 @@ class _LibrariesScreenState extends State try { // Load filters and sort options for the new library - _loadFilters(index); - _loadSortOptions(index); + _loadFilters(libraryKey); + _loadSortOptions(libraryKey); // Add sort parameter to filters if selected final filtersWithSort = Map.from(_selectedFilters); if (_selectedSort != null) { - filtersWithSort['sort'] = - _selectedSort!.getSortKey(descending: _isSortDescending); + filtersWithSort['sort'] = _selectedSort!.getSortKey( + descending: _isSortDescending, + ); } // Load content final items = await client.getLibraryContent( - _libraries[index].key, + libraryKey, filters: filtersWithSort, ); setState(() { @@ -166,9 +206,7 @@ class _LibrariesScreenState extends State } } - Future _loadFilters(int index) async { - if (index < 0 || index >= _libraries.length) return; - + Future _loadFilters(String libraryKey) async { try { final clientProvider = Provider.of( context, @@ -179,7 +217,7 @@ class _LibrariesScreenState extends State throw Exception('No client available'); } - final filters = await client.getLibraryFilters(_libraries[index].key); + final filters = await client.getLibraryFilters(libraryKey); setState(() { _filters = filters; }); @@ -190,9 +228,7 @@ class _LibrariesScreenState extends State } } - Future _loadSortOptions(int index) async { - if (index < 0 || index >= _libraries.length) return; - + Future _loadSortOptions(String libraryKey) async { try { final clientProvider = Provider.of( context, @@ -203,11 +239,11 @@ class _LibrariesScreenState extends State throw Exception('No client available'); } - final sortOptions = await client.getLibrarySorts(_libraries[index].key); + final sortOptions = await client.getLibrarySorts(libraryKey); // Load saved sort preference for this library final storage = await StorageService.getInstance(); - final savedSortKey = storage.getLibrarySort(_libraries[index].key); + final savedSortKey = storage.getLibrarySort(libraryKey); // Find the saved sort in the options PlexSort? savedSort; @@ -260,12 +296,13 @@ class _LibrariesScreenState extends State // Add sort parameter to filters if selected final filtersWithSort = Map.from(_selectedFilters); if (_selectedSort != null) { - filtersWithSort['sort'] = - _selectedSort!.getSortKey(descending: _isSortDescending); + filtersWithSort['sort'] = _selectedSort!.getSortKey( + descending: _isSortDescending, + ); } final items = await client.getLibraryContent( - _libraries[_selectedLibraryIndex].key, + _selectedLibraryKey!, filters: filtersWithSort, ); setState(() { @@ -289,7 +326,10 @@ class _LibrariesScreenState extends State // Save sort preference for this library final storage = await StorageService.getInstance(); final sortKey = sort.getSortKey(descending: descending); - await storage.saveLibrarySort(_libraries[_selectedLibraryIndex].key, sortKey); + await storage.saveLibrarySort( + _selectedLibraryKey!, + sortKey, + ); // Reload content with new sort _applyFilters(); @@ -306,11 +346,49 @@ class _LibrariesScreenState extends State // Public method to refresh content @override void refresh() { - if (_libraries.isNotEmpty) { + if (_allLibraries.isNotEmpty) { _applyFilters(); } } + Future _hideLibrary(PlexLibrary library) async { + // Hide library using provider + final hiddenLibrariesProvider = Provider.of( + context, + listen: false, + ); + await hiddenLibrariesProvider.hideLibrary(library.key); + + // Reload libraries to update the visible list + await _loadLibraries(); + + // Show snackbar with undo option + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Hidden "${library.title}"'), + action: SnackBarAction( + label: 'Undo', + onPressed: () => _unhideLibrary(library.key), + ), + duration: const Duration(seconds: 4), + ), + ); + } + } + + Future _unhideLibrary(String libraryKey) async { + // Unhide library using provider + final hiddenLibrariesProvider = Provider.of( + context, + listen: false, + ); + await hiddenLibrariesProvider.unhideLibrary(libraryKey); + + // Reload libraries to update the visible list + await _loadLibraries(); + } + void _showFiltersBottomSheet() { showModalBottomSheet( context: context, @@ -352,6 +430,15 @@ class _LibrariesScreenState extends State @override Widget build(BuildContext context) { + // Watch for hidden libraries changes to trigger rebuild + final hiddenLibrariesProvider = context.watch(); + final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; + + // Compute visible libraries (filtered from all libraries) + final visibleLibraries = _allLibraries + .where((lib) => !hiddenKeys.contains(lib.key)) + .toList(); + return Scaffold( body: CustomScrollView( slivers: [ @@ -366,10 +453,7 @@ class _LibrariesScreenState extends State actions: [ if (_sortOptions.isNotEmpty) IconButton( - icon: const Icon( - Icons.swap_vert, - semanticLabel: 'Sort', - ), + icon: const Icon(Icons.swap_vert, semanticLabel: 'Sort'), onPressed: _showSortBottomSheet, ), if (_filters.isNotEmpty) @@ -386,7 +470,7 @@ class _LibrariesScreenState extends State ), IconButton( icon: const Icon(Icons.refresh, semanticLabel: 'Refresh'), - onPressed: () => _loadLibraryContent(_selectedLibraryIndex), + onPressed: () => _loadLibraryContent(_selectedLibraryKey!), ), ], ), @@ -394,7 +478,7 @@ class _LibrariesScreenState extends State const SliverFillRemaining( child: Center(child: CircularProgressIndicator()), ) - else if (_errorMessage != null && _libraries.isEmpty) + else if (_errorMessage != null && visibleLibraries.isEmpty) SliverFillRemaining( child: Center( child: Column( @@ -416,7 +500,7 @@ class _LibrariesScreenState extends State ), ), ) - else if (_libraries.isEmpty) + else if (visibleLibraries.isEmpty) const SliverFillRemaining( child: Center( child: Column( @@ -444,41 +528,55 @@ class _LibrariesScreenState extends State child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( - children: List.generate(_libraries.length, (index) { - final library = _libraries[index]; - final isSelected = index == _selectedLibraryIndex; + children: List.generate(visibleLibraries.length, (index) { + final library = visibleLibraries[index]; + final isSelected = library.key == _selectedLibraryKey; final t = tokens(context); return Padding( padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _getLibraryIcon(library.type), - size: 16, - color: isSelected ? t.bg : t.text, - ), - const SizedBox(width: 6), - Text(library.title), - ], - ), - selected: isSelected, - onSelected: (selected) { - if (selected) { - _loadLibraryContent(index); + child: ContextMenuWrapper( + menuItems: [ + ContextMenuItem( + value: 'hide', + icon: Icons.visibility_off, + label: 'Hide "${library.title}"', + ), + ], + onMenuItemSelected: (value) { + if (value == 'hide') { + _hideLibrary(library); } }, - backgroundColor: t.surface, - selectedColor: t.text, - side: BorderSide(color: t.outline), - labelStyle: TextStyle( - color: isSelected ? t.bg : t.text, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.w400, + child: ChoiceChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getLibraryIcon(library.type), + size: 16, + color: isSelected ? t.bg : t.text, + ), + const SizedBox(width: 6), + Text(library.title), + ], + ), + selected: isSelected, + onSelected: (selected) { + if (selected) { + _loadLibraryContent(library.key); + } + }, + backgroundColor: t.surface, + selectedColor: t.text, + side: BorderSide(color: t.outline), + labelStyle: TextStyle( + color: isSelected ? t.bg : t.text, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, + ), + showCheckmark: false, ), - showCheckmark: false, ), ); }), @@ -508,7 +606,7 @@ class _LibrariesScreenState extends State const SizedBox(height: 16), ElevatedButton( onPressed: () => - _loadLibraryContent(_selectedLibraryIndex), + _loadLibraryContent(_selectedLibraryKey!), child: const Text('Retry'), ), ], diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d9c7a72c..96927186 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -3,8 +3,11 @@ import 'package:provider/provider.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import '../providers/theme_provider.dart'; import '../providers/settings_provider.dart'; +import '../providers/plex_client_provider.dart'; +import '../providers/hidden_libraries_provider.dart'; import '../services/settings_service.dart' as settings; import '../services/keyboard_shortcuts_service.dart'; +import '../models/plex_library.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/hotkey_recorder_widget.dart'; import 'about_screen.dart'; @@ -43,6 +46,21 @@ class _SettingsScreenState extends State { }); } + Future _unhideLibrary(String libraryKey) async { + // Unhide library using provider + final hiddenLibrariesProvider = Provider.of( + context, + listen: false, + ); + await hiddenLibrariesProvider.unhideLibrary(libraryKey); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Library shown')), + ); + } + } + @override Widget build(BuildContext context) { if (_isLoading) { @@ -59,6 +77,8 @@ class _SettingsScreenState extends State { delegate: SliverChildListDelegate([ _buildAppearanceSection(), const SizedBox(height: 24), + _buildLibraryManagementSection(), + const SizedBox(height: 24), _buildVideoPlaybackSection(), const SizedBox(height: 24), _buildKeyboardShortcutsSection(), @@ -116,7 +136,9 @@ class _SettingsScreenState extends State { return SwitchListTile( secondary: const Icon(Icons.image), title: const Text('Use Season Posters'), - subtitle: const Text('Show season poster instead of series poster for episodes'), + subtitle: const Text( + 'Show season poster instead of series poster for episodes', + ), value: settingsProvider.useSeasonPoster, onChanged: (value) async { await settingsProvider.setUseSeasonPoster(value); @@ -129,6 +151,92 @@ class _SettingsScreenState extends State { ); } + Widget _buildLibraryManagementSection() { + // Watch for hidden libraries changes to trigger rebuild + final hiddenLibrariesProvider = context.watch(); + final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; + + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Library Management', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + if (hiddenKeys.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Text( + 'No hidden libraries', + style: TextStyle(color: Colors.grey[600]), + ), + ) + else + // Use FutureBuilder to fetch library details for hidden keys + FutureBuilder>( + future: _fetchHiddenLibraries(hiddenKeys), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + + if (snapshot.hasError || !snapshot.hasData) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Text( + 'Error loading hidden libraries', + style: TextStyle(color: Colors.grey[600]), + ), + ); + } + + final hiddenLibraries = snapshot.data!; + return Column( + children: hiddenLibraries.map((library) { + return ListTile( + leading: const Icon(Icons.visibility_off), + title: Text(library.title), + subtitle: Text('${library.type} library'), + trailing: TextButton( + onPressed: () => _unhideLibrary(library.key), + child: const Text('Show'), + ), + ); + }).toList(), + ); + }, + ), + ], + ), + ); + } + + Future> _fetchHiddenLibraries(Set hiddenKeys) async { + final clientProvider = Provider.of( + context, + listen: false, + ); + final client = clientProvider.client; + + if (client == null) { + return []; + } + + final allLibraries = await client.getLibraries(); + return allLibraries + .where((lib) => hiddenKeys.contains(lib.key)) + .toList(); + } + Widget _buildVideoPlaybackSection() { return Card( child: Column( @@ -457,7 +565,9 @@ class _SettingsScreenState extends State { title: const Text('Compact'), subtitle: const Text('Smaller cards, more items visible'), onTap: () async { - await settingsProvider.setLibraryDensity(settings.LibraryDensity.compact); + await settingsProvider.setLibraryDensity( + settings.LibraryDensity.compact, + ); if (context.mounted) Navigator.pop(context); }, ), @@ -470,20 +580,25 @@ class _SettingsScreenState extends State { title: const Text('Normal'), subtitle: const Text('Default size'), onTap: () async { - await settingsProvider.setLibraryDensity(settings.LibraryDensity.normal); + await settingsProvider.setLibraryDensity( + settings.LibraryDensity.normal, + ); if (context.mounted) Navigator.pop(context); }, ), ListTile( leading: Icon( - provider.libraryDensity == settings.LibraryDensity.comfortable + provider.libraryDensity == + settings.LibraryDensity.comfortable ? Icons.radio_button_checked : Icons.radio_button_unchecked, ), title: const Text('Comfortable'), subtitle: const Text('Larger cards, fewer items visible'), onTap: () async { - await settingsProvider.setLibraryDensity(settings.LibraryDensity.comfortable); + await settingsProvider.setLibraryDensity( + settings.LibraryDensity.comfortable, + ); if (context.mounted) Navigator.pop(context); }, ), diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index a0895e11..db81a049 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -8,11 +8,13 @@ class StorageService { static const String _keyServerData = 'server_data'; static const String _keyClientId = 'client_identifier'; static const String _keySelectedLibraryIndex = 'selected_library_index'; + static const String _keySelectedLibraryKey = 'selected_library_key'; static const String _keyLibraryFilters = 'library_filters'; static const String _keyUserProfile = 'user_profile'; static const String _keyCurrentUserUUID = 'current_user_uuid'; static const String _keyHomeUsersCache = 'home_users_cache'; static const String _keyHomeUsersCacheExpiry = 'home_users_cache_expiry'; + static const String _keyHiddenLibraries = 'hidden_libraries'; static StorageService? _instance; late SharedPreferences _prefs; @@ -135,7 +137,7 @@ class StorageService { }; } - // Selected Library Index + // Selected Library Index (deprecated - use library key instead) Future saveSelectedLibraryIndex(int index) async { await _prefs.setInt(_keySelectedLibraryIndex, index); } @@ -144,6 +146,15 @@ class StorageService { return _prefs.getInt(_keySelectedLibraryIndex); } + // Selected Library Key (replaces index-based selection) + Future saveSelectedLibraryKey(String key) async { + await _prefs.setString(_keySelectedLibraryKey, key); + } + + String? getSelectedLibraryKey() { + return _prefs.getString(_keySelectedLibraryKey); + } + // Library Filters (stored as JSON string) Future saveLibraryFilters(Map filters) async { final jsonString = json.encode(filters); @@ -172,11 +183,31 @@ class StorageService { return _prefs.getString('library_sort_$sectionId') ?? 'titleSort'; } + // Hidden Libraries (stored as JSON array of library section IDs) + Future saveHiddenLibraries(Set libraryKeys) async { + final list = libraryKeys.toList(); + final jsonString = json.encode(list); + await _prefs.setString(_keyHiddenLibraries, jsonString); + } + + Set getHiddenLibraries() { + final jsonString = _prefs.getString(_keyHiddenLibraries); + if (jsonString == null) return {}; + + try { + final list = json.decode(jsonString) as List; + return list.map((e) => e.toString()).toSet(); + } catch (e) { + return {}; + } + } + // Clear library preferences Future clearLibraryPreferences() async { await Future.wait([ _prefs.remove(_keySelectedLibraryIndex), _prefs.remove(_keyLibraryFilters), + _prefs.remove(_keyHiddenLibraries), ]); // Also clear all library sort preferences diff --git a/lib/utils/provider_extensions.dart b/lib/utils/provider_extensions.dart index 3ebede88..973fd4c4 100644 --- a/lib/utils/provider_extensions.dart +++ b/lib/utils/provider_extensions.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/plex_client_provider.dart'; import '../providers/user_profile_provider.dart'; +import '../providers/hidden_libraries_provider.dart'; import '../client/plex_client.dart'; import '../models/plex_user_profile.dart'; @@ -18,6 +19,12 @@ extension ProviderExtensions on BuildContext { UserProfileProvider watchUserProfile() => Provider.of(this, listen: true); + HiddenLibrariesProvider get hiddenLibraries => + Provider.of(this, listen: false); + + HiddenLibrariesProvider watchHiddenLibraries() => + Provider.of(this, listen: true); + // Direct client access (nullable) PlexClient? get client => plexClient.client; diff --git a/lib/widgets/context_menu_wrapper.dart b/lib/widgets/context_menu_wrapper.dart new file mode 100644 index 00000000..255bcdbb --- /dev/null +++ b/lib/widgets/context_menu_wrapper.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import '../utils/platform_detector.dart'; + +/// A menu action item for context menus +class ContextMenuItem { + final String value; + final IconData icon; + final String label; + + const ContextMenuItem({ + required this.value, + required this.icon, + required this.label, + }); +} + +/// A wrapper widget that shows context menus differently based on platform. +/// On mobile (iOS/Android): Shows a bottom sheet on long-press +/// On desktop (Windows/macOS/Linux): Shows a popup menu on right-click or long-press +class ContextMenuWrapper extends StatefulWidget { + final Widget child; + final List menuItems; + final Function(String)? onMenuItemSelected; + final VoidCallback? onTap; + final String? title; + + const ContextMenuWrapper({ + super.key, + required this.child, + required this.menuItems, + this.onMenuItemSelected, + this.onTap, + this.title, + }); + + @override + State createState() => _ContextMenuWrapperState(); +} + +class _ContextMenuWrapperState extends State { + Offset _tapPosition = Offset.zero; + + void _storeTapPosition(TapDownDetails details) { + _tapPosition = details.globalPosition; + } + + Future _showContextMenu(BuildContext context) async { + final useBottomSheet = PlatformDetector.isMobile(context); + String? selected; + + if (useBottomSheet) { + // Mobile: Show bottom sheet + selected = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.title != null) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + widget.title!, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ...widget.menuItems.map( + (item) => ListTile( + leading: Icon(item.icon), + title: Text(item.label), + onTap: () => Navigator.pop(context, item.value), + ), + ), + ], + ), + ), + ); + } else { + // Desktop: Show popup menu + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + final overlayRect = Rect.fromPoints( + _tapPosition, + _tapPosition.translate(1, 1), + ); + + final menuItems = widget.menuItems + .map( + (item) => PopupMenuItem( + value: item.value, + child: Row( + children: [ + Icon(item.icon, size: 20), + const SizedBox(width: 12), + Expanded(child: Text(item.label)), + ], + ), + ), + ) + .toList(); + + selected = await showMenu( + context: context, + position: RelativeRect.fromRect( + overlayRect, + Offset.zero & overlay.size, + ), + items: menuItems, + elevation: 8, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + popUpAnimationStyle: AnimationStyle( + duration: const Duration(milliseconds: 150), + reverseDuration: const Duration(milliseconds: 100), + ), + ); + } + + if (selected != null && widget.onMenuItemSelected != null) { + widget.onMenuItemSelected!(selected); + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: widget.onTap, + onTapDown: _storeTapPosition, + onLongPress: () => _showContextMenu(context), + onSecondaryTapDown: _storeTapPosition, + onSecondaryTap: () => _showContextMenu(context), + child: widget.child, + ); + } +}