diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 574b8256..c2712eed 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2421,6 +2421,31 @@ class DownloadQueueNotifier extends Notifier { _requestNativeCancel(id); } + void dismissItem(String id) { + final item = _findItemById(id); + if (item == null) return; + + final isActive = + item.status == DownloadStatus.queued || + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing; + + if (isActive) { + _pausePendingItemIds.remove(id); + _locallyCancelledItemIds.add(id); + _requestNativeCancel(id); + } else { + _locallyCancelledItemIds.remove(id); + } + + final items = state.items.where((entry) => entry.id != id).toList(); + final currentDownload = state.currentDownload?.id == id + ? null + : state.currentDownload; + state = state.copyWith(items: items, currentDownload: currentDownload); + _saveQueueToStorage(); + } + void clearCompleted() { final items = state.items .where( diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 126c2f5e..888cc613 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -14,6 +14,7 @@ import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; @@ -267,8 +268,8 @@ class _AlbumScreenState extends ConsumerState { if (_isLoading) const SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), + padding: EdgeInsets.all(16), + child: AlbumTrackListSkeleton(itemCount: 10), ), ), if (_error != null) @@ -544,9 +545,12 @@ class _AlbumScreenState extends ConsumerState { final track = tracks[index]; return KeyedSubtree( key: ValueKey(track.id), - child: _AlbumTrackItem( - track: track, - onDownload: () => _downloadTrack(context, track), + child: StaggeredListItem( + index: index, + child: _AlbumTrackItem( + track: track, + onDownload: () => _downloadTrack(context, track), + ), ), ); }, childCount: tracks.length), @@ -587,7 +591,6 @@ class _AlbumScreenState extends ConsumerState { final tracks = _tracks; if (tracks == null || tracks.isEmpty) return; - // Skip already-downloaded tracks final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); final localLibState = diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 751bc797..ef72e14d 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -20,6 +20,7 @@ import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; class _ArtistCache { @@ -491,12 +492,7 @@ class _ArtistScreenState extends ConsumerState { hasDiscography: hasDiscography, ), if (_isLoadingDiscography) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), - ), - ), + const SliverToBoxAdapter(child: ArtistScreenSkeleton()), if (_error != null) SliverToBoxAdapter( child: Padding( @@ -959,7 +955,6 @@ class _ArtistScreenState extends ConsumerState { fetchedCount++; - // Update progress dialog if (mounted) { _FetchingProgressDialog.updateProgress( context, @@ -990,7 +985,6 @@ class _ArtistScreenState extends ConsumerState { return; } - // Check which tracks are already downloaded final historyState = ref.read(downloadHistoryProvider); final tracksToQueue = []; int skippedCount = 0; @@ -1041,10 +1035,7 @@ class _ArtistScreenState extends ConsumerState { content: Text(message), action: SnackBarAction( label: context.l10n.snackbarViewQueue, - onPressed: () { - // Navigate to queue tab (index 1) - // This will be handled by the navigation system - }, + onPressed: () {}, ), ), ); @@ -1851,29 +1842,14 @@ class _ArtistScreenState extends ConsumerState { Positioned( top: 8, right: 8, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: 28, - height: 28, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : colorScheme.surface.withValues(alpha: 0.9), - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 28, + unselectedColor: colorScheme.surface.withValues( + alpha: 0.9, ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 18, - ) - : null, ), ), if (showTypeBadge) @@ -2082,7 +2058,6 @@ class _FetchingProgressDialog extends StatefulWidget { required this.onCancel, }); - // Static method to update progress from outside static void updateProgress(BuildContext context, int current, int total) { final state = context .findAncestorStateOfType<_FetchingProgressDialogState>(); @@ -2155,7 +2130,6 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> { ), ), const SizedBox(height: 8), - // Progress bar ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 0d40173a..2690fb33 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class DownloadedAlbumScreen extends ConsumerStatefulWidget { final String albumName; @@ -120,7 +121,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { final tracks = allItems.where((item) { - // Use albumArtist if available and not empty, otherwise artistName final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty) ? item.albumArtist! @@ -129,7 +129,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}'; return itemKey == _albumLookupKey; }).toList()..sort((a, b) { - // Sort by disc number first, then by track number final aDisc = a.discNumber ?? 1; final bDisc = b.discNumber ?? 1; if (aDisc != bDisc) return aDisc.compareTo(bDisc); @@ -310,14 +309,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { if (!mounted) return; final result = await navigator.push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: item), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(item: item)), ); await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( item.filePath, @@ -693,7 +685,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { final track = tracks[index]; return KeyedSubtree( key: ValueKey(track.id), - child: _buildTrackItem(context, colorScheme, track), + child: StaggeredListItem( + index: index, + child: _buildTrackItem(context, colorScheme, track), + ), ); }, childCount: tracks.length), ); @@ -701,6 +696,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { final discNumbers = _getSortedDiscNumbers(tracks); final List children = []; + var revealIndex = 0; for (final discNumber in discNumbers) { final discTracks = discMap[discNumber]; @@ -712,7 +708,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { children.add( KeyedSubtree( key: ValueKey(track.id), - child: _buildTrackItem(context, colorScheme, track), + child: StaggeredListItem( + index: revealIndex++, + child: _buildTrackItem(context, colorScheme, track), + ), ), ); } @@ -796,28 +795,11 @@ class _DownloadedAlbumScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ if (_isSelectionMode) ...[ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 16, - ) - : null, + AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, ), const SizedBox(width: 12), ], diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 2c0521e0..504e7edc 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -28,6 +28,7 @@ import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; class HomeTab extends ConsumerStatefulWidget { @@ -1297,8 +1298,8 @@ class _HomeTabState extends ConsumerState exploreLoading) const SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), + padding: EdgeInsets.all(16), + child: TrackListSkeleton(itemCount: 5), ), ), @@ -1548,7 +1549,11 @@ class _HomeTabState extends ConsumerState itemCount: section.items.length, itemBuilder: (context, index) { final item = section.items[index]; - return _buildExploreItem(item, colorScheme); + return StaggeredListItem( + index: index, + staggerDelay: const Duration(milliseconds: 50), + child: _buildExploreItem(item, colorScheme), + ); }, ), ), @@ -2270,14 +2275,7 @@ class _HomeTabState extends ConsumerState ); if (!mounted) return; final result = await navigator.push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: item), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(item: item)), ); await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( item.filePath, @@ -2590,6 +2588,15 @@ class _HomeTabState extends ConsumerState required bool showLocalLibraryIndicator, required Map thumbnailSizesByExtensionId, }) { + final hasActualData = + tracks.isNotEmpty || + (searchArtists != null && searchArtists.isNotEmpty) || + (searchAlbums != null && searchAlbums.isNotEmpty) || + (searchPlaylists != null && searchPlaylists.isNotEmpty); + + if (!hasActualData && isLoading) { + return [const SliverToBoxAdapter(child: HomeSearchSkeleton())]; + } if (!hasResults) { return [const SliverToBoxAdapter(child: SizedBox.shrink())]; } @@ -2601,7 +2608,6 @@ class _HomeTabState extends ConsumerState final playlistItems = buckets.playlistItems; final artistItems = buckets.artistItems; - // Apply sorting to each list. final sortedArtists = searchArtists != null && searchArtists.isNotEmpty ? _applySortToList( searchArtists, @@ -2633,7 +2639,6 @@ class _HomeTabState extends ConsumerState ) : searchPlaylists; - // For tracks we need paired sorting (track + original index). List sortedTracks; List sortedTrackIndexes; if (realTracks.isNotEmpty && @@ -2673,7 +2678,6 @@ class _HomeTabState extends ConsumerState ), ]; - // Track whether the sort button has been shown yet (show on first section). bool sortButtonShown = false; if (sortedArtists != null && sortedArtists.isNotEmpty) { @@ -2878,19 +2882,22 @@ class _HomeTabState extends ConsumerState delegate: SliverChildBuilderDelegate((context, index) { final isFirst = index == 0; final isLast = index == itemCount - 1; - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: sectionColor, - borderRadius: BorderRadius.vertical( - top: isFirst ? const Radius.circular(20) : Radius.zero, - bottom: isLast ? const Radius.circular(20) : Radius.zero, + return StaggeredListItem( + index: index, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: sectionColor, + borderRadius: BorderRadius.vertical( + top: isFirst ? const Radius.circular(20) : Radius.zero, + bottom: isLast ? const Radius.circular(20) : Radius.zero, + ), + ), + clipBehavior: Clip.antiAlias, + child: Material( + color: Colors.transparent, + child: itemBuilder(index, !isLast), ), - ), - clipBehavior: Clip.antiAlias, - child: Material( - color: Colors.transparent, - child: itemBuilder(index, !isLast), ), ); }, childCount: itemCount), @@ -3084,7 +3091,6 @@ class _HomeTabState extends ConsumerState } if (searchProvider != null && searchProvider.isNotEmpty) { - // Check built-in providers first if (searchProvider == 'tidal') { return 'Search with Tidal...'; } @@ -3178,7 +3184,6 @@ class _HomeTabState extends ConsumerState if (text.isEmpty || text.length < _minLiveSearchChars) return; if (text.startsWith('http') || text.startsWith('spotify:')) return; - // Reset last search query to force new search _lastSearchQuery = null; _performSearch(text, filterOverride: filter); } @@ -3299,7 +3304,6 @@ class _SearchProviderDropdown extends ConsumerWidget { .firstOrNull; } - // Check if current provider is a built-in provider (tidal/qobuz) const builtInProviders = {'tidal', 'qobuz'}; final isBuiltInProvider = currentProvider != null && builtInProviders.contains(currentProvider); @@ -3379,7 +3383,6 @@ class _SearchProviderDropdown extends ConsumerWidget { ], ), ), - // Built-in Tidal search option PopupMenuItem( value: 'tidal', child: Row( @@ -3407,7 +3410,6 @@ class _SearchProviderDropdown extends ConsumerWidget { ], ), ), - // Built-in Qobuz search option PopupMenuItem( value: 'qobuz', child: Row( @@ -4230,7 +4232,6 @@ class _ExtensionAlbumScreenState extends ConsumerState { .map((t) => _parseTrack(t as Map)) .toList(); - // Extract artist info from album response final artistId = (result['artist_id'] ?? result['artistId'])?.toString(); final artistName = result['artists'] as String?; @@ -4288,7 +4289,10 @@ class _ExtensionAlbumScreenState extends ConsumerState { if (_isLoading) { return Scaffold( appBar: AppBar(title: Text(widget.albumName)), - body: const Center(child: CircularProgressIndicator()), + body: const AlbumTrackListSkeleton( + itemCount: 10, + showCoverHeader: true, + ), ); } @@ -4442,7 +4446,7 @@ class _ExtensionPlaylistScreenState if (_isLoading) { return Scaffold( appBar: AppBar(title: Text(widget.playlistName)), - body: const Center(child: CircularProgressIndicator()), + body: const TrackListSkeleton(itemCount: 8, showCoverHeader: true), ); } @@ -4614,7 +4618,7 @@ class _ExtensionArtistScreenState extends ConsumerState { if (_isLoading) { return Scaffold( appBar: AppBar(title: Text(widget.artistName)), - body: const Center(child: CircularProgressIndicator()), + body: const ArtistScreenSkeleton(), ); } diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 48847f63..17444c7b 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class LibraryTracksFolderScreen extends ConsumerStatefulWidget { final LibraryTracksFolderMode mode; @@ -273,7 +274,6 @@ class _LibraryTracksFolderScreenState break; } - // Stale selection cleanup if (_isSelectionMode) { final validKeys = entries.map((e) => e.key).toSet(); _selectedKeys.removeWhere((key) => !validKeys.contains(key)); @@ -349,20 +349,23 @@ class _LibraryTracksFolderScreenState final isSelected = _selectedKeys.contains(entry.key); return KeyedSubtree( key: ValueKey(entry.key), - child: _CollectionTrackTile( - entry: entry, - mode: widget.mode, - playlistId: widget.playlistId, - localLibraryState: localState, - folderTracks: folderTracks, - isSelectionMode: _isSelectionMode, - isSelected: isSelected, - onTap: _isSelectionMode - ? () => _toggleSelection(entry.key) - : null, - onLongPress: _isSelectionMode - ? null - : () => _enterSelectionMode(entry.key), + child: StaggeredListItem( + index: index, + child: _CollectionTrackTile( + entry: entry, + mode: widget.mode, + playlistId: widget.playlistId, + localLibraryState: localState, + folderTracks: folderTracks, + isSelectionMode: _isSelectionMode, + isSelected: isSelected, + onTap: _isSelectionMode + ? () => _toggleSelection(entry.key) + : null, + onLongPress: _isSelectionMode + ? null + : () => _enterSelectionMode(entry.key), + ), ), ); }, childCount: entries.length), @@ -373,7 +376,6 @@ class _LibraryTracksFolderScreenState ], ), - // Selection bottom bar AnimatedPositioned( duration: const Duration(milliseconds: 250), curve: Curves.easeOutCubic, @@ -1082,14 +1084,19 @@ class _CollectionTrackTile extends ConsumerWidget { final track = entry.track; final colorScheme = Theme.of(context).colorScheme; final effectiveCoverUrl = _resolveCoverUrl(track); - final isInHistory = ref.watch( + + // Fine-grained provider watches – only this tile rebuilds when its own + // history / local-library entry changes. + final historyItem = ref.watch( downloadHistoryProvider.select((state) { - if (state.isDownloaded(track.id)) return true; + final byId = state.getBySpotifyId(track.id); + if (byId != null) return byId; final isrc = track.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) { - return true; + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = state.getByIsrc(isrc); + if (byIsrc != null) return byIsrc; } - return state.findByTrackAndArtist(track.name, track.artistName) != null; + return state.findByTrackAndArtist(track.name, track.artistName); }), ); final showLocalLibraryIndicator = ref.watch( @@ -1097,17 +1104,26 @@ class _CollectionTrackTile extends ConsumerWidget { (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, ), ); - final isInLocalLibrary = showLocalLibraryIndicator + final localItem = showLocalLibraryIndicator ? ref.watch( - localLibraryProvider.select( - (state) => state.existsInLibrary( - isrc: track.isrc, - trackName: track.name, - artistName: track.artistName, - ), - ), + localLibraryProvider.select((state) { + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = state.getByIsrc(isrc); + if (byIsrc != null) return byIsrc; + } + return state.findByTrackAndArtist(track.name, track.artistName); + }), ) - : false; + : null; + + final isInHistory = historyItem != null; + final isInLocalLibrary = localItem != null; + final heroTag = historyItem != null + ? 'cover_${historyItem.id}' + : localItem != null + ? 'cover_lib_${localItem.id}' + : null; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -1125,43 +1141,51 @@ class _CollectionTrackTile extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ if (isSelectionMode) ...[ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 16, - ) - : null, + AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, ), const SizedBox(width: 12), ], - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: effectiveCoverUrl != null && effectiveCoverUrl.isNotEmpty - ? _buildTrackCover(context, effectiveCoverUrl, 52) - : Container( - width: 52, - height: 52, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, + HeroMode( + enabled: heroTag != null, + child: heroTag != null + ? Hero( + tag: heroTag, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: + effectiveCoverUrl != null && + effectiveCoverUrl.isNotEmpty + ? _buildTrackCover(context, effectiveCoverUrl, 52) + : Container( + width: 52, + height: 52, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), ), + ) + : ClipRRect( + borderRadius: BorderRadius.circular(8), + child: + effectiveCoverUrl != null && + effectiveCoverUrl.isNotEmpty + ? _buildTrackCover(context, effectiveCoverUrl, 52) + : Container( + width: 52, + height: 52, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), ), ), ], @@ -1391,7 +1415,6 @@ class _CollectionTrackTile extends ConsumerWidget { color: colorScheme.outlineVariant.withValues(alpha: 0.5), ), - // Add to playlist (hidden in wishlist unless already downloaded) if (showAddToPlaylist) BottomSheetOptionTile( icon: Icons.playlist_add, @@ -1402,7 +1425,6 @@ class _CollectionTrackTile extends ConsumerWidget { }, ), - // Remove from folder / playlist BottomSheetOptionTile( icon: Icons.remove_circle_outline, iconColor: colorScheme.error, @@ -1501,16 +1523,9 @@ class _CollectionTrackTile extends ConsumerWidget { ); if (historyItem != null) { - await Navigator.of(context).push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: historyItem), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), - ); + await Navigator.of( + context, + ).push(slidePageRoute(page: TrackMetadataScreen(item: historyItem))); return; } @@ -1525,16 +1540,9 @@ class _CollectionTrackTile extends ConsumerWidget { localItem ??= localState.findByTrackAndArtist(track.name, track.artistName); if (localItem != null) { - await Navigator.of(context).push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(localItem: localItem), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), - ); + await Navigator.of( + context, + ).push(slidePageRoute(page: TrackMetadataScreen(localItem: localItem))); return; } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 5c3c8ea3..d41e6c80 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -16,6 +16,7 @@ import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class LocalAlbumScreen extends ConsumerStatefulWidget { final String albumName; @@ -531,7 +532,6 @@ class _LocalAlbumScreenState extends ConsumerState { if (tracks.isEmpty) return null; final first = tracks.first; - // For lossy formats, use bitrate if (first.bitrate != null && first.bitrate! > 0) { final fmt = first.format?.toUpperCase() ?? ''; final firstBitrate = first.bitrate; @@ -543,7 +543,6 @@ class _LocalAlbumScreenState extends ConsumerState { return '$fmt ${firstBitrate}kbps'.trim(); } - // For lossless formats, use bit depth / sample rate if (first.bitDepth == null || first.bitDepth == 0 || first.sampleRate == null) { @@ -630,7 +629,10 @@ class _LocalAlbumScreenState extends ConsumerState { final track = discTracks[index]; return KeyedSubtree( key: ValueKey(track.id), - child: _buildTrackItem(context, colorScheme, track), + child: StaggeredListItem( + index: index, + child: _buildTrackItem(context, colorScheme, track), + ), ); }, childCount: discTracks.length), ), @@ -669,28 +671,11 @@ class _LocalAlbumScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ if (_isSelectionMode) ...[ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 16, - ) - : null, + AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, ), const SizedBox(width: 12), ], diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 61feab23..4f3fcbe1 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/update_checker.dart'; import 'package:spotiflac_android/widgets/update_dialog.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('MainShell'); @@ -461,32 +462,44 @@ class _MainShellState extends ConsumerState label: l10n.navHome, ), NavigationDestination( - icon: Badge( - isLabelVisible: queueState > 0, - label: Text('$queueState'), - child: const Icon(Icons.library_music_outlined), - ), - selectedIcon: SlidingIcon( + icon: AnimatedBadge( + count: queueState, child: Badge( isLabelVisible: queueState > 0, label: Text('$queueState'), - child: const Icon(Icons.library_music), + child: const Icon(Icons.library_music_outlined), + ), + ), + selectedIcon: SlidingIcon( + child: AnimatedBadge( + count: queueState, + child: Badge( + isLabelVisible: queueState > 0, + label: Text('$queueState'), + child: const Icon(Icons.library_music), + ), ), ), label: l10n.navLibrary, ), if (showStore) NavigationDestination( - icon: Badge( - isLabelVisible: storeUpdatesCount > 0, - label: Text('$storeUpdatesCount'), - child: const Icon(Icons.store_outlined), - ), - selectedIcon: SwingIcon( + icon: AnimatedBadge( + count: storeUpdatesCount, child: Badge( isLabelVisible: storeUpdatesCount > 0, label: Text('$storeUpdatesCount'), - child: const Icon(Icons.store), + child: const Icon(Icons.store_outlined), + ), + ), + selectedIcon: SwingIcon( + child: AnimatedBadge( + count: storeUpdatesCount, + child: Badge( + isLabelVisible: storeUpdatesCount > 0, + label: Text('$storeUpdatesCount'), + child: const Icon(Icons.store), + ), ), ), label: l10n.navStore, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index f3af578e..1ac74e77 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -15,6 +15,7 @@ import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; @@ -387,8 +388,8 @@ class _PlaylistScreenState extends ConsumerState { if (_isLoading) { return const SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), + padding: EdgeInsets.all(16), + child: TrackListSkeleton(itemCount: 8), ), ); } @@ -438,9 +439,12 @@ class _PlaylistScreenState extends ConsumerState { final track = _tracks[index]; return KeyedSubtree( key: ValueKey(track.id), - child: _PlaylistTrackItem( - track: track, - onDownload: () => _downloadTrack(context, track), + child: StaggeredListItem( + index: index, + child: _PlaylistTrackItem( + track: track, + onDownload: () => _downloadTrack(context, track), + ), ), ); }, childCount: _tracks.length), @@ -644,7 +648,6 @@ class _PlaylistScreenState extends ConsumerState { void _downloadTracks(BuildContext context, List tracks) { if (tracks.isEmpty) return; - // Skip already-downloaded tracks final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); final localLibState = @@ -754,7 +757,6 @@ class _PlaylistTrackItem extends ConsumerWidget { }), ); - // Check local library for duplicate detection final showLocalLibraryIndicator = ref.watch( settingsProvider.select( (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 21b58379..326c78e4 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -34,6 +34,7 @@ import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/path_match_keys.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; enum LibraryItemSource { downloaded, local } @@ -785,7 +786,6 @@ class _QueueTabState extends ConsumerState { String? _filterCacheQuality; String? _filterCacheFormat; String _filterCacheSortMode = 'latest'; - // Advanced filters String? _filterSource; // null = all, 'downloaded', 'local' String? _filterQuality; // null = all, 'hires', 'cd', 'lossy' String? _filterFormat; // null = all, 'flac', 'mp3', 'm4a', 'opus', 'ogg' @@ -1925,7 +1925,6 @@ class _QueueTabState extends ConsumerState { .toList(growable: false); } - // Apply sorting return _applySorting(filtered); } @@ -2286,14 +2285,7 @@ class _QueueTabState extends ConsumerState { final beforeModTime = await _readFileModTimeMillis(historyItem.filePath); if (!mounted) return; final result = await navigator.push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: historyItem), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(item: historyItem)), ); _searchFocusNode.unfocus(); if (result == true) { @@ -2319,14 +2311,7 @@ class _QueueTabState extends ConsumerState { final beforeModTime = await _readFileModTimeMillis(item.filePath); if (!mounted) return; final result = await navigator.push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: item), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(item: item)), ); _searchFocusNode.unfocus(); if (result == true) { @@ -2347,14 +2332,7 @@ class _QueueTabState extends ConsumerState { _searchFocusNode.unfocus(); Navigator.push( context, - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(localItem: item), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(localItem: item)), ).then((_) => _searchFocusNode.unfocus()); } @@ -2401,35 +2379,25 @@ class _QueueTabState extends ConsumerState { void _navigateToDownloadedAlbum(_GroupedAlbum album) { _navigateWithUnfocus( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - DownloadedAlbumScreen( - albumName: album.albumName, - artistName: album.artistName, - coverUrl: album.coverUrl, - ), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), + slidePageRoute( + page: DownloadedAlbumScreen( + albumName: album.albumName, + artistName: album.artistName, + coverUrl: album.coverUrl, + ), ), ); } void _navigateToLocalAlbum(_GroupedLocalAlbum album) { _navigateWithUnfocus( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - LocalAlbumScreen( - albumName: album.albumName, - artistName: album.artistName, - coverPath: album.coverPath, - tracks: album.tracks, - ), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), + slidePageRoute( + page: LocalAlbumScreen( + albumName: album.albumName, + artistName: album.artistName, + coverPath: album.coverPath, + tracks: album.tracks, + ), ), ); } @@ -2664,7 +2632,6 @@ class _QueueTabState extends ConsumerState { return; } - // Single track drop final track = item.toTrack(); final added = await notifier.addTrackToPlaylist(playlistId, track); @@ -2731,7 +2698,6 @@ class _QueueTabState extends ConsumerState { final allHistoryItems = ref.watch( downloadHistoryProvider.select((s) => s.items), ); - // Watch local library items final localLibraryEnabled = ref.watch( settingsProvider.select((s) => s.localLibraryEnabled), ); @@ -2953,7 +2919,6 @@ class _QueueTabState extends ConsumerState { padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Builder( builder: (context) { - // Compute filtered counts for tab chips int filteredAllCount; int filteredAlbumCount; int filteredSingleCount; @@ -3470,26 +3435,14 @@ class _QueueTabState extends ConsumerState { top: 4, right: 4, child: IgnorePointer( - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : colorScheme.surface.withValues(alpha: 0.85), - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 20, + unselectedColor: colorScheme.surface.withValues( + alpha: 0.85, ), - child: isSelected - ? Icon( - Icons.check, - size: 16, - color: colorScheme.onPrimary, - ) - : const SizedBox(width: 16, height: 16), ), ), ), @@ -3567,26 +3520,11 @@ class _QueueTabState extends ConsumerState { behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.only(left: 8), - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - size: 18, - color: colorScheme.onPrimary, - ) - : const SizedBox(width: 18, height: 18), + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, ), ), ), @@ -3653,7 +3591,6 @@ class _QueueTabState extends ConsumerState { ), ), const Spacer(), - // Filter button with long-press to reset if (!_isSelectionMode) _buildFilterButton(context, unifiedItems), if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) @@ -3693,7 +3630,6 @@ class _QueueTabState extends ConsumerState { ), ), - // Albums empty state with filter button if (filteredGroupedAlbums.isEmpty && filteredGroupedLocalAlbums.isEmpty && filterMode == 'albums' && @@ -3749,7 +3685,6 @@ class _QueueTabState extends ConsumerState { ), ), - // Combined albums grid (downloaded + local in single grid) if (filterMode == 'albums' && (filteredGroupedAlbums.isNotEmpty || filteredGroupedLocalAlbums.isNotEmpty)) @@ -3764,7 +3699,6 @@ class _QueueTabState extends ConsumerState { ), delegate: SliverChildBuilderDelegate( (context, index) { - // First render downloaded albums, then local albums if (index < filteredGroupedAlbums.length) { final album = filteredGroupedAlbums[index]; return KeyedSubtree( @@ -3791,7 +3725,6 @@ class _QueueTabState extends ConsumerState { ), ), - // Unified list/grid for 'all' filter: collection items + tracks combined if (filterMode == 'all') ...[ if (historyViewMode == 'grid') SliverPadding( @@ -3908,7 +3841,6 @@ class _QueueTabState extends ConsumerState { ), ], - // Singles filter - show unified items (downloaded + local singles) if (filterMode == 'singles') SliverToBoxAdapter( child: Padding( @@ -4996,7 +4928,6 @@ class _QueueTabState extends ConsumerState { return; } - // Confirm final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final confirmed = await showDialog( context: context, @@ -5437,7 +5368,6 @@ class _QueueTabState extends ConsumerState { const SizedBox(height: 12), - // Action buttons row: Share/Re-enrich, Convert, Delete Row( children: [ if (localOnlySelection && flacEligibleCount > 0) ...[ @@ -5524,101 +5454,148 @@ class _QueueTabState extends ConsumerState { ColorScheme colorScheme, ) { final isCompleted = item.status == DownloadStatus.completed; + final isActive = + item.status == DownloadStatus.queued || + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: InkWell( - onTap: isCompleted ? () => _navigateToMetadataScreen(item) : null, - borderRadius: BorderRadius.circular(12), - child: 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, + return Dismissible( + key: ValueKey('dismiss_${item.id}'), + direction: DismissDirection.endToStart, + confirmDismiss: isActive + ? (_) async { + return await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Cancel download?'), + content: Text( + 'This will cancel the active download for "${item.track.name}".', ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Keep'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Cancel'), + ), + ], ), - const SizedBox(height: 2), - ClickableArtistName( - artistName: item.track.artistName, - artistId: item.track.artistId, - coverUrl: item.track.coverUrl, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - if (item.status == DownloadStatus.downloading) ...[ - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: item.progress > 0 ? item.progress : null, - backgroundColor: - colorScheme.surfaceContainerHighest, - color: colorScheme.primary, - minHeight: 6, - ), - ), - ), - const SizedBox(width: 8), - Text( - // When progress is 0 (unknown size, e.g. YouTube tunnel mode), - // show bytes downloaded instead of percentage - item.progress > 0 - ? (item.speedMBps > 0 - ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : '${(item.progress * 100).toStringAsFixed(0)}%') - : (item.bytesReceived > 0 - ? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : (item.speedMBps > 0 - ? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : 'Starting...')), - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.bold, + ) ?? + false; + } + : null, + onDismissed: (_) { + ref.read(downloadQueueProvider.notifier).dismissItem(item.id); + }, + background: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: Icon(Icons.delete_outline, color: colorScheme.onErrorContainer), + ), + child: DownloadSuccessOverlay( + showSuccess: isCompleted, + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: InkWell( + onTap: isCompleted ? () => _navigateToMetadataScreen(item) : null, + borderRadius: BorderRadius.circular(12), + child: 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, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + if (item.status == DownloadStatus.downloading) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: item.progress > 0 + ? item.progress + : null, + backgroundColor: + colorScheme.surfaceContainerHighest, + color: colorScheme.primary, + minHeight: 6, + ), ), + ), + const SizedBox(width: 8), + Text( + // When progress is 0 (unknown size, e.g. YouTube tunnel mode), + // show bytes downloaded instead of percentage + item.progress > 0 + ? (item.speedMBps > 0 + ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : '${(item.progress * 100).toStringAsFixed(0)}%') + : (item.bytesReceived > 0 + ? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : (item.speedMBps > 0 + ? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : 'Starting...')), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], ), ], - ), - ], - if (item.status == DownloadStatus.failed) ...[ - const SizedBox(height: 4), - Text( - item.errorMessage, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.error, - ), - ), - ], - ], - ), + if (item.status == DownloadStatus.failed) ...[ + const SizedBox(height: 4), + Text( + item.errorMessage, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: colorScheme.error), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + _buildActionButtons(context, item, colorScheme), + ], ), - const SizedBox(width: 8), - _buildActionButtons(context, item, colorScheme), - ], + ), ), ), ), @@ -5997,34 +5974,19 @@ class _QueueTabState extends ConsumerState { Semantics( checked: isSelected, label: isSelected ? 'Deselect track' : 'Select track', - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 16, - ) - : null, + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, ), ), const SizedBox(width: 12), ], - // Cover image - supports network URL and local file path - _buildUnifiedCoverImage(item, colorScheme, 56), + Hero( + tag: 'cover_lib_${item.id}', + child: _buildUnifiedCoverImage(item, colorScheme, 56), + ), const SizedBox(width: 12), Expanded( @@ -6052,7 +6014,6 @@ class _QueueTabState extends ConsumerState { const SizedBox(height: 2), Row( children: [ - // Source badge Container( padding: const EdgeInsets.symmetric( horizontal: 6, @@ -6200,7 +6161,6 @@ class _QueueTabState extends ConsumerState { aspectRatio: 1, child: _buildUnifiedCoverImage(item, colorScheme), ), - // Source badge (top-right) Positioned( right: 4, top: 4, @@ -6224,7 +6184,6 @@ class _QueueTabState extends ConsumerState { ), ), ), - // Quality badge (top-left) if (item.quality != null && item.quality!.isNotEmpty) Positioned( left: 4, diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 42118769..0268e149 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; class SearchScreen extends ConsumerStatefulWidget { @@ -51,9 +52,9 @@ class _SearchScreenState extends ConsumerState { ref .read(downloadQueueProvider.notifier) .addToQueue(track, settings.defaultService); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), + ); } @override @@ -95,13 +96,20 @@ class _SearchScreenState extends ConsumerState { child: Text(error, style: TextStyle(color: colorScheme.error)), ), Expanded( - child: tracks.isEmpty - ? _buildEmptyState(colorScheme) - : ListView.builder( - itemCount: tracks.length, - itemBuilder: (context, index) => - _buildTrackTile(tracks[index], colorScheme), - ), + child: AnimatedStateSwitcher( + child: isLoading && tracks.isEmpty + ? const TrackListSkeleton(key: ValueKey('loading')) + : tracks.isEmpty + ? _buildEmptyState(colorScheme) + : ListView.builder( + key: const ValueKey('results'), + itemCount: tracks.length, + itemBuilder: (context, index) => StaggeredListItem( + index: index, + child: _buildTrackTile(tracks[index], colorScheme), + ), + ), + ), ), ], ), @@ -127,32 +135,30 @@ class _SearchScreenState extends ConsumerState { } Widget _buildTrackTile(Track track, ColorScheme colorScheme) { - return ListTile( - leading: track.coverUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: track.coverUrl!, - width: 48, - height: 48, - fit: BoxFit.cover, - memCacheWidth: 144, - memCacheHeight: 144, - cacheManager: CoverCacheManager.instance, - ), - ) - : Container( + final coverWidget = track.coverUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: track.coverUrl!, width: 48, height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - ), + fit: BoxFit.cover, + memCacheWidth: 144, + memCacheHeight: 144, + cacheManager: CoverCacheManager.instance, ), + ) + : Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ); + return ListTile( + leading: coverWidget, title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 7e66fc62..7e4d03f2 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -13,6 +13,7 @@ import 'package:spotiflac_android/screens/settings/donate_page.dart'; import 'package:spotiflac_android/screens/settings/log_screen.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class SettingsTab extends ConsumerWidget { const SettingsTab({super.key}); @@ -150,26 +151,6 @@ class SettingsTab extends ConsumerWidget { void _navigateTo(BuildContext context, Widget page) { FocusManager.instance.primaryFocus?.unfocus(); - - Navigator.of(context).push( - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => page, - transitionsBuilder: (context, animation, secondaryAnimation, child) { - const begin = Offset(1.0, 0.0); - const end = Offset.zero; - const curve = Curves.easeInOut; - var tween = Tween( - begin: begin, - end: end, - ).chain(CurveTween(curve: curve)); - return SlideTransition( - position: animation.drive(tween), - child: child, - ); - }, - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - ), - ); + Navigator.of(context).push(slidePageRoute(page: page)); } } diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index 41a9143c..f11cfa1a 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -4,6 +4,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/screens/store/extension_details_screen.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; @@ -259,8 +260,11 @@ class _StoreTabState extends ConsumerState { ), if (isLoading && extensions.isEmpty) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(16), + child: TrackListSkeleton(itemCount: 6), + ), ) else if (error != null && extensions.isEmpty) SliverFillRemaining(child: _buildErrorState(error, colorScheme)) diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index c27f426b..d84b9fc9 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -308,12 +308,10 @@ class _TrackMetadataScreenState extends ConsumerState { storedQuality: _quality, ); - // Fill in album name from file tags if stored value is empty final needsAlbum = resolvedAlbum != null && resolvedAlbum.isNotEmpty && (albumName.isEmpty); - // Fill in duration from file if stored value is missing/zero final needsDuration = resolvedDuration != null && resolvedDuration > 0 && @@ -520,6 +518,8 @@ class _TrackMetadataScreenState extends ConsumerState { String get _filePath => _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; + String get _coverHeroTag => + _isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId'; String? get _coverUrl => _isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl); String? get _localCoverPath => @@ -528,7 +528,6 @@ class _TrackMetadataScreenState extends ConsumerState { String get _service => _isLocalItem ? 'local' : _downloadItem!.service; DateTime get _addedAt { if (_isLocalItem) { - // Use file modification time if available, otherwise fall back to scannedAt final modTime = _localLibraryItem!.fileModTime; if (modTime != null && modTime > 0) { return DateTime.fromMillisecondsSinceEpoch(modTime); @@ -796,38 +795,42 @@ class _TrackMetadataScreenState extends ConsumerState { double expandedHeight, bool showContent, ) { - return Stack( - fit: StackFit.expand, - children: [ - if (_hasPath(_embeddedCoverPreviewPath)) - Image.file( + final coverChild = _hasPath(_embeddedCoverPreviewPath) + ? Image.file( File(_embeddedCoverPreviewPath!), fit: BoxFit.cover, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) - else if (_coverUrl != null) - CachedNetworkImage( + : _coverUrl != null + ? CachedNetworkImage( imageUrl: _coverUrl!, fit: BoxFit.cover, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), errorWidget: (_, _, _) => Container(color: colorScheme.surface), ) - else if (_localCoverPath != null && _localCoverPath!.isNotEmpty) - Image.file( + : _localCoverPath != null && _localCoverPath!.isNotEmpty + ? Image.file( File(_localCoverPath!), fit: BoxFit.cover, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) - else - Container( + : Container( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.music_note, size: 80, color: colorScheme.onSurfaceVariant, ), - ), + ); + + return Stack( + fit: StackFit.expand, + children: [ + Hero( + tag: _coverHeroTag, + child: Material(color: Colors.transparent, child: coverChild), + ), Positioned( left: 0, right: 0, @@ -1620,7 +1623,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ), - // Show "Embed Lyrics" button if lyrics are from online (not already embedded) if (!_lyricsEmbedded && _fileExists) ...[ const SizedBox(height: 16), Center( @@ -1668,7 +1670,6 @@ class _TrackMetadataScreenState extends ConsumerState { try { final durationMs = (duration ?? 0) * 1000; - // First, check if lyrics are embedded in the file if (_fileExists) { final embeddedResult = await PlatformBridge.getLyricsLRCWithSource( @@ -1702,7 +1703,6 @@ class _TrackMetadataScreenState extends ConsumerState { } } - // No embedded lyrics, fetch from online final result = await PlatformBridge.getLyricsLRCWithSource( _spotifyId ?? '', trackName, @@ -1992,7 +1992,6 @@ class _TrackMetadataScreenState extends ConsumerState { return; } - // Write temp file to SAF tree final treeUri = _downloadItem?.downloadTreeUri; final relativeDir = _downloadItem?.safRelativeDir ?? ''; if (treeUri != null && treeUri.isNotEmpty) { @@ -2039,7 +2038,6 @@ class _TrackMetadataScreenState extends ConsumerState { return; } - // Regular file path final dir = _getFileDirectory(); final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg'; @@ -2132,7 +2130,6 @@ class _TrackMetadataScreenState extends ConsumerState { return; } - // Write temp file to SAF tree final treeUri = _downloadItem?.downloadTreeUri; final relativeDir = _downloadItem?.safRelativeDir ?? ''; if (treeUri != null && treeUri.isNotEmpty) { @@ -2188,7 +2185,6 @@ class _TrackMetadataScreenState extends ConsumerState { return; } - // Regular file path final dir = _getFileDirectory(); final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc'; @@ -2263,7 +2259,6 @@ class _TrackMetadataScreenState extends ConsumerState { final result = await PlatformBridge.reEnrichFile(request); final method = result['method'] as String?; - // Update local UI state with enriched metadata from online search final enriched = result['enriched_metadata'] as Map?; if (enriched != null && mounted) { setState(() { @@ -2350,7 +2345,6 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - // For SAF files, copy processed temp file back if (ffmpegResult != null && tempPath != null && safUri != null) { final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); if (!ok && mounted) { @@ -2363,7 +2357,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ); - // Cleanup temp files if (_hasPath(downloadedCoverPath)) { try { await File(downloadedCoverPath!).delete(); @@ -2381,7 +2374,6 @@ class _TrackMetadataScreenState extends ConsumerState { } } - // Cleanup temp files if (tempPath != null && tempPath.isNotEmpty) { try { await File(tempPath).delete(); @@ -2403,7 +2395,6 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - // Cleanup temp cover from Go backend if (_hasPath(downloadedCoverPath)) { try { await File(downloadedCoverPath!).delete(); @@ -2468,7 +2459,6 @@ class _TrackMetadataScreenState extends ConsumerState { for (final line in lines) { var cleaned = line.trim(); - // Skip metadata tags if (_lrcMetadataPattern.hasMatch(cleaned) && !_lrcBackgroundLinePattern.hasMatch(cleaned)) { continue; @@ -2480,7 +2470,6 @@ class _TrackMetadataScreenState extends ConsumerState { cleaned = bgMatch.group(1)?.trim() ?? ''; } - // Remove line timestamp, inline word-by-word timestamps, and speaker prefix. cleaned = cleaned.replaceAll(_lrcTimestampPattern, '').trim(); cleaned = cleaned.replaceAll(_lrcInlineTimestampPattern, ''); cleaned = cleaned.replaceFirst(_lrcSpeakerPrefixPattern, ''); @@ -2691,11 +2680,9 @@ class _TrackMetadataScreenState extends ConsumerState { /// Whether the current file is a CUE sheet (or CUE-referenced) bool get _isCueFile { - // Check if the raw path has a CUE virtual path suffix if (isCueVirtualPath(rawFilePath)) return true; final lower = cleanFilePath.toLowerCase(); if (lower.endsWith('.cue')) return true; - // Check if local library item has cue+ format if (_isLocalItem && _localLibraryItem != null) { final format = _localLibraryItem!.format ?? ''; if (format.startsWith('cue+')) return true; @@ -2821,7 +2808,6 @@ class _TrackMetadataScreenState extends ConsumerState { final currentFormat = _currentFileFormat; final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A'; - // Build available target formats based on source final formats = []; if (currentFormat == 'FLAC') { formats.addAll(['ALAC', 'MP3', 'Opus']); @@ -2912,7 +2898,6 @@ class _TrackMetadataScreenState extends ConsumerState { }).toList(), ), - // Only show bitrate for lossy targets if (!isLosslessTarget) ...[ const SizedBox(height: 16), Text( @@ -2939,7 +2924,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ], - // Show lossless indicator if (isLosslessTarget && isLosslessSource) ...[ const SizedBox(height: 16), Row( @@ -2997,14 +2981,12 @@ class _TrackMetadataScreenState extends ConsumerState { } void _showCueSplitSheet(BuildContext context) async { - // Strip the #trackNN suffix from virtual CUE paths to get the real .cue path var cuePath = cleanFilePath; final trackSuffix = RegExp(r'#track\d+$'); if (trackSuffix.hasMatch(cuePath)) { cuePath = cuePath.replaceFirst(trackSuffix, ''); } - // Show loading indicator ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarLoadingCueSheet)), ); @@ -3099,7 +3081,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), const SizedBox(height: 16), - // Track list preview (scrollable, max 200px) ConstrainedBox( constraints: const BoxConstraints(maxHeight: 200), child: ListView.builder( @@ -3321,7 +3302,6 @@ class _TrackMetadataScreenState extends ConsumerState { workingAudioPath = tempPath; } - // Determine output directory final String outputDir; final treeUri = !_isLocalItem ? (_downloadItem?.downloadTreeUri ?? '') @@ -3348,7 +3328,6 @@ class _TrackMetadataScreenState extends ConsumerState { if (!mounted) return; _showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length)); - // Extract cover from audio file for embedding String? coverPath; try { final tempDir = await getTemporaryDirectory(); @@ -3391,11 +3370,9 @@ class _TrackMetadataScreenState extends ConsumerState { for (final path in finalOutputPaths) { if (path.toLowerCase().endsWith('.flac')) { try { - // Read existing metadata first final metadata = await PlatformBridge.readFileMetadata(path); if (metadata['error'] == null) { final fields = {'cover_path': coverPath}; - // Preserve existing fields for (final entry in metadata.entries) { if (entry.key == 'error' || entry.value == null) continue; final v = entry.value.toString().trim(); @@ -3421,7 +3398,6 @@ class _TrackMetadataScreenState extends ConsumerState { finalOutputPaths = exportedUris; } - // Cleanup cover temp if (coverPath != null) { try { await File(coverPath).delete(); @@ -3443,7 +3419,6 @@ class _TrackMetadataScreenState extends ConsumerState { _showSnackBarMessage(_l10nCueSplitFailed); } } finally { - // Cleanup SAF temp audio copy if (safTempAudioPath != null) { try { await File(safTempAudioPath).delete(); @@ -3562,7 +3537,6 @@ class _TrackMetadataScreenState extends ConsumerState { String? safTempPath; if (isSaf) { - // Copy SAF file to temp for processing safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath); if (safTempPath == null) { if (mounted) { @@ -3585,7 +3559,6 @@ class _TrackMetadataScreenState extends ConsumerState { deleteOriginal: !isSaf, // Don't delete temp copy for SAF, we handle it ); - // Cleanup cover temp if (coverPath != null) { try { await File(coverPath).delete(); @@ -3593,7 +3566,6 @@ class _TrackMetadataScreenState extends ConsumerState { } if (newPath == null) { - // Cleanup SAF temp if needed if (safTempPath != null) { try { await File(safTempPath).delete(); @@ -3695,7 +3667,6 @@ class _TrackMetadataScreenState extends ConsumerState { _log.w('Converted SAF file created but failed deleting original URI'); } - // Update history with new SAF info if (!_isLocalItem) { await HistoryDatabase.instance.updateFilePath( _downloadItem!.id, @@ -3707,7 +3678,6 @@ class _TrackMetadataScreenState extends ConsumerState { await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); } - // Cleanup temp files try { await File(newPath).delete(); } catch (_) {} @@ -3717,7 +3687,6 @@ class _TrackMetadataScreenState extends ConsumerState { } catch (_) {} } } else { - // Regular file: update history with new path if (!_isLocalItem) { await HistoryDatabase.instance.updateFilePath( _downloadItem!.id, @@ -3736,7 +3705,6 @@ class _TrackMetadataScreenState extends ConsumerState { content: Text(context.l10n.trackConvertSuccess(targetFormat)), ), ); - // Pop and let the caller refresh Navigator.pop(context, true); } } catch (e) { @@ -3754,7 +3722,6 @@ class _TrackMetadataScreenState extends ConsumerState { WidgetRef ref, ColorScheme colorScheme, ) async { - // Read current metadata from file, fall back to item data on failure Map? fileMetadata; try { final result = await PlatformBridge.readFileMetadata(cleanFilePath); @@ -3765,7 +3732,6 @@ class _TrackMetadataScreenState extends ConsumerState { debugPrint('readFileMetadata failed, using item data: $e'); } - // Build initial values map — prefer file metadata, fall back to item data String val(String key, String? fallback) { final v = fileMetadata?[key]?.toString(); return (v != null && v.isNotEmpty) ? v : (fallback ?? ''); @@ -3811,7 +3777,6 @@ class _TrackMetadataScreenState extends ConsumerState { ScaffoldMessenger.of(this.context).showSnackBar( SnackBar(content: Text(this.context.l10n.snackbarMetadataSaved)), ); - // Re-read metadata from file to refresh the display try { final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath); setState(() => _editedMetadata = refreshed); @@ -4056,10 +4021,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { String? _currentCoverTempDir; bool _loadingCurrentCover = false; - // Auto-fill field selection — which fields the user wants to fetch final Set _autoFillFields = {}; - // All auto-fillable fields and their mapping static const _fieldDefs = { 'title': 'title', 'artist': 'artist', @@ -4685,7 +4648,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { throw StateError('No metadata match resolved for auto-fill'); } - // Extract basic metadata from search result final enriched = { 'title': (selectedBest['name'] ?? '').toString(), 'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '') @@ -4763,7 +4725,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (!mounted) return; - // Fetch genre/label/copyright from Deezer extended metadata if (needsExtended && deezerId != null) { try { final extended = await PlatformBridge.getDeezerExtendedMetadata( @@ -4781,10 +4742,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (!mounted) return; - // Apply selected fields to controllers var filledCount = 0; for (final key in _autoFillFields) { - if (key == 'cover') continue; // Handle cover separately below + if (key == 'cover') continue; final value = enriched[key]; if (value != null && value.isNotEmpty && @@ -4798,7 +4758,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } } - // Handle cover art download if (_autoFillFields.contains('cover')) { final coverUrl = (selectedBest['cover_url'] ?? selectedBest['images'] ?? '') @@ -5077,7 +5036,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return; } - // For SAF files, copy the processed temp file back if (tempPath != null && safUri != null) { final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); if (!ok && mounted) { @@ -5190,7 +5148,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), _field('Genre', _genreCtrl), _field('ISRC', _isrcCtrl), - // Advanced fields toggle Padding( padding: const EdgeInsets.only(top: 8, bottom: 4), child: InkWell( @@ -5288,7 +5245,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), const SizedBox(height: 8), - // Quick select buttons Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( @@ -5308,7 +5264,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), const SizedBox(height: 8), - // Field chips Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Wrap( @@ -5345,7 +5300,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), const SizedBox(height: 10), - // Fetch button Padding( padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12), child: SizedBox( diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index c13b54ee..309a4c9a 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -89,9 +89,7 @@ class AppTheme { static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData( elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), color: scheme.surfaceContainerLow, surfaceTintColor: scheme.surfaceTint, ); @@ -148,9 +146,7 @@ class AppTheme { static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) => InputDecorationTheme( filled: true, - fillColor: scheme.surfaceContainerHighest.withValues( - alpha: 0.3, - ), + fillColor: scheme.surfaceContainerHighest.withValues(alpha: 0.3), border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none, @@ -175,9 +171,7 @@ class AppTheme { static ListTileThemeData _listTileTheme(ColorScheme scheme) => ListTileThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), ); @@ -237,7 +231,7 @@ class AppTheme { ); static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), backgroundColor: scheme.surfaceContainerLow, selectedColor: scheme.secondaryContainer, ); diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart new file mode 100644 index 00000000..d6c292d2 --- /dev/null +++ b/lib/widgets/animation_utils.dart @@ -0,0 +1,857 @@ +import 'package:flutter/material.dart'; + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Staggered List Item – fade + slide-up entrance with index-based delay +// ───────────────────────────────────────────────────────────────────────────── + +/// Wraps a child in a staggered fade-in + slide-up animation. +/// +/// [index] controls the stagger delay (each item delayed by [staggerDelay]). +/// Set [animate] to false to skip the animation (e.g. when scrolling back). +class StaggeredListItem extends StatelessWidget { + static const int _defaultMaxAnimatedItems = 10; + + final int index; + final Widget child; + final Duration duration; + final Duration staggerDelay; + final bool animate; + final int maxAnimatedItems; + + const StaggeredListItem({ + super.key, + required this.index, + required this.child, + this.duration = const Duration(milliseconds: 250), + this.staggerDelay = const Duration(milliseconds: 40), + this.animate = true, + this.maxAnimatedItems = _defaultMaxAnimatedItems, + }); + + @override + Widget build(BuildContext context) { + if (!animate || index >= maxAnimatedItems) return child; + // Cap the delay so very long lists don't have absurd wait times. + final cappedIndex = index.clamp(0, maxAnimatedItems - 1); + final delay = staggerDelay * cappedIndex; + final totalDuration = duration + delay; + + return TweenAnimationBuilder( + key: ValueKey('stagger_$index'), + tween: Tween(begin: 0.0, end: 1.0), + duration: totalDuration, + curve: Curves.easeOutCubic, + builder: (context, value, child) { + // Compute the effective progress after the stagger delay. + final delayFraction = totalDuration.inMilliseconds > 0 + ? delay.inMilliseconds / totalDuration.inMilliseconds + : 0.0; + final progress = value <= delayFraction + ? 0.0 + : ((value - delayFraction) / (1.0 - delayFraction)).clamp(0.0, 1.0); + return Opacity( + opacity: progress, + child: Transform.translate( + offset: Offset(0, 12 * (1 - progress)), + child: child, + ), + ); + }, + child: child, + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Animated State Switcher – crossfade between loading / content / empty / error +// ───────────────────────────────────────────────────────────────────────────── + +/// A convenience wrapper around [AnimatedSwitcher] that crossfades between +/// different widget states (loading, content, empty, error). +/// +/// Assign a unique [ValueKey] to each child so the switcher detects changes. +class AnimatedStateSwitcher extends StatelessWidget { + final Widget child; + final Duration duration; + + const AnimatedStateSwitcher({ + super.key, + required this.child, + this.duration = const Duration(milliseconds: 250), + }); + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: duration, + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) { + return FadeTransition(opacity: animation, child: child); + }, + child: child, + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Shared Page Route – consistent slide-from-right transition +// ───────────────────────────────────────────────────────────────────────────── + +/// Creates a platform-aware material route. +/// +/// This intentionally defers route transitions to Flutter's material route and +/// theme so Android predictive back and platform-default animations remain +/// intact. +Route slidePageRoute({required Widget page}) { + return MaterialPageRoute(builder: (context) => page); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Shimmer / Skeleton Loading Widget +// ───────────────────────────────────────────────────────────────────────────── + +/// A shimmer effect widget that can wrap skeleton placeholders. +class ShimmerLoading extends StatefulWidget { + final Widget child; + + const ShimmerLoading({super.key, required this.child}); + + @override + State createState() => _ShimmerLoadingState(); +} + +class _ShimmerLoadingState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + final baseColor = isDark + ? colorScheme.surfaceContainerHighest + : colorScheme.surfaceContainerHigh; + final highlightColor = isDark + ? colorScheme.surfaceContainerHigh + : colorScheme.surface; + + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return ShaderMask( + shaderCallback: (bounds) { + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [baseColor, highlightColor, baseColor], + stops: [ + (_controller.value - 0.3).clamp(0.0, 1.0), + _controller.value, + (_controller.value + 0.3).clamp(0.0, 1.0), + ], + tileMode: TileMode.clamp, + ).createShader(bounds); + }, + blendMode: BlendMode.srcATop, + child: child, + ); + }, + child: widget.child, + ); + } +} + +/// A skeleton placeholder box used inside [ShimmerLoading]. +class SkeletonBox extends StatelessWidget { + final double width; + final double height; + final double borderRadius; + + const SkeletonBox({ + super.key, + required this.width, + required this.height, + this.borderRadius = 8, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(borderRadius), + ), + ); + } +} + +/// Track list skeleton – mimics a list of track items while loading. +class TrackListSkeleton extends StatelessWidget { + final int itemCount; + final bool showCoverHeader; + + const TrackListSkeleton({ + super.key, + this.itemCount = 8, + this.showCoverHeader = false, + }); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + return ShimmerLoading( + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + children: [ + if (showCoverHeader) ...[ + SkeletonBox( + width: screenWidth, + height: screenWidth * 0.75, + borderRadius: 0, + ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: SkeletonBox(width: 180, height: 20, borderRadius: 4), + ), + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 20), + child: SkeletonBox(width: 110, height: 14, borderRadius: 4), + ), + ], + ...List.generate(itemCount, (index) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + const SkeletonBox(width: 48, height: 48), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBox( + width: 140 + (index % 3) * 30, + height: 14, + borderRadius: 4, + ), + const SizedBox(height: 6), + SkeletonBox( + width: 90 + (index % 2) * 20, + height: 12, + borderRadius: 4, + ), + ], + ), + ), + const SkeletonBox(width: 24, height: 24, borderRadius: 12), + ], + ), + ); + }), + ], + ), + ), + ); + } +} + +/// Grid skeleton – mimics a grid of album/playlist cards while loading. + +/// Album track list skeleton – mimics the album screen track list layout +/// (track number + title + artist + trailing icon, no cover art thumbnail). +class AlbumTrackListSkeleton extends StatelessWidget { + final int itemCount; + final bool showCoverHeader; + + const AlbumTrackListSkeleton({ + super.key, + this.itemCount = 10, + this.showCoverHeader = false, + }); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + return ShimmerLoading( + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + children: [ + if (showCoverHeader) ...[ + SkeletonBox( + width: screenWidth, + height: screenWidth * 0.75, + borderRadius: 0, + ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: SkeletonBox(width: 180, height: 20, borderRadius: 4), + ), + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 20), + child: SkeletonBox(width: 110, height: 14, borderRadius: 4), + ), + ], + ...List.generate(itemCount, (index) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 6, + ), + child: Row( + children: [ + SizedBox( + width: 32, + child: Center( + child: SkeletonBox( + width: 14, + height: 14, + borderRadius: 4, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBox( + width: 120 + (index % 4) * 35, + height: 14, + borderRadius: 4, + ), + const SizedBox(height: 6), + SkeletonBox( + width: 70 + (index % 3) * 20, + height: 12, + borderRadius: 4, + ), + ], + ), + ), + const SkeletonBox(width: 20, height: 20, borderRadius: 10), + ], + ), + ); + }), + ], + ), + ), + ); + } +} + +class GridSkeleton extends StatelessWidget { + final int itemCount; + final int crossAxisCount; + + const GridSkeleton({super.key, this.itemCount = 6, this.crossAxisCount = 2}); + + @override + Widget build(BuildContext context) { + return ShimmerLoading( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.78, + ), + itemCount: itemCount, + itemBuilder: (context, index) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const AspectRatio( + aspectRatio: 1, + child: SkeletonBox(width: double.infinity, height: 0), + ), + const SizedBox(height: 8), + SkeletonBox( + width: 80 + (index % 3) * 20, + height: 12, + borderRadius: 4, + ), + const SizedBox(height: 4), + SkeletonBox( + width: 50 + (index % 2) * 15, + height: 10, + borderRadius: 4, + ), + ], + ); + }, + ), + ), + ); + } +} + +/// Artist screen skeleton – mimics the artist page content below the header: +/// an optional "Popular" section (rank + cover 48x48 + title + trailing) then +/// a horizontal-scroll album section. +class ArtistScreenSkeleton extends StatelessWidget { + final int popularCount; + final int albumCount; + + const ArtistScreenSkeleton({ + super.key, + this.popularCount = 5, + this.albumCount = 5, + }); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + return ShimmerLoading( + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBox( + width: screenWidth, + height: screenWidth * 0.75, + borderRadius: 0, + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: SkeletonBox(width: 180, height: 24, borderRadius: 4), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: SkeletonBox(width: 120, height: 14, borderRadius: 4), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: SkeletonBox(width: 90, height: 20, borderRadius: 4), + ), + ...List.generate(popularCount, (index) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + SizedBox( + width: 24, + child: Center( + child: SkeletonBox( + width: 12, + height: 14, + borderRadius: 4, + ), + ), + ), + const SizedBox(width: 12), + const SkeletonBox(width: 48, height: 48, borderRadius: 4), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBox( + width: 110 + (index % 4) * 30, + height: 14, + borderRadius: 4, + ), + const SizedBox(height: 6), + SkeletonBox( + width: 70 + (index % 3) * 15, + height: 11, + borderRadius: 4, + ), + ], + ), + ), + const SkeletonBox(width: 20, height: 20, borderRadius: 10), + ], + ), + ); + }), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: SkeletonBox(width: 80, height: 20, borderRadius: 4), + ), + SizedBox( + height: 190, + child: ListView.builder( + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: albumCount, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SkeletonBox(width: 140, height: 140), + const SizedBox(height: 8), + SkeletonBox( + width: 80 + (index % 3) * 20, + height: 12, + borderRadius: 4, + ), + const SizedBox(height: 4), + SkeletonBox( + width: 50 + (index % 2) * 15, + height: 10, + borderRadius: 4, + ), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +/// Home search skeleton – mimics filter chips + sectioned results +/// (Artists section with rounded card items, Albums section, etc.) +class HomeSearchSkeleton extends StatelessWidget { + const HomeSearchSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return ShimmerLoading( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + SkeletonBox(width: 48, height: 32, borderRadius: 16), + const SizedBox(width: 8), + SkeletonBox(width: 64, height: 32, borderRadius: 16), + const SizedBox(width: 8), + SkeletonBox(width: 72, height: 32, borderRadius: 16), + const SizedBox(width: 8), + SkeletonBox(width: 60, height: 32, borderRadius: 16), + const SizedBox(width: 8), + SkeletonBox(width: 70, height: 32, borderRadius: 16), + ], + ), + ), + const SizedBox(height: 8), + _sectionSkeleton(context, 70, 2), + const SizedBox(height: 16), + _sectionSkeleton(context, 65, 4), + ], + ), + ); + } + + static Widget _sectionSkeleton( + BuildContext context, + double headerWidth, + int itemCount, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + SkeletonBox(width: headerWidth, height: 18, borderRadius: 4), + const Spacer(), + const SkeletonBox(width: 50, height: 16, borderRadius: 4), + ], + ), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: List.generate(itemCount, (index) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + child: Row( + children: [ + const SkeletonBox(width: 48, height: 48, borderRadius: 24), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBox( + width: 100 + (index % 3) * 40, + height: 14, + borderRadius: 4, + ), + const SizedBox(height: 6), + SkeletonBox( + width: 60 + (index % 2) * 25, + height: 12, + borderRadius: 4, + ), + ], + ), + ), + const SkeletonBox(width: 20, height: 20, borderRadius: 10), + ], + ), + ); + }), + ), + ), + ], + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Animated Selection Checkbox – scales in when entering selection mode +// ───────────────────────────────────────────────────────────────────────────── + +/// An animated selection indicator that scales in/out and crossfades the +/// checked/unchecked state. +class AnimatedSelectionCheckbox extends StatelessWidget { + final bool visible; + final bool selected; + final ColorScheme colorScheme; + final double size; + + /// Background color when not selected. Defaults to `Colors.transparent`. + final Color? unselectedColor; + + const AnimatedSelectionCheckbox({ + super.key, + required this.visible, + required this.selected, + required this.colorScheme, + this.size = 20, + this.unselectedColor, + }); + + @override + Widget build(BuildContext context) { + return AnimatedScale( + scale: visible ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutBack, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: size, + height: size, + decoration: BoxDecoration( + color: selected + ? colorScheme.primary + : unselectedColor ?? Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: selected ? colorScheme.primary : colorScheme.outline, + width: 2, + ), + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + child: selected + ? Icon( + Icons.check, + key: const ValueKey('checked'), + size: size - 6, + color: colorScheme.onPrimary, + ) + : SizedBox( + key: const ValueKey('unchecked'), + width: size - 6, + height: size - 6, + ), + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 6. Download Success Animation – green flash + checkmark +// ───────────────────────────────────────────────────────────────────────────── + +/// A widget that briefly flashes a success color behind its child and shows +/// an animated checkmark when [showSuccess] transitions to true. +class DownloadSuccessOverlay extends StatefulWidget { + final bool showSuccess; + final Widget child; + + const DownloadSuccessOverlay({ + super.key, + required this.showSuccess, + required this.child, + }); + + @override + State createState() => _DownloadSuccessOverlayState(); +} + +class _DownloadSuccessOverlayState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _flashAnimation; + late bool _wasSuccess; + + @override + void initState() { + super.initState(); + // Initialise from the current widget value so items that are already + // completed when first built do not play the flash animation. + _wasSuccess = widget.showSuccess; + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _flashAnimation = TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 0.0, end: 0.15), weight: 30), + TweenSequenceItem(tween: Tween(begin: 0.15, end: 0.0), weight: 70), + ]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + } + + @override + void didUpdateWidget(DownloadSuccessOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.showSuccess && !_wasSuccess) { + _controller.forward(from: 0); + } + _wasSuccess = widget.showSuccess; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Container( + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: _flashAnimation.value), + borderRadius: BorderRadius.circular(12), + ), + child: child, + ); + }, + child: widget.child, + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 7. Badge Bump Animation – scales the badge when count changes +// ───────────────────────────────────────────────────────────────────────────── + +/// Wraps a [Badge] child and plays a brief scale-bump whenever [count] changes. +class AnimatedBadge extends StatefulWidget { + final int count; + final Widget child; + + const AnimatedBadge({super.key, required this.count, required this.child}); + + @override + State createState() => _AnimatedBadgeState(); +} + +class _AnimatedBadgeState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + int _previousCount = 0; + + @override + void initState() { + super.initState(); + _previousCount = widget.count; + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + _scaleAnimation = TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.3), weight: 40), + TweenSequenceItem(tween: Tween(begin: 1.3, end: 1.0), weight: 60), + ]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack)); + } + + @override + void didUpdateWidget(AnimatedBadge oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.count != _previousCount && widget.count > _previousCount) { + _controller.forward(from: 0); + } + _previousCount = widget.count; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ScaleTransition(scale: _scaleAnimation, child: widget.child); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 8. Animated Removal Item – fade + slide out when removed from a list +// ───────────────────────────────────────────────────────────────────────────── + +/// Build a removal animation for [AnimatedList] items. +/// Use as the `builder` callback in [AnimatedListState.removeItem]. +Widget buildRemovalAnimation(Widget child, Animation animation) { + return SizeTransition( + sizeFactor: CurvedAnimation(parent: animation, curve: Curves.easeInOut), + child: FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeIn), + child: child, + ), + ); +}