diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-22 10:21:48 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-22 10:21:48 +0300 |
| commit | d9552904112d0ab86e961b6d8b0844619ce1005a (patch) | |
| tree | 701a8f97e5cb816b77a03f4d809d2ccefffc2904 /player-android | |
| parent | dc5efbcf0e83fae3c1c9892975dedd9eedef6e39 (diff) | |
Fix gb review issues: _isLoadingMore stuck on refresh, empty footer space, pagination tests
- Reset _isLoadingMore = false inside _load()'s setState in both
MediaGridScreen and PodcastEpisodesScreen, so a generation-mismatch
early return in an in-flight _loadMore does not leave the spinner
permanently stuck after a pull-to-refresh (major bug fix).
- Replace the empty Text('') with SizedBox.shrink() in
_buildFooterSliver when hasMore=true and isLoadingMore=false to
eliminate the 32px dead space (nit fix).
- Add three pagination widget tests to each screen: _loadMore appends
a second page, _loadMore is a no-op while already in-flight, and
pull-to-refresh while _loadMore is in-flight leaves _isLoadingMore=false
after _load completes (regression coverage for the major bug).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android')
4 files changed, 411 insertions, 9 deletions
diff --git a/player-android/lib/screens/media_grid_screen.dart b/player-android/lib/screens/media_grid_screen.dart index da304ee..3449ff9 100644 --- a/player-android/lib/screens/media_grid_screen.dart +++ b/player-android/lib/screens/media_grid_screen.dart @@ -160,8 +160,12 @@ class _MediaGridScreenState extends ConsumerState<MediaGridScreen> { _isLoading = true; _error = null; // Reset pagination so the first page is fetched from the start. + // Also clear _isLoadingMore so a stale _loadMore that was in-flight when + // _load was triggered (e.g. pull-to-refresh during pagination) does not + // leave the spinner stuck after the generation-mismatch early return fires. _offset = 0; _hasMore = true; + _isLoadingMore = false; }); try { @@ -492,20 +496,26 @@ class _MediaGrid extends StatelessWidget { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(vertical: 16), + // When more pages are coming but none is in-flight, render an invisible + // placeholder (SizedBox.shrink) instead of an empty Text so the layout + // does not reserve unnecessary vertical space. 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, - ), - ), - ), + : hasMore + ? const SizedBox.shrink() + : Center( + child: Text( + '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/podcast_episodes_screen.dart b/player-android/lib/screens/podcast_episodes_screen.dart index 058a533..67570b5 100644 --- a/player-android/lib/screens/podcast_episodes_screen.dart +++ b/player-android/lib/screens/podcast_episodes_screen.dart @@ -154,8 +154,12 @@ class _PodcastEpisodesScreenState _isLoading = true; _error = null; // Reset pagination so page 1 is fetched from scratch. + // Also clear _isLoadingMore so a stale _loadMore that was in-flight when + // _load was triggered (e.g. pull-to-refresh during pagination) does not + // leave the spinner stuck after the generation-mismatch early return fires. _offset = 0; _hasMore = true; + _isLoadingMore = false; }); try { diff --git a/player-android/test/screens/media_grid_screen_test.dart b/player-android/test/screens/media_grid_screen_test.dart index 9524e2f..7d550a1 100644 --- a/player-android/test/screens/media_grid_screen_test.dart +++ b/player-android/test/screens/media_grid_screen_test.dart @@ -196,6 +196,60 @@ class _FakeApiClientWithToggle extends PlayerApiClient { String thumbnailUrl(int mediaId) => ''; } +/// [PlayerApiClient] stub for pagination tests. +/// +/// Each call to [listMedia] pops the next response from [pages]. Supports an +/// optional [Completer] per call: when [holdNextCompleter] is non-null before a +/// call arrives, that call waits until the completer is resolved instead of +/// returning immediately — allowing tests to inspect in-flight state. +class _PaginatedFakeApiClient extends PlayerApiClient { + _PaginatedFakeApiClient({required this.pages}) : super(dio: Dio()); + + /// Successive pages to return, oldest first. Each call to [listMedia] shifts + /// one element from the front of this list. + final List<List<Media>> pages; + + /// When non-null, the next [listMedia] call returns this completer's future + /// instead of the next page. The field is cleared after the call begins so + /// subsequent calls resume normal behaviour. + Completer<List<Media>>? holdNextCompleter; + + /// Total number of [listMedia] invocations, including held ones. + int listMediaCallCount = 0; + + @override + Future<List<Media>> listMedia({ + String? search, + int? setId, + List<int>? setIds, + String? type, + bool? favorites, + List<String>? tags, + double? minDuration, + double? maxDuration, + int? fileSizeMin, + int? fileSizeMax, + String? sort, + int? limit, + int? offset, + String? folder, + String? parent, + }) async { + listMediaCallCount++; + // If a completer was staged for this call, return its future and clear + // the field so the call after this one resumes normal paged behaviour. + final held = holdNextCompleter; + if (held != null) { + holdNextCompleter = null; + return held.future; + } + return pages.isEmpty ? [] : pages.removeAt(0); + } + + @override + String thumbnailUrl(int mediaId) => ''; +} + // --------------------------------------------------------------------------- // Sample data // --------------------------------------------------------------------------- @@ -744,4 +798,157 @@ void main() { expect(fakeClient.lastFavoritesOnlyFlag, isNull); }); }); + + // -------------------------------------------------------------------------- + // Pagination (_loadMore) + // -------------------------------------------------------------------------- + + group('pagination', () { + /// Builds a full page of [count] distinct [Media] items starting at [startId]. + List<Media> makePage(int startId, int count) { + return List.generate( + count, + (i) => Media( + id: startId + i, + setId: 10, + relPath: 'p${startId + i}.mp4', + fileName: 'p${startId + i}.mp4', + absPath: '/media/p${startId + i}.mp4', + type: 'video', + duration: 60.0, + codec: 'h264', + resolution: '1920x1080', + bitrate: 1000, + fileSizeBytes: 1000000, + width: 1920, + height: 1080, + thumbnailPath: '', + playCount: 0, + ), + ); + } + + testWidgets('_loadMore appends second page to the grid', (tester) async { + // Page 1 is full (50 items), page 2 has 2 items to signal end-of-list. + final page1 = makePage(100, 50); + final page2 = makePage(200, 2); + + final fakeClient = _PaginatedFakeApiClient(pages: [page1, page2]); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // After initial load, listMedia was called once and the grid holds 50 items. + expect(fakeClient.listMediaCallCount, equals(1)); + + // Jump the CustomScrollView to its maximum scroll extent so the + // _onScroll listener fires and triggers _loadMore. + final scrollable = tester.state<ScrollableState>( + find.descendant( + of: find.byKey(const Key('media_grid')), + matching: find.byType(Scrollable), + ), + ); + scrollable.position.jumpTo(scrollable.position.maxScrollExtent); + await tester.pumpAndSettle(); + + // Two listMedia calls: initial load + one _loadMore page. + expect(fakeClient.listMediaCallCount, equals(2)); + + // Total items visible = 50 (page 1) + 2 (page 2) = 52. + // Verify via the last card key from page 2, which is id 201. + expect(find.byKey(const Key('media_card_201')), findsOneWidget); + }); + + testWidgets('_loadMore is a no-op while _isLoadingMore is already true', + (tester) async { + // Page 1 is full so _hasMore stays true after the initial load. + final page1 = makePage(100, 50); + + // Do NOT set holdNextCompleter yet — let the initial load complete normally. + final fakeClient = _PaginatedFakeApiClient(pages: [page1]); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Sanity: initial load has completed with one API call. + expect(fakeClient.listMediaCallCount, equals(1)); + + // Now stage a completer to hold the upcoming _loadMore in-flight. + final loadMoreCompleter = Completer<List<Media>>(); + fakeClient.holdNextCompleter = loadMoreCompleter; + + // Jump the CustomScrollView to its maximum extent to trigger _loadMore + // via the _onScroll listener — the request is now in-flight (blocked). + final scrollable = tester.state<ScrollableState>( + find.byType(Scrollable).first, + ); + scrollable.position.jumpTo(scrollable.position.maxScrollExtent); + await tester.pump(); // _loadMore starts, _isLoadingMore = true + + // At this point listMediaCallCount == 2 (initial load + first _loadMore). + final callsAfterFirstScroll = fakeClient.listMediaCallCount; + + // Jump again while the first _loadMore is still in-flight. + scrollable.position.jumpTo(scrollable.position.maxScrollExtent); + await tester.pump(); + + // No additional API call must have been fired (_isLoadingMore guard). + expect(fakeClient.listMediaCallCount, equals(callsAfterFirstScroll)); + + // Resolve the in-flight request before pumpAndSettle so no pending async + // work remains (prevents timeout). + loadMoreCompleter.complete(makePage(200, 2)); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'pull-to-refresh while _loadMore is in-flight leaves _isLoadingMore=false', + (tester) async { + // Page 1 is full so _hasMore is true after the initial load. + final page1 = makePage(100, 50); + // Page returned by the _load triggered by pull-to-refresh. + final refreshPage = makePage(300, 3); + + final fakeClient = _PaginatedFakeApiClient( + pages: [page1], // consumed by initial load + ); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Stage a completer to hold the upcoming _loadMore in-flight. + final loadMoreCompleter = Completer<List<Media>>(); + fakeClient.holdNextCompleter = loadMoreCompleter; + + // Jump to the maximum scroll extent to trigger _loadMore via the + // _onScroll listener — the request is now in-flight (blocked). + final scrollable = tester.state<ScrollableState>( + find.byType(Scrollable).first, + ); + scrollable.position.jumpTo(scrollable.position.maxScrollExtent); + await tester.pump(); // _loadMore starts, _isLoadingMore = true + + // Add the refresh page so that the _load triggered by pull-to-refresh + // has data to return. + fakeClient.pages.add(refreshPage); + + // Pull-to-refresh while _loadMore is still blocked — _load bumps the + // generation, so the blocked _loadMore response will be stale. + await tester.drag( + find.byKey(const Key('media_grid')), + const Offset(0, 300), + ); + await tester.pump(const Duration(seconds: 1)); + + // Resolve the stale _loadMore so the generation-mismatch path executes. + // _load() must have already reset _isLoadingMore = false in its setState. + loadMoreCompleter.complete(makePage(200, 2)); + await tester.pumpAndSettle(); + + // The bottom spinner must NOT be visible: _isLoadingMore was cleared by + // _load() so the stale _loadMore did not leave it permanently stuck. + expect(find.byKey(const Key('media_loading_more')), findsNothing); + }); + }); } diff --git a/player-android/test/screens/podcast_episodes_screen_test.dart b/player-android/test/screens/podcast_episodes_screen_test.dart index a027cc1..db046ca 100644 --- a/player-android/test/screens/podcast_episodes_screen_test.dart +++ b/player-android/test/screens/podcast_episodes_screen_test.dart @@ -142,6 +142,40 @@ class _DelayedFakeApiClient extends PlayerApiClient { _completer.future; } +/// [PlayerApiClient] stub for pagination tests. +/// +/// Each [listEpisodes] call pops the next page from [pages]. Setting +/// [holdNextCompleter] before a call blocks that call until the completer is +/// resolved — allowing tests to inspect in-flight state. +class _PaginatedFakeApiClient extends PlayerApiClient { + _PaginatedFakeApiClient({required this.pages}) : super(dio: Dio()); + + /// Successive pages to return; each [listEpisodes] call removes the first element. + final List<List<PodcastEpisode>> pages; + + /// When non-null, the next [listEpisodes] call returns this completer's + /// future and the field is cleared so later calls resume normal paged behaviour. + Completer<List<PodcastEpisode>>? holdNextCompleter; + + /// Total number of [listEpisodes] invocations, including held ones. + int listEpisodesCallCount = 0; + + @override + Future<List<PodcastEpisode>> listEpisodes( + int podcastSetId, { + int? limit, + int? offset, + }) async { + listEpisodesCallCount++; + final held = holdNextCompleter; + if (held != null) { + holdNextCompleter = null; + return held.future; + } + return pages.isEmpty ? [] : pages.removeAt(0); + } +} + // --------------------------------------------------------------------------- // Sample data // --------------------------------------------------------------------------- @@ -997,4 +1031,151 @@ void main() { ); }); }); + + // -------------------------------------------------------------------------- + // Pagination (_loadMore) + // -------------------------------------------------------------------------- + + group('pagination', () { + /// Builds a full page of [count] distinct [PodcastEpisode] items starting + /// at [startId]. + List<PodcastEpisode> makePage(int startId, int count) { + return List.generate( + count, + (i) => PodcastEpisode( + id: startId + i, + feedId: 10, + guid: 'ep-${startId + i}', + title: 'Episode ${startId + i}', + description: '', + episodeUrl: 'https://example.com/ep${startId + i}.mp3', + fileName: 'ep${startId + i}.mp3', + isDownloaded: false, + isCompleted: false, + positionSeconds: 0, + durationSeconds: 1800.0, + ), + ); + } + + testWidgets('_loadMore appends second page to the list', (tester) async { + // Page 1 is full (50 items), page 2 has 2 items to signal end-of-list. + final page1 = makePage(100, 50); + final page2 = makePage(200, 2); + + final fakeClient = _PaginatedFakeApiClient(pages: [page1, page2]); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // After initial load, listEpisodes was called once. + expect(fakeClient.listEpisodesCallCount, equals(1)); + + // Jump the ListView to its maximum scroll extent so the + // ScrollUpdateNotification fires and triggers _loadMore. + final scrollable = tester.state<ScrollableState>( + find.descendant( + of: find.byKey(const Key('episodes_list')), + matching: find.byType(Scrollable), + ), + ); + scrollable.position.jumpTo(scrollable.position.maxScrollExtent); + await tester.pumpAndSettle(); + + // Two calls: initial load + one _loadMore page. + expect(fakeClient.listEpisodesCallCount, equals(2)); + + // The last episode from page 2 (id 201) should now be in the list. + expect(find.byKey(const Key('episode_row_201')), findsOneWidget); + }); + + testWidgets('_loadMore is a no-op while _isLoadingMore is already true', + (tester) async { + final loadMoreCompleter = Completer<List<PodcastEpisode>>(); + + // Page 1 is full so _hasMore stays true after initial load. + final page1 = makePage(100, 50); + + final fakeClient = _PaginatedFakeApiClient(pages: [page1]); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Stage the completer to hold the upcoming _loadMore in-flight. + fakeClient.holdNextCompleter = loadMoreCompleter; + + // Jump the ListView to its maximum scroll extent to trigger _loadMore + // via the ScrollUpdateNotification — the request is now in-flight. + final scrollable = tester.state<ScrollableState>( + find.descendant( + of: find.byKey(const Key('episodes_list')), + matching: find.byType(Scrollable), + ), + ); + scrollable.position.jumpTo(scrollable.position.maxScrollExtent); + await tester.pump(); // _loadMore starts, _isLoadingMore = true + + final callsAfterFirstScroll = fakeClient.listEpisodesCallCount; + + // Jump again while the first _loadMore is still in-flight. + scrollable.position.jumpTo(scrollable.position.maxScrollExtent); + await tester.pump(); + + // No additional API call must have been fired (_isLoadingMore guard). + expect(fakeClient.listEpisodesCallCount, equals(callsAfterFirstScroll)); + + // Clean up: resolve the in-flight request before pumpAndSettle. + loadMoreCompleter.complete(makePage(200, 2)); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'pull-to-refresh while _loadMore is in-flight leaves _isLoadingMore=false', + (tester) async { + // Page 1 is full so _hasMore is true after the initial load. + final page1 = makePage(100, 50); + // Page returned by the _load triggered by pull-to-refresh. + final refreshPage = makePage(300, 3); + + final fakeClient = _PaginatedFakeApiClient(pages: [page1]); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Stage a completer to hold the upcoming _loadMore in-flight. + final loadMoreCompleter = Completer<List<PodcastEpisode>>(); + fakeClient.holdNextCompleter = loadMoreCompleter; + + // Jump the ListView to its maximum scroll extent to trigger _loadMore + // via the ScrollUpdateNotification — the request is now in-flight. + final scrollable = tester.state<ScrollableState>( + find.descendant( + of: find.byKey(const Key('episodes_list')), + matching: find.byType(Scrollable), + ), + ); + scrollable.position.jumpTo(scrollable.position.maxScrollExtent); + await tester.pump(); // _loadMore starts, _isLoadingMore = true + + // Queue the refresh page for the _load call that pull-to-refresh fires. + fakeClient.pages.add(refreshPage); + + // Pull-to-refresh while _loadMore is still blocked — _load bumps the + // generation so the blocked _loadMore response will be stale. + await tester.drag( + find.byKey(const Key('episodes_list')), + const Offset(0, 300), + ); + await tester.pump(const Duration(seconds: 1)); + + // Resolve the stale _loadMore: generation mismatch causes early return, + // but _load() already cleared _isLoadingMore = false in its setState. + loadMoreCompleter.complete(makePage(200, 2)); + await tester.pumpAndSettle(); + + // The bottom spinner must NOT be visible: _isLoadingMore was cleared by + // _load() so the stale _loadMore did not leave it permanently stuck. + expect(find.byKey(const Key('episodes_loading_more')), findsNothing); + }); + }); } |
