feat: add animation utilities and fix regressions in UI refactor

- Add animation_utils.dart with skeleton loaders, staggered list animations,
  animated checkboxes, badge bump, download success overlay, and shared
  page route helper
- Replace CircularProgressIndicator with shimmer skeleton loaders across
  album, artist, playlist, search, store, and extension screens
- Unify page transitions via slidePageRoute (MaterialPageRoute) for
  Android predictive back gesture support
- Extract AnimatedSelectionCheckbox with configurable unselectedColor
  to preserve original transparent/opaque backgrounds per context
- Add swipe-to-dismiss on download queue items with confirmDismiss
  dialog for active downloads to prevent accidental cancellation
- Add Hero animations for cover art transitions between list and detail
- Add AnimatedBadge bump on navigation bar badge count changes
- Add DownloadSuccessOverlay green flash on download completion
- Restore fine-grained ref.watch(.select()) in _CollectionTrackTile
  to avoid full list rebuilds on download history changes
- Fix DownloadSuccessOverlay re-flashing on widget recreation by
  initialising _wasSuccess from initial widget state
- Remove orphan Hero tag in search_screen that had no matching pair
- Chip borderRadius updated from 8 to 20 for consistency
This commit is contained in:
zarzet
2026-03-26 13:09:57 +07:00
parent 9483614bc7
commit d4b37edc2f
16 changed files with 1338 additions and 587 deletions
@@ -2421,6 +2421,31 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_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(
+9 -6
View File
@@ -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<AlbumScreen> {
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<AlbumScreen> {
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<AlbumScreen> {
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 =
+10 -36
View File
@@ -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<ArtistScreen> {
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<ArtistScreen> {
fetchedCount++;
// Update progress dialog
if (mounted) {
_FetchingProgressDialog.updateProgress(
context,
@@ -990,7 +985,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return;
}
// Check which tracks are already downloaded
final historyState = ref.read(downloadHistoryProvider);
final tracksToQueue = <Track>[];
int skippedCount = 0;
@@ -1041,10 +1035,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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<ArtistScreen> {
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(
+16 -34
View File
@@ -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<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
'${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<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
final discNumbers = _getSortedDiscNumbers(tracks);
final List<Widget> children = [];
var revealIndex = 0;
for (final discNumber in discNumbers) {
final discTracks = discMap[discNumber];
@@ -712,7 +708,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
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),
],
+39 -35
View File
@@ -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<HomeTab>
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<HomeTab>
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<HomeTab>
);
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<HomeTab>
required bool showLocalLibraryIndicator,
required Map<String, (double, double)> 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<HomeTab>
final playlistItems = buckets.playlistItems;
final artistItems = buckets.artistItems;
// Apply sorting to each list.
final sortedArtists = searchArtists != null && searchArtists.isNotEmpty
? _applySortToList<SearchArtist>(
searchArtists,
@@ -2633,7 +2639,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
)
: searchPlaylists;
// For tracks we need paired sorting (track + original index).
List<Track> sortedTracks;
List<int> sortedTrackIndexes;
if (realTracks.isNotEmpty &&
@@ -2673,7 +2678,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
];
// 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<HomeTab>
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<HomeTab>
}
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<HomeTab>
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<String>(
value: 'tidal',
child: Row(
@@ -3407,7 +3410,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
],
),
),
// Built-in Qobuz search option
PopupMenuItem<String>(
value: 'qobuz',
child: Row(
@@ -4230,7 +4232,6 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
.map((t) => _parseTrack(t as Map<String, dynamic>))
.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<ExtensionAlbumScreen> {
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<ExtensionArtistScreen> {
if (_isLoading) {
return Scaffold(
appBar: AppBar(title: Text(widget.artistName)),
body: const Center(child: CircularProgressIndicator()),
body: const ArtistScreenSkeleton(),
);
}
+93 -85
View File
@@ -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;
}
+10 -25
View File
@@ -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<LocalAlbumScreen> {
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<LocalAlbumScreen> {
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<LocalAlbumScreen> {
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<LocalAlbumScreen> {
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),
],
+27 -14
View File
@@ -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<MainShell>
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,
+9 -7
View File
@@ -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<PlaylistScreen> {
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<PlaylistScreen> {
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<PlaylistScreen> {
void _downloadTracks(BuildContext context, List<Track> 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,
+173 -214
View File
@@ -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<QueueTab> {
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<QueueTab> {
.toList(growable: false);
}
// Apply sorting
return _applySorting(filtered);
}
@@ -2286,14 +2285,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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<QueueTab> {
_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<QueueTab> {
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<QueueTab> {
return;
}
// Single track drop
final track = item.toTrack();
final added = await notifier.addTrackToPlaylist(playlistId, track);
@@ -2731,7 +2698,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
),
),
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<QueueTab> {
),
),
// Albums empty state with filter button
if (filteredGroupedAlbums.isEmpty &&
filteredGroupedLocalAlbums.isEmpty &&
filterMode == 'albums' &&
@@ -3749,7 +3685,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
// Combined albums grid (downloaded + local in single grid)
if (filterMode == 'albums' &&
(filteredGroupedAlbums.isNotEmpty ||
filteredGroupedLocalAlbums.isNotEmpty))
@@ -3764,7 +3699,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
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<QueueTab> {
),
),
// 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<QueueTab> {
),
],
// Singles filter - show unified items (downloaded + local singles)
if (filterMode == 'singles')
SliverToBoxAdapter(
child: Padding(
@@ -4996,7 +4928,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return;
}
// Confirm
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final confirmed = await showDialog<bool>(
context: context,
@@ -5437,7 +5368,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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<bool>(
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<QueueTab> {
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<QueueTab> {
const SizedBox(height: 2),
Row(
children: [
// Source badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
@@ -6200,7 +6161,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
aspectRatio: 1,
child: _buildUnifiedCoverImage(item, colorScheme),
),
// Source badge (top-right)
Positioned(
right: 4,
top: 4,
@@ -6224,7 +6184,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
),
// Quality badge (top-left)
if (item.quality != null && item.quality!.isNotEmpty)
Positioned(
left: 4,
+39 -33
View File
@@ -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<SearchScreen> {
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<SearchScreen> {
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<SearchScreen> {
}
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,
+2 -21
View File
@@ -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));
}
}
+6 -2
View File
@@ -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<StoreTab> {
),
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))
+19 -65
View File
@@ -308,12 +308,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
),
),
),
// 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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
}
}
// No embedded lyrics, fetch from online
final result = await PlatformBridge.getLyricsLRCWithSource(
_spotifyId ?? '',
trackName,
@@ -1992,7 +1992,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
return;
}
// Regular file path
final dir = _getFileDirectory();
final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg';
@@ -2132,7 +2130,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
return;
}
// Regular file path
final dir = _getFileDirectory();
final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc';
@@ -2263,7 +2259,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<String, dynamic>?;
if (enriched != null && mounted) {
setState(() {
@@ -2350,7 +2345,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
// 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<TrackMetadataScreen> {
),
),
);
// Cleanup temp files
if (_hasPath(downloadedCoverPath)) {
try {
await File(downloadedCoverPath!).delete();
@@ -2381,7 +2374,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
}
// Cleanup temp files
if (tempPath != null && tempPath.isNotEmpty) {
try {
await File(tempPath).delete();
@@ -2403,7 +2395,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
// Cleanup temp cover from Go backend
if (_hasPath(downloadedCoverPath)) {
try {
await File(downloadedCoverPath!).delete();
@@ -2468,7 +2459,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
/// 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<TrackMetadataScreen> {
final currentFormat = _currentFileFormat;
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
// Build available target formats based on source
final formats = <String>[];
if (currentFormat == 'FLAC') {
formats.addAll(['ALAC', 'MP3', 'Opus']);
@@ -2912,7 +2898,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}).toList(),
),
// Only show bitrate for lossy targets
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
@@ -2939,7 +2924,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
],
// Show lossless indicator
if (isLosslessTarget && isLosslessSource) ...[
const SizedBox(height: 16),
Row(
@@ -2997,14 +2981,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
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<TrackMetadataScreen> {
),
),
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<TrackMetadataScreen> {
workingAudioPath = tempPath;
}
// Determine output directory
final String outputDir;
final treeUri = !_isLocalItem
? (_downloadItem?.downloadTreeUri ?? '')
@@ -3348,7 +3328,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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 = <String, String>{'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<TrackMetadataScreen> {
finalOutputPaths = exportedUris;
}
// Cleanup cover temp
if (coverPath != null) {
try {
await File(coverPath).delete();
@@ -3443,7 +3419,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_showSnackBarMessage(_l10nCueSplitFailed);
}
} finally {
// Cleanup SAF temp audio copy
if (safTempAudioPath != null) {
try {
await File(safTempAudioPath).delete();
@@ -3562,7 +3537,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
}
if (newPath == null) {
// Cleanup SAF temp if needed
if (safTempPath != null) {
try {
await File(safTempPath).delete();
@@ -3695,7 +3667,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_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<TrackMetadataScreen> {
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
}
// Cleanup temp files
try {
await File(newPath).delete();
} catch (_) {}
@@ -3717,7 +3687,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} 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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
WidgetRef ref,
ColorScheme colorScheme,
) async {
// Read current metadata from file, fall back to item data on failure
Map<String, dynamic>? fileMetadata;
try {
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
@@ -3765,7 +3732,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<String> _autoFillFields = {};
// All auto-fillable fields and their mapping
static const _fieldDefs = <String, String>{
'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 = <String, String>{
'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(
+4 -10
View File
@@ -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,
);
+857
View File
@@ -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<double>(
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<T> slidePageRoute<T>({required Widget page}) {
return MaterialPageRoute<T>(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<ShimmerLoading> createState() => _ShimmerLoadingState();
}
class _ShimmerLoadingState extends State<ShimmerLoading>
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<DownloadSuccessOverlay> createState() => _DownloadSuccessOverlayState();
}
class _DownloadSuccessOverlayState extends State<DownloadSuccessOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _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<double>([
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<AnimatedBadge> createState() => _AnimatedBadgeState();
}
class _AnimatedBadgeState extends State<AnimatedBadge>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
int _previousCount = 0;
@override
void initState() {
super.initState();
_previousCount = widget.count;
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_scaleAnimation = TweenSequence<double>([
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<double> animation) {
return SizeTransition(
sizeFactor: CurvedAnimation(parent: animation, curve: Curves.easeInOut),
child: FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeIn),
child: child,
),
);
}