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 | |
| 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')
| -rw-r--r-- | player-android/lib/screens/media_grid_screen.dart | 200 | ||||
| -rw-r--r-- | player-android/lib/screens/my_shares_screen.dart | 54 | ||||
| -rw-r--r-- | player-android/lib/screens/podcast_episodes_screen.dart | 229 |
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, + ), + ), + ), ); } } |
