summaryrefslogtreecommitdiff
path: root/player-android
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-22 10:21:48 +0300
committerPaul Buetow <paul@buetow.org>2026-05-22 10:21:48 +0300
commitd9552904112d0ab86e961b6d8b0844619ce1005a (patch)
tree701a8f97e5cb816b77a03f4d809d2ccefffc2904 /player-android
parentdc5efbcf0e83fae3c1c9892975dedd9eedef6e39 (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')
-rw-r--r--player-android/lib/screens/media_grid_screen.dart28
-rw-r--r--player-android/lib/screens/podcast_episodes_screen.dart4
-rw-r--r--player-android/test/screens/media_grid_screen_test.dart207
-rw-r--r--player-android/test/screens/podcast_episodes_screen_test.dart181
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);
+ });
+ });
}