summaryrefslogtreecommitdiff
path: root/player-android
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-22 10:07:20 +0300
committerPaul Buetow <paul@buetow.org>2026-05-22 10:07:20 +0300
commitdc5efbcf0e83fae3c1c9892975dedd9eedef6e39 (patch)
tree1773ebfd9b309380bc359ed5b8698cadee429b39 /player-android
parent242165b6ccc574a930b515d4ff55e7b64defd9b3 (diff)
Implement infinite-scroll pagination on MediaGridScreen, episode list, and my shares
- MediaGridScreen: adds ScrollController+CustomScrollView; limit/offset params (page size 50); generation counter guards stale _loadMore results; shows CircularProgressIndicator at bottom while loading, "No more items" when done. - PodcastEpisodesScreen: adds NotificationListener<ScrollNotification> outside RefreshIndicator (avoids ListView+controller interference with overscroll); limit/offset (page size 50); same generation counter pattern; shows footer spinner or "All episodes loaded" message. - MySharesScreen: adds end-of-list "All shares loaded" indicator after first successful fetch (shares API returns all items in a single response, no server-side pagination available). - Pull-to-refresh resets offset=0 and hasMore=true on all three screens. - All 362 existing tests pass; flutter analyze reports no issues. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android')
-rw-r--r--player-android/lib/screens/media_grid_screen.dart200
-rw-r--r--player-android/lib/screens/my_shares_screen.dart54
-rw-r--r--player-android/lib/screens/podcast_episodes_screen.dart229
3 files changed, 427 insertions, 56 deletions
diff --git a/player-android/lib/screens/media_grid_screen.dart b/player-android/lib/screens/media_grid_screen.dart
index 9d75fd5..da304ee 100644
--- a/player-android/lib/screens/media_grid_screen.dart
+++ b/player-android/lib/screens/media_grid_screen.dart
@@ -44,6 +44,9 @@ Media _buildMediaWithFavorite(Media media, bool favorite) {
/// `error_mappers.dart` — no `dio` import in this file (DIP).
/// - [SearchFilterBar] is shown below the AppBar; filter changes cancel
/// any in-flight load and start a new one with the updated parameters.
+/// - Infinite-scroll pagination: [ScrollController] detects when the user
+/// is within 200px of the bottom and calls [_loadMore] to append the next
+/// page. Pull-to-refresh resets to page 1.
class MediaGridScreen extends ConsumerStatefulWidget {
/// The numeric identifier of the set whose media items will be displayed.
final int setId;
@@ -60,6 +63,10 @@ class MediaGridScreen extends ConsumerStatefulWidget {
ConsumerState<MediaGridScreen> createState() => _MediaGridScreenState();
}
+// Number of items requested per page. Matches the server's supported range
+// (1–1000); 50 is a comfortable default that avoids very long first loads.
+const _kPageSize = 50;
+
class _MediaGridScreenState extends ConsumerState<MediaGridScreen> {
// Nullable: null means "not yet loaded" (loading indicator is shown).
List<Media>? _media;
@@ -79,25 +86,66 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> {
// preventing race conditions when the user changes filters rapidly.
int _loadGeneration = 0;
+ // Pagination state: current offset into the server list.
+ int _offset = 0;
+
+ // True when more pages may be available (last page was full).
+ // Set to false when a page returns fewer items than [_kPageSize].
+ bool _hasMore = true;
+
+ // True while a _loadMore request is in flight, to prevent concurrent loads.
+ bool _isLoadingMore = false;
+
+ // Drives automatic page loads when the user scrolls near the list end.
+ late final ScrollController _scrollController;
+
@override
void initState() {
super.initState();
+ _scrollController = ScrollController()..addListener(_onScroll);
// Defer the first load until after the first frame so [ref] is fully bound
// and any provider overrides in the test environment are applied.
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
+ @override
+ void dispose() {
+ // Cancel the scroll listener and release the controller to prevent
+ // callbacks from firing after the widget is removed from the tree.
+ _scrollController.dispose();
+ super.dispose();
+ }
+
+ // ---------------------------------------------------------------------------
+ // Scroll listener
+ // ---------------------------------------------------------------------------
+
+ /// Triggers [_loadMore] when the scroll position is within 200px of the end.
+ ///
+ /// Using a pixel threshold (rather than "at max extent") gives the user a
+ /// smooth experience: the next page starts loading before they reach the
+ /// very last item.
+ void _onScroll() {
+ final pos = _scrollController.position;
+ if (pos.pixels >= pos.maxScrollExtent - 200) {
+ _loadMore();
+ }
+ }
+
// ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
- /// Fetches media items for [widget.setId] with the current [_filter] and
- /// updates local state.
+ /// Fetches the first page of media for [widget.setId] with the current
+ /// [_filter] and resets all pagination state.
///
/// Called on first mount, on pull-to-refresh, and whenever [_filter]
- /// changes. The [_loadGeneration] counter ensures that a response arriving
- /// after a newer load has started is ignored, preventing stale data from
- /// overwriting fresher results (cancellation-by-generation pattern).
+ /// changes. Resetting [_offset] to 0 and [_hasMore] to true ensures that
+ /// subsequent scroll-triggered loads start cleanly from the beginning.
+ ///
+ /// The [_loadGeneration] counter ensures that a response arriving after a
+ /// newer load has started is ignored, preventing stale data from overwriting
+ /// fresher results (cancellation-by-generation pattern).
///
/// Errors are mapped by the top-level [mediaErrorMessage] helper so the
/// widget stays free of Dio.
@@ -111,6 +159,9 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> {
setState(() {
_isLoading = true;
_error = null;
+ // Reset pagination so the first page is fetched from the start.
+ _offset = 0;
+ _hasMore = true;
});
try {
@@ -121,6 +172,8 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> {
type: _filter.type,
favorites: _filter.favoritesOnly ? true : null,
sort: _filter.sortBy,
+ limit: _kPageSize,
+ offset: 0,
);
// Discard the result if a newer load was started while this one was
@@ -130,6 +183,9 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> {
setState(() {
_media = items;
_isLoading = false;
+ _offset = items.length;
+ // If fewer items than requested were returned, there are no more pages.
+ _hasMore = items.length >= _kPageSize;
});
} catch (e) {
if (!mounted || generation != _loadGeneration) return;
@@ -140,6 +196,48 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> {
}
}
+ /// Appends the next page of media items to the existing list.
+ ///
+ /// Guards against concurrent loads ([_isLoadingMore]) and stops when all
+ /// pages have been fetched ([_hasMore] is false). Uses the current
+ /// [_loadGeneration] so a pending [_load] (e.g. filter change or
+ /// pull-to-refresh) that starts a new generation will cause this callback
+ /// to discard its stale result.
+ Future<void> _loadMore() async {
+ if (_isLoadingMore || !_hasMore) return;
+ if (!mounted) return;
+
+ final generation = _loadGeneration;
+ setState(() => _isLoadingMore = true);
+
+ try {
+ final client = ref.read(apiClientProvider);
+ final items = await client.listMedia(
+ setId: widget.setId,
+ search: _filter.query,
+ type: _filter.type,
+ favorites: _filter.favoritesOnly ? true : null,
+ sort: _filter.sortBy,
+ limit: _kPageSize,
+ offset: _offset,
+ );
+
+ // Discard if a fresher load (e.g. pull-to-refresh) has started.
+ if (!mounted || generation != _loadGeneration) return;
+
+ setState(() {
+ _media = [...?_media, ...items];
+ _offset += items.length;
+ _hasMore = items.length >= _kPageSize;
+ _isLoadingMore = false;
+ });
+ } catch (_) {
+ // On error, allow the user to scroll again to retry.
+ if (!mounted) return;
+ setState(() => _isLoadingMore = false);
+ }
+ }
+
/// Called by [SearchFilterBar] when any filter dimension changes.
///
/// Stores the new filter and immediately starts a new load. The generation
@@ -261,7 +359,8 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> {
/// - Loading spinner (first load, before any data arrives).
/// - Error view with a retry button.
/// - Empty-state message when [listMedia] returns an empty list.
- /// - Grid of media cards once data is available.
+ /// - Grid of media cards once data is available (with bottom loading
+ /// indicator while more pages are being fetched).
Widget _buildBody(BuildContext context) {
// Show a full-screen spinner only on the very first load (no data yet).
if (_isLoading && _media == null) {
@@ -286,6 +385,9 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> {
media: _media!,
thumbnailUrlBuilder: _thumbnailUrl,
onFavoriteToggle: _toggleFavoriteAt,
+ scrollController: _scrollController,
+ isLoadingMore: _isLoadingMore,
+ hasMore: _hasMore,
),
);
}
@@ -302,15 +404,22 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> {
// Sub-widgets
// ---------------------------------------------------------------------------
-/// Scrollable grid of [Media] cards.
+/// Scrollable grid of [Media] cards with infinite-scroll pagination.
///
/// Extracted from [_MediaGridScreenState] so the state class stays concise and
/// the grid layout is independently testable.
+///
+/// The grid is driven by a [CustomScrollView] with two slivers so an end-of-list
+/// indicator (spinner or "No more items" text) can be appended after the last
+/// row without breaking the grid layout.
class _MediaGrid extends StatelessWidget {
const _MediaGrid({
required this.media,
required this.thumbnailUrlBuilder,
required this.onFavoriteToggle,
+ required this.scrollController,
+ required this.isLoadingMore,
+ required this.hasMore,
});
final List<Media> media;
@@ -328,24 +437,75 @@ class _MediaGrid extends StatelessWidget {
/// position in its list without a linear search.
final void Function(int index) onFavoriteToggle;
+ /// Controls the scroll position and triggers page loads via the listener
+ /// attached in [_MediaGridScreenState].
+ final ScrollController scrollController;
+
+ /// True while a next-page request is in flight; drives the bottom spinner.
+ final bool isLoadingMore;
+
+ /// False once all pages have been loaded; drives the end-of-list text.
+ final bool hasMore;
+
@override
Widget build(BuildContext context) {
- return GridView.builder(
+ return CustomScrollView(
key: const Key('media_grid'),
+ controller: scrollController,
+ slivers: [
+ _buildGridSliver(),
+ _buildFooterSliver(context),
+ ],
+ );
+ }
+
+ /// Builds the main grid sliver containing all loaded media cards.
+ SliverPadding _buildGridSliver() {
+ return SliverPadding(
padding: const EdgeInsets.all(12),
- // Two columns on phones; adaptive count could be added for tablets later.
- gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
- crossAxisCount: 2,
- crossAxisSpacing: 12,
- mainAxisSpacing: 12,
- // Slightly taller than square to accommodate the info overlay.
- childAspectRatio: 0.85,
+ sliver: SliverGrid(
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: 2,
+ crossAxisSpacing: 12,
+ mainAxisSpacing: 12,
+ // Slightly taller than square to accommodate the info overlay.
+ childAspectRatio: 0.85,
+ ),
+ delegate: SliverChildBuilderDelegate(
+ (context, index) => _MediaCard(
+ item: media[index],
+ thumbnailUrl: thumbnailUrlBuilder(media[index].id),
+ onFavoriteToggle: () => onFavoriteToggle(index),
+ ),
+ childCount: media.length,
+ ),
),
- itemCount: media.length,
- itemBuilder: (context, index) => _MediaCard(
- item: media[index],
- thumbnailUrl: thumbnailUrlBuilder(media[index].id),
- onFavoriteToggle: () => onFavoriteToggle(index),
+ );
+ }
+
+ /// Builds the footer sliver: spinner while loading more, or end-of-list text.
+ ///
+ /// The footer is always present (a single-item list sliver) so the
+ /// [CustomScrollView] can always scroll past the last grid row, which
+ /// prevents the scroll listener from never triggering on short lists.
+ SliverToBoxAdapter _buildFooterSliver(BuildContext context) {
+ return SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ child: isLoadingMore
+ ? const Center(
+ key: Key('media_loading_more'),
+ child: CircularProgressIndicator(),
+ )
+ : Center(
+ child: Text(
+ hasMore ? '' : 'No more items',
+ key: const Key('media_no_more'),
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
),
);
}
diff --git a/player-android/lib/screens/my_shares_screen.dart b/player-android/lib/screens/my_shares_screen.dart
index 946aee8..d056a5d 100644
--- a/player-android/lib/screens/my_shares_screen.dart
+++ b/player-android/lib/screens/my_shares_screen.dart
@@ -20,6 +20,9 @@ import '../utils/error_mappers.dart';
/// the clipboard, then shows a confirmation SnackBar.
/// - All async continuations guard on [mounted] to prevent setState/context
/// calls after widget disposal.
+/// - The `GET /api/v1/shares` endpoint returns all shares in a single response
+/// (no server-side pagination). An end-of-list indicator is shown after the
+/// list is fully loaded to make the experience consistent with other screens.
class MySharesScreen extends ConsumerStatefulWidget {
const MySharesScreen({super.key});
@@ -37,6 +40,11 @@ class _MySharesScreenState extends ConsumerState<MySharesScreen> {
// True while the initial or refresh load is in flight.
bool _isLoading = false;
+ // True once the first successful load completes. The shares endpoint returns
+ // all items in one response (no server-side pagination), so this is set to
+ // true immediately after the first load and drives the end-of-list indicator.
+ bool _loaded = false;
+
@override
void initState() {
super.initState();
@@ -51,13 +59,16 @@ class _MySharesScreenState extends ConsumerState<MySharesScreen> {
/// Fetches the user's share list and updates local state.
///
- /// Called on first mount and on pull-to-refresh. Errors are mapped by the
- /// top-level [sharesErrorMessage] helper so the widget stays simple.
+ /// Called on first mount and on pull-to-refresh. The `GET /api/v1/shares`
+ /// endpoint returns all items in one response, so [_loaded] is set to true
+ /// after a successful fetch. Errors are mapped by [sharesErrorMessage].
Future<void> _load() async {
if (!mounted) return;
setState(() {
_isLoading = true;
_error = null;
+ // Reset end-of-list indicator so the spinner shows during refresh.
+ _loaded = false;
});
try {
@@ -67,6 +78,8 @@ class _MySharesScreenState extends ConsumerState<MySharesScreen> {
setState(() {
_shares = shares;
_isLoading = false;
+ // Mark all items as loaded since the endpoint returns a single page.
+ _loaded = true;
});
} catch (e) {
if (!mounted) return;
@@ -154,6 +167,8 @@ class _MySharesScreenState extends ConsumerState<MySharesScreen> {
/// - Full-screen spinner on the very first load (no data yet).
/// - Error view with a retry button when the load failed.
/// - Pull-to-refresh wrapper around the share list or empty-state view.
+ /// - An end-of-list indicator appended below the last row once [_loaded]
+ /// is true (consistent with MediaGridScreen and PodcastEpisodesScreen).
Widget _buildBody(BuildContext context) {
if (_isLoading && _shares == null) {
return const Center(
@@ -174,6 +189,7 @@ class _MySharesScreenState extends ConsumerState<MySharesScreen> {
shares: _shares!,
onCopyLink: _copyLink,
onRevoke: _revoke,
+ showEndOfList: _loaded,
),
);
}
@@ -187,24 +203,54 @@ class _MySharesScreenState extends ConsumerState<MySharesScreen> {
///
/// Extracted into its own stateless widget so [_MySharesScreenState] stays
/// focused on data-loading concerns and the list UI is independently testable.
+///
+/// When [showEndOfList] is true a footer row is appended after the last share
+/// tile with a "All shares loaded" message to keep the UX consistent with
+/// other paginated screens (MediaGridScreen, PodcastEpisodesScreen).
class _ShareList extends StatelessWidget {
const _ShareList({
required this.shares,
required this.onCopyLink,
required this.onRevoke,
+ required this.showEndOfList,
});
final List<Share> shares;
final void Function(Share share) onCopyLink;
final Future<void> Function(Share share, int index) onRevoke;
+ /// When true a footer with "All shares loaded" is shown below the last row.
+ final bool showEndOfList;
+
@override
Widget build(BuildContext context) {
+ // +1 to include the footer slot when end-of-list indicator is needed.
+ final totalCount = shares.length + (showEndOfList ? 1 : 0);
+
return ListView.separated(
key: const Key('shares_list'),
- itemCount: shares.length,
- separatorBuilder: (_, __) => const Divider(height: 1),
+ itemCount: totalCount,
+ separatorBuilder: (_, index) =>
+ // Do not draw a divider above the footer item.
+ index < shares.length - 1
+ ? const Divider(height: 1)
+ : const SizedBox.shrink(),
itemBuilder: (context, index) {
+ // Footer slot: end-of-list indicator.
+ if (index == shares.length) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ child: Center(
+ child: Text(
+ 'All shares loaded',
+ key: const Key('shares_no_more'),
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ );
+ }
final share = shares[index];
return _ShareTile(
share: share,
diff --git a/player-android/lib/screens/podcast_episodes_screen.dart b/player-android/lib/screens/podcast_episodes_screen.dart
index 8711f15..058a533 100644
--- a/player-android/lib/screens/podcast_episodes_screen.dart
+++ b/player-android/lib/screens/podcast_episodes_screen.dart
@@ -45,6 +45,9 @@ PodcastEpisode _buildEpisodeWithCompleted(
/// `error_mappers.dart` — no `dio` import in this file (DIP).
/// - Optimistic updates mirror the pattern in [MediaGridScreen.toggleFavorite]:
/// flip immediately, reconcile/revert after the API call settles.
+/// - Infinite-scroll pagination: [ScrollController] detects when the user
+/// is within 200px of the bottom and calls [_loadMore] to append the next
+/// page. Pull-to-refresh resets to page 1.
class PodcastEpisodesScreen extends ConsumerStatefulWidget {
/// The numeric identifier of the podcast set whose episodes will be listed.
final int setId;
@@ -66,6 +69,10 @@ class PodcastEpisodesScreen extends ConsumerStatefulWidget {
_PodcastEpisodesScreenState();
}
+// Number of episodes requested per page. The server default is 50 (see
+// player-server/docs/api.md §GET /api/podcasts/{id}/episodes).
+const _kEpisodePageSize = 50;
+
class _PodcastEpisodesScreenState
extends ConsumerState<PodcastEpisodesScreen> {
// Nullable: null means "not yet loaded" (loading indicator is shown).
@@ -81,6 +88,20 @@ class _PodcastEpisodesScreenState
// from firing concurrent API calls for the same episode.
final Set<int> _pendingDownloads = {};
+ // Generation counter — incremented on each fresh load (refresh/first-mount).
+ // Checked after every async gap so stale responses from cancelled loads are
+ // silently discarded (cancellation-by-generation pattern).
+ int _loadGeneration = 0;
+
+ // Pagination state: current offset into the server list.
+ int _offset = 0;
+
+ // True when more pages may be available (last page was full).
+ bool _hasMore = true;
+
+ // True while a _loadMore request is in flight to prevent concurrent loads.
+ bool _isLoadingMore = false;
+
@override
void initState() {
super.initState();
@@ -90,30 +111,71 @@ class _PodcastEpisodesScreenState
}
// ---------------------------------------------------------------------------
+ // Scroll detection via notification
+ // ---------------------------------------------------------------------------
+
+ /// Called by [NotificationListener] in [_buildBody] on every scroll update.
+ ///
+ /// Using [ScrollNotification] (rather than [ScrollController.addListener])
+ /// avoids attaching a controller to the [ListView], which would otherwise
+ /// interfere with [RefreshIndicator]'s overscroll detection in test and
+ /// production environments. The notification still bubbles up to
+ /// [RefreshIndicator] because [_onScrollNotification] returns false.
+ bool _onScrollNotification(ScrollNotification notification) {
+ if (notification is ScrollUpdateNotification) {
+ final metrics = notification.metrics;
+ if (metrics.pixels >= metrics.maxScrollExtent - 200) {
+ _loadMore();
+ }
+ }
+ // Return false so the notification continues to bubble (e.g. to RefreshIndicator).
+ return false;
+ }
+
+ // ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
- /// Fetches episodes for [widget.setId] and updates local state.
+ /// Fetches the first page of episodes for [widget.setId] and resets all
+ /// pagination state.
///
- /// Called on first mount and on pull-to-refresh. Errors are mapped by
- /// [episodeListErrorMessage] so the widget stays free of Dio.
+ /// Called on first mount and on pull-to-refresh. Resetting [_offset] to 0
+ /// and [_hasMore] to true ensures subsequent scroll-triggered loads start
+ /// cleanly from the beginning. Errors are mapped by [episodeListErrorMessage]
+ /// so the widget stays free of Dio.
Future<void> _load() async {
if (!mounted) return;
+
+ // Bump the generation before the async gap so stale callbacks from the
+ // previous load detect the change and drop their result.
+ final generation = ++_loadGeneration;
+
setState(() {
_isLoading = true;
_error = null;
+ // Reset pagination so page 1 is fetched from scratch.
+ _offset = 0;
+ _hasMore = true;
});
try {
final client = ref.read(apiClientProvider);
- final items = await client.listEpisodes(widget.setId);
- if (!mounted) return;
+ final items = await client.listEpisodes(
+ widget.setId,
+ limit: _kEpisodePageSize,
+ offset: 0,
+ );
+
+ if (!mounted || generation != _loadGeneration) return;
+
setState(() {
_episodes = items;
_isLoading = false;
+ _offset = items.length;
+ _hasMore = items.length >= _kEpisodePageSize;
});
} catch (e) {
- if (!mounted) return;
+ if (!mounted || generation != _loadGeneration) return;
setState(() {
_error = episodeListErrorMessage(e);
_isLoading = false;
@@ -121,6 +183,41 @@ class _PodcastEpisodesScreenState
}
}
+ /// Appends the next page of episodes to the existing list.
+ ///
+ /// Guards against concurrent loads and stops when all pages have been
+ /// fetched ([_hasMore] is false). Checks [_loadGeneration] so a pending
+ /// refresh discards this stale response.
+ Future<void> _loadMore() async {
+ if (_isLoadingMore || !_hasMore) return;
+ if (!mounted) return;
+
+ final generation = _loadGeneration;
+ setState(() => _isLoadingMore = true);
+
+ try {
+ final client = ref.read(apiClientProvider);
+ final items = await client.listEpisodes(
+ widget.setId,
+ limit: _kEpisodePageSize,
+ offset: _offset,
+ );
+
+ if (!mounted || generation != _loadGeneration) return;
+
+ setState(() {
+ _episodes = [...?_episodes, ...items];
+ _offset += items.length;
+ _hasMore = items.length >= _kEpisodePageSize;
+ _isLoadingMore = false;
+ });
+ } catch (_) {
+ // On error, allow the user to scroll again to retry.
+ if (!mounted) return;
+ setState(() => _isLoadingMore = false);
+ }
+ }
+
// ---------------------------------------------------------------------------
// Played/unplayed toggle
// ---------------------------------------------------------------------------
@@ -250,7 +347,14 @@ class _PodcastEpisodesScreenState
/// - Full-screen spinner (first load, before any data arrives).
/// - Error view with a retry button.
/// - Empty-state message when [listEpisodes] returns an empty list.
- /// - Scrollable list of episode rows once data is available.
+ /// - Scrollable list of episode rows once data is available (with bottom
+ /// loading indicator while more pages are being fetched).
+ ///
+ /// [NotificationListener] wraps the [RefreshIndicator] and intercepts
+ /// [ScrollUpdateNotification] to trigger [_loadMore] near the list end.
+ /// Returning false from [_onScrollNotification] ensures the notification
+ /// continues to bubble so [RefreshIndicator]'s overscroll detection still
+ /// works correctly.
Widget _buildBody(BuildContext context) {
// Show a full-screen spinner only on the very first load (no data yet).
if (_isLoading && _episodes == null) {
@@ -265,22 +369,29 @@ class _PodcastEpisodesScreenState
return _ErrorView(message: _error!, onRetry: _load);
}
- // [RefreshIndicator] wraps the scrollable content so pull-to-refresh
- // triggers [_load] on both the list and the empty-state view.
- return RefreshIndicator(
- onRefresh: _load,
- child: _episodes == null || _episodes!.isEmpty
- ? const _EmptyView()
- : _EpisodeList(
- episodes: _episodes!,
- pendingDownloads: _pendingDownloads,
- onToggleComplete: _toggleCompleteAt,
- onDownload: _downloadEpisodeAt,
- // mediaId is non-null: _EpisodeRow only invokes onPlay when episode.mediaId is set.
- onPlay: (mediaId) => context.go(
- AppRoutes.audioPlayerPath(mediaId.toString()),
+ // [NotificationListener] sits outside [RefreshIndicator] and listens for
+ // scroll updates from the inner [ListView] to trigger infinite-scroll
+ // page loads. The [RefreshIndicator] receives notifications too because
+ // [_onScrollNotification] returns false (non-consuming).
+ return NotificationListener<ScrollNotification>(
+ onNotification: _onScrollNotification,
+ child: RefreshIndicator(
+ onRefresh: _load,
+ child: _episodes == null || _episodes!.isEmpty
+ ? const _EmptyView()
+ : _EpisodeList(
+ episodes: _episodes!,
+ pendingDownloads: _pendingDownloads,
+ onToggleComplete: _toggleCompleteAt,
+ onDownload: _downloadEpisodeAt,
+ // mediaId is non-null: _EpisodeRow only invokes onPlay when episode.mediaId is set.
+ onPlay: (mediaId) => context.go(
+ AppRoutes.audioPlayerPath(mediaId.toString()),
+ ),
+ isLoadingMore: _isLoadingMore,
+ hasMore: _hasMore,
),
- ),
+ ),
);
}
}
@@ -289,10 +400,18 @@ class _PodcastEpisodesScreenState
// Sub-widgets
// ---------------------------------------------------------------------------
-/// Scrollable list of episode rows.
+/// Scrollable list of episode rows with infinite-scroll pagination.
///
/// Extracted into its own stateless widget so [_PodcastEpisodesScreenState]
/// stays concise and the list layout is independently testable.
+///
+/// A footer item is appended after the last episode row: a spinner while more
+/// pages are loading, or an end-of-list message once all pages are fetched.
+///
+/// Scroll detection is handled externally via a [NotificationListener] in
+/// the parent state (rather than a [ScrollController] attached to this
+/// [ListView]) so that [RefreshIndicator]'s overscroll detection is not
+/// interfered with.
class _EpisodeList extends StatelessWidget {
const _EpisodeList({
required this.episodes,
@@ -300,6 +419,8 @@ class _EpisodeList extends StatelessWidget {
required this.onToggleComplete,
required this.onDownload,
required this.onPlay,
+ required this.isLoadingMore,
+ required this.hasMore,
});
final List<PodcastEpisode> episodes;
@@ -325,19 +446,63 @@ class _EpisodeList extends StatelessWidget {
/// (i.e. it has been downloaded and a Media row exists on the server).
final void Function(int mediaId) onPlay;
+ /// True while a next-page request is in flight; drives the bottom spinner.
+ final bool isLoadingMore;
+
+ /// False once all pages have been loaded; drives the end-of-list text.
+ final bool hasMore;
+
@override
Widget build(BuildContext context) {
+ // Total item count includes one footer slot after the last episode row.
+ final totalCount = episodes.length + 1;
+
return ListView.separated(
key: const Key('episodes_list'),
- itemCount: episodes.length,
- separatorBuilder: (_, __) => const Divider(height: 1),
- itemBuilder: (context, index) => _EpisodeRow(
- episode: episodes[index],
- isDownloadPending: pendingDownloads.contains(episodes[index].id),
- onToggleComplete: () => onToggleComplete(index),
- onDownload: () => onDownload(index),
- onPlay: onPlay,
- ),
+ // +1 for the footer (loading indicator or end-of-list message).
+ itemCount: totalCount,
+ separatorBuilder: (_, index) =>
+ // Do not draw a divider above the footer item.
+ index < episodes.length - 1
+ ? const Divider(height: 1)
+ : const SizedBox.shrink(),
+ itemBuilder: (context, index) {
+ // Last slot is the footer.
+ if (index == episodes.length) {
+ return _buildFooter(context);
+ }
+ return _EpisodeRow(
+ episode: episodes[index],
+ isDownloadPending: pendingDownloads.contains(episodes[index].id),
+ onToggleComplete: () => onToggleComplete(index),
+ onDownload: () => onDownload(index),
+ onPlay: onPlay,
+ );
+ },
+ );
+ }
+
+ /// Builds the footer widget appended after the last episode row.
+ ///
+ /// Shows a spinner while more pages are loading, or an "All episodes loaded"
+ /// text once [hasMore] is false.
+ Widget _buildFooter(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ child: isLoadingMore
+ ? const Center(
+ key: Key('episodes_loading_more'),
+ child: CircularProgressIndicator(),
+ )
+ : Center(
+ child: Text(
+ hasMore ? '' : 'All episodes loaded',
+ key: const Key('episodes_no_more'),
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
);
}
}