Skip to content
This repository was archived by the owner on Sep 10, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions lib/core/api/games_page.dart
Original file line number Diff line number Diff line change
@@ -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<GameSummary> games, String? nextCursor});
4 changes: 4 additions & 0 deletions lib/core/errors/error_messages.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
37 changes: 22 additions & 15 deletions lib/features/game/data/game_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<List<GameSummary>> 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<GamesPage> 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<List<GameSummary>> 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<GamesPage> 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.
Expand All @@ -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<List<GameSummary>> getPlayerGames(
Future<GamesPage> 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.
Expand Down
46 changes: 30 additions & 16 deletions lib/features/game/presentation/screens/history_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,28 +55,42 @@ class HistoryScreen extends ConsumerStatefulWidget {
class _HistoryScreenState extends ConsumerState<HistoryScreen> {
late final PagingController<String, _HistoryEntry> _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<String, _HistoryEntry>(
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
Expand Down Expand Up @@ -134,15 +148,15 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
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',
),
),
),
Expanded(
child: RefreshIndicator(
onRefresh: () async => _pagingController.refresh(),
onRefresh: () async => _refresh(),
child: PagingListener(
controller: _pagingController,
builder: (context, state, fetchNextPage) {
Expand Down Expand Up @@ -174,7 +188,7 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
Text(humanize(state.error ?? 'Unknown error')),
const SizedBox(height: 16),
FilledButton(
onPressed: _pagingController.refresh,
onPressed: _refresh,
child: const Text('Retry'),
),
],
Expand Down
56 changes: 35 additions & 21 deletions lib/features/game/presentation/screens/lobby_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -189,28 +188,43 @@ class _LobbyTabContentState extends ConsumerState<_LobbyTabContent>
with AutomaticKeepAliveClientMixin {
late final PagingController<String, GameSummary> _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;

@override
void initState() {
super.initState();
_pagingController = PagingController<String, GameSummary>(
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);
Expand Down Expand Up @@ -256,15 +270,15 @@ 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',
),
),
),
Expanded(
child: RefreshIndicator(
onRefresh: () async => _pagingController.refresh(),
onRefresh: () async => _refresh(),
child: PagingListener(
controller: _pagingController,
builder: (context, state, fetchNextPage) {
Expand All @@ -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,
Expand Down Expand Up @@ -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'),
),
],
Expand Down
7 changes: 4 additions & 3 deletions lib/features/game/providers/game_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ bool soloPlayAvailable(Ref ref) {
/// the deadline, so nothing has to be derived from a second read.
@riverpod
Future<List<GameSummary>> 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) {
Expand Down Expand Up @@ -347,6 +347,7 @@ List<Outcome> gameOutcomes(Ref ref, {required String gameId}) {
Future<List<GameSummary>> playerPublicFinishedGames(
Ref ref, {
required String playerId,
}) {
return ref.watch(gameRepositoryProvider).getPlayerGames(playerId);
}) async {
final page = await ref.watch(gameRepositoryProvider).getPlayerGames(playerId);
return page.games;
}
4 changes: 2 additions & 2 deletions lib/features/game/providers/game_providers.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions lib/features/social/data/social_repository.dart
Original file line number Diff line number Diff line change
@@ -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.
///
Expand Down Expand Up @@ -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<List<GameSummary>> getFriendsGames({int? limit, int? cursor}) async {
Future<GamesPage> 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.
Expand Down
4 changes: 2 additions & 2 deletions lib/features/social/providers/social_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ Future<List<FriendRequest>> outgoingRequests(Ref ref) async {

/// Joinable games created by the caller's friends.
@riverpod
Future<List<GameSummary>> friendsGames(Ref ref) {
return ref.watch(socialRepositoryProvider).getFriendsGames();
Future<List<GameSummary>> friendsGames(Ref ref) async {
return (await ref.watch(socialRepositoryProvider).getFriendsGames()).games;
}

/// The current relationship between the local user and another player.
Expand Down
Loading