feat(collections): unify selection-aware screens

This commit is contained in:
zarzet
2026-07-29 17:13:21 +07:00
parent 78670a6ea3
commit 4ee7558bca
7 changed files with 412 additions and 452 deletions
+50 -148
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
@@ -82,9 +83,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
String? _headerVideoUrl;
String? _headerImageUrl;
List<String> _audioTraits = const [];
OverlayEntry? _selectionOverlayEntry;
List<Track> _selectionOverlayTracks = const [];
double _selectionOverlayBottomPadding = 0;
String _effectiveMetadataProviderIdFromAlbumId() {
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
@@ -133,26 +131,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
}
}
@override
void dispose() {
_hideSelectionOverlay();
super.dispose();
}
@override
void exitSelectionMode() {
super.exitSelectionMode();
_hideSelectionOverlay();
}
@override
void toggleSelection(String itemId) {
super.toggleSelection(itemId);
if (!isSelectionMode) _hideSelectionOverlay();
}
Future<void> _fetchTracks() async {
setState(() => _isLoading = true);
setState(() {
_isLoading = true;
_error = null;
});
try {
final directProviderId = _directMetadataProviderId();
if (directProviderId != null) {
@@ -242,6 +225,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
_audioTraits = (audioTraits != null && audioTraits.isNotEmpty)
? audioTraits
: _audioTraits;
_error = null;
_isLoading = false;
});
}
@@ -354,50 +338,43 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
_trackSelectionId(tracks[index], index),
});
if (isSelectionMode || _selectionOverlayEntry != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_syncSelectionOverlay(tracks: tracks, bottomPadding: bottomPadding);
});
}
return PopScope(
canPop: !isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && isSelectionMode) exitSelectionMode();
},
child: 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),
),
),
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,
return CollectionScaffold(
scrollController: scrollController,
isSelectionMode: isSelectionMode,
onExitSelectionMode: exitSelectionMode,
backgroundColor: pageBackgroundColor,
bottomInset: bottomInset,
selectionBar: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
appBar: _buildAppBar(context, colorScheme, pageBackgroundColor),
slivers: [
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,
onRetry: _fetchTracks,
),
),
],
),
),
),
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackList(context, colorScheme, tracks),
_buildAlbumFooter(context, colorScheme, tracks),
],
],
);
}
@@ -453,6 +430,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
showTitleInAppBar: showTitleInAppBar,
backgroundColor: pageBackgroundColor,
background: background,
paletteSource: coverThumbUrl,
blurAndScrimBackground: showSquareCover,
coverBuilder: showSquareCover
? (context, coverSize) => coverThumbUrl != null
@@ -508,28 +486,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
_buildLoveAllButton(),
const SizedBox(width: 12),
Flexible(
child: FilledButton.icon(
child: HeaderFilledButton(
icon: Icons.download,
label: context.l10n.downloadAllCount(tracks.length),
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),
@@ -542,17 +504,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
leading: isSelectionMode
? Padding(
padding: const EdgeInsets.only(left: 8),
child: IconButton(
child: HeaderCircleButton(
icon: Icons.close,
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),
),
),
)
: null,
@@ -561,19 +516,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
child: HeaderCircleButton(
icon: Icons.open_in_new_rounded,
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),
),
),
@@ -659,49 +604,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
tracks[index],
];
void _hideSelectionOverlay() {
_selectionOverlayEntry?.remove();
_selectionOverlayEntry = null;
}
void _syncSelectionOverlay({
required List<Track> tracks,
required double bottomPadding,
}) {
if (!isSelectionMode) {
_hideSelectionOverlay();
return;
}
_selectionOverlayTracks = tracks;
_selectionOverlayBottomPadding = bottomPadding;
if (_selectionOverlayEntry != null) {
_selectionOverlayEntry!.markNeedsBuild();
return;
}
final overlay = Overlay.of(context, rootOverlay: true);
_selectionOverlayEntry = OverlayEntry(
builder: (overlayContext) => Positioned(
left: 0,
right: 0,
bottom: 0,
child: AnimatedSelectionBottomBar(
child: Material(
color: Colors.transparent,
child: _buildSelectionBottomBar(
overlayContext,
Theme.of(overlayContext).colorScheme,
_selectionOverlayTracks,
_selectionOverlayBottomPadding,
),
),
),
),
);
overlay.insert(_selectionOverlayEntry!);
}
Widget _buildSelectionBottomBar(
BuildContext barContext,
ColorScheme colorScheme,
@@ -796,7 +698,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
return HeaderCircleButton(
icon: allLoved ? Icons.favorite : Icons.favorite_border,
iconColor: allLoved ? Colors.redAccent : Colors.white,
iconColor: allLoved ? Theme.of(context).colorScheme.error : null,
tooltip: allLoved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.tooltipLoveAll,
+8 -7
View File
@@ -1,6 +1,7 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/theme/cover_palette.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:share_plus/share_plus.dart';
@@ -24,7 +25,7 @@ import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
import 'package:spotiflac_android/screens/selection_mode_mixin.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/album_scaffold_body.dart';
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/widgets/album_track_tile.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
@@ -274,20 +275,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
pruneSelection(tracks.map((t) => t.id).toSet());
return AlbumScaffoldBody(
return CollectionScaffold(
scrollController: scrollController,
isSelectionMode: isSelectionMode,
onExitSelectionMode: exitSelectionMode,
appBar: _buildAppBar(context, colorScheme, tracks, qualityLabelMode),
trackList: _buildTrackList(context, colorScheme, tracks),
bottomBar: _buildSelectionBottomBar(
slivers: [_buildTrackList(context, colorScheme, tracks)],
selectionBar: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
bottomInset: bottomInset,
bottomPadding: bottomPadding,
);
}
@@ -355,6 +355,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
expandedHeight: expandedHeight,
showTitleInAppBar: showTitleInAppBar,
background: background,
paletteSource: embeddedCoverPath ?? widget.coverUrl,
blurAndScrimBackground:
embeddedCoverPath != null || widget.coverUrl != null,
coverBuilder: (context, coverSize) => _buildSquareCover(
@@ -366,8 +367,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
),
subtitle: Text(
widget.artistName,
style: const TextStyle(
color: Colors.white70,
style: TextStyle(
color: HeaderPalette.of(context).onSurfaceVariant,
fontSize: 16,
fontWeight: FontWeight.w600,
),
+119 -198
View File
@@ -2,6 +2,10 @@ import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/widgets/track_card.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus, XFile;
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
@@ -307,87 +311,62 @@ class _LibraryTracksFolderScreenState
final bottomPadding = MediaQuery.paddingOf(context).bottom;
final bottomInset = context.navBarBottomInset;
return PopScope(
canPop: !isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && isSelectionMode) {
exitSelectionMode();
}
},
child: Scaffold(
body: Stack(
children: [
CustomScrollView(
controller: scrollController,
slivers: [
_buildAppBar(context, colorScheme, title, entries, playlist),
if (entries.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: _EmptyFolderState(
title: emptyTitle,
subtitle: emptySubtitle,
),
)
else
SliverPadding(
padding: EdgeInsets.symmetric(
horizontal: wideListInset(context),
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final entry = entries[index];
final isSelected = selectedIds.contains(entry.key);
final isInHistory = existingHistoryKeys.contains(
historyLookups[index].lookupKey,
);
return KeyedSubtree(
key: ValueKey(entry.key),
child: StaggeredListItem(
index: index,
child: _CollectionTrackTile(
entry: entry,
mode: widget.mode,
playlistId: widget.playlistId,
folderTracks: folderTracks,
isInHistory: isInHistory,
isSelectionMode: isSelectionMode,
isSelected: isSelected,
onTap: isSelectionMode
? () => toggleSelection(entry.key)
: null,
onLongPress: isSelectionMode
? null
: () => enterSelectionMode(entry.key),
),
),
);
}, childCount: entries.length),
return CollectionScaffold(
scrollController: scrollController,
isSelectionMode: isSelectionMode,
onExitSelectionMode: exitSelectionMode,
bottomInset: bottomInset,
selectionBar: _buildSelectionBottomBar(
context,
colorScheme,
entries,
bottomPadding,
),
appBar: _buildAppBar(context, colorScheme, title, entries, playlist),
slivers: [
if (entries.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: _EmptyFolderState(
title: emptyTitle,
subtitle: emptySubtitle,
),
)
else
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: wideListInset(context)),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final entry = entries[index];
final isSelected = selectedIds.contains(entry.key);
final isInHistory = existingHistoryKeys.contains(
historyLookups[index].lookupKey,
);
return KeyedSubtree(
key: ValueKey(entry.key),
child: StaggeredListItem(
index: index,
child: _CollectionTrackTile(
entry: entry,
mode: widget.mode,
playlistId: widget.playlistId,
folderTracks: folderTracks,
isInHistory: isInHistory,
isSelectionMode: isSelectionMode,
isSelected: isSelected,
onTap: isSelectionMode
? () => toggleSelection(entry.key)
: null,
onLongPress: isSelectionMode
? null
: () => enterSelectionMode(entry.key),
),
),
SliverToBoxAdapter(
child: SizedBox(height: isSelectionMode ? 200 : 32),
),
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
],
);
}, childCount: entries.length),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: isSelectionMode ? 0 : -(280 + bottomPadding),
child: _buildSelectionBottomBar(
context,
colorScheme,
entries,
bottomPadding,
),
),
],
),
),
),
],
);
}
@@ -574,6 +553,7 @@ class _LibraryTracksFolderScreenState
expandedHeight: expandedHeight,
showTitleInAppBar: showTitleInAppBar,
background: background,
paletteSource: coverUrl,
blurAndScrimBackground: hasCustomCover || hasCoverUrl,
coverBuilder: (context, coverSize) {
if (hasCustomCover) {
@@ -748,22 +728,11 @@ class _LibraryTracksFolderScreenState
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
const AppSheetHandle(),
const SizedBox(height: 16),
ListTile(
contentPadding: const EdgeInsets.symmetric(
@@ -964,116 +933,68 @@ class _CollectionTrackTile extends ConsumerWidget {
? 'cover_${inMemoryHistoryItem.id}'
: null;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
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: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelectionMode) ...[
AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
),
const SizedBox(width: 12),
],
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,
),
),
),
),
],
),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Row(
children: [
Flexible(
child: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (isInLocalLibrary || isInHistory) ...[
const SizedBox(width: 6),
const InLibraryBadge(),
],
],
),
trailing: isSelectionMode
? null
: isInHistory || isInLocalLibrary
? IconButton(
tooltip: context.l10n.tooltipPlay,
onPressed: () {
ref.read(playbackProvider.notifier).playTrackList([track]);
},
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
)
: null,
onTap: isSelectionMode
? onTap
: () {
if (mode == LibraryTracksFolderMode.wishlist) {
_downloadTrack(context, ref);
return;
}
_navigateToMetadata(context, ref);
},
onLongPress: isSelectionMode ? onTap : onLongPress,
return TrackCard(
style: TrackCardStyle.flat,
isSelectionMode: isSelectionMode,
isSelected: isSelected,
leading: HeroMode(
enabled: heroTag != null,
child: Builder(
builder: (context) {
final size = context.tokens.coverCompact;
final cover = ClipRRect(
borderRadius: context.tokens.borderRadiusThumb,
child: effectiveCoverUrl != null && effectiveCoverUrl.isNotEmpty
? _buildTrackCover(context, effectiveCoverUrl, size)
: TrackCoverPlaceholder(size: size),
);
return heroTag != null ? Hero(tag: heroTag, child: cover) : cover;
},
),
),
title: track.name,
subtitle: Row(
children: [
Flexible(
child: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
if (isInLocalLibrary || isInHistory) ...[
const SizedBox(width: 6),
const InLibraryBadge(),
],
],
),
trailing: isInHistory || isInLocalLibrary
? IconButton(
tooltip: context.l10n.tooltipPlay,
onPressed: () {
ref.read(playbackProvider.notifier).playTrackList([track]);
},
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
minimumSize: Size.square(context.tokens.minTouchTarget),
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
)
: null,
onTap: isSelectionMode
? onTap
: () {
if (mode == LibraryTracksFolderMode.wishlist) {
_downloadTrack(context, ref);
return;
}
_navigateToMetadata(context, ref);
},
onLongPress: isSelectionMode ? onTap : onLongPress,
);
}
+8 -7
View File
@@ -1,6 +1,7 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/theme/cover_palette.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
@@ -28,7 +29,7 @@ import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/music_player_provider.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/album_scaffold_body.dart';
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
import 'package:spotiflac_android/widgets/album_track_tile.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/destructive_selection_button.dart';
@@ -188,20 +189,19 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
pruneSelection(tracks.map((t) => t.id).toSet());
return AlbumScaffoldBody(
return CollectionScaffold(
scrollController: scrollController,
isSelectionMode: isSelectionMode,
onExitSelectionMode: exitSelectionMode,
appBar: _buildAppBar(context, colorScheme, qualityLabelMode),
trackList: _buildTrackList(context, colorScheme, tracks),
bottomBar: _buildSelectionBottomBar(
slivers: [_buildTrackList(context, colorScheme, tracks)],
selectionBar: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
bottomInset: bottomInset,
bottomPadding: bottomPadding,
);
}
@@ -236,6 +236,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
expandedHeight: expandedHeight,
showTitleInAppBar: showTitleInAppBar,
background: background,
paletteSource: widget.coverPath,
blurAndScrimBackground: widget.coverPath != null,
coverBuilder: (context, coverSize) => widget.coverPath != null
? Image.file(
@@ -264,8 +265,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
),
subtitle: Text(
widget.artistName,
style: const TextStyle(
color: Colors.white70,
style: TextStyle(
color: HeaderPalette.of(context).onSurfaceVariant,
fontSize: 16,
fontWeight: FontWeight.w600,
),
+103 -27
View File
@@ -1,5 +1,9 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/screens/selection_mode_mixin.dart';
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
import 'package:spotiflac_android/widgets/selection_action_button.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
@@ -44,7 +48,9 @@ class PlaylistScreen extends ConsumerStatefulWidget {
}
class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
with CollapsingHeaderScrollMixin<PlaylistScreen> {
with
SelectionModeMixin<PlaylistScreen>,
CollapsingHeaderScrollMixin<PlaylistScreen> {
List<Track>? _fetchedTracks;
bool _isLoading = false;
String? _error;
@@ -58,6 +64,67 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
String? get _headerVideoUrl =>
_resolvedHeaderVideoUrl ?? widget.headerVideoUrl;
/// Playlists can legitimately contain the same track twice, so selection is
/// keyed by position as well as id (matching the album screen).
String _trackSelectionId(Track track, int index) => '${track.id}#$index';
List<Track> get _selectedTracks => [
for (var index = 0; index < _tracks.length; index++)
if (selectedIds.contains(_trackSelectionId(_tracks[index], index)))
_tracks[index],
];
Widget _buildSelectionBottomBar(
BuildContext context,
ColorScheme colorScheme,
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,
colorScheme: colorScheme,
),
),
],
);
}
Future<void> _downloadSelected() async {
final selected = _selectedTracks;
if (selected.isEmpty) return;
await queueTracksSkippingDownloaded(
context,
ref,
selected,
artistNameForPicker: _playlistName,
recommendedService: widget.recommendedService,
forceQualityPicker: true,
onQueued: exitSelectionMode,
);
}
String? _metadataProviderId(String playlistId) {
final providerId = legacyProviderIdFromResourceId(playlistId);
if (providerId == null) return null;
@@ -172,19 +239,28 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.paddingOf(context).bottom;
return Scaffold(
body: CustomScrollView(
controller: scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildTrackList(context, colorScheme),
_buildPlaylistFooter(context, colorScheme),
SliverToBoxAdapter(
child: SizedBox(height: 32 + context.navBarBottomInset),
),
],
pruneSelection({
for (var index = 0; index < _tracks.length; index++)
_trackSelectionId(_tracks[index], index),
});
return CollectionScaffold(
scrollController: scrollController,
isSelectionMode: isSelectionMode,
onExitSelectionMode: exitSelectionMode,
bottomInset: context.navBarBottomInset,
selectionBar: _buildSelectionBottomBar(
context,
colorScheme,
bottomPadding,
),
appBar: _buildAppBar(context, colorScheme),
slivers: [
_buildTrackList(context, colorScheme),
_buildPlaylistFooter(context, colorScheme),
],
);
}
@@ -224,6 +300,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
background: hasMotion
? MotionHeaderBanner(videoUrl: motionUrl, fallback: coverImage)
: coverImage,
paletteSource: _coverUrl,
blurAndScrimBackground: !hasMotion && _coverUrl != null,
coverBuilder: hasMotion
? null
@@ -279,7 +356,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: ErrorCard(error: _error!, colorScheme: colorScheme),
child: ErrorCard(
error: _error!,
colorScheme: colorScheme,
onRetry: _fetchTracksIfNeeded,
),
),
);
}
@@ -313,6 +394,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final track = _tracks[index];
final selectionId = _trackSelectionId(track, index);
final isInHistory = existingHistoryKeys.contains(
historyLookups[index].lookupKey,
);
@@ -323,6 +405,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
child: TrackListTile(
track: track,
isInHistory: isInHistory,
isSelectionMode: isSelectionMode,
isSelected: selectedIds.contains(selectionId),
onToggleSelection: () => toggleSelection(selectionId),
onEnterSelectionMode: () => enterSelectionMode(selectionId),
onDownload: ({bool forceQualityPicker = false}) =>
_downloadTrack(
context,
@@ -383,7 +469,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
return HeaderCircleButton(
icon: allLoved ? Icons.favorite : Icons.favorite_border,
iconColor: allLoved ? Colors.redAccent : Colors.white,
iconColor: allLoved ? Theme.of(context).colorScheme.error : null,
tooltip: allLoved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.tooltipLoveAll,
@@ -392,20 +478,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
}
Widget _buildDownloadAllCenterButton(BuildContext context) {
return FilledButton.icon(
return HeaderFilledButton(
icon: Icons.download_rounded,
label: context.l10n.downloadAllCount(_tracks.length),
onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context),
icon: const Icon(Icons.download_rounded, size: 18),
label: Text(
context.l10n.downloadAllCount(_tracks.length),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
);
}
-65
View File
@@ -1,65 +0,0 @@
import 'package:flutter/material.dart';
/// Shared body for album-style screens: a [PopScope] that exits selection
/// mode instead of popping, a scrollable track list, and a bottom-select bar
/// that slides in over it.
class AlbumScaffoldBody extends StatelessWidget {
const AlbumScaffoldBody({
super.key,
required this.scrollController,
required this.isSelectionMode,
required this.onExitSelectionMode,
required this.appBar,
required this.trackList,
required this.bottomBar,
required this.bottomInset,
required this.bottomPadding,
});
final ScrollController scrollController;
final bool isSelectionMode;
final VoidCallback onExitSelectionMode;
final Widget appBar;
final Widget trackList;
final Widget bottomBar;
final double bottomInset;
final double bottomPadding;
@override
Widget build(BuildContext context) {
return PopScope(
canPop: !isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && isSelectionMode) {
onExitSelectionMode();
}
},
child: Scaffold(
body: Stack(
children: [
CustomScrollView(
controller: scrollController,
slivers: [
appBar,
trackList,
SliverToBoxAdapter(
child: SizedBox(height: isSelectionMode ? 120 : 32),
),
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
],
),
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: isSelectionMode ? 0 : -(200 + bottomPadding),
child: bottomBar,
),
],
),
),
);
}
}
+124
View File
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
/// Shared shell for every track-collection screen: album, playlist, local
/// album, downloaded album and library folders.
///
/// Before this existed each of those screens built its own `Scaffold` +
/// `PopScope` + selection plumbing, which is why the same four screens had three
/// different selection-bar mechanisms and why the playlist screen ended up with
/// none at all. Screens now supply their header, their content slivers and the
/// bar contents; everything else is shared:
///
/// * back gesture exits selection mode instead of popping the route,
/// * the selection bar is mounted in the root overlay so it floats above the
/// shell navigation bar,
/// * trailing space is reserved so the last row stays reachable while the bar
/// is up.
class CollectionScaffold extends StatefulWidget {
const CollectionScaffold({
super.key,
required this.scrollController,
required this.isSelectionMode,
required this.onExitSelectionMode,
required this.appBar,
required this.slivers,
required this.bottomInset,
this.selectionBar,
this.backgroundColor,
});
final ScrollController scrollController;
final bool isSelectionMode;
final VoidCallback onExitSelectionMode;
/// The collapsing header sliver.
final Widget appBar;
/// Content slivers below the header (track list, error card, footer, ...).
final List<Widget> slivers;
/// Bottom inset needed to clear the shell navigation bar.
final double bottomInset;
/// Rebuilt by the caller each frame; shown only while [isSelectionMode] is
/// true. Null means the screen has no multi-select.
final Widget? selectionBar;
final Color? backgroundColor;
@override
State<CollectionScaffold> createState() => _CollectionScaffoldState();
}
class _CollectionScaffoldState extends State<CollectionScaffold> {
final SelectionOverlayController _selectionOverlay =
SelectionOverlayController();
final GlobalKey _selectionBarKey = GlobalKey();
// Used for the first frame only. Once the overlay has laid out, the spacer
// tracks its real height so multi-row actions and large text cannot cover the
// final collection rows.
double _selectionBarHeight = 160;
@override
void dispose() {
_selectionOverlay.dispose();
super.dispose();
}
void _syncSelectionOverlay() {
if (!mounted) return;
final bar = widget.selectionBar;
if (!widget.isSelectionMode || bar == null) {
_selectionOverlay.hide();
return;
}
_selectionOverlay.show(
context,
(_) => KeyedSubtree(key: _selectionBarKey, child: bar),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !widget.isSelectionMode) return;
final height = _selectionBarKey.currentContext?.size?.height;
if (height == null || height <= 0) return;
if ((height - _selectionBarHeight).abs() < 0.5) return;
setState(() => _selectionBarHeight = height);
});
}
@override
Widget build(BuildContext context) {
if (widget.isSelectionMode || _selectionOverlay.isVisible) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _syncSelectionOverlay(),
);
}
return PopScope(
canPop: !widget.isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && widget.isSelectionMode) {
widget.onExitSelectionMode();
}
},
child: Scaffold(
backgroundColor: widget.backgroundColor,
body: CustomScrollView(
controller: widget.scrollController,
slivers: [
widget.appBar,
...widget.slivers,
SliverToBoxAdapter(
child: SizedBox(
key: const ValueKey('collection-selection-spacer'),
height: widget.isSelectionMode ? _selectionBarHeight : 32,
),
),
SliverToBoxAdapter(child: SizedBox(height: widget.bottomInset)),
],
),
),
);
}
}