import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../services/plex_client.dart'; import '../services/play_queue_launcher.dart'; import '../models/plex_metadata.dart'; import '../models/plex_playlist.dart'; import '../utils/download_version_utils.dart'; import '../utils/download_utils.dart'; import '../utils/content_utils.dart'; import '../utils/global_key_utils.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/offline_mode_provider.dart'; import '../providers/offline_watch_provider.dart'; import '../providers/user_profile_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; import '../utils/library_refresh_notifier.dart'; import '../utils/snackbar_helper.dart'; import '../utils/dialogs.dart'; import '../utils/focus_utils.dart'; import '../services/external_player_service.dart'; import '../focus/focusable_button.dart'; import '../focus/dpad_navigator.dart'; import '../screens/media_detail_screen.dart'; import '../screens/metadata_edit_screen.dart'; import '../utils/smart_deletion_handler.dart'; import '../utils/video_player_navigation.dart'; import '../utils/deletion_notifier.dart'; import '../theme/mono_tokens.dart'; import '../widgets/file_info_bottom_sheet.dart'; import 'pill_input_decoration.dart'; import '../widgets/focusable_list_tile.dart'; import '../widgets/overlay_sheet.dart'; import '../widgets/rating_bottom_sheet.dart'; import '../i18n/strings.g.dart'; /// Helper class to store menu action data class _MenuAction { final String value; final IconData icon; final String label; final Color? hoverColor; final Color? foregroundColor; _MenuAction({required this.value, required this.icon, required this.label, this.hoverColor, this.foregroundColor}); } /// A reusable wrapper widget that adds a context menu (long press / right click) /// to any media item with appropriate actions based on the item type. class MediaContextMenu extends StatefulWidget { final dynamic item; // Can be PlexMetadata or PlexPlaylist final void Function(String ratingKey)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; final VoidCallback? onListRefresh; // For refreshing list after deletion final VoidCallback? onTap; final Widget child; final bool isInContinueWatching; final String? collectionId; // The collection ID if displaying within a collection const MediaContextMenu({ super.key, required this.item, this.onRefresh, this.onRemoveFromContinueWatching, this.onListRefresh, this.onTap, required this.child, this.isInContinueWatching = false, this.collectionId, }); @override State createState() => MediaContextMenuState(); } class MediaContextMenuState extends State { Offset? _tapPosition; bool _openedFromKeyboard = false; bool _isContextMenuOpen = false; bool get isContextMenuOpen => _isContextMenuOpen; /// Show the context menu programmatically. /// Used for keyboard/gamepad long-press activation. /// If [position] is null, the menu will appear at the center of this widget. void showContextMenu(BuildContext menuContext, {Offset? position}) { _openedFromKeyboard = position == null; if (position != null) { _tapPosition = position; } else { // Calculate center of the widget for keyboard activation final RenderBox? renderBox = context.findRenderObject() as RenderBox?; if (renderBox != null) { final size = renderBox.size; final topLeft = renderBox.localToGlobal(Offset.zero); _tapPosition = Offset(topLeft.dx + size.width / 2, topLeft.dy + size.height / 2); } } _showContextMenu(menuContext); } /// Get the serverId from the item (PlexMetadata or PlexPlaylist) String? get _itemServerId { if (widget.item is PlexMetadata) return (widget.item as PlexMetadata).serverId; if (widget.item is PlexPlaylist) return (widget.item as PlexPlaylist).serverId; return null; } /// Get the correct PlexClient for this item's server PlexClient _getClientForItem() => context.getClientWithFallback(_itemServerId); void _showContextMenu(BuildContext context) async { if (_isContextMenuOpen) return; _isContextMenuOpen = true; // Capture the currently focused node for restoration after menu closes final previousFocus = FocusManager.instance.primaryFocus; bool didNavigate = false; final isPlaylist = widget.item is PlexPlaylist; final metadata = isPlaylist ? null : widget.item as PlexMetadata; final mediaType = isPlaylist ? null : metadata!.mediaType; final isCollection = mediaType == PlexMediaType.collection; final isPartiallyWatched = !isPlaylist && metadata!.isPartiallyWatched; final hasActiveProgress = mediaType != null && (mediaType == PlexMediaType.movie || mediaType == PlexMediaType.episode) && metadata?.hasActiveProgress == true; // Check if we should use bottom sheet (on iOS and Android) final useBottomSheet = Platform.isIOS || Platform.isAndroid; // Check if user has admin privileges (server owned + admin or single user) final multiServerProvider = Provider.of(context, listen: false); final server = _itemServerId != null ? multiServerProvider.serverManager.getServer(_itemServerId!) : null; final currentUser = context.read().currentUser; final isAdmin = server?.owned == true && (currentUser == null || currentUser.admin); // Build menu actions final menuActions = <_MenuAction>[]; // Special actions for collections and playlists if (isCollection || isPlaylist) { // Play menuActions.add(_MenuAction(value: 'play', icon: Symbols.play_arrow_rounded, label: t.common.play)); // Shuffle menuActions.add(_MenuAction(value: 'shuffle', icon: Symbols.shuffle_rounded, label: t.mediaMenu.shufflePlay)); // Download + sync-rule management. Video playlists and any collection // qualify — collections can contain movies, episodes, and shows. final isVideoPlaylist = isPlaylist && (widget.item as PlexPlaylist).playlistType == 'video'; if (isVideoPlaylist || isCollection) { final hasRule = Provider.of(context, listen: false).hasSyncRule(_itemGlobalKey()); if (hasRule) { menuActions.add( _MenuAction(value: 'manage_sync', icon: Symbols.sync_rounded, label: t.downloads.manageSyncRule), ); menuActions.add( _MenuAction(value: 'remove_sync', icon: Symbols.sync_disabled_rounded, label: t.downloads.removeSyncRule), ); } else { menuActions.add( _MenuAction( value: isPlaylist ? 'download_playlist' : 'download_collection', icon: Symbols.download_rounded, label: t.downloads.downloadNow, ), ); } } // Delete menuActions.add(_MenuAction(value: 'delete', icon: Symbols.delete_rounded, label: t.common.delete)); // Skip other menu items for collections and playlists } else { // Regular menu items for other types // Play from Beginning (for movies and episodes with active progress) if (hasActiveProgress) { menuActions.add( _MenuAction(value: 'play_from_beginning', icon: Symbols.replay_rounded, label: t.mediaMenu.playFromBeginning), ); } // Mark as Watched if (!metadata!.isWatched || isPartiallyWatched || hasActiveProgress) { menuActions.add( _MenuAction(value: 'watch', icon: Symbols.check_circle_outline_rounded, label: t.mediaMenu.markAsWatched), ); } // Mark as Unwatched if (metadata.isWatched || isPartiallyWatched || hasActiveProgress) { menuActions.add( _MenuAction( value: 'unwatch', icon: Symbols.remove_circle_outline_rounded, label: t.mediaMenu.markAsUnwatched, ), ); } // Remove from Continue Watching (only in continue watching section) if (widget.isInContinueWatching) { menuActions.add( _MenuAction( value: 'remove_from_continue_watching', icon: Symbols.close_rounded, label: t.mediaMenu.removeFromContinueWatching, ), ); } // Rate (for movies, shows, seasons, and episodes) if (mediaType == PlexMediaType.movie || mediaType == PlexMediaType.show || mediaType == PlexMediaType.season || mediaType == PlexMediaType.episode) { menuActions.add(_MenuAction(value: 'rate', icon: Symbols.star_rounded, label: t.mediaMenu.rate)); } // Edit Metadata (for movies, shows, seasons, and episodes) — admin only if (isAdmin && (mediaType == PlexMediaType.movie || mediaType == PlexMediaType.show || mediaType == PlexMediaType.season || mediaType == PlexMediaType.episode)) { menuActions.add( _MenuAction(value: 'edit_metadata', icon: Symbols.edit_rounded, label: t.metadataEdit.editMetadata), ); } // Remove from Collection (only when viewing items within a collection) if (widget.collectionId != null) { menuActions.add( _MenuAction( value: 'remove_from_collection', icon: Symbols.delete_outline_rounded, label: t.collections.removeFromCollection, ), ); } // Go to Series (for episodes and seasons) — hide if already on that series' detail screen final ancestorMediaDetail = context.findAncestorWidgetOfExactType(); final ancestorMeta = ancestorMediaDetail?.metadata; final ancestorSeriesKey = ancestorMeta != null && ancestorMeta.isSeason ? ancestorMeta.parentRatingKey : ancestorMeta?.ratingKey; // For episodes, the show key is grandparentRatingKey; for seasons, it's parentRatingKey final itemSeriesKey = mediaType == PlexMediaType.episode ? metadata.grandparentRatingKey : metadata.parentRatingKey; if ((mediaType == PlexMediaType.episode || mediaType == PlexMediaType.season) && itemSeriesKey != null && ancestorSeriesKey != itemSeriesKey) { menuActions.add(_MenuAction(value: 'series', icon: Symbols.tv_rounded, label: t.mediaMenu.goToSeries)); } // Go to Season (for episodes) — hide if already viewing that season's MediaDetailScreen if (mediaType == PlexMediaType.episode && metadata.parentTitle != null && !(ancestorMeta != null && ancestorMeta.isSeason && ancestorMeta.ratingKey == metadata.parentRatingKey)) { menuActions.add( _MenuAction(value: 'season', icon: Symbols.playlist_play_rounded, label: t.mediaMenu.goToSeason), ); } // Shuffle Play (for shows and seasons) if (mediaType == PlexMediaType.show || mediaType == PlexMediaType.season) { menuActions.add( _MenuAction(value: 'shuffle_play', icon: Symbols.shuffle_rounded, label: t.mediaMenu.shufflePlay), ); } // Play Version (for episodes and movies with multiple versions) if ((mediaType == PlexMediaType.episode || mediaType == PlexMediaType.movie) && metadata.mediaVersions != null && metadata.mediaVersions!.length > 1) { menuActions.add( _MenuAction(value: 'play_version', icon: Symbols.video_file_rounded, label: t.mediaMenu.playVersion), ); } // File Info (for episodes and movies) if (mediaType == PlexMediaType.episode || mediaType == PlexMediaType.movie) { menuActions.add(_MenuAction(value: 'fileinfo', icon: Symbols.info_rounded, label: t.mediaMenu.fileInfo)); } // Play in External Player (for episodes and movies) if (mediaType == PlexMediaType.episode || mediaType == PlexMediaType.movie) { menuActions.add( _MenuAction( value: 'play_external', icon: Symbols.open_in_new_rounded, label: t.externalPlayer.playInExternalPlayer, ), ); } // Download options (for episodes, movies, shows, and seasons) if (mediaType == PlexMediaType.episode || mediaType == PlexMediaType.movie || mediaType == PlexMediaType.show || mediaType == PlexMediaType.season) { final downloadProvider = Provider.of(context, listen: false); final globalKey = metadata.globalKey; final isDownloaded = downloadProvider.isDownloaded(globalKey); final hasSyncRule = downloadProvider.hasSyncRule(globalKey); final hasAnyDownload = downloadProvider.getProgress(globalKey) != null; if (hasSyncRule) { // Synced item: manage sync + delete options menuActions.add( _MenuAction(value: 'manage_sync', icon: Symbols.sync_rounded, label: t.downloads.manageSyncRule), ); menuActions.add( _MenuAction(value: 'remove_sync', icon: Symbols.sync_disabled_rounded, label: t.downloads.removeSyncRule), ); if (hasAnyDownload) { menuActions.add( _MenuAction(value: 'delete_download', icon: Symbols.delete_rounded, label: t.downloads.deleteDownload), ); } } else if (isDownloaded) { // Show delete download option menuActions.add( _MenuAction(value: 'delete_download', icon: Symbols.delete_rounded, label: t.downloads.deleteDownload), ); } else { // Show download option menuActions.add( _MenuAction(value: 'download', icon: Symbols.download_rounded, label: t.downloads.downloadNow), ); } } // Add to... (for episodes, movies, shows, and seasons) if (mediaType == PlexMediaType.episode || mediaType == PlexMediaType.movie || mediaType == PlexMediaType.show || mediaType == PlexMediaType.season) { menuActions.add(_MenuAction(value: 'add_to', icon: Symbols.add_rounded, label: t.common.addTo)); } // Delete media item (for episodes, movies, shows, and seasons) — admin only if (isAdmin && (mediaType == PlexMediaType.episode || mediaType == PlexMediaType.movie || mediaType == PlexMediaType.show || mediaType == PlexMediaType.season)) { menuActions.add( _MenuAction( value: 'delete_media', icon: Symbols.delete_forever_rounded, label: t.mediaMenu.deleteFromServer, hoverColor: Theme.of(context).colorScheme.error, foregroundColor: Theme.of(context).colorScheme.error, ), ); } } // End of regular menu items else block String? selected; final openedFromKeyboard = _openedFromKeyboard; _openedFromKeyboard = false; if (useBottomSheet) { selected = await OverlaySheetController.showAdaptive( context, showDragHandle: true, builder: (context) => _FocusableContextMenuSheet( title: widget.item.displayTitle, actions: menuActions, focusFirstItem: openedFromKeyboard, ), ); } else { // Show custom focusable popup menu on larger screens // Use stored tap position or fallback to widget position final RenderBox? overlay = Overlay.of(context).context.findRenderObject() as RenderBox?; Offset position; if (_tapPosition != null) { position = _tapPosition!; } else { final RenderBox renderBox = context.findRenderObject() as RenderBox; position = renderBox.localToGlobal(Offset.zero, ancestor: overlay); } selected = await showDialog( context: context, barrierColor: Colors.transparent, builder: (dialogContext) => _FocusablePopupMenu(actions: menuActions, position: position, focusFirstItem: openedFromKeyboard), ); } try { if (!context.mounted) return; switch (selected) { case 'play_from_beginning': didNavigate = true; if (context.mounted) { await navigateToVideoPlayer(context, metadata: metadata!.copyWith(viewOffset: 0)); } break; case 'watch': final isOffline = context.read().isOffline; if (isOffline && metadata?.serverId != null) { // Offline mode: queue action for later sync (emits WatchStateEvent) final offlineWatch = context.read(); await offlineWatch.markAsWatched(serverId: metadata!.serverId!, ratingKey: metadata.ratingKey); if (context.mounted) { showAppSnackBar(context, t.messages.markedAsWatchedOffline); widget.onRefresh?.call(metadata.ratingKey); } } else { // Pass metadata to emit WatchStateEvent for cross-screen updates await _executeAction( context, () => _getClientForItem().markAsWatched(metadata!.ratingKey, metadata: metadata), t.messages.markedAsWatched, ); } break; case 'unwatch': final isOffline = context.read().isOffline; if (isOffline && metadata?.serverId != null) { // Offline mode: queue action for later sync (emits WatchStateEvent) final offlineWatch = context.read(); await offlineWatch.markAsUnwatched(serverId: metadata!.serverId!, ratingKey: metadata.ratingKey); if (context.mounted) { showAppSnackBar(context, t.messages.markedAsUnwatchedOffline); widget.onRefresh?.call(metadata.ratingKey); } } else { // Pass metadata to emit WatchStateEvent for cross-screen updates await _executeAction( context, () => _getClientForItem().markAsUnwatched(metadata!.ratingKey, metadata: metadata), t.messages.markedAsUnwatched, ); } break; case 'remove_from_continue_watching': // Remove from Continue Watching without affecting watch status or progress // This preserves the progression for partially watched items // and doesn't mark unwatched next episodes as watched try { final client = _getClientForItem(); await client.removeFromOnDeck(metadata!.ratingKey); if (context.mounted) { showSuccessSnackBar(context, t.messages.removedFromContinueWatching); // Use specific callback if provided, otherwise fallback to onRefresh if (widget.onRemoveFromContinueWatching != null) { widget.onRemoveFromContinueWatching!(); } else { widget.onRefresh?.call(metadata.ratingKey); } } } catch (e) { if (context.mounted) { showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } } break; case 'rate': if (context.mounted) { try { final client = _getClientForItem(); await _showRatingSheet(context, metadata!, client); } catch (e) { if (context.mounted) { showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } } } break; case 'edit_metadata': didNavigate = true; if (context.mounted) { await Navigator.push( context, MaterialPageRoute(builder: (context) => MetadataEditScreen(metadata: metadata!)), ); widget.onRefresh?.call(metadata!.ratingKey); } break; case 'remove_from_collection': await _handleRemoveFromCollection(context, metadata!); break; case 'series': didNavigate = true; await _navigateToRelated( context, metadata!.mediaType == PlexMediaType.season ? metadata.parentRatingKey : metadata.grandparentRatingKey, (metadata) => MediaDetailScreen(metadata: metadata), t.messages.errorLoadingSeries, ); break; case 'season': didNavigate = true; // Navigate to the show with the season tab pre-selected final seasonParentKey = metadata!.mediaType == PlexMediaType.episode ? metadata.grandparentRatingKey : metadata.parentRatingKey; final seasonIndex = metadata.parentIndex; await _navigateToRelated( context, seasonParentKey, (show) => MediaDetailScreen(metadata: show, initialSeasonIndex: seasonIndex), t.messages.errorLoadingSeason, ); break; case 'play_version': didNavigate = await _handlePlayVersion(context); break; case 'fileinfo': await _showFileInfo(context); break; case 'add_to': await _showAddToSubmenu(context); break; case 'shuffle_play': await _handleShufflePlayWithQueue(context); break; case 'play': await _handlePlay(context, isCollection, isPlaylist); break; case 'shuffle': await _handleShuffle(context, isCollection, isPlaylist); break; case 'delete': await _handleDelete(context, isCollection, isPlaylist); break; case 'play_external': await _handlePlayExternal(context); break; case 'download_playlist': await _handleDownloadPlaylist(context); break; case 'download_collection': await _handleDownloadCollection(context); break; case 'download': await _handleDownload(context); break; case 'delete_download': await _handleDeleteDownload(context); break; case 'manage_sync': await _handleManageSyncRule(context); break; case 'remove_sync': await _handleRemoveSyncRule(context); break; case 'delete_media': await _handleDeleteMediaItem(context, mediaType); break; } } finally { _isContextMenuOpen = false; // Restore focus to the previously focused item after the menu closes, // but only if no navigation occurred and the focus node is still valid if (!didNavigate && previousFocus != null && previousFocus.canRequestFocus) { WidgetsBinding.instance.addPostFrameCallback((_) { if (previousFocus.canRequestFocus) { previousFocus.requestFocus(); } }); } } } /// Execute an action with error handling and refresh Future _executeAction(BuildContext context, Future Function() action, String successMessage) async { try { await action(); if (context.mounted) { showSuccessSnackBar(context, successMessage); widget.onRefresh?.call(widget.item.ratingKey); } } catch (e) { if (context.mounted) { showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } } } /// Navigate to a related item (series or season) Future _navigateToRelated( BuildContext context, String? ratingKey, Widget Function(PlexMetadata) screenBuilder, String errorPrefix, ) async { if (ratingKey == null) return; final client = _getClientForItem(); try { final metadata = await client.getMetadataWithImages(ratingKey); if (metadata != null && context.mounted) { await Navigator.push(context, MaterialPageRoute(builder: (context) => screenBuilder(metadata))); widget.onRefresh?.call(widget.item.ratingKey); } } catch (e) { if (context.mounted) { showErrorSnackBar(context, '$errorPrefix: $e'); } } } /// Show file info bottom sheet Future _showFileInfo(BuildContext context) async { final client = _getClientForItem(); try { // Show loading indicator if (context.mounted) { showDialog( context: context, barrierDismissible: false, builder: (context) => const Center(child: CircularProgressIndicator()), ); } // Fetch file info final metadata = widget.item as PlexMetadata; final fileInfo = await client.getFileInfo(metadata.ratingKey); // Close loading indicator if (context.mounted) { Navigator.pop(context); } if (fileInfo != null && context.mounted) { // Show file info bottom sheet await OverlaySheetController.showAdaptive( context, isScrollControlled: true, builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.displayTitle), ); } else if (context.mounted) { showErrorSnackBar(context, t.messages.fileInfoNotAvailable); } } catch (e) { // Close loading indicator if it's still open if (context.mounted && Navigator.canPop(context)) { Navigator.pop(context); } if (context.mounted) { showErrorSnackBar(context, t.messages.errorLoadingFileInfo(error: e.toString())); } } } /// Handle play version selection Future _handlePlayVersion(BuildContext context) async { final metadata = widget.item as PlexMetadata; final versions = metadata.mediaVersions!; final selectedIndex = await showVersionPickerDialog(context, versions, t.mediaMenu.playVersion); if (selectedIndex != null && context.mounted) { await navigateToVideoPlayer(context, metadata: metadata, selectedMediaIndex: selectedIndex); return true; } return false; } /// Handle shuffle play using play queues Future _handleShufflePlayWithQueue(BuildContext context) async { final client = _getClientForItem(); final metadata = widget.item as PlexMetadata; final launcher = PlayQueueLauncher( context: context, client: client, serverId: metadata.serverId, serverName: metadata.serverName, ); await launcher.launchShuffledShow(metadata: metadata, showLoadingIndicator: true); } /// Show submenu for Add to... (Playlist or Collection) Future _showAddToSubmenu(BuildContext context) async { final selected = await showOptionPickerDialog( context, title: t.common.addTo, options: [ (icon: Symbols.playlist_play_rounded, label: t.playlists.playlist, value: 'playlist'), (icon: Symbols.collections_rounded, label: t.collections.collection, value: 'collection'), ], ); // Handle the submenu selection if (selected == 'playlist' && context.mounted) { await _showAddToPlaylistDialog(context); } else if (selected == 'collection' && context.mounted) { await _showAddToCollectionDialog(context); } } /// Show dialog to select playlist and add item Future _showAddToPlaylistDialog(BuildContext context) async { final client = _getClientForItem(); try { final metadata = widget.item as PlexMetadata; final itemType = metadata.mediaType.name; // Load playlists final playlists = await client.getPlaylists(playlistType: 'video'); if (!context.mounted) return; // Show dialog to select playlist or create new final result = await showDialog( context: context, builder: (context) => _PlaylistSelectionDialog(playlists: playlists), ); if (result == null || !context.mounted) return; // Build URI for the item (works for all types: movies, episodes, seasons, shows) // For seasons/shows, the Plex API should automatically expand to include all episodes final itemUri = await client.buildMetadataUri(metadata.ratingKey); appLogger.d('Built URI for $itemType: $itemUri'); if (!context.mounted) return; if (result == '_create_new') { // Create new playlist flow final playlistName = await showTextInputDialog( context, title: t.playlists.create, labelText: t.playlists.playlistName, hintText: t.playlists.enterPlaylistName, ); if (playlistName == null || playlistName.isEmpty || !context.mounted) { return; } // Create playlist with the item(s) appLogger.d('Creating playlist "$playlistName" with URI length: ${itemUri.length}'); final newPlaylist = await client.createPlaylist(title: playlistName, uri: itemUri); if (!context.mounted) return; if (context.mounted) { if (newPlaylist != null) { appLogger.d('Successfully created playlist: ${newPlaylist.title}'); showSuccessSnackBar(context, t.playlists.created); // Trigger refresh of playlists tab LibraryRefreshNotifier().notifyPlaylistsChanged(); } else { appLogger.e('Failed to create playlist - API returned null'); showErrorSnackBar(context, t.playlists.errorCreating); } } } else { // Add to existing playlist appLogger.d('Adding to playlist $result with URI: $itemUri'); final success = await client.addToPlaylist(playlistId: result, uri: itemUri); if (!context.mounted) return; if (context.mounted) { if (success) { appLogger.d('Successfully added item(s) to playlist $result'); showSuccessSnackBar(context, t.playlists.itemAdded); // Trigger refresh of playlists tab LibraryRefreshNotifier().notifyPlaylistsChanged(); _triggerEagerSyncIfRuleExists(context, client.serverId, result); } else { appLogger.e('Failed to add item(s) to playlist $result - API returned false'); showErrorSnackBar(context, t.playlists.errorAdding); } } } } catch (e, stackTrace) { appLogger.e('Error in add to playlist flow', error: e, stackTrace: stackTrace); if (context.mounted) { showErrorSnackBar(context, '${t.playlists.errorLoading}: ${e.toString()}'); } } } /// Show dialog to select collection and add item Future _showAddToCollectionDialog(BuildContext context) async { final client = _getClientForItem(); try { final metadata = widget.item as PlexMetadata; final itemType = metadata.mediaType; // Get the library section ID from the item // First try from the metadata itself int? sectionId = metadata.librarySectionID; appLogger.d('Attempting to get section ID for ${metadata.title}'); appLogger.d(' - librarySectionID: $sectionId'); appLogger.d(' - key: ${metadata.key}'); // If not available, fetch the full metadata which should include the section ID if (sectionId == null) { try { appLogger.d(' - Fetching full metadata for: ${metadata.ratingKey}'); final fullMetadata = await client.getMetadataWithImages(metadata.ratingKey); if (fullMetadata != null) { sectionId = fullMetadata.librarySectionID; appLogger.d(' - Section ID from full metadata: $sectionId'); } } catch (e) { appLogger.w('Failed to get full metadata for section ID: $e'); } } // If still not found, try to extract from the key field if (sectionId == null && metadata.key != null) { final keyMatch = RegExp(r'/library/sections/(\d+)').firstMatch(metadata.key!); if (keyMatch != null) { sectionId = int.tryParse(keyMatch.group(1)!); appLogger.d(' - Extracted from key: $sectionId'); } } // Last resort: try to get it from the item's parent (for episodes/seasons) if (sectionId == null && metadata.grandparentRatingKey != null) { try { appLogger.d(' - Trying to get from parent: ${metadata.grandparentRatingKey}'); final parentMeta = await client.getMetadataWithImages(metadata.grandparentRatingKey!); sectionId = parentMeta?.librarySectionID; appLogger.d(' - Parent sectionId: $sectionId'); } catch (e) { appLogger.w('Failed to get parent metadata for section ID: $e'); } } appLogger.d(' - Final sectionId: $sectionId'); if (sectionId == null) { if (context.mounted) { showErrorSnackBar(context, t.messages.unableToDetermineLibrarySection); } return; } // Load collections for this library section final collections = await client.getLibraryCollections(sectionId.toString()); if (!context.mounted) return; // Show dialog to select collection or create new final result = await showDialog( context: context, builder: (context) => _CollectionSelectionDialog(collections: collections), ); if (result == null || !context.mounted) return; // Build URI for the item final itemUri = await client.buildMetadataUri(metadata.ratingKey); appLogger.d('Built URI for $itemType: $itemUri'); if (!context.mounted) return; if (result == '_create_new') { // Create new collection flow final collectionName = await showTextInputDialog( context, title: t.common.createNew, labelText: t.collections.collectionName, hintText: t.collections.enterCollectionName, ); if (collectionName == null || collectionName.isEmpty || !context.mounted) { return; } // Create collection first (without items) // Determine the collection type based on the item type int? collectionType; switch (itemType) { case PlexMediaType.movie: collectionType = 1; break; case PlexMediaType.show: collectionType = 2; break; case PlexMediaType.season: collectionType = 3; break; case PlexMediaType.episode: collectionType = 4; break; default: break; } appLogger.d('Creating collection "$collectionName" with type $collectionType'); final newCollectionId = await client.createCollection( sectionId: sectionId.toString(), title: collectionName, uri: '', // Empty for regular collections type: collectionType, ); if (!context.mounted) return; if (context.mounted) { if (newCollectionId != null) { appLogger.d('Successfully created collection with ID: $newCollectionId'); // Now add the item to the newly created collection appLogger.d('Adding item to new collection $newCollectionId with URI: $itemUri'); final addSuccess = await client.addToCollection(collectionId: newCollectionId, uri: itemUri); if (!context.mounted) return; if (addSuccess) { appLogger.d('Successfully added item to new collection'); showSuccessSnackBar(context, t.collections.created); // Trigger refresh of collections tab LibraryRefreshNotifier().notifyCollectionsChanged(); _triggerEagerSyncIfRuleExists(context, client.serverId, newCollectionId); } else { appLogger.e('Failed to add item to new collection'); showErrorSnackBar(context, t.collections.errorAddingToCollection); } } else { appLogger.e('Failed to create collection - API returned null'); showErrorSnackBar(context, t.collections.errorAddingToCollection); } } } else { // Add to existing collection appLogger.d('Adding to collection $result with URI: $itemUri'); final success = await client.addToCollection(collectionId: result, uri: itemUri); if (!context.mounted) return; if (context.mounted) { if (success) { appLogger.d('Successfully added item(s) to collection $result'); showSuccessSnackBar(context, t.collections.addedToCollection); // Trigger refresh of collections tab LibraryRefreshNotifier().notifyCollectionsChanged(); _triggerEagerSyncIfRuleExists(context, client.serverId, result); } else { appLogger.e('Failed to add item(s) to collection $result - API returned false'); showErrorSnackBar(context, t.collections.errorAddingToCollection); } } } } catch (e, stackTrace) { appLogger.e('Error in add to collection flow', error: e, stackTrace: stackTrace); if (context.mounted) { showErrorSnackBar(context, '${t.collections.errorAddingToCollection}: ${e.toString()}'); } } } /// Handle remove from collection action Future _showRatingSheet(BuildContext context, PlexMetadata metadata, PlexClient client) async { final currentStarValue = (metadata.userRating != null && metadata.userRating! > 0) ? metadata.userRating! / 2.0 : 0.0; await OverlaySheetController.showAdaptive( context, showDragHandle: true, builder: (context) => RatingBottomSheet( currentRating: currentStarValue, onRate: (stars) async { final plexRating = stars * 2.0; final success = await client.rateItem(metadata.ratingKey, plexRating); if (success) widget.onRefresh?.call(metadata.ratingKey); }, onClear: () async { final success = await client.rateItem(metadata.ratingKey, -1); if (success) widget.onRefresh?.call(metadata.ratingKey); }, ), ); } Future _handleRemoveFromCollection(BuildContext context, PlexMetadata metadata) async { final client = _getClientForItem(); if (widget.collectionId == null) { appLogger.e('Cannot remove from collection: collectionId is null'); return; } // Show confirmation dialog final confirmed = await showDeleteConfirmation( context, title: t.collections.removeFromCollection, message: t.collections.removeFromCollectionConfirm(title: metadata.displayTitle), ); if (!confirmed || !context.mounted) return; try { appLogger.d('Removing item ${metadata.ratingKey} from collection ${widget.collectionId}'); final success = await client.removeFromCollection(collectionId: widget.collectionId!, itemId: metadata.ratingKey); if (context.mounted) { if (success) { showSuccessSnackBar(context, t.collections.removedFromCollection); // Trigger refresh of collections tab LibraryRefreshNotifier().notifyCollectionsChanged(); // Trigger list refresh to remove the item from the view widget.onListRefresh?.call(); } else { showErrorSnackBar(context, t.collections.removeFromCollectionFailed); } } } catch (e) { appLogger.e('Failed to remove from collection', error: e); if (context.mounted) { showErrorSnackBar(context, t.collections.removeFromCollectionError(error: e.toString())); } } } /// Handle play action for collections and playlists Future _handlePlay(BuildContext context, bool _, bool _) async { await _launchCollectionOrPlaylist(context, shuffle: false); } /// Handle shuffle action for collections and playlists Future _handleShuffle(BuildContext context, bool _, bool _) async { await _launchCollectionOrPlaylist(context, shuffle: true); } /// Launch playback for collection or playlist Future _launchCollectionOrPlaylist(BuildContext context, {required bool shuffle}) async { final client = _getClientForItem(); final item = widget.item; final launcher = PlayQueueLauncher( context: context, client: client, serverId: item is PlexMetadata ? item.serverId : (item as PlexPlaylist).serverId, serverName: item is PlexMetadata ? item.serverName : (item as PlexPlaylist).serverName, ); await launcher.launchFromCollectionOrPlaylist(item: item, shuffle: shuffle, showLoadingIndicator: false); } /// Handle delete action for collections and playlists Future _handleDelete(BuildContext context, bool isCollection, bool isPlaylist) async { final client = _getClientForItem(); final itemTitle = widget.item.displayTitle; final itemTypeLabel = isCollection ? t.collections.collection : t.playlists.playlist; // Show confirmation dialog final confirmed = await showDeleteConfirmation( context, title: isCollection ? t.collections.deleteCollection : t.playlists.delete, message: isCollection ? t.collections.deleteConfirm(title: itemTitle) : t.playlists.deleteMessage(name: itemTitle), ); if (!confirmed || !context.mounted) return; try { bool success = false; if (isCollection) { final metadata = widget.item as PlexMetadata; final sectionId = metadata.librarySectionID?.toString() ?? '0'; success = await client.deleteCollection(sectionId, metadata.ratingKey); } else if (isPlaylist) { final playlist = widget.item as PlexPlaylist; success = await client.deletePlaylist(playlist.ratingKey); } if (context.mounted) { if (success) { showSuccessSnackBar(context, isCollection ? t.collections.deleted : t.playlists.deleted); // Trigger list refresh widget.onListRefresh?.call(); } else { showErrorSnackBar(context, isCollection ? t.collections.deleteFailed : t.playlists.errorDeleting); } } } catch (e) { appLogger.e('Failed to delete $itemTypeLabel', error: e); if (context.mounted) { showErrorSnackBar( context, isCollection ? t.collections.deleteFailedWithError(error: e.toString()) : t.playlists.errorDeleting, ); } } } /// Handle play in external player action Future _handlePlayExternal(BuildContext context) async { final metadata = widget.item as PlexMetadata; // Check if the item is downloaded and use local file path if available final downloadProvider = Provider.of(context, listen: false); final globalKey = metadata.globalKey; if (downloadProvider.isDownloaded(globalKey)) { final videoPath = await downloadProvider.getVideoFilePath(globalKey); if (videoPath != null && context.mounted) { final videoUrl = videoPath.contains('://') ? videoPath : 'file://$videoPath'; await ExternalPlayerService.launch(context: context, videoUrl: videoUrl); return; } } final client = _getClientForItem(); if (!context.mounted) return; await ExternalPlayerService.launch(context: context, metadata: metadata, client: client); } /// Handle download collection action — opens the same sync/one-time dialog /// as playlists, wired to [showCollectionDownloadOptionsAndQueue]. Future _handleDownloadCollection(BuildContext context) async { final collection = widget.item as PlexMetadata; final downloadProvider = Provider.of(context, listen: false); final client = _getClientForItem(); try { final items = await client.getCollectionItems(collection.ratingKey); if (!context.mounted) return; final result = await showCollectionDownloadOptionsAndQueue( context, collectionMetadata: collection, items: items, client: client, downloadProvider: downloadProvider, ); if (result == null || !context.mounted) return; showSuccessSnackBar(context, result.toSnackBarMessage()); } on CellularDownloadBlockedException { if (context.mounted) { showErrorSnackBar(context, t.settings.cellularDownloadBlocked); } } catch (e) { appLogger.e('Failed to queue collection download', error: e); if (context.mounted) { showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } } } /// Handle download playlist action Future _handleDownloadPlaylist(BuildContext context) async { final playlist = widget.item as PlexPlaylist; final downloadProvider = Provider.of(context, listen: false); final client = _getClientForItem(); try { final items = await client.getPlaylist(playlist.ratingKey); if (!context.mounted) return; final playlistMetadata = PlexMetadata( ratingKey: playlist.ratingKey, type: ContentTypes.playlist, title: playlist.title, thumb: playlist.thumb, serverId: playlist.serverId ?? client.serverId, serverName: playlist.serverName, ); final result = await showPlaylistDownloadOptionsAndQueue( context, playlistMetadata: playlistMetadata, items: items, client: client, downloadProvider: downloadProvider, ); if (result == null || !context.mounted) return; showSuccessSnackBar(context, result.toSnackBarMessage()); } on CellularDownloadBlockedException { if (context.mounted) { showErrorSnackBar(context, t.settings.cellularDownloadBlocked); } } catch (e) { appLogger.e('Failed to queue playlist download', error: e); if (context.mounted) { showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } } } /// Handle download action Future _handleDownload(BuildContext context) async { final downloadProvider = Provider.of(context, listen: false); final metadata = widget.item as PlexMetadata; final client = _getClientForItem(); try { final result = await showDownloadOptionsAndQueue( context, metadata: metadata, client: client, downloadProvider: downloadProvider, ); if (result == null || !context.mounted) return; showSuccessSnackBar(context, result.toSnackBarMessage()); } on CellularDownloadBlockedException { if (context.mounted) { showErrorSnackBar(context, t.settings.cellularDownloadBlocked); } } catch (e) { appLogger.e('Failed to queue download', error: e); if (context.mounted) { showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } } } /// Handle delete download action Future _handleDeleteDownload(BuildContext context) async { final downloadProvider = Provider.of(context, listen: false); final metadata = widget.item as PlexMetadata; final globalKey = metadata.globalKey; // Show confirmation dialog final confirmed = await showDeleteConfirmation( context, title: t.downloads.deleteDownload, message: t.downloads.deleteConfirm(title: metadata.displayTitle), ); if (!confirmed || !context.mounted) return; try { // Use smart deletion handler (shows progress only if >500ms) await SmartDeletionHandler.deleteWithProgress(context: context, provider: downloadProvider, globalKey: globalKey); if (context.mounted) { showSuccessSnackBar(context, t.downloads.downloadDeleted); // Notify DeletionAware screens (e.g. offline season detail) DeletionNotifier().notifyDeleted(metadata: metadata, isDownloadOnly: true); // Refresh the view if needed widget.onRefresh?.call(metadata.ratingKey); } } catch (e) { appLogger.e('Failed to delete download', error: e); if (context.mounted) { showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } } } /// Resolve the sync-rule global key for whatever the menu item is — works /// for both PlexMetadata (shows/seasons/collections/movies/episodes) and /// PlexPlaylist. String _itemGlobalKey() { final item = widget.item; if (item is PlexPlaylist) { final serverId = item.serverId ?? _getClientForItem().serverId; return buildGlobalKey(serverId, item.ratingKey); } return (item as PlexMetadata).globalKey; } String _itemDisplayTitle() { final item = widget.item; if (item is PlexPlaylist) return item.title; return (item as PlexMetadata).displayTitle; } Future _handleManageSyncRule(BuildContext context) => manageSyncRule(context, downloadProvider: context.read(), globalKey: _itemGlobalKey()); /// Fire-and-forget: if a sync rule exists for the target list, run it now so /// newly-added items download immediately instead of waiting for the next /// cooldown-gated general pass. Fails silently — errors are logged only. static void _triggerEagerSyncIfRuleExists(BuildContext context, String serverId, String listId) { try { final downloadProvider = Provider.of(context, listen: false); final globalKey = buildGlobalKey(serverId, listId); if (!downloadProvider.hasSyncRule(globalKey)) return; final serverManager = Provider.of(context, listen: false).serverManager; unawaited( downloadProvider.executeSyncRuleFor(globalKey, serverManager).catchError((e) { appLogger.w('Eager sync-rule run failed for $globalKey: $e'); return null; }), ); } catch (e) { appLogger.w('Failed to schedule eager sync-rule run: $e'); } } Future _handleRemoveSyncRule(BuildContext context) => removeSyncRuleAndSnack( context, downloadProvider: context.read(), globalKey: _itemGlobalKey(), displayTitle: _itemDisplayTitle(), ); /// Handle delete media item action /// This permanently removes the media item and its associated files from the server Future _handleDeleteMediaItem(BuildContext context, PlexMediaType? mediaType) async { final metadata = widget.item as PlexMetadata; final isMultipleMediaItems = mediaType == PlexMediaType.show || mediaType == PlexMediaType.season; // Show confirmation dialog final confirmed = await showDeleteConfirmation( context, title: t.mediaMenu.deleteFromServer, message: "${t.mediaMenu.confirmDelete}${isMultipleMediaItems ? "\n\n${t.mediaMenu.deleteMultipleWarning}" : ""}", confirmText: t.mediaMenu.deleteFromServer, ); if (!confirmed || !context.mounted) return; try { final client = _getClientForItem(); final success = await client.deleteMediaItem(metadata.ratingKey); if (context.mounted) { if (success) { showSuccessSnackBar(context, t.mediaMenu.mediaDeletedSuccessfully); // Broadcast deletion event for cross-screen propagation DeletionNotifier().notifyDeleted(metadata: metadata); // Backward-compatible list refresh for screens that are not DeletionAware yet widget.onListRefresh?.call(); } else { showErrorSnackBar(context, t.mediaMenu.mediaFailedToDelete); } } } catch (e) { appLogger.e(t.mediaMenu.mediaFailedToDelete, error: e); if (context.mounted) { showErrorSnackBar(context, t.mediaMenu.mediaFailedToDelete); } } } @override Widget build(BuildContext context) { // GestureDetector wrapping removed — gesture callbacks are now on InkWell // directly in the card widgets, saving 1 element level. The context menu // is still accessible programmatically via showContextMenu(). return widget.child; } } /// Dialog to select a playlist or create a new one class _PlaylistSelectionDialog extends StatelessWidget { final List playlists; const _PlaylistSelectionDialog({required this.playlists}); @override Widget build(BuildContext context) { return AlertDialog( title: Text(t.playlists.selectPlaylist), content: SizedBox( width: double.maxFinite, child: ListView.builder( shrinkWrap: true, itemCount: playlists.length + 1, itemBuilder: (context, index) { if (index == 0) { // Create new playlist option (always shown first) return ListTile( leading: const AppIcon(Symbols.add_rounded, fill: 1), title: Text(t.common.createNew), onTap: () => Navigator.pop(context, '_create_new'), ); } final playlist = playlists[index - 1]; final subtitleText = playlist.leafCount == 1 ? t.playlists.oneItem : t.playlists.itemCount(count: playlist.leafCount!); return ListTile( leading: playlist.smart ? const AppIcon(Symbols.auto_awesome_rounded, fill: 1) : const AppIcon(Symbols.playlist_play_rounded, fill: 1), title: Text(playlist.title), subtitle: playlist.leafCount != null ? Text(subtitleText) : null, onTap: playlist.smart ? null // Disable smart playlists : () => Navigator.pop(context, playlist.ratingKey), enabled: !playlist.smart, ); }, ), ), actions: [ FocusableButton( autofocus: true, onPressed: () => Navigator.pop(context), child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), ), ], ); } } /// Dialog to select a collection or create a new one class _CollectionSelectionDialog extends StatefulWidget { final List collections; const _CollectionSelectionDialog({required this.collections}); @override State<_CollectionSelectionDialog> createState() => _CollectionSelectionDialogState(); } class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog> { final _filterController = TextEditingController(); late List _filteredCollections = widget.collections; @override void dispose() { _filterController.dispose(); super.dispose(); } void _onFilterChanged(String query) { final lower = query.toLowerCase(); setState(() { _filteredCollections = lower.isEmpty ? widget.collections : widget.collections.where((c) => (c.title ?? '').toLowerCase().contains(lower)).toList(); }); } @override Widget build(BuildContext context) { return AlertDialog( title: Text(t.collections.selectCollection), content: SizedBox( width: double.maxFinite, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (widget.collections.length >= 10) ...[ TextField( controller: _filterController, autofocus: true, decoration: pillInputDecoration( context, hintText: t.collections.searchCollections, prefixIcon: const Icon(Symbols.search_rounded, size: 20), ), onChanged: _onFilterChanged, ), const SizedBox(height: 8), ], Flexible( child: ListView.builder( shrinkWrap: true, itemCount: _filteredCollections.length + 1, itemBuilder: (context, index) { if (index == 0) { return ListTile( leading: const AppIcon(Symbols.add_rounded, fill: 1), title: Text(t.common.createNew), onTap: () => Navigator.pop(context, '_create_new'), ); } final collection = _filteredCollections[index - 1]; return ListTile( leading: const AppIcon(Symbols.collections_rounded, fill: 1), title: Text(collection.title!), subtitle: collection.childCount != null ? Text(t.playlists.itemCount(count: collection.childCount!)) : null, onTap: () => Navigator.pop(context, collection.ratingKey), ); }, ), ), ], ), ), actions: [ FocusableButton( onPressed: () => Navigator.pop(context), child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), ), ], ); } } /// Focusable context menu sheet for keyboard/gamepad navigation (mobile) class _FocusableContextMenuSheet extends StatefulWidget { final String title; final List<_MenuAction> actions; final bool focusFirstItem; const _FocusableContextMenuSheet({required this.title, required this.actions, this.focusFirstItem = false}); @override State<_FocusableContextMenuSheet> createState() => _FocusableContextMenuSheetState(); } class _FocusableContextMenuSheetState extends State<_FocusableContextMenuSheet> { late final FocusNode _initialFocusNode; @override void initState() { super.initState(); _initialFocusNode = FocusNode(debugLabel: 'ContextMenuSheetInitialFocus'); } @override void dispose() { _initialFocusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), child: Text( widget.title, style: Theme.of(context).textTheme.titleMedium, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), Flexible( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ ...widget.actions.asMap().entries.map((entry) { final index = entry.key; final action = entry.value; return FocusableListTile( focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null, leading: AppIcon(action.icon, fill: 1), title: Text(action.label), onTap: () => OverlaySheetController.closeAdaptive(context, action.value), hoverColor: action.hoverColor, textColor: action.foregroundColor, iconColor: action.foregroundColor, ); }), ], ), ), ), ], ); } } /// Focusable popup menu for keyboard/gamepad navigation (desktop) class _FocusablePopupMenu extends StatefulWidget { final List<_MenuAction> actions; final Offset position; final bool focusFirstItem; const _FocusablePopupMenu({required this.actions, required this.position, this.focusFirstItem = false}); @override State<_FocusablePopupMenu> createState() => _FocusablePopupMenuState(); } class _FocusablePopupMenuState extends State<_FocusablePopupMenu> { late final FocusNode _initialFocusNode; @override void initState() { super.initState(); _initialFocusNode = FocusNode(debugLabel: 'PopupMenuInitialFocus'); if (widget.focusFirstItem) { FocusUtils.requestFocusAfterBuild(this, _initialFocusNode); } } @override void dispose() { _initialFocusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final screenSize = MediaQuery.of(context).size; const menuWidth = 220.0; // Clamp menu position to stay within screen bounds const edgePadding = 8.0; final left = widget.position.dx.clamp(edgePadding, screenSize.width - menuWidth - edgePadding); final estimatedHeight = widget.actions.length * 48.0 + 16; final spaceBelow = screenSize.height - widget.position.dy - edgePadding; final spaceAbove = widget.position.dy - edgePadding; // Place menu above the click point if it doesn't fit below and there's more room above final double top; final double maxHeight; if (estimatedHeight <= spaceBelow) { top = widget.position.dy; maxHeight = spaceBelow; } else if (spaceAbove > spaceBelow) { final menuHeight = estimatedHeight.clamp(0.0, spaceAbove); top = widget.position.dy - menuHeight; maxHeight = menuHeight; } else { top = widget.position.dy; maxHeight = spaceBelow; } return FocusScope( // When opened via mouse, don't autofocus any item — let hover handle highlights. // When opened via keyboard/dpad, autofocus is handled by _initialFocusNode. autofocus: false, child: Focus( canRequestFocus: false, skipTraversal: true, onKeyEvent: (node, event) { if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { return KeyEventResult.handled; } if (BackKeyUpSuppressor.consumeIfSuppressed(event)) { return KeyEventResult.handled; } return KeyEventResult.ignored; }, child: Stack( children: [ // Barrier to close menu when clicking outside Positioned.fill( child: GestureDetector( onTap: () => Navigator.pop(context), behavior: HitTestBehavior.opaque, child: Container(color: Colors.transparent), ), ), // Menu Positioned( left: left, top: top, child: Material( elevation: 8, color: Color.alphaBlend( Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08), Theme.of(context).colorScheme.surface, ), borderRadius: BorderRadius.circular(tokens(context).radiusSm), clipBehavior: Clip.antiAlias, child: ConstrainedBox( constraints: BoxConstraints(minWidth: menuWidth, maxWidth: menuWidth, maxHeight: maxHeight), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: widget.actions.asMap().entries.map((entry) { final index = entry.key; final action = entry.value; return FocusableListTile( focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null, leading: AppIcon(action.icon, fill: 1, size: 20), title: Text(action.label), onTap: () => Navigator.pop(context, action.value), hoverColor: action.hoverColor, textColor: action.foregroundColor, iconColor: action.foregroundColor, ); }).toList(), ), ), ), ), ), ], ), ), ); } }