mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-10 08:33:57 +02:00
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:
@@ -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(
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user