feat(album): batch download selected tracks

This commit is contained in:
zarzet
2026-07-28 15:48:45 +07:00
parent fb538e9517
commit 6c89a1e177
4 changed files with 390 additions and 96 deletions
+214 -69
View File
@@ -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<List<Track>>(const Duration(minutes: 10));
@@ -67,7 +70,9 @@ class AlbumScreen extends ConsumerStatefulWidget {
}
class _AlbumScreenState extends ConsumerState<AlbumScreen>
with CollapsingHeaderScrollMixin<AlbumScreen> {
with
SelectionModeMixin<AlbumScreen>,
CollapsingHeaderScrollMixin<AlbumScreen> {
List<Track>? _tracks;
bool _isLoading = false;
String? _error;
@@ -321,33 +326,69 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
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<AlbumScreen>
)
: 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<AlbumScreen>
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<AlbumScreen>
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<AlbumScreen>
);
}
String _trackSelectionId(Track track, int index) =>
'$index|${track.discNumber ?? 0}|${track.trackNumber ?? 0}|'
'${track.id}|${track.name}';
List<Track> _selectedTracks(List<Track> 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<Track> 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<void> _downloadSelected(
BuildContext context,
List<Track> 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, {
+18 -1
View File
@@ -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<void> queueTracksSkippingDownloaded(
BuildContext context,
WidgetRef ref,
@@ -134,6 +143,8 @@ Future<void> queueTracksSkippingDownloaded(
String? recommendedService,
String? playlistName,
bool resolveDefaultService = true,
bool forceQualityPicker = false,
VoidCallback? onQueued,
}) async {
if (tracks.isEmpty) return;
@@ -198,7 +209,11 @@ Future<void> 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<void> queueTracksSkippingDownloaded(
qualityOverride: quality,
playlistName: playlistName,
);
onQueued?.call();
showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
},
);
@@ -238,6 +254,7 @@ Future<void> queueTracksSkippingDownloaded(
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracksToQueue, service, playlistName: playlistName);
onQueued?.call();
showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
}
+60 -26
View File
@@ -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,
),
),
),
);
+98
View File
@@ -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);
});
}