Files
Ensemble/lib/widgets/media_context_menu.dart
ChrisandClaude Opus 4.5 ebc3288bfc Replace library icon with book_2 from Material Symbols
- Add material_symbols_icons package for access to book_2 icon
- Update library icon across all detail screens, context menus, bottom
  nav, and search screen to use Symbols.book_2 with fill parameter
- Add podcast episode detail screen with hero animation and full
  scrollable description
- Add episode context menu support with Play, Play On, Add to Queue,
  and View Details options
- Add sort controls for podcast episodes (newest, oldest, alpha, duration)
- Improve context menu: slightly larger buttons (46px), wider menu (185px),
  neutral text color for labels
- Search library filter now uses filled icon without background highlight

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 22:13:42 +00:00

1015 lines
36 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart' show GlobalPlayerOverlay, BottomSpacing;
import '../l10n/app_localizations.dart';
import '../services/debug_logger.dart';
/// The type of media item for the context menu
enum ContextMenuMediaType {
album,
artist,
playlist,
track,
audiobook,
podcast,
podcastEpisode,
radio,
}
/// Shows a floating context menu for media actions
class MediaContextMenu {
static OverlayEntry? _currentOverlay;
/// Show the context menu at the given position
static void show({
required BuildContext context,
required Offset position,
required ContextMenuMediaType mediaType,
required dynamic item,
required bool isFavorite,
required bool isInLibrary,
ColorScheme? adaptiveColorScheme,
VoidCallback? onDismiss,
VoidCallback? onToggleFavorite,
VoidCallback? onToggleLibrary,
bool showTopRow = true,
// Optional layout/sort controls for detail screens
String? sortOrder,
VoidCallback? onToggleSort,
String? viewMode,
VoidCallback? onCycleView,
// Episode-specific callbacks (for podcastEpisode type)
VoidCallback? onPlay,
VoidCallback? onPlayOn,
VoidCallback? onAddToQueue,
VoidCallback? onViewDetails,
}) {
// Close any existing menu
hide();
// Haptic feedback for long-press menu
HapticFeedback.mediumImpact();
final overlay = Navigator.of(context).overlay;
if (overlay == null) return;
_currentOverlay = OverlayEntry(
builder: (context) => _MediaContextMenuOverlay(
position: position,
mediaType: mediaType,
item: item,
isFavorite: isFavorite,
isInLibrary: isInLibrary,
adaptiveColorScheme: adaptiveColorScheme,
onToggleFavorite: onToggleFavorite,
onToggleLibrary: onToggleLibrary,
showTopRow: showTopRow,
sortOrder: sortOrder,
onToggleSort: onToggleSort,
viewMode: viewMode,
onCycleView: onCycleView,
onPlay: onPlay,
onPlayOn: onPlayOn,
onAddToQueue: onAddToQueue,
onViewDetails: onViewDetails,
onDismiss: () {
hide();
onDismiss?.call();
},
),
);
overlay.insert(_currentOverlay!);
}
/// Hide the current context menu
static void hide() {
_currentOverlay?.remove();
_currentOverlay = null;
}
}
class _MediaContextMenuOverlay extends StatefulWidget {
final Offset position;
final ContextMenuMediaType mediaType;
final dynamic item;
final bool isFavorite;
final bool isInLibrary;
final ColorScheme? adaptiveColorScheme;
final VoidCallback onDismiss;
final VoidCallback? onToggleFavorite;
final VoidCallback? onToggleLibrary;
final bool showTopRow;
// Optional layout/sort controls
final String? sortOrder;
final VoidCallback? onToggleSort;
final String? viewMode;
final VoidCallback? onCycleView;
// Episode-specific callbacks
final VoidCallback? onPlay;
final VoidCallback? onPlayOn;
final VoidCallback? onAddToQueue;
final VoidCallback? onViewDetails;
const _MediaContextMenuOverlay({
required this.position,
required this.mediaType,
required this.item,
required this.isFavorite,
required this.isInLibrary,
this.adaptiveColorScheme,
required this.onDismiss,
this.onToggleFavorite,
this.onToggleLibrary,
this.showTopRow = true,
this.sortOrder,
this.onToggleSort,
this.viewMode,
this.onCycleView,
this.onPlay,
this.onPlayOn,
this.onAddToQueue,
this.onViewDetails,
});
@override
State<_MediaContextMenuOverlay> createState() => _MediaContextMenuOverlayState();
}
class _MediaContextMenuOverlayState extends State<_MediaContextMenuOverlay>
with SingleTickerProviderStateMixin {
static final _logger = DebugLogger();
late AnimationController _animController;
late Animation<double> _scaleAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_animController = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 0.95, end: 1.0).animate(
CurvedAnimation(parent: _animController, curve: Curves.easeOut),
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animController, curve: Curves.easeOut),
);
_animController.forward();
}
@override
void dispose() {
_animController.dispose();
super.dispose();
}
/// Get the item name for display
String get _itemName {
switch (widget.mediaType) {
case ContextMenuMediaType.album:
return (widget.item as Album).name;
case ContextMenuMediaType.artist:
return (widget.item as Artist).name;
case ContextMenuMediaType.playlist:
return (widget.item as Playlist).name;
case ContextMenuMediaType.track:
return (widget.item as Track).name;
case ContextMenuMediaType.audiobook:
return (widget.item as Audiobook).name;
case ContextMenuMediaType.podcast:
case ContextMenuMediaType.podcastEpisode:
case ContextMenuMediaType.radio:
return (widget.item as MediaItem).name;
}
}
/// Play the item instantly using selected player
Future<void> _play(BuildContext context) async {
final maProvider = context.read<MusicAssistantProvider>();
final player = maProvider.selectedPlayer;
final l10n = S.of(context)!;
if (player == null) {
_showSnackBar(context, l10n.noPlayerSelected);
return;
}
widget.onDismiss();
try {
switch (widget.mediaType) {
case ContextMenuMediaType.album:
final album = widget.item as Album;
await maProvider.api?.playAlbum(player.playerId, album);
break;
case ContextMenuMediaType.artist:
// For artists, play artist radio (no direct "play artist" method)
final artist = widget.item as Artist;
await maProvider.api?.playArtistRadio(player.playerId, artist);
break;
case ContextMenuMediaType.playlist:
final playlist = widget.item as Playlist;
await maProvider.api?.playPlaylist(player.playerId, playlist);
break;
case ContextMenuMediaType.track:
final track = widget.item as Track;
await maProvider.playTracks(player.playerId, [track], startIndex: 0);
break;
case ContextMenuMediaType.audiobook:
final audiobook = widget.item as Audiobook;
await maProvider.api?.playAudiobook(player.playerId, audiobook);
break;
case ContextMenuMediaType.podcast:
// Podcasts are shows, not directly playable - would need to play latest episode
break;
case ContextMenuMediaType.podcastEpisode:
// Episodes are handled via onPlay callback
break;
case ContextMenuMediaType.radio:
final station = widget.item as MediaItem;
await maProvider.api?.playRadioStation(player.playerId, station);
break;
}
} catch (e) {
_logger.log('Error playing item: $e');
}
}
/// Show player selector for play
void _playOn(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final l10n = S.of(context)!;
widget.onDismiss();
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: l10n.playOn,
onPlayerSelected: (player) async {
try {
switch (widget.mediaType) {
case ContextMenuMediaType.album:
final album = widget.item as Album;
await maProvider.api?.playAlbum(player.playerId, album);
break;
case ContextMenuMediaType.artist:
final artist = widget.item as Artist;
await maProvider.api?.playArtistRadio(player.playerId, artist);
break;
case ContextMenuMediaType.playlist:
final playlist = widget.item as Playlist;
await maProvider.api?.playPlaylist(player.playerId, playlist);
break;
case ContextMenuMediaType.track:
final track = widget.item as Track;
await maProvider.playTracks(player.playerId, [track], startIndex: 0);
break;
case ContextMenuMediaType.audiobook:
final audiobook = widget.item as Audiobook;
await maProvider.api?.playAudiobook(player.playerId, audiobook);
break;
case ContextMenuMediaType.podcast:
// Podcasts are shows, not directly playable
break;
case ContextMenuMediaType.podcastEpisode:
// Episodes are handled via onPlayOn callback
break;
case ContextMenuMediaType.radio:
final station = widget.item as MediaItem;
await maProvider.api?.playRadioStation(player.playerId, station);
break;
}
} catch (e) {
_logger.log('Error playing item: $e');
}
},
);
}
/// Add to queue instantly using selected player
Future<void> _addToQueue(BuildContext context) async {
final maProvider = context.read<MusicAssistantProvider>();
final player = maProvider.selectedPlayer;
final l10n = S.of(context)!;
if (player == null) {
_showSnackBar(context, l10n.noPlayerSelected);
return;
}
widget.onDismiss();
try {
switch (widget.mediaType) {
case ContextMenuMediaType.album:
final album = widget.item as Album;
final tracks = await maProvider.api?.getAlbumTracks(album.provider, album.itemId);
if (tracks != null && tracks.isNotEmpty) {
await maProvider.addTracksToQueue(player.playerId, tracks);
if (context.mounted) _showSnackBar(context, l10n.albumAddedToQueue);
}
break;
case ContextMenuMediaType.playlist:
final playlist = widget.item as Playlist;
final tracks = await maProvider.api?.getPlaylistTracks(playlist.provider, playlist.itemId);
if (tracks != null && tracks.isNotEmpty) {
await maProvider.addTracksToQueue(player.playerId, tracks);
if (context.mounted) _showSnackBar(context, l10n.tracksAddedToQueue);
}
break;
case ContextMenuMediaType.track:
final track = widget.item as Track;
await maProvider.addTracksToQueue(player.playerId, [track]);
if (context.mounted) _showSnackBar(context, l10n.trackAddedToQueue);
break;
case ContextMenuMediaType.artist:
case ContextMenuMediaType.audiobook:
case ContextMenuMediaType.podcast:
case ContextMenuMediaType.podcastEpisode:
case ContextMenuMediaType.radio:
// These types don't support direct queue addition (episodes use callback)
break;
}
} catch (e) {
_logger.log('Error adding to queue: $e');
}
}
/// Show player selector for add to queue
void _addToQueueOn(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final l10n = S.of(context)!;
widget.onDismiss();
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: l10n.addToQueueOn,
onPlayerSelected: (player) async {
try {
switch (widget.mediaType) {
case ContextMenuMediaType.album:
final album = widget.item as Album;
final tracks = await maProvider.api?.getAlbumTracks(album.provider, album.itemId);
if (tracks != null && tracks.isNotEmpty) {
await maProvider.addTracksToQueue(player.playerId, tracks);
}
break;
case ContextMenuMediaType.playlist:
final playlist = widget.item as Playlist;
final tracks = await maProvider.api?.getPlaylistTracks(playlist.provider, playlist.itemId);
if (tracks != null && tracks.isNotEmpty) {
await maProvider.addTracksToQueue(player.playerId, tracks);
}
break;
case ContextMenuMediaType.track:
final track = widget.item as Track;
await maProvider.addTracksToQueue(player.playerId, [track]);
break;
case ContextMenuMediaType.artist:
case ContextMenuMediaType.audiobook:
case ContextMenuMediaType.podcast:
case ContextMenuMediaType.podcastEpisode:
case ContextMenuMediaType.radio:
// These types don't support direct queue addition
break;
}
} catch (e) {
_logger.log('Error adding to queue: $e');
}
},
);
}
/// Start radio instantly using selected player
Future<void> _startRadio(BuildContext context) async {
final maProvider = context.read<MusicAssistantProvider>();
final player = maProvider.selectedPlayer;
final l10n = S.of(context)!;
if (player == null) {
_showSnackBar(context, l10n.noPlayerSelected);
return;
}
widget.onDismiss();
try {
switch (widget.mediaType) {
case ContextMenuMediaType.artist:
final artist = widget.item as Artist;
await maProvider.api?.playArtistRadio(player.playerId, artist);
break;
case ContextMenuMediaType.track:
final track = widget.item as Track;
await maProvider.playRadio(player.playerId, track);
break;
case ContextMenuMediaType.album:
case ContextMenuMediaType.playlist:
case ContextMenuMediaType.audiobook:
case ContextMenuMediaType.podcast:
case ContextMenuMediaType.podcastEpisode:
case ContextMenuMediaType.radio:
// These types don't support radio mode
break;
}
} catch (e) {
_logger.log('Error starting radio: $e');
}
}
/// Show player selector for start radio
void _startRadioOn(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final l10n = S.of(context)!;
widget.onDismiss();
GlobalPlayerOverlay.showPlayerSelectorForAction(
contextHint: l10n.startRadioOn(_itemName),
onPlayerSelected: (player) async {
try {
switch (widget.mediaType) {
case ContextMenuMediaType.artist:
final artist = widget.item as Artist;
await maProvider.api?.playArtistRadio(player.playerId, artist);
break;
case ContextMenuMediaType.track:
final track = widget.item as Track;
await maProvider.playRadio(player.playerId, track);
break;
case ContextMenuMediaType.album:
case ContextMenuMediaType.playlist:
case ContextMenuMediaType.audiobook:
case ContextMenuMediaType.podcast:
case ContextMenuMediaType.podcastEpisode:
case ContextMenuMediaType.radio:
// These types don't support radio mode
break;
}
} catch (e) {
_logger.log('Error starting radio: $e');
}
},
);
}
void _handleToggleFavorite() {
widget.onDismiss();
widget.onToggleFavorite?.call();
}
void _handleToggleLibrary() {
widget.onDismiss();
widget.onToggleLibrary?.call();
}
void _showSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 2),
),
);
}
/// Check if queue actions should be shown for this media type
bool get _supportsQueue {
return widget.mediaType == ContextMenuMediaType.album ||
widget.mediaType == ContextMenuMediaType.playlist ||
widget.mediaType == ContextMenuMediaType.track;
}
/// Check if radio actions should be shown for this media type
bool get _supportsRadio {
return widget.mediaType == ContextMenuMediaType.artist ||
widget.mediaType == ContextMenuMediaType.track;
}
@override
Widget build(BuildContext context) {
final colorScheme = widget.adaptiveColorScheme ?? Theme.of(context).colorScheme;
final l10n = S.of(context)!;
// Calculate menu position
const menuWidth = 185.0;
final screenSize = MediaQuery.of(context).size;
final viewPadding = MediaQuery.of(context).viewPadding;
// Account for mini player and bottom nav bar
final bottomInset = BottomSpacing.withMiniPlayer + viewPadding.bottom;
// Position near the tap, but keep on screen
double left = widget.position.dx - menuWidth / 2;
double top = widget.position.dy;
// Keep menu on screen horizontally
left = left.clamp(8.0, screenSize.width - menuWidth - 8);
// Estimate menu height based on content
double estimatedHeight;
if (widget.mediaType == ContextMenuMediaType.podcastEpisode) {
// Episode menu: 4 items + divider = ~200px
estimatedHeight = 200.0;
} else {
estimatedHeight = widget.showTopRow ? 280.0 : 180.0;
// Add height for sort section (header + items)
if (widget.onToggleSort != null) {
// Podcast has 4 sort options, others have 2
estimatedHeight += widget.mediaType == ContextMenuMediaType.podcast ? 170.0 : 110.0;
}
// Add height for view section (divider + header + 3 items)
if (widget.onCycleView != null) {
estimatedHeight += 140.0;
}
}
// If menu would go off bottom (accounting for mini player), position above the tap point
if (top + estimatedHeight > screenSize.height - bottomInset) {
top = widget.position.dy - estimatedHeight;
}
top = top.clamp(viewPadding.top + 8, screenSize.height - estimatedHeight - bottomInset);
return Stack(
children: [
// Dismiss on tap outside
FadeTransition(
opacity: _fadeAnimation,
child: ModalBarrier(
dismissible: true,
onDismiss: widget.onDismiss,
color: Colors.black12,
),
),
// Menu with scale and fade animation
Positioned(
left: left,
top: top,
child: FadeTransition(
opacity: _fadeAnimation,
child: ScaleTransition(
scale: _scaleAnimation,
alignment: Alignment.center,
// Wrap in Theme to ensure FilledButton.tonal uses our colorScheme
child: Theme(
data: Theme.of(context).copyWith(colorScheme: colorScheme),
child: SizedBox(
width: menuWidth,
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
// Use lighter surface when adaptive colors are applied for better contrast
// Blend with white to make it subtly lighter than the background
color: widget.adaptiveColorScheme != null
? Color.lerp(colorScheme.surface, Colors.white, 0.03)!
: colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Podcast episode menu - simplified options
if (widget.mediaType == ContextMenuMediaType.podcastEpisode) ...[
_buildMenuItem(
icon: Icons.play_arrow_rounded,
label: l10n.play,
onTap: () {
widget.onPlay?.call();
widget.onDismiss();
},
colorScheme: colorScheme,
),
_buildMenuItem(
icon: Icons.speaker_group_outlined,
label: l10n.playOn,
onTap: () {
widget.onPlayOn?.call();
widget.onDismiss();
},
colorScheme: colorScheme,
),
_buildMenuItem(
icon: Icons.playlist_add,
label: l10n.addToQueue,
onTap: () {
widget.onAddToQueue?.call();
widget.onDismiss();
},
colorScheme: colorScheme,
),
Divider(height: 1, color: colorScheme.onSurface.withValues(alpha: 0.15)),
_buildMenuItem(
icon: Icons.info_outline,
label: l10n.viewDetails,
onTap: () {
widget.onViewDetails?.call();
widget.onDismiss();
},
colorScheme: colorScheme,
),
] else ...[
// Standard menu for other media types
// Top row: instant action buttons
if (widget.showTopRow) ...[
Padding(
padding: const EdgeInsets.all(8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Play button
_buildIconButton(
icon: Icons.play_arrow_rounded,
label: l10n.play,
onTap: () => _play(context),
colorScheme: colorScheme,
),
// Library button
if (widget.onToggleLibrary != null) ...[
const SizedBox(width: 12),
_buildIconButton(
icon: Symbols.book_2,
label: widget.isInLibrary ? l10n.inLibrary : l10n.addToLibrary,
onTap: _handleToggleLibrary,
colorScheme: colorScheme,
isActive: widget.isInLibrary,
fill: widget.isInLibrary ? 1 : 0,
),
],
// Favorite button
if (widget.onToggleFavorite != null) ...[
const SizedBox(width: 12),
_buildIconButton(
icon: Icons.favorite,
label: l10n.favorite,
onTap: _handleToggleFavorite,
colorScheme: colorScheme,
isActive: widget.isFavorite,
activeColor: Colors.red,
),
],
],
),
),
Divider(height: 1, color: colorScheme.onSurface.withValues(alpha: 0.15)),
],
// List items
_buildMenuItem(
icon: Icons.speaker_group_outlined,
label: l10n.playOn,
onTap: () => _playOn(context),
colorScheme: colorScheme,
),
if (_supportsQueue) ...[
_buildMenuItem(
icon: Icons.playlist_add,
label: l10n.addToQueue,
onTap: () => _addToQueue(context),
colorScheme: colorScheme,
),
_buildMenuItem(
icon: Icons.speaker_group_outlined,
label: l10n.addToQueueOn,
onTap: () => _addToQueueOn(context),
colorScheme: colorScheme,
),
],
if (_supportsRadio) ...[
_buildMenuItem(
icon: Icons.radio,
label: l10n.startRadio,
onTap: () => _startRadio(context),
colorScheme: colorScheme,
),
_buildMenuItem(
icon: Icons.speaker_group_outlined,
label: l10n.startRadioOn(_itemName),
onTap: () => _startRadioOn(context),
colorScheme: colorScheme,
),
],
// Layout/sort controls (optional) - styled like library options menu
if (widget.onToggleSort != null || widget.onCycleView != null) ...[
Divider(height: 1, color: colorScheme.onSurface.withValues(alpha: 0.15)),
// Sort section
if (widget.onToggleSort != null) ...[
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 4),
child: Text(
'Sort',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
// Podcast-specific sort options
if (widget.mediaType == ContextMenuMediaType.podcast) ...[
_buildViewModeItem(
mode: 'newest',
label: l10n.newestFirst,
icon: Icons.arrow_downward,
isSelected: widget.sortOrder == 'newest',
onTap: () {
_setSortOrder('newest');
},
colorScheme: colorScheme,
),
_buildViewModeItem(
mode: 'oldest',
label: l10n.oldestFirst,
icon: Icons.arrow_upward,
isSelected: widget.sortOrder == 'oldest',
onTap: () {
_setSortOrder('oldest');
},
colorScheme: colorScheme,
),
_buildViewModeItem(
mode: 'alpha',
label: l10n.sortAlphabetically,
icon: Icons.sort_by_alpha,
isSelected: widget.sortOrder == 'alpha',
onTap: () {
_setSortOrder('alpha');
},
colorScheme: colorScheme,
),
_buildViewModeItem(
mode: 'duration',
label: l10n.sortByDuration,
icon: Icons.timer_outlined,
isSelected: widget.sortOrder == 'duration',
onTap: () {
_setSortOrder('duration');
},
colorScheme: colorScheme,
),
] else ...[
// Default sort options (alpha/year)
_buildViewModeItem(
mode: 'alpha',
label: l10n.sortAlphabetically,
icon: Icons.sort_by_alpha,
isSelected: widget.sortOrder == 'alpha',
onTap: () {
if (widget.sortOrder != 'alpha') widget.onToggleSort!();
widget.onDismiss();
},
colorScheme: colorScheme,
),
_buildViewModeItem(
mode: 'year',
label: l10n.sortByYear,
icon: Icons.calendar_today,
isSelected: widget.sortOrder != 'alpha',
onTap: () {
if (widget.sortOrder == 'alpha') widget.onToggleSort!();
widget.onDismiss();
},
colorScheme: colorScheme,
),
],
],
// View section
if (widget.onCycleView != null) ...[
Divider(height: 1, color: colorScheme.onSurface.withValues(alpha: 0.15)),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 4),
child: Text(
'View',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
_buildViewModeItem(
mode: 'list',
label: l10n.listView,
icon: Icons.view_list,
isSelected: widget.viewMode == 'list',
onTap: () {
_setViewMode('list');
},
colorScheme: colorScheme,
),
_buildViewModeItem(
mode: 'grid2',
label: l10n.twoColumnGrid,
icon: Icons.grid_on,
isSelected: widget.viewMode == 'grid2',
onTap: () {
_setViewMode('grid2');
},
colorScheme: colorScheme,
),
_buildViewModeItem(
mode: 'grid3',
label: l10n.threeColumnGrid,
icon: Icons.grid_view,
isSelected: widget.viewMode == 'grid3',
onTap: () {
_setViewMode('grid3');
},
colorScheme: colorScheme,
),
],
],
], // end else (standard menu)
],
),
),
),
),
),
),
),
),
],
);
}
Widget _buildIconButton({
required IconData icon,
required String label,
required VoidCallback onTap,
required ColorScheme colorScheme,
bool isActive = false,
Color? activeColor,
Color? inactiveColor,
double? fill,
}) {
final iconColor = isActive
? (activeColor ?? colorScheme.primary)
: (inactiveColor ?? Colors.white70);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 46,
height: 46,
child: FilledButton.tonal(
onPressed: onTap,
style: FilledButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Icon(
icon,
color: iconColor,
size: 23,
fill: fill,
),
),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
fontSize: 10,
color: colorScheme.onSurface,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
),
maxLines: 1,
textAlign: TextAlign.center,
),
],
);
}
Widget _buildMenuItem({
required IconData icon,
required String label,
required VoidCallback onTap,
required ColorScheme colorScheme,
}) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Icon(
icon,
size: 18,
color: colorScheme.onSurface.withValues(alpha: 0.7),
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 14,
color: colorScheme.onSurface,
),
),
),
],
),
),
);
}
/// Set view mode and cycle through until we reach the target
void _setViewMode(String targetMode) {
if (widget.viewMode == targetMode) {
widget.onDismiss();
return;
}
// Cycle until we reach target mode
String current = widget.viewMode ?? 'grid2';
while (current != targetMode) {
widget.onCycleView!();
// Simulate the cycle logic
switch (current) {
case 'grid2':
current = 'grid3';
break;
case 'grid3':
current = 'list';
break;
default:
current = 'grid2';
}
}
widget.onDismiss();
}
/// Set sort order and cycle through until we reach the target (for podcasts)
void _setSortOrder(String targetOrder) {
if (widget.sortOrder == targetOrder) {
widget.onDismiss();
return;
}
// Cycle until we reach target order
String current = widget.sortOrder ?? 'newest';
while (current != targetOrder) {
widget.onToggleSort!();
// Simulate the cycle logic: newest -> oldest -> alpha -> duration -> newest
switch (current) {
case 'newest':
current = 'oldest';
break;
case 'oldest':
current = 'alpha';
break;
case 'alpha':
current = 'duration';
break;
default:
current = 'newest';
}
}
widget.onDismiss();
}
Widget _buildViewModeItem({
required String mode,
required String label,
required IconData icon,
required bool isSelected,
required VoidCallback onTap,
required ColorScheme colorScheme,
}) {
return Material(
color: isSelected ? colorScheme.primary.withOpacity(0.12) : Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Icon(
icon,
size: 18,
color: isSelected ? colorScheme.primary : colorScheme.onSurface.withOpacity(0.7),
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: TextStyle(
color: isSelected ? colorScheme.primary : colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
),
],
),
),
),
);
}
}