diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index dededb28..a0b208f0 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -7,6 +7,7 @@ import '../models/plex_file_info.dart'; import '../models/plex_filter.dart'; import '../models/plex_sort.dart'; import '../models/plex_media_version.dart'; +import '../models/plex_hub.dart'; import '../utils/app_logger.dart'; /// Result of testing a connection, including success status and latency @@ -862,4 +863,86 @@ class PlexClient { return null; } + + /// Get library hubs (recommendations for a specific library section) + /// Returns a list of recommendation hubs like "Trending Movies", "Top in Genre", etc. + Future> getLibraryHubs( + String sectionId, { + int limit = 10, + }) async { + try { + final response = await _dio.get( + '/hubs/sections/$sectionId', + queryParameters: {'count': limit, 'includeGuids': 1}, + ); + + final container = _getMediaContainer(response); + if (container != null && container['Hub'] != null) { + final hubs = []; + for (final hubJson in container['Hub'] as List) { + try { + final hub = PlexHub.fromJson(hubJson); + // Only include hubs that have items and are movie/show content + if (hub.items.isNotEmpty) { + // Filter out non-video content types + final videoItems = hub.items.where((item) { + final type = item.type.toLowerCase(); + return type == 'movie' || type == 'show'; + }).toList(); + + if (videoItems.isNotEmpty) { + hubs.add( + PlexHub( + hubKey: hub.hubKey, + title: hub.title, + type: hub.type, + hubIdentifier: hub.hubIdentifier, + size: hub.size, + more: hub.more, + items: videoItems, + ), + ); + } + } + } catch (e) { + appLogger.w('Failed to parse hub', error: e); + } + } + return hubs; + } + } catch (e) { + appLogger.e('Failed to get library hubs: $e'); + } + return []; + } + + /// Get full content from a hub using its hub key + /// Returns the complete list of metadata items in the hub + Future> getHubContent(String hubKey) async { + try { + final response = await _dio.get(hubKey); + final allItems = _extractMetadataList(response); + + // Filter out non-video content types + return allItems.where((item) { + final type = item.type.toLowerCase(); + return type == 'movie' || type == 'show'; + }).toList(); + } catch (e) { + appLogger.e('Failed to get hub content: $e'); + return []; + } + } + + /// Get playlist content by playlist ID + /// Returns the list of metadata items in the playlist + Future> getPlaylist(String playlistId) async { + try { + final response = await _dio.get('/playlists/$playlistId/items'); + return _extractMetadataList(response); + } catch (e) { + appLogger.e('Failed to get playlist: $e'); + return []; + } + } } diff --git a/lib/models/plex_hub.dart b/lib/models/plex_hub.dart new file mode 100644 index 00000000..e568718f --- /dev/null +++ b/lib/models/plex_hub.dart @@ -0,0 +1,57 @@ +import 'plex_metadata.dart'; + +/// Represents a Plex hub/recommendation section (e.g., Trending Movies, Top Thrillers) +class PlexHub { + final String hubKey; + final String title; + final String type; + final String? hubIdentifier; + final int size; + final bool more; + final List items; + + PlexHub({ + required this.hubKey, + required this.title, + required this.type, + this.hubIdentifier, + required this.size, + required this.more, + required this.items, + }); + + factory PlexHub.fromJson(Map json) { + final metadataList = []; + + // Hubs can contain either Metadata or Directory entries + if (json['Metadata'] != null) { + for (final item in json['Metadata'] as List) { + try { + metadataList.add(PlexMetadata.fromJson(item)); + } catch (e) { + // Skip items that fail to parse + } + } + } + + if (json['Directory'] != null) { + for (final item in json['Directory'] as List) { + try { + metadataList.add(PlexMetadata.fromJson(item)); + } catch (e) { + // Skip items that fail to parse + } + } + } + + return PlexHub( + hubKey: json['key'] as String? ?? '', + title: json['title'] as String? ?? 'Unknown', + type: json['type'] as String? ?? 'hub', + hubIdentifier: json['hubIdentifier'] as String?, + size: (json['size'] as num?)?.toInt() ?? metadataList.length, + more: json['more'] == true || json['more'] == 1, + items: metadataList, + ); + } +} diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 4324d97c..9cd7f601 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:provider/provider.dart'; import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; +import '../models/plex_hub.dart'; import '../providers/plex_client_provider.dart'; import '../services/storage_service.dart'; import '../services/plex_auth_service.dart'; @@ -13,6 +14,7 @@ import '../widgets/user_avatar_widget.dart'; import '../widgets/horizontal_scroll_with_arrows.dart'; import 'profile_switch_screen.dart'; import 'server_selection_screen.dart'; +import 'hub_detail_screen.dart'; import '../providers/user_profile_provider.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; @@ -40,6 +42,7 @@ class _DiscoverScreenState extends State List _onDeck = []; List _recentlyAdded = []; + List _hubs = []; bool _isLoading = true; String? _errorMessage; final PageController _heroController = PageController(); @@ -74,8 +77,11 @@ class _DiscoverScreenState extends State _indicatorAnimationController.forward(from: 0.0); _autoScrollTimer = Timer.periodic(_heroAutoScrollDuration, (timer) { - if (_onDeck.isEmpty || !_heroController.hasClients || _isAutoScrollPaused) + if (_onDeck.isEmpty || + !_heroController.hasClients || + _isAutoScrollPaused) { return; + } // Validate current index is within bounds before calculating next page if (_currentHeroIndex >= _onDeck.length) { @@ -170,12 +176,47 @@ class _DiscoverScreenState extends State final onDeck = await client.getOnDeck(); final recentlyAdded = await client.getRecentlyAdded(limit: 20); + // Load hubs from all libraries + final libraries = await client.getLibraries(); + final allHubs = []; + + for (final library in libraries) { + // Only fetch hubs for movie and show libraries + if (library.type == 'movie' || library.type == 'show') { + try { + final libraryHubs = await client.getLibraryHubs( + library.key, + limit: 12, + ); + // Filter out duplicate hubs that we already fetch separately + final filteredHubs = libraryHubs.where((hub) { + final hubId = hub.hubIdentifier?.toLowerCase() ?? ''; + final title = hub.title.toLowerCase(); + // Skip "Continue Watching", "On Deck", and "Recently Added" hubs + return !hubId.contains('ondeck') && + !hubId.contains('continue') && + !hubId.contains('recentlyadded') && + !title.contains('continue watching') && + !title.contains('on deck') && + !title.contains('recently added'); + }).toList(); + allHubs.addAll(filteredHubs); + } catch (e) { + appLogger.w( + 'Failed to load hubs for library ${library.title}', + error: e, + ); + } + } + } + appLogger.d( - 'Received ${onDeck.length} on deck items and ${recentlyAdded.length} recently added items', + 'Received ${onDeck.length} on deck items, ${recentlyAdded.length} recently added items, and ${allHubs.length} hubs', ); setState(() { _onDeck = onDeck; _recentlyAdded = recentlyAdded; + _hubs = allHubs; _isLoading = false; // Reset hero index to avoid sync issues @@ -204,6 +245,106 @@ class _DiscoverScreenState extends State _loadContent(); } + /// Get icon for hub based on its title + IconData _getHubIcon(String title) { + final lowerTitle = title.toLowerCase(); + + // Trending/Popular content + if (lowerTitle.contains('trending')) { + return Icons.trending_up; + } + if (lowerTitle.contains('popular') || lowerTitle.contains('imdb')) { + return Icons.whatshot; + } + + // Seasonal/Time-based + if (lowerTitle.contains('seasonal')) { + return Icons.calendar_month; + } + if (lowerTitle.contains('newly') || lowerTitle.contains('new release')) { + return Icons.new_releases; + } + if (lowerTitle.contains('recently released') || + lowerTitle.contains('recent')) { + return Icons.schedule; + } + + // Top/Rated content + if (lowerTitle.contains('top rated') || + lowerTitle.contains('highest rated')) { + return Icons.star; + } + if (lowerTitle.contains('top ')) { + return Icons.military_tech; + } + + // Genre-specific + if (lowerTitle.contains('thriller')) { + return Icons.warning_amber_rounded; + } + if (lowerTitle.contains('comedy') || lowerTitle.contains('comedier')) { + return Icons.mood; + } + if (lowerTitle.contains('action')) { + return Icons.flash_on; + } + if (lowerTitle.contains('drama')) { + return Icons.theater_comedy; + } + if (lowerTitle.contains('fantasy')) { + return Icons.auto_fix_high; + } + if (lowerTitle.contains('science') || lowerTitle.contains('sci-fi')) { + return Icons.rocket_launch; + } + if (lowerTitle.contains('horror') || lowerTitle.contains('skräck')) { + return Icons.nights_stay; + } + if (lowerTitle.contains('romance') || lowerTitle.contains('romantic')) { + return Icons.favorite_border; + } + if (lowerTitle.contains('adventure') || lowerTitle.contains('äventyr')) { + return Icons.explore; + } + + // Watchlist/Playlists + if (lowerTitle.contains('playlist') || lowerTitle.contains('watchlist')) { + return Icons.playlist_play; + } + if (lowerTitle.contains('unwatched') || lowerTitle.contains('unplayed')) { + return Icons.visibility_off; + } + if (lowerTitle.contains('watched') || lowerTitle.contains('played')) { + return Icons.visibility; + } + + // Network/Studio + if (lowerTitle.contains('network') || lowerTitle.contains('more from')) { + return Icons.tv; + } + + // Actor/Director + if (lowerTitle.contains('actor') || lowerTitle.contains('director')) { + return Icons.person; + } + + // Year-based (80s, 90s, etc.) + if (lowerTitle.contains('80') || + lowerTitle.contains('90') || + lowerTitle.contains('00')) { + return Icons.history; + } + + // Rediscover/Start Watching + if (lowerTitle.contains('rediscover') || + lowerTitle.contains('start watching')) { + return Icons.play_arrow; + } + + // Default icon for other hubs + return Icons.auto_awesome; + } + @override void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { // Check and update in _onDeck list @@ -460,7 +601,46 @@ class _DiscoverScreenState extends State _buildHorizontalList(_recentlyAdded, isLarge: false), ], - if (_onDeck.isEmpty && _recentlyAdded.isEmpty) + // Recommendation Hubs (Trending, Top in Genre, etc.) + for (final hub in _hubs) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => HubDetailScreen(hub: hub), + ), + ); + }, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Row( + children: [ + Icon(_getHubIcon(hub.title)), + const SizedBox(width: 8), + Text( + hub.title, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 20), + ], + ), + ), + ), + ), + ), + _buildHorizontalList(hub.items, isLarge: false), + ], + + if (_onDeck.isEmpty && _recentlyAdded.isEmpty && _hubs.isEmpty) const SliverFillRemaining( child: Center( child: Column( @@ -543,63 +723,70 @@ class _DiscoverScreenState extends State // Page indicators (limited to 5 dots) ...() { final range = _getVisibleDotRange(); - return List.generate( - range.end - range.start + 1, - (i) { - final index = range.start + i; - final isActive = _currentHeroIndex == index; - final dotSize = _getDotSize(index, range.start, range.end); + return List.generate(range.end - range.start + 1, (i) { + final index = range.start + i; + final isActive = _currentHeroIndex == index; + final dotSize = _getDotSize( + index, + range.start, + range.end, + ); - if (isActive) { - // Animated progress indicator for active page - return AnimatedBuilder( - animation: _indicatorAnimationController, - builder: (context, child) { - // Fill width animates based on dot size - final maxWidth = dotSize * 3; // 24px for normal, 15px for small - final fillWidth = - dotSize + - ((maxWidth - dotSize) * _indicatorAnimationController.value); - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - margin: const EdgeInsets.symmetric(horizontal: 4), - width: maxWidth, - height: dotSize, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(dotSize / 2), + if (isActive) { + // Animated progress indicator for active page + return AnimatedBuilder( + animation: _indicatorAnimationController, + builder: (context, child) { + // Fill width animates based on dot size + final maxWidth = + dotSize * 3; // 24px for normal, 15px for small + final fillWidth = + dotSize + + ((maxWidth - dotSize) * + _indicatorAnimationController.value); + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + margin: const EdgeInsets.symmetric(horizontal: 4), + width: maxWidth, + height: dotSize, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular( + dotSize / 2, ), - child: Align( - alignment: Alignment.centerLeft, - child: Container( - width: fillWidth, - height: dotSize, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(dotSize / 2), + ), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: fillWidth, + height: dotSize, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular( + dotSize / 2, ), ), ), - ); - }, - ); - } else { - // Static indicator for inactive pages - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - margin: const EdgeInsets.symmetric(horizontal: 4), - width: dotSize, - height: dotSize, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(dotSize / 2), - ), - ); - } - }, - ); + ), + ); + }, + ); + } else { + // Static indicator for inactive pages + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + margin: const EdgeInsets.symmetric(horizontal: 4), + width: dotSize, + height: dotSize, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(dotSize / 2), + ), + ); + } + }); }(), ], ), diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart new file mode 100644 index 00000000..3b539b5b --- /dev/null +++ b/lib/screens/hub_detail_screen.dart @@ -0,0 +1,510 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; +import '../models/plex_hub.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_sort.dart'; +import '../providers/settings_provider.dart'; +import '../services/settings_service.dart'; +import '../utils/provider_extensions.dart'; +import '../utils/app_logger.dart'; +import '../widgets/media_card.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../mixins/refreshable.dart'; + +/// Screen to display full content of a recommendation hub +class HubDetailScreen extends StatefulWidget { + final PlexHub hub; + + const HubDetailScreen({super.key, required this.hub}); + + @override + State createState() => _HubDetailScreenState(); +} + +class _HubDetailScreenState extends State with Refreshable { + PlexClient get client => context.clientSafe; + + List _items = []; + List _filteredItems = []; + List _sortOptions = []; + PlexSort? _selectedSort; + bool _isSortDescending = false; + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + // Start with items already loaded in the hub + _items = widget.hub.items; + _filteredItems = widget.hub.items; + // Load more items if available + if (widget.hub.more) { + _loadMoreItems(); + } + // Load sorts based on the library type + _loadSorts(); + } + + Future _loadSorts() async { + try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + + // Get the library key from the hub key + // Hub keys can have various formats: + // - /hubs/sections/1/... + // - /library/sections/1/all?... + final hubKey = widget.hub.hubKey; + appLogger.d('Hub key: $hubKey'); + + RegExpMatch? match; + + // Try different patterns + match = RegExp(r'/hubs/sections/(\d+)').firstMatch(hubKey); + if (match == null) { + match = RegExp(r'/library/sections/(\d+)').firstMatch(hubKey); + } + if (match == null) { + match = RegExp(r'sections/(\d+)').firstMatch(hubKey); + } + + if (match != null) { + final sectionId = match.group(1)!; + appLogger.d('Loading sorts for section: $sectionId'); + + // Load sorts for this library + final sorts = await client.getLibrarySorts(sectionId); + + appLogger.d('Loaded ${sorts.length} sorts'); + + setState(() { + _sortOptions = sorts.isNotEmpty ? sorts : _getDefaultSortOptions(); + // Don't set a default sort - let items stay in original order + }); + } else { + appLogger.w('Could not extract section ID from hub key: $hubKey'); + // Provide default sort options even if we can't get library-specific ones + setState(() { + _sortOptions = _getDefaultSortOptions(); + // Don't set a default sort - let items stay in original order + }); + } + } catch (e) { + appLogger.e('Failed to load sorts', error: e); + // Provide default sort options on error + setState(() { + _sortOptions = _getDefaultSortOptions(); + // Don't set a default sort - let items stay in original order + }); + } + } + + List _getDefaultSortOptions() { + return [ + PlexSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'), + PlexSort( + key: 'year', + descKey: 'year:desc', + title: 'Release Year', + defaultDirection: 'desc', + ), + PlexSort( + key: 'addedAt', + descKey: 'addedAt:desc', + title: 'Date Added', + defaultDirection: 'desc', + ), + PlexSort( + key: 'rating', + descKey: 'rating:desc', + title: 'Rating', + defaultDirection: 'desc', + ), + ]; + } + + void _applySort() { + setState(() { + _filteredItems = List.from(_items); + + // Apply sorting + if (_selectedSort != null) { + final sortKey = _selectedSort!.key; + _filteredItems.sort((a, b) { + int comparison = 0; + + switch (sortKey) { + case 'titleSort': + case 'title': + comparison = a.title.compareTo(b.title); + break; + case 'addedAt': + comparison = (a.addedAt ?? 0).compareTo(b.addedAt ?? 0); + break; + case 'originallyAvailableAt': + case 'year': + comparison = (a.year ?? 0).compareTo(b.year ?? 0); + break; + case 'rating': + comparison = (a.rating ?? 0).compareTo(b.rating ?? 0); + break; + default: + comparison = a.title.compareTo(b.title); + } + + return _isSortDescending ? -comparison : comparison; + }); + } + }); + } + + void _showSortBottomSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _SortBottomSheet( + sortOptions: _sortOptions, + selectedSort: _selectedSort, + isSortDescending: _isSortDescending, + onSortChanged: (sort, descending) { + setState(() { + _selectedSort = sort; + _isSortDescending = descending; + }); + _applySort(); + }, + onClear: () { + setState(() { + // Reset to no sorting (original order) + _selectedSort = null; + _isSortDescending = false; + }); + _applySort(); + }, + ), + ); + } + + Future _loadMoreItems() async { + if (_isLoading) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) { + throw Exception('No client available'); + } + + // Fetch more items from the hub using the hubKey + final response = await client.getHubContent(widget.hub.hubKey); + + setState(() { + _items = response; + _filteredItems = response; + _isLoading = false; + }); + + // Apply any existing sort + _applySort(); + + appLogger.d( + 'Loaded ${response.length} items for hub: ${widget.hub.title}', + ); + } catch (e) { + appLogger.e('Failed to load hub content', error: e); + setState(() { + _errorMessage = 'Failed to load content: $e'; + _isLoading = false; + }); + } + } + + void _handleItemRefresh(String ratingKey) { + // Refresh the specific item in the list + setState(() { + final index = _items.indexWhere((item) => item.ratingKey == ratingKey); + if (index != -1) { + // The item will be refreshed by the MediaCard itself + appLogger.d('Item refresh requested for: $ratingKey'); + } + }); + } + + @override + void refresh() { + _loadMoreItems(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + DesktopSliverAppBar( + title: Text(widget.hub.title), + floating: true, + pinned: true, + actions: [ + IconButton( + icon: const Icon(Icons.swap_vert, semanticLabel: 'Sort'), + onPressed: _showSortBottomSheet, + ), + ], + ), + if (_errorMessage != null) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadMoreItems, + child: const Text('Retry'), + ), + ], + ), + ), + ) + else if (_filteredItems.isEmpty && _isLoading) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_filteredItems.isEmpty) + const SliverFillRemaining( + child: Center(child: Text('No items found')), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _getMaxCrossAxisExtent( + context, + context.watch().libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + delegate: SliverChildBuilderDelegate((context, index) { + return MediaCard( + item: _filteredItems[index], + onRefresh: _handleItemRefresh, + ); + }, childCount: _filteredItems.length), + ), + ), + ], + ), + ); + } + + double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { + final screenWidth = MediaQuery.of(context).size.width; + final padding = 16.0; // 8px left + 8px right + final availableWidth = screenWidth - padding; + + if (screenWidth >= 900) { + // Wide screens (desktop/large tablet landscape): Responsive division + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 6.5; + maxItemWidth = 280; + break; + case LibraryDensity.normal: + divisor = 8.0; + maxItemWidth = 200; + break; + case LibraryDensity.compact: + divisor = 10.0; + maxItemWidth = 160; + break; + } + + return (availableWidth / divisor).clamp(0, maxItemWidth); + } else if (screenWidth >= 600) { + // Medium screens (tablets): Fixed 4-5-6 items + int targetItemCount = switch (density) { + LibraryDensity.comfortable => 4, + LibraryDensity.normal => 5, + LibraryDensity.compact => 6, + }; + return availableWidth / targetItemCount; + } else { + // Small screens (phones): Fixed 2-3-4 items + int targetItemCount = switch (density) { + LibraryDensity.comfortable => 2, + LibraryDensity.normal => 3, + LibraryDensity.compact => 4, + }; + return availableWidth / targetItemCount; + } + } +} + +// Bottom sheet for sorting +class _SortBottomSheet extends StatefulWidget { + final List sortOptions; + final PlexSort? selectedSort; + final bool isSortDescending; + final Function(PlexSort, bool) onSortChanged; + final VoidCallback onClear; + + const _SortBottomSheet({ + required this.sortOptions, + required this.selectedSort, + required this.isSortDescending, + required this.onSortChanged, + required this.onClear, + }); + + @override + State<_SortBottomSheet> createState() => _SortBottomSheetState(); +} + +class _SortBottomSheetState extends State<_SortBottomSheet> { + late PlexSort? _currentSort; + late bool _currentDescending; + + @override + void initState() { + super.initState(); + _currentSort = widget.selectedSort; + _currentDescending = widget.isSortDescending; + } + + void _handleSortChange(PlexSort sort, bool descending) { + setState(() { + _currentSort = sort; + _currentDescending = descending; + }); + widget.onSortChanged(sort, descending); + } + + void _handleClear() { + setState(() { + _currentSort = null; + _currentDescending = false; + }); + widget.onClear(); + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.9, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Row( + children: [ + const Expanded( + child: Text( + 'Sort By', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + TextButton( + onPressed: _handleClear, + child: const Text('Clear'), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: widget.sortOptions.length, + itemBuilder: (context, index) { + final sort = widget.sortOptions[index]; + final isSelected = _currentSort?.key == sort.key; + + return ListTile( + title: Text(sort.title), + trailing: isSelected + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment( + value: false, + icon: Icon(Icons.arrow_upward, size: 16), + ), + ButtonSegment( + value: true, + icon: Icon(Icons.arrow_downward, size: 16), + ), + ], + selected: {_currentDescending}, + onSelectionChanged: (Set newSelection) { + _handleSortChange(sort, newSelection.first); + }, + ), + ], + ) + : null, + leading: Radio( + value: sort, + groupValue: _currentSort, + onChanged: (PlexSort? value) { + if (value != null) { + _handleSortChange( + value, + value.defaultDirection == 'desc', + ); + } + }, + ), + onTap: () { + _handleSortChange(sort, sort.defaultDirection == 'desc'); + }, + ); + }, + ), + ), + ], + ); + }, + ); + } +}