mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
feat(album): batch download selected tracks
This commit is contained in:
+214
-69
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user