diff --git a/CHANGELOG.md b/CHANGELOG.md index e8b6124..b33115a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,17 @@ Pre-1.0, breaking changes land in a **MINOR** bump: `^0.1.0` resolves to [Versions and compatibility](https://eigeninteractive.com/docs/reference/compatibility) for how this package, the engine and the generated `eigen_api` client pair up. +## [Unreleased] +### Added +- Copy for the engine's new invalidCursor code. A cursor is echoed, never composed, so a user can neither cause nor fix one; it means the list needs restarting from the top, which is what a refresh does. + +### Changed +- Paged lists follow the engine's opaque cursors. getLobby, getMyGames, getPlayerGames and getFriendsGames return a GamesPage record carrying the page and the server's nextCursor, instead of a bare list. That removes two things the client had no business knowing: the sort order, which history\_screen reconstructed from the last row's finishedAt ?? updatedAt, a hand-maintained copy of a rule the server owns; and where the list ends, which was inferred from a page coming back shorter than the page size. That inference is wrong exactly when the final page is full, and costs the reader a spinner and a request that returns nothing. nextCursor is null when the list is exhausted, so it is an answer rather than a guess. +- eigen\_api pin raised to ^0.4.0: the engine now returns nextCursor on every paged response and takes cursor as an opaque string, and a shell speaking 0.3.x cannot read it. + +### Fixed +- Refreshing a paged list no longer pages on from a stale cursor. The cursor lives beside the paging controller, so the two have to be reset together; refreshing without clearing it refetched page one and then continued from wherever the last scroll had reached. Both screens now route every refresh through one \_refresh(), which matters because history has three refresh affordances and the lobby has five (toolbar, pull-to-refresh, error retry, and on the lobby a game being joined or cancelled). + ## [0.5.0] - 2026-08-12 ## [0.4.1] - 2026-08-12 ### Added @@ -167,6 +178,7 @@ server-side concern now live in the engine. - `google_fonts`, which fetched Inter at runtime, replaced by the bundled package font above. +[Unreleased]: https://github.com/eigeninteractive/eigen-flutter/compare/v0.5.0...HEAD [0.5.0]: https://github.com/eigeninteractive/eigen-flutter/compare/v0.4.1...v0.5.0 [0.4.1]: https://github.com/eigeninteractive/eigen-flutter/compare/v0.4.0...v0.4.1 [0.4.0]: https://github.com/eigeninteractive/eigen-flutter/compare/v0.3.7...v0.4.0 diff --git a/lib/core/api/games_page.dart b/lib/core/api/games_page.dart new file mode 100644 index 0000000..41781e0 --- /dev/null +++ b/lib/core/api/games_page.dart @@ -0,0 +1,17 @@ +import 'package:eigen_api/eigen_api.dart'; + +/// One page of a paged game list, plus the token that continues it. +/// +/// [nextCursor] is opaque: it comes from the server and goes back to the server +/// unread. That is the point of it. The previous design sent the sort value of +/// the last row, which meant every screen that paged had to know how the server +/// sorted that particular list - that finished games order by their finish time +/// and fall back to their last update, while active ones order by their last +/// update. Two copies of a rule the server owns, kept in sync by hand. +/// +/// [nextCursor] being null is an *answer*: the server has told us this is the +/// end of the list. The screens used to infer that from a page coming back +/// shorter than the page size, which is a guess, and it is wrong exactly when +/// the final page happens to be full - the reader then sees a spinner and one +/// pointless request that returns nothing. +typedef GamesPage = ({List games, String? nextCursor}); diff --git a/lib/core/errors/error_messages.dart b/lib/core/errors/error_messages.dart index 62119d1..16662c2 100644 --- a/lib/core/errors/error_messages.dart +++ b/lib/core/errors/error_messages.dart @@ -55,6 +55,10 @@ String messageForCode(ErrorCode code) => switch (code) { ErrorCode.unsupportedImageType => 'Use a JPEG, PNG, or WebP image.', ErrorCode.rateLimited => "You're doing that too quickly. Wait a moment and try again.", + // A cursor is echoed back, never composed, so a user can neither cause nor + // fix this. It means a paged list needs restarting from the top, which is + // what a refresh does, so the copy asks for exactly that. + ErrorCode.invalidCursor => 'That list is out of date. Pull to refresh.', ErrorCode.unknownDefaultOpenApi => _unexpected, }; diff --git a/lib/features/game/data/game_repository.dart b/lib/features/game/data/game_repository.dart index 06bc97c..984da67 100644 --- a/lib/features/game/data/game_repository.dart +++ b/lib/features/game/data/game_repository.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:eigen_api/eigen_api.dart'; import 'package:eigen_flutter/core/api/engine_call.dart'; +import 'package:eigen_flutter/core/api/games_page.dart'; import 'package:eigen_flutter/core/api/game_socket.dart'; import 'package:eigen_flutter/core/game/game_session.dart'; @@ -41,34 +42,35 @@ class GameRepository { /// Public games waiting for players, newest first. /// - /// [cursor] is the previous page's last `created_at`; omit it for the first - /// page. Paging by cursor rather than offset keeps a page stable while the - /// lobby churns underneath the reader - with an offset, one new game shifts - /// every subsequent page by one and a scroll shows the same row twice. - Future> getLobby({ + /// [cursor] is the previous page's [GamesPage.nextCursor]; omit it for the + /// first page. Paging by cursor rather than offset keeps a page stable while + /// the lobby churns underneath the reader - with an offset, one new game + /// shifts every subsequent page by one and a scroll shows the same row twice. + Future getLobby({ int limit = lobbyPageSize, - int? cursor, + String? cursor, }) async { final body = await engineData( () => _api.getLobby(limit: limit, cursor: cursor), ); - return body.games; + return (games: body.games.toList(), nextCursor: body.nextCursor); } /// The caller's games in one bucket: `active` (still playable) or `finished` /// (the history list). /// - /// [cursor] is the previous page's last sort value - `updated_at` for active - /// games, `finished_at` (falling back to `updated_at`) for finished ones. - Future> getMyGames({ + /// [cursor] is the previous page's [GamesPage.nextCursor]; omit it for the + /// first page. How each bucket is ordered is the server's business and is not + /// restated here. + Future getMyGames({ String bucket = activeGamesBucket, int limit = historyPageSize, - int? cursor, + String? cursor, }) async { final body = await engineData( () => _api.getMyGames(bucket: bucket, limit: limit, cursor: cursor), ); - return body.games; + return (games: body.games.toList(), nextCursor: body.nextCursor); } /// One game's index entry: what discovery and history show. @@ -93,14 +95,19 @@ class GameRepository { /// /// Any player, human or bot. Public and finished only, so this exposes /// nothing that was not already replayable by anyone holding the game's id. - Future> getPlayerGames( + Future getPlayerGames( String playerId, { int? limit, + String? cursor, }) async { final body = await engineData( - () => _players.getPlayerGames(playerId: playerId, limit: limit), + () => _players.getPlayerGames( + playerId: playerId, + limit: limit, + cursor: cursor, + ), ); - return body.games; + return (games: body.games.toList(), nextCursor: body.nextCursor); } /// The bots available to seat, for this build's schema version. diff --git a/lib/features/game/presentation/screens/history_screen.dart b/lib/features/game/presentation/screens/history_screen.dart index 6b08766..fe301d4 100644 --- a/lib/features/game/presentation/screens/history_screen.dart +++ b/lib/features/game/presentation/screens/history_screen.dart @@ -55,28 +55,42 @@ class HistoryScreen extends ConsumerStatefulWidget { class _HistoryScreenState extends ConsumerState { late final PagingController _pagingController; + /// The cursor for the page after the one most recently fetched, or null once + /// the server has said the list is exhausted. The empty string is "no cursor + /// yet", i.e. the first page; it cannot be null, because null is how this + /// controller is told there are no more pages. + String? _nextKey = ''; + + /// Reload from the top. + /// + /// The cursor lives beside the controller rather than inside it, so the two + /// have to be reset together - refreshing the list without clearing the + /// cursor would refetch page one and then continue from wherever the last + /// scroll had reached. There are three refresh affordances on this screen + /// (the toolbar button, pull-to-refresh, and the error retry), which is + /// exactly why this is a method and not three copies of two lines. + void _refresh() { + _nextKey = ''; + _pagingController.refresh(); + } + @override void initState() { super.initState(); _pagingController = PagingController( - getNextPageKey: (state) { - final pages = state.pages; - if (pages == null || pages.isEmpty) return ''; - final lastPage = pages.last; - if (lastPage.length < historyPageSize) return null; - // The server pages by keyset: the next page is everything strictly - // older than this page's last sort value, which for finished games is - // the finish time (falling back to the last update for aborted ones). - final last = lastPage.last.game; - return '${last.finishedAt ?? last.updatedAt}'; - }, + getNextPageKey: (state) => _nextKey, fetchPage: (key) async { - final games = await ref + final page = await ref .read(gameRepositoryProvider) .getMyGames( bucket: finishedGamesBucket, - cursor: key.isEmpty ? null : int.parse(key), + cursor: key.isEmpty ? null : key, ); + // The server says where the next page starts, and says so with a token + // this screen never opens. Nothing here knows that finished games sort + // by their finish time; that is the server's rule to keep. + _nextKey = page.nextCursor; + final games = page.games; // The summary carries the roster, the outcomes and the rating deltas, // so every field of a row is derived from the one response. Joining a // separate rating log here would have been a second round trip per @@ -134,7 +148,7 @@ class _HistoryScreenState extends ConsumerState { child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: IconButton( - onPressed: _pagingController.refresh, + onPressed: _refresh, icon: const Icon(Icons.refresh), tooltip: 'Refresh history', ), @@ -142,7 +156,7 @@ class _HistoryScreenState extends ConsumerState { ), Expanded( child: RefreshIndicator( - onRefresh: () async => _pagingController.refresh(), + onRefresh: () async => _refresh(), child: PagingListener( controller: _pagingController, builder: (context, state, fetchNextPage) { @@ -174,7 +188,7 @@ class _HistoryScreenState extends ConsumerState { Text(humanize(state.error ?? 'Unknown error')), const SizedBox(height: 16), FilledButton( - onPressed: _pagingController.refresh, + onPressed: _refresh, child: const Text('Retry'), ), ], diff --git a/lib/features/game/presentation/screens/lobby_screen.dart b/lib/features/game/presentation/screens/lobby_screen.dart index 5313c2b..dbaae4e 100644 --- a/lib/features/game/presentation/screens/lobby_screen.dart +++ b/lib/features/game/presentation/screens/lobby_screen.dart @@ -5,7 +5,6 @@ import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:eigen_flutter/core/errors/error_messages.dart'; import 'package:eigen_flutter/core/adaptive/adaptive_layout.dart'; import 'package:eigen_flutter/features/auth/providers/auth_providers.dart'; -import 'package:eigen_flutter/features/game/data/game_repository.dart'; import 'package:eigen_flutter/features/game/providers/game_providers.dart'; import 'package:eigen_flutter/features/game/utils/game_timing.dart'; @@ -189,6 +188,26 @@ class _LobbyTabContentState extends ConsumerState<_LobbyTabContent> with AutomaticKeepAliveClientMixin { late final PagingController _pagingController; + /// The cursor for the page after the one most recently fetched, or null once + /// the server has said the list is exhausted. The empty string is "no cursor + /// yet", i.e. the first page; it cannot be null, because null is how this API + /// says there are no more pages. + String? _nextKey = ''; + + /// Reload from the top. + /// + /// The cursor lives beside the controller rather than inside it, so the two + /// have to be reset together - refreshing without clearing the cursor would + /// refetch page one and then continue paging from wherever the last scroll + /// had reached. This screen has five refresh affordances (the toolbar button, + /// pull-to-refresh, the error retry, and a game being joined or cancelled), + /// which is exactly why this is a method rather than five copies of two + /// lines. + void _refresh() { + _nextKey = ''; + _pagingController.refresh(); + } + @override bool get wantKeepAlive => true; @@ -196,21 +215,16 @@ class _LobbyTabContentState extends ConsumerState<_LobbyTabContent> void initState() { super.initState(); _pagingController = PagingController( - getNextPageKey: (state) { - final pages = state.pages; - if (pages == null || pages.isEmpty) return ''; - final lastPage = pages.last; - if (lastPage.length < lobbyPageSize) return null; - return '${lastPage.last.createdAt}'; - }, - fetchPage: (key) { - final cursor = key.isEmpty ? null : int.parse(key); - if (widget.mode == _LobbyMode.public) { - return ref.read(gameRepositoryProvider).getLobby(cursor: cursor); - } - return ref - .read(socialRepositoryProvider) - .getFriendsGames(cursor: cursor); + getNextPageKey: (state) => _nextKey, + fetchPage: (key) async { + final cursor = key.isEmpty ? null : key; + final page = widget.mode == _LobbyMode.public + ? await ref.read(gameRepositoryProvider).getLobby(cursor: cursor) + : await ref + .read(socialRepositoryProvider) + .getFriendsGames(cursor: cursor); + _nextKey = page.nextCursor; + return page.games; }, ); _pagingController.addListener(_onPagingError); @@ -256,7 +270,7 @@ class _LobbyTabContentState extends ConsumerState<_LobbyTabContent> child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: IconButton( - onPressed: _pagingController.refresh, + onPressed: _refresh, icon: const Icon(Icons.refresh), tooltip: 'Refresh games', ), @@ -264,7 +278,7 @@ class _LobbyTabContentState extends ConsumerState<_LobbyTabContent> ), Expanded( child: RefreshIndicator( - onRefresh: () async => _pagingController.refresh(), + onRefresh: () async => _refresh(), child: PagingListener( controller: _pagingController, builder: (context, state, fetchNextPage) { @@ -274,8 +288,8 @@ class _LobbyTabContentState extends ConsumerState<_LobbyTabContent> itemBuilder: (context, item, index) => _GameCard( key: ValueKey(item.id), game: item, - onCancelled: _pagingController.refresh, - onJoined: _pagingController.refresh, + onCancelled: _refresh, + onJoined: _refresh, ), noItemsFoundIndicatorBuilder: (_) => EmptyStateView( icon: Icons.sports_esports_outlined, @@ -305,7 +319,7 @@ class _LobbyTabContentState extends ConsumerState<_LobbyTabContent> Text(humanize(state.error ?? 'Unknown error')), const SizedBox(height: 16), FilledButton( - onPressed: _pagingController.refresh, + onPressed: _refresh, child: const Text('Retry'), ), ], diff --git a/lib/features/game/providers/game_providers.dart b/lib/features/game/providers/game_providers.dart index a4c6cdf..40ccd69 100644 --- a/lib/features/game/providers/game_providers.dart +++ b/lib/features/game/providers/game_providers.dart @@ -129,7 +129,7 @@ bool soloPlayAvailable(Ref ref) { /// the deadline, so nothing has to be derived from a second read. @riverpod Future> activeGames(Ref ref) async { - final games = await ref.watch(gameRepositoryProvider).getMyGames(); + final games = (await ref.watch(gameRepositoryProvider).getMyGames()).games; final myUserId = ref.watch(currentUserIdProvider); bool isMyTurn(GameSummary game) { @@ -347,6 +347,7 @@ List gameOutcomes(Ref ref, {required String gameId}) { Future> playerPublicFinishedGames( Ref ref, { required String playerId, -}) { - return ref.watch(gameRepositoryProvider).getPlayerGames(playerId); +}) async { + final page = await ref.watch(gameRepositoryProvider).getPlayerGames(playerId); + return page.games; } diff --git a/lib/features/game/providers/game_providers.g.dart b/lib/features/game/providers/game_providers.g.dart index fc574f8..e7c6428 100644 --- a/lib/features/game/providers/game_providers.g.dart +++ b/lib/features/game/providers/game_providers.g.dart @@ -428,7 +428,7 @@ final class ActiveGamesProvider } } -String _$activeGamesHash() => r'3d2e1b090ecec8292f8d2329cbeb8899ac6b843d'; +String _$activeGamesHash() => r'0937340c6c0063bdeb40a6a06ce7abc114af0e2d'; /// One game's live session: the single subscription a game screen needs. /// @@ -1263,7 +1263,7 @@ final class PlayerPublicFinishedGamesProvider } String _$playerPublicFinishedGamesHash() => - r'75eaf75a9b4b3f2d4b7cb424f2f09cb59f1ac449'; + r'4eb0d86534a5f56d0ea7eeaef5d47e668c07d1e5'; /// A player's most recent finished public games, for the replay list on their /// profile. diff --git a/lib/features/social/data/social_repository.dart b/lib/features/social/data/social_repository.dart index 8fe73a5..11902a9 100644 --- a/lib/features/social/data/social_repository.dart +++ b/lib/features/social/data/social_repository.dart @@ -1,5 +1,6 @@ import 'package:eigen_api/eigen_api.dart'; import 'package:eigen_flutter/core/api/engine_call.dart'; +import 'package:eigen_flutter/core/api/games_page.dart'; /// The friend graph: friends, pending requests, blocks, and user search. /// @@ -29,13 +30,13 @@ class SocialRepository { /// Joinable games created by the caller's friends. /// - /// [cursor] is the previous page's last `created_at`; omit for the first + /// [cursor] is the previous page's [GamesPage.nextCursor]; omit for the first /// page. - Future> getFriendsGames({int? limit, int? cursor}) async { + Future getFriendsGames({int? limit, String? cursor}) async { final body = await engineData( () => _api.getFriendsGames(limit: limit, cursor: cursor), ); - return body.games; + return (games: body.games.toList(), nextCursor: body.nextCursor); } /// Case-insensitive search over usernames and display names. diff --git a/lib/features/social/providers/social_providers.dart b/lib/features/social/providers/social_providers.dart index d547c6c..b97cfc7 100644 --- a/lib/features/social/providers/social_providers.dart +++ b/lib/features/social/providers/social_providers.dart @@ -138,8 +138,8 @@ Future> outgoingRequests(Ref ref) async { /// Joinable games created by the caller's friends. @riverpod -Future> friendsGames(Ref ref) { - return ref.watch(socialRepositoryProvider).getFriendsGames(); +Future> friendsGames(Ref ref) async { + return (await ref.watch(socialRepositoryProvider).getFriendsGames()).games; } /// The current relationship between the local user and another player. diff --git a/lib/features/social/providers/social_providers.g.dart b/lib/features/social/providers/social_providers.g.dart index d145c11..4ce1985 100644 --- a/lib/features/social/providers/social_providers.g.dart +++ b/lib/features/social/providers/social_providers.g.dart @@ -314,7 +314,7 @@ final class FriendsGamesProvider } } -String _$friendsGamesHash() => r'd2418a76d6c91fefa247c31f379503f3edb0fd38'; +String _$friendsGamesHash() => r'b66fa0dc6cf0d0304b7b031e4eaca94a35ce7cd3'; @ProviderFor(friendStatus) final friendStatusProvider = FriendStatusFamily._(); diff --git a/pubspec.yaml b/pubspec.yaml index f46235f..7eaf31e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,7 +64,7 @@ dependencies: # Pre-1.0 that resolves to `>=0.1.0 <0.2.0`: the engine announces breakage in # the MINOR position, so 0.2.0 is the release that needs a deliberate bump # here. See https://eigeninteractive.com/docs/reference/compatibility. - eigen_api: ^0.3.0 + eigen_api: ^0.4.0 dio: ^5.10.0 dio_smart_retry: ^7.0.1 firebase_auth: ^6.5.6 diff --git a/test/shared/api_contract_test.dart b/test/shared/api_contract_test.dart index d333428..13e267f 100644 --- a/test/shared/api_contract_test.dart +++ b/test/shared/api_contract_test.dart @@ -45,6 +45,8 @@ void main() { 'unsupportedImageType', // Per-user write rate limiting (429). 'rateLimited', + // A pagination cursor that did not decode (400). + 'invalidCursor', }); });