feat(flutter): infinite scroll on library Artists + Albums tabs

Both providers were FutureProvider<Paged<T>> returning a single page
of 50, so once the user scrolled past 50 items the list just ended.
Replace with AsyncNotifier<Paged<T>> that exposes loadMore(): fetches
the next page using items.length as the offset and appends to the
existing list. Idempotent guard via _loadingMore flag — concurrent
scroll events near the bottom collapse to a single fetch.

Both tabs now wrap the GridView in NotificationListener<ScrollNotification>
that fires loadMore() when within 800px of maxScrollExtent. The
lookahead is enough that the next page lands before the user hits
the visible bottom on a typical phone scroll.

Pull-to-refresh updated to invalidate + re-await the provider so a
manual refresh always starts from the first page.
This commit is contained in:
2026-05-11 15:41:01 -04:00
parent 2500914498
commit 8c00ee21c7
+151 -42
View File
@@ -37,13 +37,89 @@ final _meApiProvider = FutureProvider<MeApi>((ref) async {
return MeApi(await ref.watch(dioProvider.future));
});
final _libraryArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
return (await ref.watch(_libraryListsApiProvider.future)).listArtists();
});
/// Paginated artist list. AsyncNotifier so the screen can call
/// `loadMore()` when the scroll approaches the bottom; subsequent
/// pages append to the existing items rather than replacing them.
class _LibraryArtistsNotifier extends AsyncNotifier<wire.Paged<ArtistRef>> {
static const _pageSize = 50;
bool _loadingMore = false;
final _libraryAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
return (await ref.watch(_libraryListsApiProvider.future)).listAlbums();
});
@override
Future<wire.Paged<ArtistRef>> build() async {
final api = await ref.watch(_libraryListsApiProvider.future);
return api.listArtists(limit: _pageSize, offset: 0);
}
Future<void> loadMore() async {
if (_loadingMore) return;
final cur = state.value;
if (cur == null) return;
if (cur.items.length >= cur.total) return;
_loadingMore = true;
try {
final api = await ref.read(_libraryListsApiProvider.future);
final next = await api.listArtists(
limit: _pageSize,
offset: cur.items.length,
);
state = AsyncData(wire.Paged(
items: [...cur.items, ...next.items],
total: next.total,
limit: next.limit,
offset: 0,
));
} catch (_) {
// Swallow — the next near-bottom event will retry. The current
// partial list stays visible.
} finally {
_loadingMore = false;
}
}
}
final _libraryArtistsProvider = AsyncNotifierProvider<
_LibraryArtistsNotifier,
wire.Paged<ArtistRef>>(_LibraryArtistsNotifier.new);
class _LibraryAlbumsNotifier extends AsyncNotifier<wire.Paged<AlbumRef>> {
static const _pageSize = 50;
bool _loadingMore = false;
@override
Future<wire.Paged<AlbumRef>> build() async {
final api = await ref.watch(_libraryListsApiProvider.future);
return api.listAlbums(limit: _pageSize, offset: 0);
}
Future<void> loadMore() async {
if (_loadingMore) return;
final cur = state.value;
if (cur == null) return;
if (cur.items.length >= cur.total) return;
_loadingMore = true;
try {
final api = await ref.read(_libraryListsApiProvider.future);
final next = await api.listAlbums(
limit: _pageSize,
offset: cur.items.length,
);
state = AsyncData(wire.Paged(
items: [...cur.items, ...next.items],
total: next.total,
limit: next.limit,
offset: 0,
));
} catch (_) {
// Swallow — next scroll event will retry.
} finally {
_loadingMore = false;
}
}
}
final _libraryAlbumsProvider = AsyncNotifierProvider<
_LibraryAlbumsNotifier,
wire.Paged<AlbumRef>>(_LibraryAlbumsNotifier.new);
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
return (await ref.watch(_meApiProvider.future)).history();
@@ -134,24 +210,42 @@ class _ArtistsTab extends ConsumerWidget {
data: (page) => page.items.isEmpty
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
: RefreshIndicator(
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
child: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.78,
),
itemCount: page.items.length,
itemBuilder: (ctx, i) {
final artist = page.items[i];
return ArtistCard(
artist: artist,
onTap: () => ctx.push('/artists/${artist.id}',
extra: artist),
);
onRefresh: () async {
ref.invalidate(_libraryArtistsProvider);
await ref.read(_libraryArtistsProvider.future);
},
// Listen for scroll-near-bottom and ask the notifier
// to fetch the next page. 800px lookahead so the next
// page lands before the user reaches the visible end.
child: NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n.metrics.pixels >=
n.metrics.maxScrollExtent - 800) {
ref
.read(_libraryArtistsProvider.notifier)
.loadMore();
}
return false;
},
child: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.78,
),
itemCount: page.items.length,
itemBuilder: (ctx, i) {
final artist = page.items[i];
return ArtistCard(
artist: artist,
onTap: () => ctx.push('/artists/${artist.id}',
extra: artist),
);
},
),
),
),
);
@@ -170,7 +264,10 @@ class _AlbumsTab extends ConsumerWidget {
data: (page) => page.items.isEmpty
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
: RefreshIndicator(
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
onRefresh: () async {
ref.invalidate(_libraryAlbumsProvider);
await ref.read(_libraryAlbumsProvider.future);
},
// Same responsive 3-up grid as the artist detail
// album list — LayoutBuilder + AlbumCard sized to the
// cell, mainAxisExtent matched to actual card height.
@@ -185,25 +282,37 @@ class _AlbumsTab extends ConsumerWidget {
// cover (cellW - 16) + gap (8) + 2-line title (~36)
// + artist line (~16) + small fudge.
final cellH = (cellW - 16) + 8 + 36 + 16 + 4;
return GridView.builder(
padding: const EdgeInsets.all(sidePad),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisExtent: cellH,
mainAxisSpacing: gap,
crossAxisSpacing: gap,
),
itemCount: page.items.length,
itemBuilder: (ctx, i) {
final album = page.items[i];
return AlbumCard(
album: album,
width: cellW,
titleMaxLines: 2,
onTap: () => ctx.push('/albums/${album.id}',
extra: album),
);
return NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n.metrics.pixels >=
n.metrics.maxScrollExtent - 800) {
ref
.read(_libraryAlbumsProvider.notifier)
.loadMore();
}
return false;
},
child: GridView.builder(
padding: const EdgeInsets.all(sidePad),
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisExtent: cellH,
mainAxisSpacing: gap,
crossAxisSpacing: gap,
),
itemCount: page.items.length,
itemBuilder: (ctx, i) {
final album = page.items[i];
return AlbumCard(
album: album,
width: cellW,
titleMaxLines: 2,
onTap: () => ctx.push('/albums/${album.id}',
extra: album),
);
},
),
);
}),
),