mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
feat(library): add sectioned browsing and adaptive fast scrolling
This commit is contained in:
1 parent
9bac4a6efb
commit
83a55fb80b
14 files changed
+1693
-132
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -110,6 +110,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
'all',
|
||||
'albums',
|
||||
'singles',
|
||||
'artists',
|
||||
'playlists',
|
||||
};
|
||||
static const Set<String> _extensionVerificationBrowserModeValues = {
|
||||
|
||||
+110
-62
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user