feat(library): add sectioned browsing and adaptive fast scrolling

This commit is contained in:
zarzet committed 2026-10-14 23:04:46 +07:00
1 parent 9bac4a6efb
commit 83a55fb80b
14 files changed
+1693 -132

No files matched your search

+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -110,6 +110,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
'all',
'albums',
'singles',
'artists',
'playlists',
};
static const Set<String> _extensionVerificationBrowserModeValues = {
+110 -62
View File
@@ -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<QueueTab> {
double _playlistSelectionOverlayBottomPadding = 0;
PageController? _filterPageController;
final List<String> _filterModes = ['all', 'albums', 'singles', 'playlists'];
/// Pages of the Library pager: Songs, Albums, Artists and Playlists.
/// Singles share the Albums page behind [_albumsShowSingles].
final List<String> _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<QueueTab> {
double? _libraryGridPinchStartExtent;
final Map<String, int> _libraryPageOffsetByFilter = {};
bool _libraryPageLoadScheduled = false;
final Map<_QueueLibraryCountsRequest, QueueLibraryCounts>
_queueLibraryCountsCache = {};
final Map<_QueueLibraryPageRequest, _QueueLibraryPageData>
@@ -353,16 +385,16 @@ class _QueueTabState extends ConsumerState<QueueTab> {
(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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
_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<QueueTab> {
}
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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,
+174 -17
View File
@@ -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<UnifiedLibraryItem>(
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<UnifiedLibraryItem>(
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<ScrollNotification>(
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;
+20 -30
View File
@@ -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<bool>(
@@ -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<void> _showQueuedItemMenu(BuildContext context, DownloadItem item) {
final notifier = ref.read(downloadQueueProvider.notifier);
return showModalBottomSheet<void>(
return showAppBottomSheet<void>(
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);
+513
View File
@@ -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<UnifiedLibraryItem> 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<UnifiedLibraryItem> 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<void> _showSortSheet(BuildContext context) async {
final selected = await showAppBottomSheet<String>(
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<int>(
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 <LibraryBrowseEntry>[];
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<void>(
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<ScrollNotification>(
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),
],
),
),
),
),
);
}
}
+23 -19
View File
@@ -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(
@@ -421,11 +421,13 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
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<LibrarySettingsPage> {
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<void>(
+420
View File
@@ -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<LibraryFastScroller> createState() => _LibraryFastScrollerState();
}
class _LibraryFastScrollerState extends State<LibraryFastScroller> {
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<ScrollMetricsNotification>(
onNotification: _onMetricsNotification,
child: NotificationListener<ScrollNotification>(
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<double>(
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,
),
),
),
),
),
),
),
);
}
}
+89
View File
@@ -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<SwipeDeleteDismissible> createState() => _SwipeDeleteDismissibleState();
}
class _SwipeDeleteDismissibleState extends State<SwipeDeleteDismissible> {
static const _threshold = 0.4;
final _swipe = ValueNotifier<DismissUpdateDetails?>(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,
);
}
}
+68 -1
View File
@@ -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<TrackCardSegment>();
}
/// 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(
+237
View File
@@ -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<ScrollableState>(
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);
});
}
+30
View File
@@ -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);
});
}