diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 7944923b..f9a67fcf 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -2,13 +2,18 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/services/shell_navigation_service.dart'; +import 'package:spotiflac_android/widgets/error_card.dart'; +import 'package:spotiflac_android/widgets/track_card.dart'; +import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_sliver_header.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:share_plus/share_plus.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; -import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/re_enrich_release_policy.dart'; @@ -227,7 +232,8 @@ class _QueueTabState extends ConsumerState { bool _isSelectionMode = false; final Set _selectedIds = {}; - OverlayEntry? _selectionOverlayEntry; + final SelectionOverlayController _selectionOverlay = + SelectionOverlayController(); List _selectionOverlayItems = const []; double _selectionOverlayBottomPadding = 0; @@ -237,7 +243,8 @@ class _QueueTabState extends ConsumerState { bool _isPlaylistSelectionMode = false; final Set _selectedPlaylistIds = {}; - OverlayEntry? _playlistSelectionOverlayEntry; + final SelectionOverlayController _playlistSelectionOverlay = + SelectionOverlayController(); List _playlistSelectionOverlayItems = const []; double _playlistSelectionOverlayBottomPadding = 0; @@ -751,9 +758,6 @@ class _QueueTabState extends ConsumerState { useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surfaceContainerLow, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), builder: (context) => StatefulBuilder( builder: (context, setSheetState) { return SafeArea( @@ -770,17 +774,7 @@ class _QueueTabState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Center( - child: Container( - width: 32, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), - ), - ), + const AppSheetHandle(), Row( children: [ @@ -1272,7 +1266,6 @@ class _QueueTabState extends ConsumerState { settingsProvider.select((s) => s.libraryQualityLabelMode), ); final colorScheme = Theme.of(context).colorScheme; - final topPadding = normalizedHeaderTopPadding(context); final countsRequest = _QueueLibraryCountsRequest( searchQuery: _searchQuery, filterSource: _filterSource, @@ -1424,41 +1417,7 @@ class _QueueTabState extends ConsumerState { ).copyWith(overscroll: false), child: NestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) => [ - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - automaticallyImplyLeading: false, - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final maxHeight = 120 + topPadding; - final minHeight = kToolbarHeight + topPadding; - final expandRatio = - ((constraints.maxHeight - minHeight) / - (maxHeight - minHeight)) - .clamp(0.0, 1.0); - - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: const EdgeInsets.only( - left: 24, - bottom: 16, - ), - title: Text( - context.l10n.navLibrary, - style: TextStyle( - fontSize: 20 + (14 * expandRatio), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ); - }, - ), - ), + AppSliverHeader.tabRoot(title: context.l10n.navLibrary), if (shouldShowLibraryControls || hasQueueItems) SliverToBoxAdapter( diff --git a/lib/screens/queue_tab_collection_items.dart b/lib/screens/queue_tab_collection_items.dart index f8698097..1ab97ee3 100644 --- a/lib/screens/queue_tab_collection_items.dart +++ b/lib/screens/queue_tab_collection_items.dart @@ -6,7 +6,7 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { DownloadItem item, ColorScheme colorScheme, ) { - final radius = BorderRadius.circular(8); + final radius = context.tokens.borderRadiusThumb; final isDownloading = item.status == DownloadStatus.downloading; final isFinalizing = item.status == DownloadStatus.finalizing; final isQueued = item.status == DownloadStatus.queued; @@ -19,13 +19,7 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { borderRadius: radius, fadeInDuration: const Duration(milliseconds: 180), ) - : Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: radius, - ), - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ); + : const TrackCoverPlaceholder(); final onTap = isFailed || item.status == DownloadStatus.skipped ? () => _showDownloadErrorDialog(context, item) @@ -34,111 +28,80 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { return SmoothedProgressScope( value: item.progress, animate: _shouldAnimateDownloadProgress(context, item), - child: GestureDetector( + child: TrackGridCard( onTap: onTap, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AspectRatio( - aspectRatio: 1, - child: Stack( - fit: StackFit.expand, - children: [ - ClipRRect(borderRadius: radius, child: cover), - if (isDownloading || isFinalizing || isQueued) - ClipRRect( - borderRadius: radius, - child: ColoredBox( - color: Colors.black.withValues(alpha: 0.45), - ), - ), - if (isDownloading || isFinalizing || isQueued) - Center( - child: isDownloading && progress > 0 - ? SmoothedProgressBuilder( - builder: (context, visualProgress, child) => - Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 34, - height: 34, - child: CircularProgressIndicator( - value: visualProgress, - strokeWidth: 3, - color: Colors.white, - backgroundColor: Colors.white - .withValues(alpha: 0.25), - ), - ), - const SizedBox(height: 6), - Text( - '${(visualProgress * 100).round()}%', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w700, - fontSize: 14, - ), - ), - ], - ), - ) - : Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 34, - height: 34, - child: CircularProgressIndicator( - strokeWidth: 3, - color: Colors.white, - backgroundColor: Colors.white.withValues( - alpha: 0.25, - ), - ), - ), - ], - ), - ), - if (isFailed) - Positioned( - right: 4, - top: 4, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: colorScheme.errorContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.error_outline, - color: colorScheme.error, - size: 14, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 6), - Text( - item.track.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), - ), - Text( - item.track.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], + semanticLabel: context.l10n.a11yTrackByArtist( + item.track.name, + item.track.artistName, ), + cover: cover, + overlays: [ + if (isDownloading || isFinalizing || isQueued) + ClipRRect( + borderRadius: radius, + child: ColoredBox(color: Colors.black.withValues(alpha: 0.45)), + ), + if (isDownloading || isFinalizing || isQueued) + Center( + child: isDownloading && progress > 0 + ? SmoothedProgressBuilder( + builder: (context, visualProgress, child) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 34, + height: 34, + child: CircularProgressIndicator( + value: visualProgress, + strokeWidth: 3, + color: Colors.white, + backgroundColor: Colors.white.withValues( + alpha: 0.25, + ), + ), + ), + const SizedBox(height: 6), + Text( + '${(visualProgress * 100).round()}%', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + ], + ), + ) + : SizedBox( + width: 34, + height: 34, + child: CircularProgressIndicator( + strokeWidth: 3, + color: Colors.white, + backgroundColor: Colors.white.withValues(alpha: 0.25), + ), + ), + ), + if (isFailed) + Positioned( + right: 4, + top: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.error_outline, + color: colorScheme.error, + size: 14, + ), + ), + ), + ], + title: item.track.name, + subtitle: Text(item.track.artistName), ), ); } @@ -163,74 +126,32 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { ? _buildUnifiedCoverImage(unifiedItem, colorScheme) : track.coverUrl != null ? CachedCoverImage(imageUrl: track.coverUrl!, borderRadius: radius) - : Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: radius, - ), - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ); + : const TrackCoverPlaceholder(); final heroTag = historyItem != null ? 'cover_lib_dl_${historyItem.id}' : 'cover_${item.id}'; final trackName = historyItem?.trackName ?? track.name; final artistName = historyItem?.artistName ?? track.artistName; - return Semantics( - button: true, - label: context.l10n.a11yTrackByArtist(trackName, artistName), - child: GestureDetector( - onTap: () => historyItem != null - ? _navigateToHistoryMetadataScreen( - historyItem, - navigationItems: navigationItems, - navigationIndex: navigationIndex, - ) - : _navigateToMetadataScreen(item), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AspectRatio( - aspectRatio: 1, - child: Stack( - fit: StackFit.expand, - children: [ - Hero( - tag: heroTag, - child: ClipRRect(borderRadius: radius, child: cover), - ), - if (quality != null && quality.isNotEmpty) - Positioned( - left: 4, - top: 4, - child: _buildLibraryQualityBadge( - context, - colorScheme, - quality, - ), - ), - ], - ), - ), - const SizedBox(height: 6), - Text( - trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), - ), - Text( - artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), + return TrackGridCard( + semanticLabel: context.l10n.a11yTrackByArtist(trackName, artistName), + onTap: () => historyItem != null + ? _navigateToHistoryMetadataScreen( + historyItem, + navigationItems: navigationItems, + navigationIndex: navigationIndex, + ) + : _navigateToMetadataScreen(item), + cover: Hero(tag: heroTag, child: cover), + overlays: [ + if (quality != null && quality.isNotEmpty) + Positioned( + left: 4, + top: 4, + child: _buildLibraryQualityBadge(context, colorScheme, quality), + ), + ], + title: trackName, + subtitle: Text(artistName), ); } @@ -274,62 +195,41 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { : 'cover_${item.id}'; final trackName = historyItem?.trackName ?? track.name; final artistName = historyItem?.artistName ?? track.artistName; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => historyItem != null - ? _navigateToHistoryMetadataScreen( - historyItem, - navigationItems: navigationItems, - navigationIndex: navigationIndex, - ) - : _navigateToMetadataScreen(item), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Hero(tag: heroTag, child: cover), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - if (quality != null && quality.isNotEmpty) ...[ - const SizedBox(height: 4), - Align( - alignment: Alignment.centerLeft, - child: _buildLibraryQualityBadge( - context, - colorScheme, - quality, - listStyle: true, - ), - ), - ], - ], - ), - ), - ], + return TrackCard( + onTap: () => historyItem != null + ? _navigateToHistoryMetadataScreen( + historyItem, + navigationItems: navigationItems, + navigationIndex: navigationIndex, + ) + : _navigateToMetadataScreen(item), + leading: Hero(tag: heroTag, child: cover), + title: trackName, + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), - ), + if (quality != null && quality.isNotEmpty) ...[ + const SizedBox(height: 4), + Align( + alignment: Alignment.centerLeft, + child: _buildLibraryQualityBadge( + context, + colorScheme, + quality, + listStyle: true, + ), + ), + ], + ], ), ); } @@ -340,16 +240,17 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { String quality, { bool listStyle = false, }) { + final tokens = context.tokens; final isHighlightedQuality = shouldHighlightAudioQualityBadge(quality); return Container( - padding: EdgeInsets.symmetric(horizontal: listStyle ? 6 : 4, vertical: 2), + padding: tokens.badgePadding, decoration: BoxDecoration( color: isHighlightedQuality ? listStyle ? colorScheme.primaryContainer : colorScheme.primary : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), + borderRadius: tokens.borderRadiusBadge, ), child: Text( listStyle ? quality : _getQualityBadgeText(quality), @@ -359,7 +260,7 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { ? colorScheme.onPrimaryContainer : colorScheme.onPrimary : colorScheme.onSurfaceVariant, - fontSize: listStyle ? 10 : 9, + fontSize: tokens.badgeFontSize, fontWeight: listStyle ? FontWeight.w500 : FontWeight.w600, ), ), diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index 8588795d..78521065 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -188,135 +188,99 @@ extension _QueueTabItemWidgets on _QueueTabState { ), child: DownloadSuccessOverlay( showSuccess: isCompleted, - child: Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: isCompleted - ? () => _navigateToMetadataScreen(item) - : item.status == DownloadStatus.failed || - item.status == DownloadStatus.skipped - ? () => _showDownloadErrorDialog(context, item) - : null, - onLongPress: item.status == DownloadStatus.queued - ? () => _showQueuedItemMenu(context, item) - : null, - borderRadius: BorderRadius.circular(12), - child: Stack( - children: [ - if (item.status == DownloadStatus.downloading) - Positioned.fill( - child: SmoothedProgressBuilder( - builder: (context, progress, child) => Align( - alignment: Alignment.centerLeft, - child: FractionallySizedBox( - widthFactor: progress, - child: child, - ), - ), - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - colorScheme.primary.withValues(alpha: 0.16), - colorScheme.primary.withValues(alpha: 0.04), - ], - ), - ), + child: TrackCard( + onTap: isCompleted + ? () => _navigateToMetadataScreen(item) + : item.status == DownloadStatus.failed || + item.status == DownloadStatus.skipped + ? () => _showDownloadErrorDialog(context, item) + : null, + onLongPress: item.status == DownloadStatus.queued + ? () => _showQueuedItemMenu(context, item) + : null, + background: item.status == DownloadStatus.downloading + ? SmoothedProgressBuilder( + builder: (context, progress, child) => Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: progress, + child: child, + ), + ), + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + colorScheme.primary.withValues(alpha: 0.16), + colorScheme.primary.withValues(alpha: 0.04), + ], ), ), ), - Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - isCompleted - ? Hero( - tag: 'cover_${item.id}', - child: _buildCoverArt(item, colorScheme), - ) - : _buildCoverArt(item, colorScheme), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.track.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall - ?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 2), - ClickableArtistName( - artistName: item.track.artistName, - artistId: item.track.artistId, - coverUrl: item.track.coverUrl, - extensionId: item.track.source, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - if (item.status == - DownloadStatus.downloading) ...[ - const SizedBox(height: 5), - Row( - children: [ - Icon( - Icons.download_rounded, - size: 12, - color: colorScheme.primary, - ), - const SizedBox(width: 4), - Expanded( - child: SmoothedProgressBuilder( - builder: (context, progress, child) => - Text( - _formatDownloadStatusLine( - context, - item, - visualProgress: progress, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ], + ) + : null, + leading: isCompleted + ? Hero( + tag: 'cover_${item.id}', + child: _buildCoverArt(item, colorScheme), + ) + : _buildCoverArt(item, colorScheme), + title: item.track.name, + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ClickableArtistName( + artistName: item.track.artistName, + artistId: item.track.artistId, + coverUrl: item.track.coverUrl, + extensionId: item.track.source, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if (item.status == DownloadStatus.downloading) ...[ + const SizedBox(height: 5), + Row( + children: [ + Icon( + Icons.download_rounded, + size: 12, + color: colorScheme.primary, + ), + const SizedBox(width: 4), + Expanded( + child: SmoothedProgressBuilder( + builder: (context, progress, child) => Text( + _formatDownloadStatusLine( + context, + item, + visualProgress: progress, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, ), - ], - if (item.status == DownloadStatus.failed) ...[ - const SizedBox(height: 4), - _buildDownloadFailureMessage( - context, - item, - colorScheme, - ), - ], - ], ), ), - const SizedBox(width: 8), - _buildActionButtons(context, item, colorScheme), - ], - ), + ), + ], ), ], - ), + if (item.status == DownloadStatus.failed) ...[ + const SizedBox(height: 4), + _buildDownloadFailureMessage(context, item, colorScheme), + ], + ], ), + trailing: _buildActionButtons(context, item, colorScheme), ), ), ), @@ -894,170 +858,123 @@ extension _QueueTabItemWidgets on _QueueTabState { return Semantics( label: context.l10n.a11yTrackByArtist(item.trackName, item.artistName), selected: isSelected, - child: Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - color: isSelected - ? colorScheme.primaryContainer.withValues(alpha: 0.3) - : null, - child: InkWell( - onTap: _isSelectionMode - ? () => _toggleSelection(item.id) - : isDownloaded - ? () => _navigateToHistoryMetadataScreen( - item.historyItem!, - navigationItems: downloadedNavigationItems, - navigationIndex: downloadedNavigationIndex, - ) - : item.localItem != null - ? () => _navigateToLocalMetadataScreen( - item.localItem!, - navigationItems: localNavigationItems, - navigationIndex: localNavigationIndex, - ) - : () => _openFile( - item.filePath, - title: item.trackName, - artist: item.artistName, - album: item.albumName, - coverUrl: item.coverUrl ?? item.localCoverPath ?? '', - ), - onLongPress: _isSelectionMode - ? null - : () => _enterSelectionMode(item.id), - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( + child: TrackCard( + isSelectionMode: _isSelectionMode, + isSelected: isSelected, + onTap: _isSelectionMode + ? () => _toggleSelection(item.id) + : isDownloaded + ? () => _navigateToHistoryMetadataScreen( + item.historyItem!, + navigationItems: downloadedNavigationItems, + navigationIndex: downloadedNavigationIndex, + ) + : item.localItem != null + ? () => _navigateToLocalMetadataScreen( + item.localItem!, + navigationItems: localNavigationItems, + navigationIndex: localNavigationIndex, + ) + : () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: item.coverUrl ?? item.localCoverPath ?? '', + ), + onLongPress: _isSelectionMode + ? null + : () => _enterSelectionMode(item.id), + leading: Hero( + tag: 'cover_lib_${item.id}', + child: _buildUnifiedCoverImage( + item, + colorScheme, + context.tokens.coverList, + ), + ), + title: item.trackName, + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ClickableArtistName( + artistName: item.artistName, + coverUrl: item.coverUrl, + extensionId: item.historyItem?.service, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Row( children: [ - if (_isSelectionMode) ...[ - Semantics( - checked: isSelected, - label: isSelected - ? context.l10n.a11yDeselectTrack - : context.l10n.a11ySelectTrack, - child: AnimatedSelectionCheckbox( - visible: true, - selected: isSelected, - colorScheme: colorScheme, - size: 24, - ), + Container( + padding: context.tokens.badgePadding, + decoration: BoxDecoration( + color: sourceColor, + borderRadius: context.tokens.borderRadiusBadge, ), - const SizedBox(width: 12), - ], - Hero( - tag: 'cover_lib_${item.id}', - child: _buildUnifiedCoverImage(item, colorScheme, 56), - ), - const SizedBox(width: 12), - - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - ClickableArtistName( - artistName: item.artistName, - coverUrl: item.coverUrl, - extensionId: item.historyItem?.service, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 2), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: sourceColor, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - sourceLabel, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: sourceTextColor, - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), - ), - const SizedBox(width: 8), - Flexible( - child: Text( - dateStr, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: colorScheme.onSurfaceVariant - .withValues(alpha: 0.7), - ), - ), - ), - if (quality != null && quality.isNotEmpty) ...[ - const SizedBox(width: 8), - _buildLibraryQualityBadge( - context, - colorScheme, - quality, - listStyle: true, - ), - ], - ], - ), - ], + child: Text( + sourceLabel, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: sourceTextColor, + fontSize: context.tokens.badgeFontSize, + fontWeight: FontWeight.w500, + ), ), ), const SizedBox(width: 8), - - if (!_isSelectionMode) - ValueListenableBuilder( - valueListenable: fileExistsListenable, - builder: (context, fileExists, child) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (fileExists) - IconButton( - onPressed: () => - _playLibraryItem(item, libraryItems), - icon: Icon( - Icons.play_arrow, - color: colorScheme.primary, - ), - tooltip: context.l10n.tooltipPlay, - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer - .withValues(alpha: 0.3), - ), - ) - else - Icon( - Icons.error_outline, - color: colorScheme.error, - size: 20, - ), - ], - ); - }, + Flexible( + child: Text( + dateStr, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), + ), + if (quality != null && quality.isNotEmpty) ...[ + const SizedBox(width: 8), + _buildLibraryQualityBadge( + context, + colorScheme, + quality, + listStyle: true, + ), + ], ], ), - ), + ], + ), + trailing: ValueListenableBuilder( + valueListenable: fileExistsListenable, + builder: (context, fileExists, child) { + if (fileExists) { + return IconButton( + onPressed: () => _playLibraryItem(item, libraryItems), + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + tooltip: context.l10n.tooltipPlay, + style: IconButton.styleFrom( + minimumSize: Size.square(context.tokens.minTouchTarget), + backgroundColor: colorScheme.primaryContainer.withValues( + alpha: 0.3, + ), + ), + ); + } + return Tooltip( + message: context.l10n.queueDownloadedFileMissing, + child: Icon( + Icons.error_outline, + color: colorScheme.error, + size: 20, + ), + ); + }, ), ), ); @@ -1078,7 +995,12 @@ extension _QueueTabItemWidgets on _QueueTabState { final isDownloaded = item.source == LibraryItemSource.downloaded; final quality = item.qualityForMode(_libraryQualityLabelMode); - return GestureDetector( + return TrackGridCard( + isSelected: _isSelectionMode && isSelected, + semanticLabel: context.l10n.a11yTrackByArtist( + item.trackName, + item.artistName, + ), onTap: _isSelectionMode ? () => _toggleSelection(item.id) : isDownloaded @@ -1101,156 +1023,116 @@ extension _QueueTabItemWidgets on _QueueTabState { coverUrl: item.coverUrl ?? item.localCoverPath ?? '', ), onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id), - child: Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - children: [ - AspectRatio( - aspectRatio: 1, - child: Hero( - tag: 'cover_lib_${item.id}', - child: _buildUnifiedCoverImage(item, colorScheme), - ), - ), - Positioned( - right: 4, - top: 4, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - decoration: BoxDecoration( - color: isDownloaded - ? colorScheme.primaryContainer - : colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: Icon( - isDownloaded ? Icons.download_done : Icons.folder, - size: 12, - color: isDownloaded - ? colorScheme.onPrimaryContainer - : colorScheme.onSecondaryContainer, - ), - ), - ), - if (quality != null && quality.isNotEmpty) - Positioned( - left: 4, - top: 4, - child: _buildLibraryQualityBadge( - context, - colorScheme, - quality, - ), - ), - if (!_isSelectionMode) - Positioned( - right: 4, - bottom: 4, - child: ValueListenableBuilder( - valueListenable: fileExistsListenable, - builder: (context, fileExists, child) { - return fileExists - ? Semantics( - button: true, - label: context.l10n.a11yPlayTrackByArtist( - item.trackName, - item.artistName, - ), - child: GestureDetector( - onTap: () => - _playLibraryItem(item, libraryItems), - child: Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: colorScheme.primary, - shape: BoxShape.circle, - ), - child: ExcludeSemantics( - child: Icon( - Icons.play_arrow, - color: colorScheme.onPrimary, - size: 16, - ), - ), - ), - ), - ) - : Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: colorScheme.errorContainer, - shape: BoxShape.circle, - ), - child: Icon( - Icons.error_outline, - color: colorScheme.error, - size: 14, - ), - ); - }, - ), - ), - if (_isSelectionMode) - Positioned.fill( - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.3) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ], + cover: Hero( + tag: 'cover_lib_${item.id}', + child: _buildUnifiedCoverImage(item, colorScheme), + ), + overlays: [ + if (!_isSelectionMode) + Positioned( + right: 4, + top: 4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: isDownloaded + ? colorScheme.primaryContainer + : colorScheme.secondaryContainer, + borderRadius: context.tokens.borderRadiusBadge, ), - const SizedBox(height: 6), - Text( - item.trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500), - ), - ClickableArtistName( - artistName: item.artistName, - coverUrl: item.coverUrl, - extensionId: item.historyItem?.service, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - if (_isSelectionMode) - Positioned( - right: 4, - top: 4, - child: Container( - decoration: BoxDecoration( - color: isSelected ? colorScheme.primary : colorScheme.surface, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon(Icons.check, color: colorScheme.onPrimary, size: 16) - : const SizedBox(width: 16, height: 16), + child: Icon( + isDownloaded ? Icons.download_done : Icons.folder, + size: 12, + color: isDownloaded + ? colorScheme.onPrimaryContainer + : colorScheme.onSecondaryContainer, ), ), - ], + ), + if (quality != null && quality.isNotEmpty) + Positioned( + left: 4, + top: 4, + child: _buildLibraryQualityBadge(context, colorScheme, quality), + ), + if (!_isSelectionMode) + Positioned( + right: 4, + bottom: 4, + child: ValueListenableBuilder( + valueListenable: fileExistsListenable, + builder: (context, fileExists, child) { + if (fileExists) { + return Semantics( + button: true, + label: context.l10n.a11yPlayTrackByArtist( + item.trackName, + item.artistName, + ), + child: IconButton.filled( + onPressed: () => _playLibraryItem(item, libraryItems), + visualDensity: VisualDensity.compact, + iconSize: 18, + icon: const Icon(Icons.play_arrow), + style: IconButton.styleFrom( + minimumSize: const Size.square(36), + padding: EdgeInsets.zero, + ), + ), + ); + } + return Tooltip( + message: context.l10n.queueDownloadedFileMissing, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.error_outline, + color: colorScheme.error, + size: 14, + ), + ), + ); + }, + ), + ), + if (_isSelectionMode) + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.3) + : Colors.transparent, + borderRadius: context.tokens.borderRadiusThumb, + ), + ), + ), + if (_isSelectionMode) + Positioned( + right: 4, + top: 4, + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, + unselectedColor: colorScheme.surface, + ), + ), + ], + title: item.trackName, + subtitle: ClickableArtistName( + artistName: item.artistName, + coverUrl: item.coverUrl, + extensionId: item.historyItem?.service, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), ), ); } diff --git a/lib/widgets/album_track_tile.dart b/lib/widgets/album_track_tile.dart index acaaff1c..346a5885 100644 --- a/lib/widgets/album_track_tile.dart +++ b/lib/widgets/album_track_tile.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/widgets/animation_utils.dart'; +import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/widgets/track_card.dart'; -/// Shared track row for album-style screens: track number, selection -/// checkbox, title/subtitle, and a trailing play button that's replaced by -/// the checkbox once selection mode is active. +/// Album-style track row: track number, title/subtitle, and a trailing play +/// button that's hidden once selection mode is active. +/// +/// A thin arrangement over [TrackCard]; the container, selection tick, padding +/// and typography all come from there. class AlbumTrackTile extends StatelessWidget { const AlbumTrackTile({ super.key, @@ -33,66 +36,32 @@ class AlbumTrackTile extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Card( - elevation: 0, - color: isSelected - ? colorScheme.primaryContainer.withValues(alpha: 0.3) - : Colors.transparent, - margin: const EdgeInsets.symmetric(vertical: 2), - child: ListTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + return TrackCard( + style: TrackCardStyle.flat, + isSelectionMode: isSelectionMode, + isSelected: isSelected, + onTap: isSelectionMode ? onToggleSelection : onOpen, + onLongPress: isSelectionMode ? null : onEnterSelectionMode, + leading: SizedBox( + width: 24, + child: Text( + trackNumber?.toString() ?? '-', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, ), - onTap: isSelectionMode ? onToggleSelection : onOpen, - onLongPress: isSelectionMode ? null : onEnterSelectionMode, - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isSelectionMode) ...[ - AnimatedSelectionCheckbox( - visible: true, - selected: isSelected, - colorScheme: colorScheme, - size: 24, - ), - const SizedBox(width: 12), - ], - SizedBox( - width: 24, - child: Text( - trackNumber?.toString() ?? '-', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - ], - ), - title: Text( - trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), - ), - subtitle: subtitle, - trailing: isSelectionMode - ? null - : IconButton( - tooltip: context.l10n.tooltipPlay, - onPressed: onPlay, - icon: Icon(Icons.play_arrow, color: colorScheme.primary), - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer.withValues( - alpha: 0.3, - ), - ), - ), + textAlign: TextAlign.center, + ), + ), + title: trackName, + subtitle: subtitle, + trailing: IconButton( + tooltip: context.l10n.tooltipPlay, + onPressed: onPlay, + icon: Icon(Icons.play_arrow, color: colorScheme.primary), + style: IconButton.styleFrom( + minimumSize: Size.square(context.tokens.minTouchTarget), + backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), ), ), ); diff --git a/lib/widgets/track_card.dart b/lib/widgets/track_card.dart new file mode 100644 index 00000000..31f58e7a --- /dev/null +++ b/lib/widgets/track_card.dart @@ -0,0 +1,299 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; + +/// How a [TrackCard] paints its container. +enum TrackCardStyle { + /// Filled card surface. Used where rows are discrete objects the user acts + /// on individually: the download queue, history, the local library. + filled, + + /// Transparent container. Used inside a collection that already has its own + /// header and reads as one continuous list: album, playlist and folder track + /// lists. + flat, +} + +/// The one track row in the app. +/// +/// Six near-identical implementations existed before this +/// (`TrackListTile`, `AlbumTrackTile`, the queue item, the bridge item, the +/// unified library item and the folder tile), each with its own radius, +/// padding, title style and selection treatment. Screens now supply only the +/// parts that genuinely differ: [leading], [subtitle], [trailing] and an +/// optional [background] layer for download progress. +class TrackCard extends StatelessWidget { + const TrackCard({ + super.key, + required this.leading, + required this.title, + this.subtitle, + this.trailing, + this.background, + this.style = TrackCardStyle.filled, + this.isSelectionMode = false, + this.isSelected = false, + this.onTap, + this.onLongPress, + this.margin, + this.titleStyle, + }); + + /// Track number, cover art, or anything else that identifies the row. + final Widget leading; + + final String title; + + /// Secondary line. A widget rather than a string because callers mix in + /// clickable artist names, quality badges and status text. + final Widget? subtitle; + + /// Hidden while [isSelectionMode] is active, so the row cannot be acted on + /// while the user is picking items. + final Widget? trailing; + + /// Painted behind the content, clipped to the card. Used for the queue's + /// progress fill. + final Widget? background; + + final TrackCardStyle style; + final bool isSelectionMode; + final bool isSelected; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + /// Overrides the default outer margin. Prefer the default so track lists stay + /// aligned with each other. + final EdgeInsetsGeometry? margin; + + /// Overrides the title text style for rows that need to signal a different + /// state (e.g. a dimmed unavailable track). + final TextStyle? titleStyle; + + @override + Widget build(BuildContext context) { + final tokens = context.tokens; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final borderRadius = tokens.borderRadiusCover; + + final Color? cardColor; + if (isSelected) { + cardColor = colorScheme.primaryContainer.withValues(alpha: 0.3); + } else if (style == TrackCardStyle.flat) { + cardColor = Colors.transparent; + } else { + cardColor = null; // Inherits cardTheme.color. + } + + return Card( + elevation: 0, + // The two styles carry their own outer margin so no call site has to + // repeat it: flat rows sit tight inside a collection list, filled cards + // get the Material list margin. + margin: + margin ?? + (style == TrackCardStyle.flat + ? EdgeInsets.symmetric(horizontal: tokens.gapSm, vertical: 2) + : EdgeInsets.symmetric( + horizontal: tokens.gapLg, + vertical: tokens.gapXs, + )), + color: cardColor, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: borderRadius), + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + borderRadius: borderRadius, + child: Stack( + children: [ + if (background != null) Positioned.fill(child: background!), + Padding( + padding: EdgeInsets.all(tokens.gapMd), + child: Row( + children: [ + if (isSelectionMode) ...[ + Semantics( + checked: isSelected, + label: isSelected + ? context.l10n.a11yDeselectTrack + : context.l10n.a11ySelectTrack, + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, + ), + ), + SizedBox(width: tokens.gapMd), + ], + leading, + SizedBox(width: tokens.gapMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + titleStyle ?? + theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + subtitle!, + ], + ], + ), + ), + if (trailing != null && !isSelectionMode) ...[ + SizedBox(width: tokens.gapSm), + trailing!, + ], + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Grid counterpart of [TrackCard]: square artwork with overlays, then the +/// title and subtitle underneath. +/// +/// Replaces three copies that each re-declared the radius, the overlay stack +/// and the label typography, and used a bare `GestureDetector` (no ripple, no +/// semantics). +class TrackGridCard extends StatelessWidget { + const TrackGridCard({ + super.key, + required this.cover, + required this.title, + this.subtitle, + this.overlays = const [], + this.onTap, + this.onLongPress, + this.isSelected = false, + this.semanticLabel, + }); + + /// Artwork, already sized to fill the square. Clipping is handled here. + final Widget cover; + + final String title; + + /// Secondary line. A widget so cells can host a clickable artist name; plain + /// `Text` children inherit the shared subtitle style. + final Widget? subtitle; + + /// Progress rings, status badges and selection ticks stacked over the cover. + final List overlays; + + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final bool isSelected; + final String? semanticLabel; + + @override + Widget build(BuildContext context) { + final tokens = context.tokens; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final borderRadius = tokens.borderRadiusThumb; + + return Semantics( + label: semanticLabel, + button: onTap != null, + selected: isSelected, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + borderRadius: borderRadius, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 1, + child: Stack( + fit: StackFit.expand, + children: [ + ClipRRect(borderRadius: borderRadius, child: cover), + if (isSelected) + DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + border: Border.all( + color: colorScheme.primary, + width: 2, + ), + ), + ), + ...overlays, + ], + ), + ), + SizedBox(height: tokens.gapXs + 2), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (subtitle != null) + DefaultTextStyle.merge( + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall!.copyWith( + color: colorScheme.onSurfaceVariant, + ), + child: subtitle!, + ), + ], + ), + ), + ); + } +} + +/// Square artwork placeholder shared by every track row and grid cell. +/// +/// The `Container` + `surfaceContainerHighest` + `music_note` combination was +/// repeated at roughly a dozen call sites with four different radii. +class TrackCoverPlaceholder extends StatelessWidget { + const TrackCoverPlaceholder({super.key, this.size, this.borderRadius}); + + /// Omit to fill the available space (grid cells). + final double? size; + + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + final tokens = context.tokens; + final colorScheme = Theme.of(context).colorScheme; + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: borderRadius ?? tokens.borderRadiusThumb, + ), + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + size: size == null ? null : size! * 0.45, + ), + ); + } +} diff --git a/lib/widgets/track_list_tile.dart b/lib/widgets/track_list_tile.dart index 4bbf123f..9ef1d4d2 100644 --- a/lib/widgets/track_list_tile.dart +++ b/lib/widgets/track_list_tile.dart @@ -7,7 +7,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/local_playback.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; -import 'package:spotiflac_android/widgets/animation_utils.dart'; +import 'package:spotiflac_android/widgets/track_card.dart'; import 'package:spotiflac_android/widgets/in_library_badge.dart'; import 'package:spotiflac_android/widgets/preview_button.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; @@ -70,106 +70,71 @@ class TrackListTile extends ConsumerWidget { final isQueued = queueItem != null; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Card( - elevation: 0, - color: isSelected - ? colorScheme.primaryContainer.withValues(alpha: 0.3) - : Colors.transparent, - margin: const EdgeInsets.symmetric(vertical: 2), - child: ListTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + return TrackCard( + style: TrackCardStyle.flat, + isSelectionMode: isSelectionMode, + isSelected: isSelected, + leading: leading, + title: track.name, + subtitle: Row( + children: [ + Flexible( + child: clickableArtist && !isSelectionMode + ? ClickableArtistName( + artistName: track.artistName, + artistId: track.artistId, + coverUrl: track.coverUrl, + extensionId: track.source, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ) + : Text( + track.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), ), - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isSelectionMode) ...[ - AnimatedSelectionCheckbox( - visible: true, - selected: isSelected, - colorScheme: colorScheme, - size: 24, - ), - const SizedBox(width: 12), - ], - leading, - ], + ...buildQualityBadges( + audioQuality: track.audioQuality, + audioModes: track.audioModes, + colorScheme: colorScheme, + explicit: track.isExplicit, ), - title: Text( - track.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( + if (isInLocalLibrary || isInHistory) ...[ + const SizedBox(width: 6), + const InLibraryBadge(), + ], + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + PreviewButton(track: track), + TrackCollectionQuickActions( + track: track, + hasLocalPlaybackCandidate: isInHistory || isInLocalLibrary, + ), + ], + ), + onTap: isSelectionMode + ? onToggleSelection + : () => _handleTap( context, - ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), - ), - subtitle: Row( - children: [ - Flexible( - child: clickableArtist && !isSelectionMode - ? ClickableArtistName( - artistName: track.artistName, - artistId: track.artistId, - coverUrl: track.coverUrl, - extensionId: track.source, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: colorScheme.onSurfaceVariant), - ) - : Text( - track.artistName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - ), - ...buildQualityBadges( - audioQuality: track.audioQuality, - audioModes: track.audioModes, - colorScheme: colorScheme, - explicit: track.isExplicit, - ), - if (isInLocalLibrary || isInHistory) ...[ - const SizedBox(width: 6), - const InLibraryBadge(), - ], - ], - ), - trailing: isSelectionMode - ? null - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - PreviewButton(track: track), - TrackCollectionQuickActions( - track: track, - hasLocalPlaybackCandidate: - isInHistory || isInLocalLibrary, - ), - ], - ), - onTap: isSelectionMode - ? onToggleSelection - : () => _handleTap( + ref, + isQueued: isQueued, + isInLocalLibrary: isInLocalLibrary, + ), + onLongPress: isSelectionMode + ? null + : onEnterSelectionMode ?? + () => TrackCollectionQuickActions.showTrackOptionsSheet( context, ref, - isQueued: isQueued, - isInLocalLibrary: isInLocalLibrary, + track, + hasLocalPlaybackCandidate: isInHistory || isInLocalLibrary, ), - onLongPress: isSelectionMode - ? null - : onEnterSelectionMode ?? - () => TrackCollectionQuickActions.showTrackOptionsSheet( - context, - ref, - track, - hasLocalPlaybackCandidate: - isInHistory || isInLocalLibrary, - ), - ), - ), ); }