diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 14794b26..6a6f1a53 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -7131,6 +7131,7 @@ "listeningStatsClear": "Clear listening statistics", "listeningStatsClearError": "Could not clear listening statistics. Please try again.", "listeningStatsClearDescription": "Delete all listening statistics on this device? Your music and playlists will be kept.", + "libraryArtistsEmpty": "Artists appear here once you download music or scan a local library.", "weeklyReleases": "New releases", "weeklyReleasesDescription": "Recent albums and singles from your favorite artists, supplied by installed metadata extensions.", "weeklyReleasesEmpty": "No dated releases found in this period. Add favorite artists or try a longer period.", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 961fdd6c..25e0db75 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -101,6 +101,7 @@ "listeningStatsClear": "Hapus statistik mendengarkan", "listeningStatsClearError": "Statistik mendengarkan tidak dapat dihapus. Silakan coba lagi.", "listeningStatsClearDescription": "Hapus semua statistik mendengarkan di perangkat ini? Musik dan playlist tetap tersimpan.", + "libraryArtistsEmpty": "Artis muncul di sini setelah kamu mengunduh musik atau memindai library lokal.", "weeklyReleases": "Rilis baru", "weeklyReleasesDescription": "Album dan single terbaru dari artis favorit, melalui extension metadata yang terpasang.", "weeklyReleasesEmpty": "Tidak ditemukan rilis dengan tanggal dalam periode ini. Tambahkan artis favorit atau coba periode lebih panjang.", diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 28e37a86..e307ac8d 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -110,6 +110,7 @@ class SettingsNotifier extends Notifier { 'all', 'albums', 'singles', + 'artists', 'playlists', }; static const Set _extensionVerificationBrowserModeValues = { diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index f1ab85fa..442e0167 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -7,8 +7,12 @@ import 'package:spotiflac_android/widgets/app_choice_chip.dart'; import 'package:spotiflac_android/services/local_track_batch_actions.dart'; import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/widgets/error_card.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/swipe_delete_dismissible.dart'; import 'package:spotiflac_android/widgets/track_card.dart'; +import 'package:spotiflac_android/theme/app_motion.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/app_icon.dart'; @@ -26,7 +30,18 @@ import 'package:spotiflac_android/widgets/library_playlist_drag_source.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart' show DateFormat; +import 'package:material_3_expressive/material_3_expressive.dart' + show + M3EButtonGroup, + M3EButtonGroupAction, + M3EButtonGroupType, + M3EButtonStyle; +import 'package:spotiflac_android/providers/library_browse_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/widgets/app_shape.dart'; +import 'package:spotiflac_android/widgets/library_fast_scroller.dart'; +import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; @@ -76,6 +91,7 @@ part 'queue_tab_selection.dart'; part 'queue_tab_navigation.dart'; part 'queue_tab_collection_items.dart'; part 'queue_tab_filter_widgets.dart'; +part 'queue_tab_sections.dart'; part 'queue_tab_batch_actions.dart'; part 'queue_tab_item_widgets.dart'; @@ -269,7 +285,22 @@ class _QueueTabState extends ConsumerState { double _playlistSelectionOverlayBottomPadding = 0; PageController? _filterPageController; - final List _filterModes = ['all', 'albums', 'singles', 'playlists']; + + /// Pages of the Library pager: Songs, Albums, Artists and Playlists. + /// Singles share the Albums page behind [_albumsShowSingles]. + final List _filterModes = ['all', 'albums', 'artists', 'playlists']; + bool _albumsShowSingles = false; + + int _pageIndexFor(String mode) => + _filterModes.indexOf(mode == 'singles' ? 'albums' : mode); + + /// Data mode of the page at [index], resolving Albums to Singles when the + /// Albums page shows singles. + String _modeForPage(int index) { + final mode = _filterModes[index]; + return mode == 'albums' && _albumsShowSingles ? 'singles' : mode; + } + bool _isPageControllerInitialized = false; bool _wasTabVisible = false; final TextEditingController _searchController = TextEditingController(); @@ -289,6 +320,7 @@ class _QueueTabState extends ConsumerState { double? _libraryGridPinchStartExtent; final Map _libraryPageOffsetByFilter = {}; bool _libraryPageLoadScheduled = false; + final Map<_QueueLibraryCountsRequest, QueueLibraryCounts> _queueLibraryCountsCache = {}; final Map<_QueueLibraryPageRequest, _QueueLibraryPageData> @@ -353,16 +385,16 @@ class _QueueTabState extends ConsumerState { (settings.defaultLibraryView == 'last' ? settings.historyFilterMode : settings.defaultLibraryView); - final initialPage = _filterModes - .indexOf(initialFilter) - .clamp(0, _filterModes.length - 1); + final initialPage = _pageIndexFor( + initialFilter, + ).clamp(0, _filterModes.length - 1); + _albumsShowSingles = initialFilter == 'singles'; + final initialMode = _modeForPage(initialPage); if (widget.librarySection == null && - settings.historyFilterMode != _filterModes[initialPage]) { + settings.historyFilterMode != initialMode) { Future.microtask(() { if (!mounted) return; - ref - .read(settingsProvider.notifier) - .setHistoryFilterMode(_filterModes[initialPage]); + ref.read(settingsProvider.notifier).setHistoryFilterMode(initialMode); }); } _filterPageController = PageController(initialPage: initialPage); @@ -380,10 +412,13 @@ class _QueueTabState extends ConsumerState { if (!becameVisible) return; final defaultView = ref.read(settingsProvider).defaultLibraryView; if (defaultView == 'last') return; - final index = _filterModes.indexOf(defaultView); + final index = _pageIndexFor(defaultView); if (index < 0) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + if (_filterModes[index] == 'albums') { + _setState(() => _albumsShowSingles = defaultView == 'singles'); + } if (ref.read(settingsProvider).historyFilterMode != defaultView) { ref.read(settingsProvider.notifier).setHistoryFilterMode(defaultView); } @@ -662,24 +697,39 @@ class _QueueTabState extends ConsumerState { void _onFilterPageChanged(int index) { if (widget.librarySection != null) return; HapticFeedback.selectionClick(); - final filterMode = _filterModes[index]; - ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode); + ref + .read(settingsProvider.notifier) + .setHistoryFilterMode(_modeForPage(index)); } void _animateToFilterPage(int index) { if (index >= 0 && index < _filterModes.length) { - final filterMode = _filterModes[index]; + final filterMode = _modeForPage(index); if (ref.read(settingsProvider).historyFilterMode != filterMode) { ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode); } } + final motion = AppMotion.spatialSlow( + context, + const AppMotionSpec(Duration(milliseconds: 300), Curves.easeOutCubic), + ); _filterPageController?.animateToPage( index, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, + duration: motion.duration, + curve: motion.curve, ); } + /// Switches the Albums page between albums and singles. + void _setAlbumsShowSingles(bool singles) { + if (_albumsShowSingles == singles) return; + HapticFeedback.selectionClick(); + setState(() => _albumsShowSingles = singles); + ref + .read(settingsProvider.notifier) + .setHistoryFilterMode(singles ? 'singles' : 'albums'); + } + void _onEmbeddedCoverChanged() { if (!mounted || _embeddedCoverRefreshScheduled) return; _embeddedCoverRefreshScheduled = true; @@ -1333,11 +1383,15 @@ class _QueueTabState extends ConsumerState { _buildQueueHeaderSliver(context, colors), SliverList.builder( itemCount: ids.length, - itemBuilder: (context, index) => _QueueItemSliverRow( + itemBuilder: (context, index) => TrackCardSegment.at( key: ValueKey(ids[index]), - itemId: ids[index], - colorScheme: colors, - itemBuilder: _buildQueueItem, + index: index, + count: ids.length, + child: _QueueItemSliverRow( + itemId: ids[index], + colorScheme: colors, + itemBuilder: _buildQueueItem, + ), ), ), if (ids.isEmpty) @@ -1465,7 +1519,8 @@ class _QueueTabState extends ConsumerState { } final activePageRequest = pageRequest(historyFilterMode); - final activePageValue = searchingLibrary + // The Artists page browses its own query. + final activePageValue = searchingLibrary || historyFilterMode == 'artists' ? const AsyncData(_QueueLibraryPageData()) : ref.watch(_queueLibraryPageProvider(activePageRequest)); @@ -1517,7 +1572,7 @@ class _QueueTabState extends ConsumerState { final currentTotalCount = switch (historyFilterMode) { 'albums' => queueCounts.albumCount, 'singles' => queueCounts.singleTrackCount, - 'playlists' => 0, + 'playlists' || 'artists' => 0, _ => queueCounts.allTrackCount, }; final hasMoreLibrary = currentLoadedCount < currentTotalCount; @@ -1622,14 +1677,14 @@ class _QueueTabState extends ConsumerState { padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: LayoutBuilder( builder: (context, constraints) { - int filteredAllCount; - int filteredAlbumCount; - int filteredSingleCount; - - filteredAllCount = queueCounts.allTrackCount; - filteredAlbumCount = queueCounts.albumCount; - filteredSingleCount = queueCounts.singleTrackCount; - + final activePage = _pageIndexFor(historyFilterMode); + Widget tab(int index, String label, int? count) => + _FilterChip( + label: label, + count: count, + isSelected: activePage == index, + onTap: () => _animateToFilterPage(index), + ); return ScrollEdgeFade( axis: Axis.horizontal, size: 48, @@ -1642,46 +1697,36 @@ class _QueueTabState extends ConsumerState { constraints: BoxConstraints( minWidth: constraints.maxWidth, ), + // Mornye spreads its glass pills across the + // search field; Material chips sit 8dp apart + // from the start like other filter rows. child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, + mainAxisAlignment: context.isMornye + ? MainAxisAlignment.spaceBetween + : MainAxisAlignment.start, children: [ - _FilterChip( - label: context.l10n.historyFilterAll, - count: filteredAllCount, - isSelected: historyFilterMode == 'all', - onTap: () { - _animateToFilterPage(0); - }, + tab( + 0, + context.l10n.searchSongs, + queueCounts.allTrackCount, ), const SizedBox(width: 8), - _FilterChip( - label: context.l10n.historyFilterAlbums, - count: filteredAlbumCount, - isSelected: historyFilterMode == 'albums', - onTap: () { - _animateToFilterPage(1); - }, + tab( + 1, + context.l10n.historyFilterAlbums, + queueCounts.albumCount, ), const SizedBox(width: 8), - _FilterChip( - label: context.l10n.historyFilterSingles, - count: filteredSingleCount, - isSelected: - historyFilterMode == 'singles', - onTap: () { - _animateToFilterPage(2); - }, - ), + // Artist totals need a full scan; the + // tab shows none rather than a guess. + tab(2, context.l10n.searchArtists, null), const SizedBox(width: 8), - _FilterChip( - label: context.l10n.searchPlaylists, - count: collectionState.playlists.length, - isSelected: - historyFilterMode == 'playlists', - onTap: () { - _animateToFilterPage(3); - }, + tab( + 3, + context.l10n.searchPlaylists, + _getVisibleCollectionEntries( + collectionState, + ).length, ), ], ), @@ -1733,7 +1778,10 @@ class _QueueTabState extends ConsumerState { onPageChanged: _onFilterPageChanged, itemCount: _filterModes.length, itemBuilder: (context, index) { - final filterMode = _filterModes[index]; + if (_filterModes[index] == 'artists') { + return _buildArtistsPage(context, colorScheme); + } + final filterMode = _modeForPage(index); final filterData = getFilterData(filterMode); return _buildFilterContent( context: context, diff --git a/lib/screens/queue_tab_filter_widgets.dart b/lib/screens/queue_tab_filter_widgets.dart index 566889e5..b347f14a 100644 --- a/lib/screens/queue_tab_filter_widgets.dart +++ b/lib/screens/queue_tab_filter_widgets.dart @@ -221,10 +221,20 @@ extension _QueueTabFilterWidgets on _QueueTabState { } final leadCount = activeDownloadIds.length + bridgeIds.length; - final collectionEntries = filterMode == 'all' + // The tab-root Library keeps Songs to songs: collections live on the + // Playlists page. Section pages opened from Mornye keep them inline. + final rootLibrary = widget.librarySection == null; + final collectionEntries = filterMode == 'all' && !rootLibrary ? _getVisibleCollectionEntries(collectionState) : const <_CollectionEntry>[]; final collectionCount = collectionEntries.length; + final specialEntries = filterMode == 'playlists' && rootLibrary + ? _getVisibleCollectionEntries(collectionState) + .where((entry) => entry.type != _CollectionEntryType.playlist) + .toList(growable: false) + : const <_CollectionEntry>[]; + // Material lists group rows under pinned sections. + final sectioned = rootLibrary && !context.isMornye; // Indexes into collectionState.playlists, name-filtered by the search // query, for the 'playlists' view. @@ -277,19 +287,31 @@ extension _QueueTabFilterWidgets on _QueueTabState { ); } + // Active downloads and the library tracks below them form one connected + // run of rows. Widget leadListCell(int index) { + // Sectioned lists keep the active downloads as their own run. + final runLength = rootLibrary && !context.isMornye + ? leadCount + : leadCount + filteredUnifiedItems.length; if (index < activeDownloadIds.length) { final id = activeDownloadIds[index]; - return _QueueItemSliverRow( + return TrackCardSegment.at( key: ValueKey('dllist_$id'), - itemId: id, - colorScheme: colorScheme, - itemBuilder: _buildQueueItem, + index: index, + count: runLength, + child: _QueueItemSliverRow( + itemId: id, + colorScheme: colorScheme, + itemBuilder: _buildQueueItem, + ), ); } final bridgeId = bridgeIds[index - activeDownloadIds.length]; - return KeyedSubtree( + return TrackCardSegment.at( key: ValueKey('dllist_bridge_$bridgeId'), + index: index, + count: runLength, child: _buildBridgeListItem( context, _completionBridge[bridgeId]!, @@ -303,14 +325,23 @@ extension _QueueTabFilterWidgets on _QueueTabState { final content = CustomScrollView( slivers: [ + if (rootLibrary && (filterMode == 'albums' || filterMode == 'singles')) + SliverToBoxAdapter(child: _buildAlbumsModeToggle(context)), + if (totalTrackCount > 0 && filterMode == 'all') SliverToBoxAdapter( child: _countHeaderRow( context, context.l10n.queueTrackCount(totalTrackCount), [ + if (rootLibrary && !_isSelectionMode) _buildSortButton(context), if (!_isSelectionMode) _buildFilterButton(context), - if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) + if (rootLibrary && !_isSelectionMode) + _buildViewModeButton(context, historyViewMode), + // The root Library creates playlists from the Playlists page. + if (!rootLibrary && + !_isSelectionMode && + filteredUnifiedItems.isNotEmpty) ExpressiveButton( text: true, onPressed: () => _showCreatePlaylistDialog(context), @@ -331,7 +362,10 @@ extension _QueueTabFilterWidgets on _QueueTabState { child: _countHeaderRow( context, context.l10n.queueAlbumCount(totalAlbumCount), - [_buildFilterButton(context)], + [ + if (rootLibrary) _buildSortButton(context), + _buildFilterButton(context), + ], ), ), @@ -447,7 +481,8 @@ extension _QueueTabFilterWidgets on _QueueTabState { ), ), - if (filterMode == 'playlists' && playlistIndexes.isNotEmpty) ...[ + if (filterMode == 'playlists' && + (playlistIndexes.isNotEmpty || specialEntries.isNotEmpty)) ...[ SliverToBoxAdapter( child: _countHeaderRow( context, @@ -474,7 +509,21 @@ extension _QueueTabFilterWidgets on _QueueTabState { crossAxisSpacing: 12, childAspectRatio: 0.72, delegate: SliverChildBuilderDelegate((context, index) { - final playlistIndex = playlistIndexes[index]; + // Wishlist, Loved and Favorite artists lead the grid. + if (index < specialEntries.length) { + final entry = specialEntries[index]; + return KeyedSubtree( + key: ValueKey('plgrid_special_${entry.type.name}'), + child: _buildAllTabGridCollectionItem( + context: context, + colorScheme: colorScheme, + entry: entry, + collectionState: collectionState, + ), + ); + } + final playlistIndex = + playlistIndexes[index - specialEntries.length]; return KeyedSubtree( key: ValueKey( 'plgrid_${collectionState.playlists[playlistIndex].id}', @@ -486,7 +535,7 @@ extension _QueueTabFilterWidgets on _QueueTabState { collectionState: collectionState, ), ); - }, childCount: playlistIndexes.length), + }, childCount: specialEntries.length + playlistIndexes.length), ), ), ], @@ -552,6 +601,38 @@ extension _QueueTabFilterWidgets on _QueueTabState { ), ), ) + else if (sectioned) + _buildSectionedTrackSlivers( + context: context, + items: filteredUnifiedItems, + leadCount: leadCount, + leadBuilder: leadListCell, + rowBuilder: (index) { + final item = filteredUnifiedItems[index]; + return LibraryPlaylistDragSource( + data: item, + onSelect: () => + _selectDraggedTrack(item, filteredUnifiedItems), + onDragStarted: _startLibraryTrackDrag, + onDragEnd: _endLibraryTrackDrag, + feedbackBuilder: (context) => + _buildDragFeedback(context, item, colorScheme), + child: _buildUnifiedLibraryItem( + context, + item, + colorScheme, + downloadedNavigationItems: downloadedNavigationItems, + downloadedNavigationIndex: + downloadedNavigationIndexByUnifiedId[item.id], + localNavigationItems: localNavigationItems, + localNavigationIndex: + localNavigationIndexByUnifiedId[item.id], + libraryItems: filteredUnifiedItems, + playlistDragEnabled: true, + ), + ); + }, + ) else SliverPadding( padding: EdgeInsets.symmetric(horizontal: wideListInset(context)), @@ -574,8 +655,10 @@ extension _QueueTabFilterWidgets on _QueueTabState { final trackIndex = afterCollections - leadCount; if (trackIndex < filteredUnifiedItems.length) { final item = filteredUnifiedItems[trackIndex]; - return KeyedSubtree( + return TrackCardSegment.at( key: ValueKey(item.id), + index: afterCollections, + count: leadCount + filteredUnifiedItems.length, child: LibraryPlaylistDragSource( data: item, onSelect: () => @@ -616,8 +699,14 @@ extension _QueueTabFilterWidgets on _QueueTabState { context, context.l10n.queueTrackCount(totalTrackCount), [ + if (rootLibrary && !_isSelectionMode) _buildSortButton(context), if (!_isSelectionMode) _buildFilterButton(context), - if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) + if (rootLibrary && !_isSelectionMode) + _buildViewModeButton(context, historyViewMode), + // The root Library creates playlists from the Playlists page. + if (!rootLibrary && + !_isSelectionMode && + filteredUnifiedItems.isNotEmpty) ExpressiveButton( text: true, onPressed: () => _showCreatePlaylistDialog(context), @@ -667,6 +756,28 @@ extension _QueueTabFilterWidgets on _QueueTabState { }, childCount: leadCount + filteredUnifiedItems.length), ), ) + : sectioned + ? _buildSectionedTrackSlivers( + context: context, + items: filteredUnifiedItems, + leadCount: leadCount, + leadBuilder: leadListCell, + rowBuilder: (index) { + final item = filteredUnifiedItems[index]; + return _buildUnifiedLibraryItem( + context, + item, + colorScheme, + downloadedNavigationItems: downloadedNavigationItems, + downloadedNavigationIndex: + downloadedNavigationIndexByUnifiedId[item.id], + localNavigationItems: localNavigationItems, + localNavigationIndex: + localNavigationIndexByUnifiedId[item.id], + libraryItems: filteredUnifiedItems, + ); + }, + ) : SliverPadding( padding: EdgeInsets.symmetric( horizontal: wideListInset(context), @@ -677,8 +788,10 @@ extension _QueueTabFilterWidgets on _QueueTabState { return leadListCell(index); } final item = filteredUnifiedItems[index - leadCount]; - return KeyedSubtree( + return TrackCardSegment.at( key: ValueKey(item.id), + index: index, + count: leadCount + filteredUnifiedItems.length, child: _buildUnifiedLibraryItem( context, item, @@ -701,7 +814,8 @@ extension _QueueTabFilterWidgets on _QueueTabState { (filterMode != 'albums' || (filteredGroupedAlbums.isEmpty && filteredGroupedLocalAlbums.isEmpty)) && - (filterMode != 'playlists' || playlistIndexes.isEmpty) && + (filterMode != 'playlists' || + (playlistIndexes.isEmpty && specialEntries.isEmpty)) && !showFilteringIndicator && !isPageLoading) SliverFillRemaining( @@ -727,7 +841,8 @@ extension _QueueTabFilterWidgets on _QueueTabState { (filterMode == 'albums' && (filteredGroupedAlbums.isNotEmpty || filteredGroupedLocalAlbums.isNotEmpty)) || - (filterMode == 'playlists' && playlistIndexes.isNotEmpty)) + (filterMode == 'playlists' && + (playlistIndexes.isNotEmpty || specialEntries.isNotEmpty))) SliverToBoxAdapter( child: SizedBox(height: _isSelectionMode ? 100 : 16), ), @@ -735,6 +850,43 @@ extension _QueueTabFilterWidgets on _QueueTabState { ], ); + // The fast scroller follows the loaded rows. Near-end notifications load + // the next page while a drag continues. + final albumCount = + filteredGroupedAlbums.length + filteredGroupedLocalAlbums.length; + String? labelAt(double fraction) { + if (filterMode == 'albums') { + if (albumCount == 0) return null; + final index = (fraction * (albumCount - 1)).round(); + if (index < filteredGroupedAlbums.length) { + final album = filteredGroupedAlbums[index]; + return _albumSectionLabel( + context, + album: album.albumName, + artist: album.artistName, + added: album.latestDownload, + ); + } + final album = + filteredGroupedLocalAlbums[index - filteredGroupedAlbums.length]; + return _albumSectionLabel( + context, + album: album.albumName, + artist: album.artistName, + added: album.latestScanned, + ); + } + if (filterMode != 'all' && filterMode != 'singles') return null; + final rows = collectionCount + leadCount + filteredUnifiedItems.length; + if (rows == 0) return null; + final itemIndex = + (fraction * (rows - 1)).round() - collectionCount - leadCount; + if (itemIndex < 0 || itemIndex >= filteredUnifiedItems.length) { + return null; + } + return _trackSectionLabel(context, filteredUnifiedItems[itemIndex]); + } + final scrollAwareContent = NotificationListener( onNotification: (notification) => _handleLibraryScrollNotification( notification: notification, @@ -742,7 +894,12 @@ extension _QueueTabFilterWidgets on _QueueTabState { hasMoreLibrary: hasMoreLibrary, isPageLoading: isPageLoading, ), - child: content, + child: LibraryFastScroller( + topPadding: MediaQuery.paddingOf(context).top + kToolbarHeight, + bottomPadding: context.navBarBottomInset, + labelAt: labelAt, + child: content, + ), ); if (historyViewMode != 'grid') return scrollAwareContent; diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index 805565c1..bc77aa99 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -54,9 +54,8 @@ extension _QueueTabItemWidgets on _QueueTabState { return SmoothedProgressScope( value: item.progress, animate: _shouldAnimateDownloadProgress(context, item), - child: Dismissible( - key: ValueKey('dismiss_${item.id}'), - direction: DismissDirection.endToStart, + child: SwipeDeleteDismissible( + dismissKey: ValueKey('dismiss_${item.id}'), confirmDismiss: isActive ? (_) async { return await showAppDialog( @@ -85,19 +84,6 @@ extension _QueueTabItemWidgets on _QueueTabState { onDismissed: (_) { ref.read(downloadQueueProvider.notifier).dismissItem(item.id); }, - background: Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - decoration: BoxDecoration( - color: colorScheme.errorContainer, - borderRadius: BorderRadius.circular(12), - ), - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 20), - child: AppIcon( - Icons.delete_outline, - color: colorScheme.onErrorContainer, - ), - ), child: DownloadSuccessOverlay( showSuccess: isCompleted, child: TrackCard( @@ -379,32 +365,36 @@ extension _QueueTabItemWidgets on _QueueTabState { Future _showQueuedItemMenu(BuildContext context, DownloadItem item) { final notifier = ref.read(downloadQueueProvider.notifier); - return showModalBottomSheet( + return showAppBottomSheet( context: context, - showDragHandle: true, - builder: (ctx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, + title: item.track.name, + builder: (ctx) => Padding( + padding: EdgeInsets.only(bottom: ctx.tokens.gapLg), + child: SettingsGroup( children: [ - ListTile( - leading: const AppIcon(Icons.skip_next), - title: Text(context.l10n.queueDownloadNext), + SettingsItem( + icon: Icons.skip_next, + showIconInMornye: true, + title: context.l10n.queueDownloadNext, onTap: () { Navigator.of(ctx).pop(); notifier.downloadNext(item.id); }, ), - ListTile( - leading: const AppIcon(Icons.arrow_upward), - title: Text(context.l10n.queueMoveUp), + SettingsItem( + icon: Icons.arrow_upward, + showIconInMornye: true, + title: context.l10n.queueMoveUp, onTap: () { Navigator.of(ctx).pop(); notifier.moveQueuedItem(item.id, -1); }, ), - ListTile( - leading: const AppIcon(Icons.arrow_downward), - title: Text(context.l10n.queueMoveDown), + SettingsItem( + icon: Icons.arrow_downward, + showIconInMornye: true, + title: context.l10n.queueMoveDown, + showDivider: false, onTap: () { Navigator.of(ctx).pop(); notifier.moveQueuedItem(item.id, 1); diff --git a/lib/screens/queue_tab_sections.dart b/lib/screens/queue_tab_sections.dart new file mode 100644 index 00000000..0fca3417 --- /dev/null +++ b/lib/screens/queue_tab_sections.dart @@ -0,0 +1,513 @@ +part of 'queue_tab.dart'; + +/// A run of consecutive rows sharing one section label. +class _LibrarySection { + const _LibrarySection(this.label, this.start, this.end); + + final String label; + final int start; + + /// Exclusive. + final int end; + + int get length => end - start; +} + +/// First letter of [text] for an alphabetical index; digits and symbols +/// share `#`. +String _sectionInitial(String text) { + final trimmed = text.trimLeft(); + for (final character in trimmed.characters) { + final upper = character.toUpperCase(); + if (upper != character.toLowerCase()) return upper; + if (RegExp(r'[\p{L}]', unicode: true).hasMatch(character)) return upper; + return '#'; + } + return '#'; +} + +const _alphabeticalSorts = {'a-z', 'z-a'}; +const _artistSorts = {'artist-asc', 'artist-desc'}; +const _albumSorts = {'album-asc', 'album-desc'}; +const _dateSorts = {'latest', 'oldest'}; + +/// Sort choices offered by the Library sort sheet, in display order. +const _librarySortModes = [ + 'latest', + 'oldest', + 'a-z', + 'z-a', + 'artist-asc', + 'artist-desc', + 'album-asc', + 'album-desc', + 'release-newest', + 'release-oldest', + 'genre-asc', + 'genre-desc', +]; + +extension _QueueTabSections on _QueueTabState { + String _sortLabel(BuildContext context, String mode) { + final l10n = context.l10n; + return switch (mode) { + 'oldest' => l10n.libraryFilterSortOldest, + 'a-z' => l10n.searchSortTitleAZ, + 'z-a' => l10n.searchSortTitleZA, + 'artist-asc' => l10n.searchSortArtistAZ, + 'artist-desc' => l10n.searchSortArtistZA, + 'album-asc' => l10n.libraryFilterSortAlbumAsc, + 'album-desc' => l10n.libraryFilterSortAlbumDesc, + 'release-newest' => l10n.searchSortDateNewest, + 'release-oldest' => l10n.searchSortDateOldest, + 'genre-asc' => l10n.libraryFilterSortGenreAsc, + 'genre-desc' => l10n.libraryFilterSortGenreDesc, + _ => l10n.libraryFilterSortLatest, + }; + } + + /// Section label of [item] under the current sort, or null when the sort + /// has no readable sections (genre and release dates are not loaded). + String? _trackSectionLabel(BuildContext context, UnifiedLibraryItem item) { + if (_alphabeticalSorts.contains(_sortMode)) { + return _sectionInitial(item.trackName); + } + if (_artistSorts.contains(_sortMode)) { + return _sectionInitial(item.artistName); + } + if (_albumSorts.contains(_sortMode)) { + return _sectionInitial(item.albumName); + } + if (_dateSorts.contains(_sortMode)) { + return DateFormat.yMMMM( + Localizations.localeOf(context).toLanguageTag(), + ).format(item.addedAt); + } + return null; + } + + String? _albumSectionLabel( + BuildContext context, { + required String album, + required String artist, + required DateTime added, + }) { + if (_alphabeticalSorts.contains(_sortMode) || + _albumSorts.contains(_sortMode)) { + return _sectionInitial(album); + } + if (_artistSorts.contains(_sortMode)) return _sectionInitial(artist); + if (_dateSorts.contains(_sortMode)) { + return DateFormat.yMMMM( + Localizations.localeOf(context).toLanguageTag(), + ).format(added); + } + return null; + } + + /// Splits [items] into runs that share a section label. + List<_LibrarySection> _trackSections( + BuildContext context, + List items, + ) { + final sections = <_LibrarySection>[]; + String? label; + var start = 0; + for (var i = 0; i < items.length; i++) { + final next = _trackSectionLabel(context, items[i]) ?? ''; + if (i == 0) { + label = next; + } else if (next != label) { + sections.add(_LibrarySection(label!, start, i)); + label = next; + start = i; + } + } + if (items.isNotEmpty) { + sections.add(_LibrarySection(label!, start, items.length)); + } + return sections; + } + + /// Track rows grouped under pinned section headers. Each section is its own + /// connected run of rows. + Widget _buildSectionedTrackSlivers({ + required BuildContext context, + required List items, + required Widget Function(int index) rowBuilder, + required int leadCount, + required Widget Function(int index) leadBuilder, + }) { + final sections = _trackSections(context, items); + final headed = sections.length > 1 || sections.firstOrNull?.label != ''; + return SliverPadding( + padding: EdgeInsets.symmetric(horizontal: wideListInset(context)), + sliver: SliverMainAxisGroup( + slivers: [ + if (leadCount > 0) + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => leadBuilder(index), + childCount: leadCount, + ), + ), + for (final section in sections) + SliverMainAxisGroup( + slivers: [ + if (headed && section.label.isNotEmpty) + PinnedHeaderSliver( + child: _LibrarySectionHeader(label: section.label), + ), + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final itemIndex = section.start + index; + return TrackCardSegment.at( + key: ValueKey(items[itemIndex].id), + index: index, + count: section.length, + child: rowBuilder(itemIndex), + ); + }, childCount: section.length), + ), + ], + ), + ], + ), + ); + } + + /// Quick sort control beside a page's count. + Widget _buildSortButton(BuildContext context) => ExpressiveButton( + text: true, + onPressed: () => _showSortSheet(context), + icon: const AppIcon(Icons.sort, size: 18), + style: TextButton.styleFrom(visualDensity: VisualDensity.compact), + child: Text(_sortLabel(context, _sortMode)), + ); + + /// Switches song pages between the list and the cover grid. + Widget _buildViewModeButton(BuildContext context, String viewMode) { + final grid = viewMode == 'grid'; + return AppIconButton( + tooltip: grid + ? context.l10n.appearanceHistoryViewList + : context.l10n.appearanceHistoryViewGrid, + icon: AppIcon(grid ? Icons.view_list : Icons.grid_view), + onPressed: () => ref + .read(settingsProvider.notifier) + .setHistoryViewMode(grid ? 'list' : 'grid'), + ); + } + + Future _showSortSheet(BuildContext context) async { + final selected = await showAppBottomSheet( + context: context, + title: context.l10n.libraryFilterSort, + builder: (sheetContext) => SingleChildScrollView( + padding: EdgeInsets.only(bottom: sheetContext.tokens.gapLg), + child: SettingsGroup( + children: [ + for (final mode in _librarySortModes) + SettingsItem( + title: _sortLabel(sheetContext, mode), + trailing: mode == _sortMode + ? AppIcon( + Icons.check, + color: Theme.of(sheetContext).colorScheme.primary, + ) + : null, + showDivider: mode != _librarySortModes.last, + onTap: () => Navigator.pop(sheetContext, mode), + ), + ], + ), + ), + ); + if (selected == null || selected == _sortMode || !mounted) return; + _setState(() { + _sortMode = selected; + _resetLibraryPaging(); + }); + } + + /// Albums | Singles switch at the top of the Albums page. + Widget _buildAlbumsModeToggle(BuildContext context) { + final l10n = context.l10n; + final labels = [l10n.historyFilterAlbums, l10n.historyFilterSingles]; + final selected = _albumsShowSingles ? 1 : 0; + final Widget toggle; + if (materialExpressiveEnabled(context)) { + toggle = MaterialExpressiveScope( + child: M3EButtonGroup( + type: M3EButtonGroupType.connected, + style: M3EButtonStyle.filled, + selectedIndex: selected, + selectionRequired: true, + onSelectedIndexChanged: (index) { + if (index != null) _setAlbumsShowSingles(index == 1); + }, + actions: [ + for (final label in labels) + M3EButtonGroupAction(label: Text(label)), + ], + ), + ); + } else { + toggle = SegmentedButton( + showSelectedIcon: false, + segments: [ + for (final (index, label) in labels.indexed) + ButtonSegment(value: index, label: Text(label)), + ], + selected: {selected}, + onSelectionChanged: (value) => _setAlbumsShowSingles(value.first == 1), + ); + } + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Align(alignment: Alignment.centerLeft, child: toggle), + ); + } + + /// Artists across downloaded and scanned music, alphabetically under + /// pinned letter headers. Tapping one opens that artist's albums. + Widget _buildArtistsPage(BuildContext context, ColorScheme colorScheme) { + const request = ( + artists: true, + artist: null, + search: '', + sort: 'a-z', + limit: 80, + completeness: null, + ); + return Consumer( + builder: (context, ref, _) { + final state = ref.watch(libraryBrowseProvider(request)); + final entries = state.value?.entries ?? const []; + final sections = <_LibrarySection>[]; + for (var i = 0; i < entries.length; i++) { + final label = _sectionInitial(entries[i].artist); + if (sections.isEmpty || sections.last.label != label) { + if (sections.isNotEmpty) { + final last = sections.removeLast(); + sections.add(_LibrarySection(last.label, last.start, i)); + } + sections.add(_LibrarySection(label, i, entries.length)); + } + } + void openArtist(String artist) => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => Scaffold( + body: MornyeLibraryScreen( + page: MornyeLibraryPage.albums, + artist: artist, + onOpenSection: (_) {}, + ), + ), + ), + ); + final content = CustomScrollView( + slivers: [ + if (state.isLoading && entries.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: AppLoadingIndicator()), + ) + else if (entries.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + context.l10n.libraryArtistsEmpty, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ) + else + SliverPadding( + padding: EdgeInsets.fromLTRB( + wideListInset(context), + 8, + wideListInset(context), + 0, + ), + sliver: SliverMainAxisGroup( + slivers: [ + for (final section in sections) + SliverMainAxisGroup( + slivers: [ + PinnedHeaderSliver( + child: _LibrarySectionHeader(label: section.label), + ), + SliverList( + delegate: SliverChildBuilderDelegate(( + context, + index, + ) { + final entry = entries[section.start + index]; + return _ArtistRow( + key: ValueKey('artist_${entry.key}'), + entry: entry, + first: index == 0, + last: index == section.length - 1, + onTap: () => openArtist(entry.artist), + ); + }, childCount: section.length), + ), + ], + ), + if (state.value?.isLoadingMore ?? false) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(16), + child: Center(child: AppLoadingIndicator(size: 22)), + ), + ), + ], + ), + ), + const NavBarSliverSpacer(), + ], + ); + return NotificationListener( + onNotification: (notification) { + if (notification.depth == 0 && + notification.metrics.extentAfter < 800 && + (state.value?.hasMore ?? false)) { + ref.read(libraryBrowseProvider(request).notifier).loadMore(); + } + return false; + }, + child: LibraryFastScroller( + topPadding: MediaQuery.paddingOf(context).top + kToolbarHeight, + bottomPadding: context.navBarBottomInset, + // Artist totals are unknown; the thumb spans the loaded rows. + labelAt: (fraction) => entries.isEmpty + ? null + : _sectionInitial( + entries[(fraction * (entries.length - 1)).round()].artist, + ), + child: content, + ), + ); + }, + ); + } +} + +/// Pinned label above a section of library rows. +class _LibrarySectionHeader extends StatelessWidget { + const _LibrarySectionHeader({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ColoredBox( + color: theme.scaffoldBackgroundColor, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 6), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.primary, + ), + ), + ), + ); + } +} + +class _ArtistRow extends StatelessWidget { + const _ArtistRow({ + super.key, + required this.entry, + required this.first, + required this.last, + required this.onTap, + }); + + final LibraryBrowseEntry entry; + final bool first; + final bool last; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final cover = entry.cover; + return Padding( + padding: EdgeInsets.fromLTRB(16, first ? 0 : 1, 16, last ? 4 : 1), + child: Material( + color: settingsGroupColor(context), + borderRadius: connectedRowRadius(context, first: first, last: last), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + AppShapeClip( + kind: AppShapes.artist, + child: SizedBox.square( + dimension: 52, + child: cover == null || cover.isEmpty + ? ColoredBox( + color: colors.surfaceContainerHighest, + child: AppIcon( + Icons.person, + color: colors.onSurfaceVariant, + ), + ) + : PlayerArtwork( + artUri: cover, + colorScheme: colors, + cacheWidth: + (52 * MediaQuery.devicePixelRatioOf(context)) + .ceil(), + iconSize: 24, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + context.l10n.tracksCount(entry.trackCount), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + AppIcon(Icons.chevron_right, color: colors.onSurfaceVariant), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/queue_tab_widgets.dart b/lib/screens/queue_tab_widgets.dart index 08957199..09bb785b 100644 --- a/lib/screens/queue_tab_widgets.dart +++ b/lib/screens/queue_tab_widgets.dart @@ -44,7 +44,9 @@ class _CollectionEntry { class _FilterChip extends ConsumerWidget { final String label; - final int count; + + /// Items in the tab; null hides the count. + final int? count; final bool isSelected; final VoidCallback onTap; @@ -74,26 +76,28 @@ class _FilterChip extends ConsumerWidget { ) : null, ), - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.2) - : colorScheme.outline.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - count.toString(), - style: TextStyle( - fontSize: 11, + if (count != null) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, + ? colorScheme.primary.withValues(alpha: 0.2) + : colorScheme.outline.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + count.toString(), + style: TextStyle( + fontSize: 11, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), ), ), - ), + ], ], ); @@ -101,7 +105,7 @@ class _FilterChip extends ConsumerWidget { return Semantics( button: true, selected: isSelected, - label: '$label, $count', + label: count == null ? label : '$label, $count', excludeSemantics: true, onTap: onTap, child: MornyeGlass( diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 1e405c2a..ed20e5fe 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -421,11 +421,13 @@ class _LibrarySettingsPageState extends ConsumerState { String _getDefaultViewLabel(BuildContext context, String view) { switch (view) { case 'all': - return context.l10n.historyFilterAll; + return context.l10n.searchSongs; case 'albums': return context.l10n.historyFilterAlbums; case 'singles': return context.l10n.historyFilterSingles; + case 'artists': + return context.l10n.searchArtists; case 'playlists': return context.l10n.searchPlaylists; default: @@ -437,9 +439,10 @@ class _LibrarySettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; final options = [ ('last', Icons.history, context.l10n.libraryDefaultViewLastUsed), - ('all', Icons.apps, context.l10n.historyFilterAll), + ('all', Icons.music_note, context.l10n.searchSongs), ('albums', Icons.album, context.l10n.historyFilterAlbums), - ('singles', Icons.music_note, context.l10n.historyFilterSingles), + ('singles', Icons.audiotrack, context.l10n.historyFilterSingles), + ('artists', Icons.person, context.l10n.searchArtists), ('playlists', Icons.queue_music, context.l10n.searchPlaylists), ]; showAppModalBottomSheet( diff --git a/lib/widgets/library_fast_scroller.dart b/lib/widgets/library_fast_scroller.dart new file mode 100644 index 00000000..c964f2a4 --- /dev/null +++ b/lib/widgets/library_fast_scroller.dart @@ -0,0 +1,420 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:spotiflac_android/theme/app_motion.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +/// A draggable scroll thumb for long library lists, with a bubble naming the +/// section under the thumb (a letter or a month). +/// +/// The thumb tracks the scroll position like a scrollbar. Dragging moves the +/// list by the finger's travel rather than jumping to where it touched, so +/// grabbing the thumb never moves the list by itself. Slow drags scrub with +/// finer control in large libraries; quick drags still cover the full range. +/// The thumb shows while scrolling and only for lists several screens long. +class LibraryFastScroller extends StatefulWidget { + const LibraryFastScroller({ + super.key, + required this.child, + required this.labelAt, + this.onDragStart, + this.onDragEnd, + this.topPadding = 0, + this.bottomPadding = 0, + }); + + /// A scroll view. The scroller drives the [Scrollable] that reports scroll + /// notifications from it. + final Widget child; + + /// Label for the row at [fraction] of the scrollable content, or null. + final String? Function(double fraction) labelAt; + + final VoidCallback? onDragStart; + final VoidCallback? onDragEnd; + + /// Height of the pinned header. While an outer scroll view is expanded, + /// only its overlap with this viewport is reserved. + final double topPadding; + final double bottomPadding; + + @override + State createState() => _LibraryFastScrollerState(); +} + +class _LibraryFastScrollerState extends State { + static const _thumbHeight = 52.0; + static const _hitWidth = 32.0; + static const _minScreens = 4; + + ScrollPosition? _position; + ScrollPosition? _outerPosition; + double _pixels = 0; + double _maxExtent = 0; + double _viewport = 0; + bool _visible = false; + bool _dragging = false; + double _lastDragDy = 0; + Duration? _lastDragTime; + double _dragTravel = 0; + double _dragDirection = 0; + Timer? _hideTimer; + + bool get _long => _maxExtent > _viewport * _minScreens; + + double get _fraction => + _maxExtent <= 0 ? 0 : (_pixels / _maxExtent).clamp(0.0, 1.0); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final outer = Scrollable.maybeOf(context, axis: Axis.vertical)?.position; + if (outer == _outerPosition) return; + _outerPosition?.removeListener(_onOuterPositionChanged); + _outerPosition = outer; + outer?.addListener(_onOuterPositionChanged); + } + + void _onOuterPositionChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _hideTimer?.cancel(); + _position?.removeListener(_onPositionChanged); + _outerPosition?.removeListener(_onOuterPositionChanged); + if (_dragging) { + final onDragEnd = widget.onDragEnd; + WidgetsBinding.instance.addPostFrameCallback((_) => onDragEnd?.call()); + } + super.dispose(); + } + + void _attachPosition(BuildContext? context) { + if (context == null) return; + final position = Scrollable.maybeOf(context, axis: Axis.vertical)?.position; + if (position == null || position == _position) return; + _position?.removeListener(_onPositionChanged); + _position = position; + position.addListener(_onPositionChanged); + } + + void _onPositionChanged() { + final position = _position; + if (!mounted || position == null || !position.hasContentDimensions) return; + _readMetrics(position, show: true); + } + + void _readMetrics(ScrollMetrics metrics, {bool show = false}) { + setState(() { + _pixels = metrics.pixels; + _maxExtent = metrics.maxScrollExtent; + _viewport = metrics.viewportDimension; + if (show) _show(); + }); + } + + bool _onMetricsNotification(ScrollMetricsNotification notification) { + if (notification.depth != 0 || notification.metrics.axis != Axis.vertical) { + return false; + } + _attachPosition(notification.context); + _readMetrics(notification.metrics); + return false; + } + + bool _onNotification(ScrollNotification notification) { + if (notification.depth != 0) return false; + final metrics = notification.metrics; + if (metrics.axis != Axis.vertical) return false; + _attachPosition(notification.context); + _readMetrics(metrics, show: notification is ScrollUpdateNotification); + return false; + } + + void _show() { + _visible = true; + _hideTimer?.cancel(); + if (!_dragging) { + _hideTimer = Timer(const Duration(milliseconds: 1500), () { + if (mounted && !_dragging) setState(() => _visible = false); + }); + } + } + + void _startDrag(DragStartDetails details) { + HapticFeedback.selectionClick(); + _lastDragDy = details.globalPosition.dy; + _lastDragTime = details.sourceTimeStamp; + _dragTravel = 0; + _dragDirection = 0; + setState(() { + _dragging = true; + _show(); + }); + widget.onDragStart?.call(); + } + + void _updateDrag(DragUpdateDetails details, double trackHeight) { + final position = _position; + if (position == null || !position.hasContentDimensions) return; + final travel = math.max(1.0, trackHeight - _thumbHeight); + final dy = details.globalPosition.dy - _lastDragDy; + final timestamp = details.sourceTimeStamp; + final elapsed = timestamp != null && _lastDragTime != null + ? (timestamp - _lastDragTime!).inMicroseconds / 1e6 + : 0.0; + _lastDragDy = details.globalPosition.dy; + _lastDragTime = timestamp; + if (dy != 0 && dy.sign != _dragDirection) { + _dragTravel = 0; + _dragDirection = dy.sign; + } + _dragTravel += dy.abs(); + final speed = dy.abs() / (elapsed > 0 ? elapsed : 1 / 60); + final coarse = position.maxScrollExtent / travel; + // Tiny movements must not skip hundreds of rows in a large library. + // Accelerate to scrollbar speed only for a deliberate, quick sweep. + final fine = math.min(coarse, 16.0); + final acceleration = + Curves.easeIn.transform(((speed - 240) / 720).clamp(0.0, 1.0)) * + ((_dragTravel - 24) / 96).clamp(0.0, 1.0); + final scale = fine + (coarse - fine) * acceleration; + position.jumpTo( + (position.pixels + dy * scale).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ), + ); + } + + void _endDrag() { + if (!_dragging) return; + setState(() { + _dragging = false; + _show(); + }); + widget.onDragEnd?.call(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final mornye = context.isMornye; + final reduced = MediaQuery.disableAnimationsOf(context); + final motion = AppMotion.spatialFast( + context, + AppMotionSpec( + reduced ? Duration.zero : const Duration(milliseconds: 180), + Curves.easeOutCubic, + ), + ); + // NestedScrollView's body slides behind its pinned header. Reserve only + // the part of that header currently overlapping this body's viewport. + final remainingHeader = _outerPosition?.hasContentDimensions == true + ? _outerPosition!.extentAfter + : 0.0; + final topPadding = (widget.topPadding - remainingHeader).clamp( + 0.0, + widget.topPadding, + ); + return NotificationListener( + onNotification: _onMetricsNotification, + child: NotificationListener( + onNotification: _onNotification, + child: Stack( + children: [ + widget.child, + if (_long) + Positioned( + top: topPadding, + bottom: widget.bottomPadding, + right: 0, + width: 240, + child: LayoutBuilder( + builder: (context, constraints) { + final track = constraints.maxHeight; + final top = _fraction * math.max(0, track - _thumbHeight); + final label = _dragging ? widget.labelAt(_fraction) : null; + return Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned( + top: top, + right: 0, + width: _hitWidth, + height: _thumbHeight, + child: Semantics( + // Screen readers scroll the list itself. + excludeSemantics: true, + child: IgnorePointer( + // A hidden thumb must not catch edge swipes. + ignoring: !_visible && !_dragging, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragStart: _startDrag, + onVerticalDragUpdate: (details) => + _updateDrag(details, track), + onVerticalDragEnd: (_) => _endDrag(), + onVerticalDragCancel: _endDrag, + child: AnimatedOpacity( + opacity: _visible || _dragging ? 1 : 0, + duration: const Duration(milliseconds: 200), + child: Align( + alignment: Alignment.centerRight, + child: AnimatedContainer( + duration: motion.duration, + curve: motion.curve, + margin: const EdgeInsets.only(right: 4), + width: _dragging ? 10 : 6, + height: _thumbHeight, + decoration: ShapeDecoration( + color: _dragging + ? colors.primary + : colors.onSurfaceVariant + .withValues( + alpha: mornye ? 0.45 : 0.6, + ), + shape: const StadiumBorder(), + ), + ), + ), + ), + ), + ), + ), + ), + if (label != null) + Positioned.fill( + child: IgnorePointer( + child: CustomSingleChildLayout( + delegate: _BubbleLayout( + center: top + _thumbHeight / 2, + right: _hitWidth + 8, + ), + child: _Bubble( + key: const ValueKey('library-scroll-bubble'), + label: label, + mornye: mornye, + motion: motion, + ), + ), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _BubbleLayout extends SingleChildLayoutDelegate { + const _BubbleLayout({required this.center, required this.right}); + + final double center; + final double right; + static const _inset = 8.0; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) => + BoxConstraints( + maxWidth: math.max(0, constraints.maxWidth - right - _inset), + maxHeight: math.max(0, constraints.maxHeight - _inset * 2), + ); + + @override + Offset getPositionForChild(Size size, Size childSize) => Offset( + math.max(0, size.width - right - childSize.width), + (center - childSize.height / 2).clamp( + _inset, + math.max(_inset, size.height - childSize.height - _inset), + ), + ); + + @override + bool shouldRelayout(_BubbleLayout oldDelegate) => + center != oldDelegate.center || right != oldDelegate.right; +} + +/// The section bubble beside the thumb. Material uses a container-colored +/// shape pointing at the thumb; Mornye a plain rounded tile. +class _Bubble extends StatelessWidget { + const _Bubble({ + super.key, + required this.label, + required this.mornye, + required this.motion, + }); + + final String label; + final bool mornye; + final AppMotionSpec motion; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final short = label.characters.length <= 2; + return TweenAnimationBuilder( + tween: Tween(begin: 0.6, end: 1), + duration: motion.duration, + curve: motion.curve, + builder: (context, scale, child) => Transform.scale( + scale: scale, + alignment: Alignment.centerRight, + child: child, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 72, minHeight: 72), + child: DecoratedBox( + decoration: ShapeDecoration( + color: mornye + ? colors.surfaceContainerHighest + : colors.primaryContainer, + shadows: kElevationToShadow[2], + shape: RoundedRectangleBorder( + borderRadius: mornye + ? BorderRadius.circular(18) + : const BorderRadius.only( + topLeft: Radius.circular(36), + topRight: Radius.circular(36), + bottomLeft: Radius.circular(36), + bottomRight: Radius.circular(6), + ), + ), + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: short ? 0 : 18), + child: Center( + widthFactor: short ? null : 1, + heightFactor: 1, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + label, + style: + (short + ? Theme.of(context).textTheme.headlineMedium + : Theme.of(context).textTheme.titleMedium) + ?.copyWith( + fontWeight: FontWeight.w700, + color: mornye + ? colors.onSurface + : colors.onPrimaryContainer, + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/swipe_delete_dismissible.dart b/lib/widgets/swipe_delete_dismissible.dart new file mode 100644 index 00000000..96d4e1c9 --- /dev/null +++ b/lib/widgets/swipe_delete_dismissible.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; +import 'package:spotiflac_android/widgets/app_icon.dart'; +import 'package:spotiflac_android/widgets/track_card.dart'; + +/// End-to-start swipe that removes a track row over a delete background. +/// +/// The background takes the row's [TrackCardSegment] corners. With expressive +/// motion the icon grows with the swipe and the fill deepens to the error +/// color once letting go would dismiss. Mornye and reduced motion keep the +/// static background. +class SwipeDeleteDismissible extends StatefulWidget { + const SwipeDeleteDismissible({ + super.key, + required this.dismissKey, + required this.onDismissed, + required this.child, + this.confirmDismiss, + }); + + final Key dismissKey; + final DismissDirectionCallback onDismissed; + final ConfirmDismissCallback? confirmDismiss; + final Widget child; + + @override + State createState() => _SwipeDeleteDismissibleState(); +} + +class _SwipeDeleteDismissibleState extends State { + static const _threshold = 0.4; + final _swipe = ValueNotifier(null); + + @override + void dispose() { + _swipe.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final expressive = materialExpressiveEnabled(context); + final radius = + TrackCardSegment.radiusOf(context) ?? BorderRadius.circular(12); + final margin = + TrackCardSegment.marginOf(context) ?? + const EdgeInsets.symmetric(horizontal: 16, vertical: 4); + Widget background({required bool reached, required double progress}) { + final scale = 0.7 + 0.45 * (progress / _threshold).clamp(0.0, 1.0); + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: margin, + decoration: BoxDecoration( + color: reached ? colorScheme.error : colorScheme.errorContainer, + borderRadius: radius, + ), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: Transform.scale( + scale: expressive ? scale : 1, + child: AppIcon( + Icons.delete_outline, + color: reached ? colorScheme.onError : colorScheme.onErrorContainer, + ), + ), + ); + } + + return Dismissible( + key: widget.dismissKey, + direction: DismissDirection.endToStart, + dismissThresholds: const {DismissDirection.endToStart: _threshold}, + confirmDismiss: widget.confirmDismiss, + onDismissed: widget.onDismissed, + onUpdate: expressive ? (details) => _swipe.value = details : null, + background: expressive + ? ValueListenableBuilder( + valueListenable: _swipe, + builder: (context, swipe, _) => background( + reached: swipe?.reached ?? false, + progress: swipe?.progress ?? 0, + ), + ) + : background(reached: false, progress: _threshold), + child: widget.child, + ); + } +} diff --git a/lib/widgets/track_card.dart b/lib/widgets/track_card.dart index 14e86866..07c895d3 100644 --- a/lib/widgets/track_card.dart +++ b/lib/widgets/track_card.dart @@ -3,6 +3,7 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; /// How a [TrackCard] paints its container. enum TrackCardStyle { @@ -16,6 +17,67 @@ enum TrackCardStyle { flat, } +/// Marks a filled [TrackCard] as one row of a run of cards, so the Material +/// theme draws the run as an expressive connected list: 2dp apart, large +/// corners at the ends of the run and small corners between rows. +class TrackCardSegment extends InheritedWidget { + const TrackCardSegment({ + super.key, + required this.first, + required this.last, + required super.child, + }); + + /// Positions row [index] of a run of [count] rows. + const TrackCardSegment.at({ + Key? key, + required int index, + required int count, + required Widget child, + }) : this( + key: key, + first: index == 0, + last: index == count - 1, + child: child, + ); + + final bool first; + final bool last; + + static TrackCardSegment? _of(BuildContext context) { + if (context.isMornye) return null; + return context.dependOnInheritedWidgetOfExactType(); + } + + /// Corners of the enclosing segment, or null outside a connected run. + static BorderRadius? radiusOf(BuildContext context) { + final segment = _of(context); + if (segment == null) return null; + return connectedRowRadius( + context, + first: segment.first, + last: segment.last, + ); + } + + /// Outer margin of the enclosing segment, or null outside a connected run. + static EdgeInsets? marginOf(BuildContext context) { + final segment = _of(context); + if (segment == null) return null; + final tokens = context.tokens; + return EdgeInsets.fromLTRB( + tokens.gapLg, + segment.first ? tokens.gapXs : 1, + tokens.gapLg, + segment.last ? tokens.gapXs : 1, + ); + } + + @override + bool updateShouldNotify(TrackCardSegment oldWidget) => + first != oldWidget.first || last != oldWidget.last; +} + /// The one track row in the app. /// /// Screens provide [leading], [subtitle], [trailing], and an optional @@ -68,7 +130,11 @@ class TrackCard extends StatelessWidget { final tokens = context.tokens; final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final borderRadius = tokens.borderRadiusCover; + final filled = style == TrackCardStyle.filled; + final borderRadius = + (filled ? TrackCardSegment.radiusOf(context) : null) ?? + tokens.borderRadiusCover; + final segmentMargin = filled ? TrackCardSegment.marginOf(context) : null; final contentPadding = style == TrackCardStyle.flat ? EdgeInsets.fromLTRB(tokens.gapMd, tokens.gapMd, 6, tokens.gapMd) : EdgeInsets.all(tokens.gapMd); @@ -89,6 +155,7 @@ class TrackCard extends StatelessWidget { // get the Material list margin. margin: margin ?? + segmentMargin ?? (style == TrackCardStyle.flat ? EdgeInsets.symmetric(horizontal: tokens.gapSm, vertical: 2) : EdgeInsets.symmetric( diff --git a/test/library_fast_scroller_test.dart b/test/library_fast_scroller_test.dart new file mode 100644 index 00000000..1f0a04bf --- /dev/null +++ b/test/library_fast_scroller_test.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/widgets/library_fast_scroller.dart'; + +void main() { + Widget host({required ScrollController controller, int itemCount = 400}) => + MaterialApp( + home: Scaffold( + body: LibraryFastScroller( + labelAt: (fraction) => + String.fromCharCode(65 + (fraction * 25).round()), + child: ListView.builder( + controller: controller, + itemExtent: 56, + itemCount: itemCount, + itemBuilder: (context, index) => Text('Row $index'), + ), + ), + ), + ); + + Finder thumb() => find.byWidgetPredicate( + (widget) => + widget is GestureDetector && widget.onVerticalDragUpdate != null, + ); + + testWidgets('dragging the thumb scrolls and names the section', ( + tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget(host(controller: controller)); + // The thumb appears once the list scrolls. + controller.jumpTo(10); + await tester.pump(); + expect(thumb(), findsOneWidget); + + final gesture = await tester.startGesture(tester.getCenter(thumb())); + await gesture.moveBy(const Offset(0, 20)); + await tester.pump(); + await gesture.moveBy(const Offset(0, 260)); + await tester.pump(); + expect(controller.offset, greaterThan(1000)); + // The bubble names the section at the thumb. + expect(find.textContaining(RegExp(r'^[A-Z]$')), findsOneWidget); + + await gesture.moveBy(const Offset(0, 2000)); + await tester.pump(); + expect(controller.offset, controller.position.maxScrollExtent); + await gesture.up(); + await tester.pump(const Duration(seconds: 2)); + expect(find.textContaining(RegExp(r'^[A-Z]$')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('thumb follows the actual scroll position', (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget(host(controller: controller)); + final track = tester.getSize(find.byType(LibraryFastScroller)).height; + for (final fraction in [0.25, 0.5, 0.75, 1.0]) { + controller.jumpTo(controller.position.maxScrollExtent * fraction); + await tester.pump(); + expect( + tester.getTopLeft(thumb()).dy, + closeTo((track - 52) * fraction, 1), + ); + } + await tester.pump(const Duration(seconds: 2)); + }); + + testWidgets('small drags stay precise with ten thousand songs', ( + tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget(host(controller: controller, itemCount: 10000)); + controller.jumpTo(1000); + await tester.pump(); + final gesture = await tester.startGesture(tester.getCenter(thumb())); + await gesture.moveBy( + const Offset(0, 20), + timeStamp: const Duration(milliseconds: 16), + ); + await tester.pump(); + final before = controller.offset; + await gesture.moveBy( + const Offset(0, 2), + timeStamp: const Duration(milliseconds: 116), + ); + await tester.pump(); + expect(controller.offset - before, inInclusiveRange(1, 64)); + final beforeNudge = controller.offset; + await gesture.moveBy( + const Offset(0, 2), + timeStamp: const Duration(milliseconds: 117), + ); + await tester.pump(); + expect(controller.offset - beforeNudge, inInclusiveRange(1, 64)); + // A deliberate quick sweep still reaches the end of the library. + await gesture.moveBy( + const Offset(0, 700), + timeStamp: const Duration(milliseconds: 132), + ); + await tester.pump(); + expect(controller.offset, controller.position.maxScrollExtent); + await gesture.up(); + await tester.pump(const Duration(seconds: 2)); + }); + + testWidgets('new pages update the thumb without moving a held list', ( + tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget(host(controller: controller, itemCount: 80)); + controller.jumpTo(1000); + await tester.pump(); + final gesture = await tester.startGesture(tester.getCenter(thumb())); + await gesture.moveBy( + const Offset(0, 20), + timeStamp: const Duration(milliseconds: 16), + ); + await tester.pump(); + final before = controller.offset; + await tester.pumpWidget(host(controller: controller, itemCount: 160)); + await tester.pump(); + expect(controller.offset, before); + final track = tester.getSize(find.byType(LibraryFastScroller)).height; + expect( + tester.getTopLeft(thumb()).dy, + closeTo((track - 52) * before / controller.position.maxScrollExtent, 1), + ); + await gesture.moveBy( + const Offset(0, 2), + timeStamp: const Duration(milliseconds: 116), + ); + await tester.pump(); + expect(controller.offset - before, inInclusiveRange(1, 64)); + await gesture.up(); + await tester.pump(const Duration(seconds: 2)); + }); + + testWidgets('short lists show no thumb', (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget(host(controller: controller, itemCount: 20)); + controller.jumpTo(10); + await tester.pump(); + expect(thumb(), findsNothing); + }); + + testWidgets('thumb and date bubble stay clear of the pinned Library header', ( + tester, + ) async { + final outer = ScrollController(); + addTearDown(outer.dispose); + const bottomInset = 100.0; + const topInset = 24.0; + const headerExtent = kToolbarHeight + topInset; + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(1.8), + padding: const EdgeInsets.only(top: topInset), + viewPadding: const EdgeInsets.only(top: topInset), + ), + child: child!, + ), + home: Scaffold( + body: NestedScrollView( + controller: outer, + headerSliverBuilder: (context, _) => [ + const SliverAppBar( + pinned: true, + expandedHeight: 200, + title: Text('Library'), + ), + const SliverToBoxAdapter(child: SizedBox(height: 100)), + ], + body: LibraryFastScroller( + topPadding: headerExtent, + bottomPadding: bottomInset, + labelAt: (_) => 'October 2026', + child: ListView.builder( + itemExtent: 56, + itemCount: 400, + itemBuilder: (_, index) => Text('Row $index'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final inner = tester + .state( + find.descendant( + of: find.byType(LibraryFastScroller), + matching: find.byType(Scrollable), + ), + ) + .position; + outer.jumpTo(outer.position.maxScrollExtent); + inner.jumpTo(1); + await tester.pump(); + expect(tester.getTopLeft(thumb()).dy, greaterThanOrEqualTo(headerExtent)); + + final gesture = await tester.startGesture(tester.getCenter(thumb())); + await gesture.moveBy(const Offset(0, 20)); + await tester.pump(const Duration(seconds: 1)); + final bubble = find.byKey(const ValueKey('library-scroll-bubble')); + expect(bubble, findsOneWidget); + expect( + tester.getTopLeft(bubble).dy, + greaterThanOrEqualTo(headerExtent + 8), + ); + inner.jumpTo(inner.maxScrollExtent); + await tester.pump(const Duration(seconds: 1)); + final body = tester.getRect(find.byType(LibraryFastScroller)); + expect( + tester.getBottomRight(bubble).dy, + lessThanOrEqualTo(body.bottom - bottomInset - 8), + ); + await gesture.up(); + await tester.pump(const Duration(seconds: 2)); + + // An expanded header already puts the body below it; no extra gap is needed. + inner.jumpTo(0); + outer.jumpTo(0); + await tester.pump(); + final expandedBody = tester.getRect(find.byType(LibraryFastScroller)); + expect(tester.getTopLeft(thumb()).dy, closeTo(expandedBody.top, 1)); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/material_expressive_test.dart b/test/material_expressive_test.dart index fe5e7b59..16d79869 100644 --- a/test/material_expressive_test.dart +++ b/test/material_expressive_test.dart @@ -22,6 +22,7 @@ import 'package:spotiflac_android/widgets/expressive_icon_button.dart'; import 'package:spotiflac_android/widgets/expressive_navigation_bar.dart'; import 'package:spotiflac_android/widgets/selection_action_button.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/track_card.dart'; import 'support/app_icon_finder.dart'; @@ -560,4 +561,33 @@ void main() { expect(calls, 2); expect(tester.takeException(), isNull); }); + + testWidgets('track rows connect only on the Material theme', (tester) async { + BorderRadius? radius; + Widget segment({required bool first, required bool last}) => + TrackCardSegment( + first: first, + last: last, + child: Builder( + builder: (context) { + radius = TrackCardSegment.radiusOf(context); + return const SizedBox(); + }, + ), + ); + await tester.pumpWidget(_host(segment(first: true, last: false))); + expect(radius?.topLeft, const Radius.circular(20)); + expect(radius?.bottomLeft, const Radius.circular(4)); + await tester.pumpWidget(_host(segment(first: false, last: true))); + expect(radius?.topLeft, const Radius.circular(4)); + expect(radius?.bottomLeft, const Radius.circular(20)); + await tester.pumpWidget( + _host( + segment(first: true, last: true), + theme: MornyeTheme.build(Brightness.dark), + ), + ); + await tester.pumpAndSettle(); + expect(radius, isNull); + }); }