From 6c89a1e17768d1eeced52a0f2a945d08cf3d541f Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 28 Jul 2026 15:48:45 +0700 Subject: [PATCH] feat(album): batch download selected tracks --- lib/screens/album_screen.dart | 283 +++++++++++++++++------ lib/widgets/track_detail_actions.dart | 19 +- lib/widgets/track_list_tile.dart | 86 ++++--- test/track_list_tile_selection_test.dart | 98 ++++++++ 4 files changed, 390 insertions(+), 96 deletions(-) create mode 100644 test/track_list_tile_selection_test.dart diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 12bec6f0..f889812d 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -12,6 +12,7 @@ import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/cover_art_utils.dart'; import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart'; +import 'package:spotiflac_android/screens/selection_mode_mixin.dart'; import 'package:spotiflac_android/widgets/error_card.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; @@ -26,6 +27,8 @@ import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart'; import 'package:spotiflac_android/widgets/track_list_tile.dart'; import 'package:spotiflac_android/widgets/motion_header_banner.dart'; import 'package:spotiflac_android/widgets/track_detail_actions.dart'; +import 'package:spotiflac_android/widgets/selection_action_button.dart'; +import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; class _AlbumCache { static final _cache = TtlCache>(const Duration(minutes: 10)); @@ -67,7 +70,9 @@ class AlbumScreen extends ConsumerStatefulWidget { } class _AlbumScreenState extends ConsumerState - with CollapsingHeaderScrollMixin { + with + SelectionModeMixin, + CollapsingHeaderScrollMixin { List? _tracks; bool _isLoading = false; String? _error; @@ -321,33 +326,69 @@ class _AlbumScreenState extends ConsumerState final tracks = _tracks ?? []; final pageBackgroundColor = colorScheme.surface; final bottomInset = context.navBarBottomInset; + final bottomPadding = MediaQuery.paddingOf(context).bottom; - return Scaffold( - backgroundColor: pageBackgroundColor, - body: CustomScrollView( - controller: scrollController, - slivers: [ - _buildAppBar(context, colorScheme, pageBackgroundColor), - if (_isLoading) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(16), - child: AlbumTrackListSkeleton(itemCount: 10), + pruneSelection({ + for (var index = 0; index < tracks.length; index++) + _trackSelectionId(tracks[index], index), + }); + + return PopScope( + canPop: !isSelectionMode, + onPopInvokedWithResult: (didPop, result) { + if (!didPop && isSelectionMode) exitSelectionMode(); + }, + child: Scaffold( + backgroundColor: pageBackgroundColor, + body: Stack( + children: [ + CustomScrollView( + controller: scrollController, + slivers: [ + _buildAppBar(context, colorScheme, pageBackgroundColor), + if (_isLoading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(16), + child: AlbumTrackListSkeleton(itemCount: 10), + ), + ), + if (_error != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: ErrorCard( + error: _error!, + colorScheme: colorScheme, + ), + ), + ), + if (!_isLoading && _error == null && tracks.isNotEmpty) ...[ + _buildTrackList(context, colorScheme, tracks), + _buildAlbumFooter(context, colorScheme, tracks), + ], + SliverToBoxAdapter( + child: SizedBox( + height: (isSelectionMode ? 140 : 32) + bottomInset, + ), + ), + ], + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + left: 0, + right: 0, + bottom: isSelectionMode ? 0 : -(200 + bottomPadding), + child: _buildSelectionBottomBar( + context, + colorScheme, + tracks, + bottomPadding, ), ), - if (_error != null) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: ErrorCard(error: _error!, colorScheme: colorScheme), - ), - ), - if (!_isLoading && _error == null && tracks.isNotEmpty) ...[ - _buildTrackList(context, colorScheme, tracks), - _buildAlbumFooter(context, colorScheme, tracks), ], - SliverToBoxAdapter(child: SizedBox(height: 32 + bottomInset)), - ], + ), ), ); } @@ -451,53 +492,84 @@ class _AlbumScreenState extends ConsumerState ) : null, meta: _buildHeaderMeta(context, releaseDate), - actions: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildLoveAllButton(), - const SizedBox(width: 12), - Flexible( - child: FilledButton.icon( - onPressed: tracks.isEmpty ? null : () => _downloadAll(context), - icon: const Icon(Icons.download, size: 18), - label: Text( - context.l10n.downloadAllCount(tracks.length), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - disabledBackgroundColor: Colors.white.withValues(alpha: 0.45), - disabledForegroundColor: Colors.black54, - minimumSize: const Size(0, 48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), + actions: isSelectionMode + ? null + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLoveAllButton(), + const SizedBox(width: 12), + Flexible( + child: FilledButton.icon( + onPressed: tracks.isEmpty + ? null + : () => _downloadAll(context), + icon: const Icon(Icons.download, size: 18), + label: Text( + context.l10n.downloadAllCount(tracks.length), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + disabledBackgroundColor: Colors.white.withValues( + alpha: 0.45, + ), + disabledForegroundColor: Colors.black54, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + ), + ), + const SizedBox(width: 12), + _buildAddToPlaylistButton(context), + ], + ), + appBarTitle: isSelectionMode + ? context.l10n.selectionSelected(selectedIds.length) + : null, + leading: isSelectionMode + ? Padding( + padding: const EdgeInsets.only(left: 8), + child: IconButton( + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + onPressed: exitSelectionMode, + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, color: Colors.white), ), ), - ), - ), - const SizedBox(width: 12), - _buildAddToPlaylistButton(context), - ], - ), - appBarActions: [ - Padding( - padding: const EdgeInsets.only(right: 8), - child: IconButton( - tooltip: context.l10n.openInOtherServices, - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.4), - shape: BoxShape.circle, + ) + : null, + appBarActions: isSelectionMode + ? const [] + : [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + tooltip: context.l10n.openInOtherServices, + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.open_in_new_rounded, + color: Colors.white, + ), + ), + onPressed: () => _showShareSheet(context, tracks, artistName), + ), ), - child: const Icon(Icons.open_in_new_rounded, color: Colors.white), - ), - onPressed: () => _showShareSheet(context, tracks, artistName), - ), - ), - ], + ], ); } @@ -527,11 +599,12 @@ class _AlbumScreenState extends ConsumerState sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { final track = tracks[index]; + final selectionId = _trackSelectionId(track, index); final isInHistory = existingHistoryKeys.contains( historyLookups[index].lookupKey, ); return KeyedSubtree( - key: ValueKey(track.id), + key: ValueKey(selectionId), child: StaggeredListItem( index: index, child: TrackListTile( @@ -544,6 +617,10 @@ class _AlbumScreenState extends ConsumerState forceQualityPicker: forceQualityPicker, ), clickableArtist: true, + isSelectionMode: isSelectionMode, + isSelected: selectedIds.contains(selectionId), + onToggleSelection: () => toggleSelection(selectionId), + onEnterSelectionMode: () => enterSelectionMode(selectionId), leading: SizedBox( width: 32, child: Center( @@ -564,6 +641,74 @@ class _AlbumScreenState extends ConsumerState ); } + String _trackSelectionId(Track track, int index) => + '$index|${track.discNumber ?? 0}|${track.trackNumber ?? 0}|' + '${track.id}|${track.name}'; + + List _selectedTracks(List tracks) => [ + for (var index = 0; index < tracks.length; index++) + if (selectedIds.contains(_trackSelectionId(tracks[index], index))) + tracks[index], + ]; + + Widget _buildSelectionBottomBar( + BuildContext context, + ColorScheme colorScheme, + List tracks, + double bottomPadding, + ) { + final selectedCount = selectedIds.length; + final allSelected = tracks.isNotEmpty && selectedCount == tracks.length; + + return SelectionBottomBar( + selectedCount: selectedCount, + allSelected: allSelected, + onClose: exitSelectionMode, + onToggleSelectAll: () { + if (allSelected) { + exitSelectionMode(); + } else { + selectAll([ + for (var index = 0; index < tracks.length; index++) + _trackSelectionId(tracks[index], index), + ]); + } + }, + bottomPadding: bottomPadding, + children: [ + SizedBox( + width: double.infinity, + child: SelectionActionButton( + icon: Icons.download_rounded, + label: '${context.l10n.dialogDownload} ($selectedCount)', + onPressed: selectedCount == 0 + ? null + : () => _downloadSelected(context, tracks), + colorScheme: colorScheme, + ), + ), + ], + ); + } + + Future _downloadSelected( + BuildContext context, + List tracks, + ) async { + final selectedTracks = _selectedTracks(tracks); + if (selectedTracks.isEmpty) return; + + await queueTracksSkippingDownloaded( + context, + ref, + selectedTracks, + artistNameForPicker: widget.albumName, + recommendedService: _recommendedDownloadService(), + forceQualityPicker: true, + onQueued: exitSelectionMode, + ); + } + void _downloadTrack( BuildContext context, Track track, { diff --git a/lib/widgets/track_detail_actions.dart b/lib/widgets/track_detail_actions.dart index d5b59178..206ff69d 100644 --- a/lib/widgets/track_detail_actions.dart +++ b/lib/widgets/track_detail_actions.dart @@ -118,6 +118,12 @@ void showQueuedSnackbar(BuildContext context, int added, int skipped) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); } +bool shouldShowBatchDownloadPicker({ + required bool forceQualityPicker, + required bool askQualityBeforeDownload, + required bool allowQualityVariants, +}) => forceQualityPicker || askQualityBeforeDownload || allowQualityVariants; + /// Shared batch "add to queue" flow for detail screens: skips tracks already /// present in download history or the local library, then either shows the /// quality/service picker or resolves a default service, before enqueueing @@ -126,6 +132,9 @@ void showQueuedSnackbar(BuildContext context, int added, int skipped) { /// Set [resolveDefaultService] to false to match screens that pass /// `settings.defaultService` straight through without resolving it against /// enabled extensions (playlist / library-folder screens do this today). +/// [forceQualityPicker] lets an explicit multi-select action choose one +/// provider and quality for the complete batch. [onQueued] runs only after +/// tracks are actually added, not when the picker is merely opened. Future queueTracksSkippingDownloaded( BuildContext context, WidgetRef ref, @@ -134,6 +143,8 @@ Future queueTracksSkippingDownloaded( String? recommendedService, String? playlistName, bool resolveDefaultService = true, + bool forceQualityPicker = false, + VoidCallback? onQueued, }) async { if (tracks.isEmpty) return; @@ -198,7 +209,11 @@ Future queueTracksSkippingDownloaded( return; } - if (settings.askQualityBeforeDownload || settings.allowQualityVariants) { + if (shouldShowBatchDownloadPicker( + forceQualityPicker: forceQualityPicker, + askQualityBeforeDownload: settings.askQualityBeforeDownload, + allowQualityVariants: settings.allowQualityVariants, + )) { DownloadServicePicker.show( context, trackName: '${tracksToQueue.length} tracks', @@ -213,6 +228,7 @@ Future queueTracksSkippingDownloaded( qualityOverride: quality, playlistName: playlistName, ); + onQueued?.call(); showQueuedSnackbar(context, tracksToQueue.length, skippedCount); }, ); @@ -238,6 +254,7 @@ Future queueTracksSkippingDownloaded( ref .read(downloadQueueProvider.notifier) .addMultipleToQueue(tracksToQueue, service, playlistName: playlistName); + onQueued?.call(); showQueuedSnackbar(context, tracksToQueue.length, skippedCount); } diff --git a/lib/widgets/track_list_tile.dart b/lib/widgets/track_list_tile.dart index e15d4e6f..4bbf123f 100644 --- a/lib/widgets/track_list_tile.dart +++ b/lib/widgets/track_list_tile.dart @@ -7,6 +7,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/local_playback.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/in_library_badge.dart'; import 'package:spotiflac_android/widgets/preview_button.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; @@ -22,6 +23,10 @@ class TrackListTile extends ConsumerWidget { final void Function({bool forceQualityPicker}) onDownload; final Widget leading; final bool clickableArtist; + final bool isSelectionMode; + final bool isSelected; + final VoidCallback? onToggleSelection; + final VoidCallback? onEnterSelectionMode; const TrackListTile({ super.key, @@ -30,7 +35,11 @@ class TrackListTile extends ConsumerWidget { required this.onDownload, required this.leading, this.clickableArtist = false, - }); + this.isSelectionMode = false, + this.isSelected = false, + this.onToggleSelection, + this.onEnterSelectionMode, + }) : assert(!isSelectionMode || onToggleSelection != null); @override Widget build(BuildContext context, WidgetRef ref) { @@ -65,13 +74,29 @@ class TrackListTile extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 8), child: Card( elevation: 0, - color: Colors.transparent, + color: isSelected + ? colorScheme.primaryContainer.withValues(alpha: 0.3) + : Colors.transparent, margin: const EdgeInsets.symmetric(vertical: 2), child: ListTile( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), - leading: leading, + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelectionMode) ...[ + AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, + ), + const SizedBox(width: 12), + ], + leading, + ], + ), title: Text( track.name, maxLines: 1, @@ -83,7 +108,7 @@ class TrackListTile extends ConsumerWidget { subtitle: Row( children: [ Flexible( - child: clickableArtist + child: clickableArtist && !isSelectionMode ? ClickableArtistName( artistName: track.artistName, artistId: track.artistId, @@ -112,28 +137,37 @@ class TrackListTile extends ConsumerWidget { ], ], ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - PreviewButton(track: track), - TrackCollectionQuickActions( - track: track, - hasLocalPlaybackCandidate: isInHistory || isInLocalLibrary, - ), - ], - ), - onTap: () => _handleTap( - context, - ref, - isQueued: isQueued, - isInLocalLibrary: isInLocalLibrary, - ), - onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( - context, - ref, - track, - hasLocalPlaybackCandidate: isInHistory || isInLocalLibrary, - ), + trailing: isSelectionMode + ? null + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + PreviewButton(track: track), + TrackCollectionQuickActions( + track: track, + hasLocalPlaybackCandidate: + isInHistory || isInLocalLibrary, + ), + ], + ), + onTap: isSelectionMode + ? onToggleSelection + : () => _handleTap( + context, + ref, + isQueued: isQueued, + isInLocalLibrary: isInLocalLibrary, + ), + onLongPress: isSelectionMode + ? null + : onEnterSelectionMode ?? + () => TrackCollectionQuickActions.showTrackOptionsSheet( + context, + ref, + track, + hasLocalPlaybackCandidate: + isInHistory || isInLocalLibrary, + ), ), ), ); diff --git a/test/track_list_tile_selection_test.dart b/test/track_list_tile_selection_test.dart new file mode 100644 index 00000000..3299a5b3 --- /dev/null +++ b/test/track_list_tile_selection_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; +import 'package:spotiflac_android/widgets/track_detail_actions.dart'; +import 'package:spotiflac_android/widgets/track_list_tile.dart'; + +void main() { + test('explicit batch selection forces one provider and quality picker', () { + expect( + shouldShowBatchDownloadPicker( + forceQualityPicker: true, + askQualityBeforeDownload: false, + allowQualityVariants: false, + ), + isTrue, + ); + }); + + const track = Track( + id: 'track-1', + name: 'Selected song', + artistName: 'Artist', + albumName: 'Album', + duration: 180, + trackNumber: 1, + ); + + Widget buildTile({ + bool selectionMode = false, + bool selected = false, + VoidCallback? onToggleSelection, + VoidCallback? onEnterSelectionMode, + void Function()? onDownload, + }) { + return ProviderScope( + child: MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: TrackListTile( + track: track, + isInHistory: false, + onDownload: ({bool forceQualityPicker = false}) => + onDownload?.call(), + leading: const SizedBox(width: 32, child: Text('1')), + isSelectionMode: selectionMode, + isSelected: selected, + onToggleSelection: onToggleSelection, + onEnterSelectionMode: onEnterSelectionMode, + ), + ), + ), + ); + } + + testWidgets('selection mode toggles the row without starting a download', ( + tester, + ) async { + var toggled = false; + var downloaded = false; + await tester.pumpWidget( + buildTile( + selectionMode: true, + selected: true, + onToggleSelection: () => toggled = true, + onDownload: () => downloaded = true, + ), + ); + + expect(find.byType(AnimatedSelectionCheckbox), findsOneWidget); + await tester.tap(find.text('Selected song')); + + expect(toggled, isTrue); + expect(downloaded, isFalse); + }); + + testWidgets('long press enters selection mode when the caller enables it', ( + tester, + ) async { + var enteredSelection = false; + await tester.pumpWidget( + buildTile(onEnterSelectionMode: () => enteredSelection = true), + ); + + await tester.longPress(find.text('Selected song')); + + expect(enteredSelection, isTrue); + }); +}