diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-22 10:07:20 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-22 10:07:20 +0300 |
| commit | dc5efbcf0e83fae3c1c9892975dedd9eedef6e39 (patch) | |
| tree | 1773ebfd9b309380bc359ed5b8698cadee429b39 /player-android/lib/screens/podcast_episodes_screen.dart | |
| parent | 242165b6ccc574a930b515d4ff55e7b64defd9b3 (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/lib/screens/podcast_episodes_screen.dart')
| -rw-r--r-- | player-android/lib/screens/podcast_episodes_screen.dart | 229 |
1 files changed, 197 insertions, 32 deletions
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, + ), + ), + ), ); } } |
