From 4ee7558bca67a60b6d1122bc25f712a469bf8f5e Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 29 Jul 2026 17:13:21 +0700 Subject: [PATCH] feat(collections): unify selection-aware screens --- lib/screens/album_screen.dart | 198 +++-------- lib/screens/downloaded_album_screen.dart | 15 +- lib/screens/library_tracks_folder_screen.dart | 317 +++++++----------- lib/screens/local_album_screen.dart | 15 +- lib/screens/playlist_screen.dart | 130 +++++-- lib/widgets/album_scaffold_body.dart | 65 ---- lib/widgets/collection_scaffold.dart | 124 +++++++ 7 files changed, 412 insertions(+), 452 deletions(-) delete mode 100644 lib/widgets/album_scaffold_body.dart create mode 100644 lib/widgets/collection_scaffold.dart diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 004f87ae..96199672 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -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 String? _headerVideoUrl; String? _headerImageUrl; List _audioTraits = const []; - OverlayEntry? _selectionOverlayEntry; - List _selectionOverlayTracks = const []; - double _selectionOverlayBottomPadding = 0; String _effectiveMetadataProviderIdFromAlbumId() { if (widget.extensionId != null && widget.extensionId!.isNotEmpty) { @@ -133,26 +131,11 @@ class _AlbumScreenState extends ConsumerState } } - @override - void dispose() { - _hideSelectionOverlay(); - super.dispose(); - } - - @override - void exitSelectionMode() { - super.exitSelectionMode(); - _hideSelectionOverlay(); - } - - @override - void toggleSelection(String itemId) { - super.toggleSelection(itemId); - if (!isSelectionMode) _hideSelectionOverlay(); - } - Future _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 _audioTraits = (audioTraits != null && audioTraits.isNotEmpty) ? audioTraits : _audioTraits; + _error = null; _isLoading = false; }); } @@ -354,50 +338,43 @@ class _AlbumScreenState extends ConsumerState _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 showTitleInAppBar: showTitleInAppBar, backgroundColor: pageBackgroundColor, background: background, + paletteSource: coverThumbUrl, blurAndScrimBackground: showSquareCover, coverBuilder: showSquareCover ? (context, coverSize) => coverThumbUrl != null @@ -508,28 +486,12 @@ class _AlbumScreenState extends ConsumerState _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 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 : [ 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 tracks[index], ]; - void _hideSelectionOverlay() { - _selectionOverlayEntry?.remove(); - _selectionOverlayEntry = null; - } - - void _syncSelectionOverlay({ - required List 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 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, diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 1c4db523..18aebbce 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -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 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 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 ), subtitle: Text( widget.artistName, - style: const TextStyle( - color: Colors.white70, + style: TextStyle( + color: HeaderPalette.of(context).onSurfaceVariant, fontSize: 16, fontWeight: FontWeight.w600, ), diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index a8837b7c..a314ccd5 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -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, ); } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 5bd97406..1db4e92f 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -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 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 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 ), subtitle: Text( widget.artistName, - style: const TextStyle( - color: Colors.white70, + style: TextStyle( + color: HeaderPalette.of(context).onSurfaceVariant, fontSize: 16, fontWeight: FontWeight.w600, ), diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 24dc6655..73dc37d5 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -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 - with CollapsingHeaderScrollMixin { + with + SelectionModeMixin, + CollapsingHeaderScrollMixin { List? _fetchedTracks; bool _isLoading = false; String? _error; @@ -58,6 +64,67 @@ class _PlaylistScreenState extends ConsumerState 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 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 _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 @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 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 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 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 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 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 } 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)), - ), ); } diff --git a/lib/widgets/album_scaffold_body.dart b/lib/widgets/album_scaffold_body.dart deleted file mode 100644 index 66f90776..00000000 --- a/lib/widgets/album_scaffold_body.dart +++ /dev/null @@ -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, - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/collection_scaffold.dart b/lib/widgets/collection_scaffold.dart new file mode 100644 index 00000000..eb8cd3c8 --- /dev/null +++ b/lib/widgets/collection_scaffold.dart @@ -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 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 createState() => _CollectionScaffoldState(); +} + +class _CollectionScaffoldState extends State { + 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)), + ], + ), + ), + ); + } +}