mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 17:18:36 +02:00
feat(collections): unify selection-aware screens
This commit is contained in:
+50
-148
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user