Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bookmark games #1302

Closed
wants to merge 32 commits into from
Closed
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2d34caf
feat: bookmark in game list context menu
ZTL-UwU Jul 17, 2024
c58e691
feat: game history bookmark slidable
ZTL-UwU Jul 21, 2024
e8ef9b6
chore: remove unused import & var
ZTL-UwU Jul 21, 2024
b8ed607
chore: remove unused import
ZTL-UwU Jul 21, 2024
8083469
feat: bookmark button in game screens
ZTL-UwU Jul 21, 2024
0ccc01f
feat: bookmark action in game history slidable
ZTL-UwU Jul 21, 2024
56fccaa
feat: bookmark action in game analysis
ZTL-UwU Jul 21, 2024
f1e26e9
Merge branch 'main' into main
ZTL-UwU Aug 5, 2024
40ef91a
Merge upstream into bookmark-games
julien4215 Dec 23, 2024
52c9fce
Add bookmark translation
julien4215 Dec 24, 2024
50ccaf2
More work on game bookmark
julien4215 Dec 24, 2024
47878d7
Merge upstream into bookmark-games
julien4215 Jan 7, 2025
fa5ca9f
Add bookmark state on game history screen
julien4215 Jan 10, 2025
3c9bd14
Merge upstream into bookmark-games
julien4215 Jan 13, 2025
90511bb
Add a provider to know if user is logged in
julien4215 Jan 19, 2025
38b715d
Show an error message if bookmark fails
julien4215 Jan 19, 2025
42411a4
Simplify bookmark method in game repository
julien4215 Jan 19, 2025
8d1b28d
Use isLoggedIn provider instead of watching authSessionProvider
julien4215 Jan 20, 2025
1b7deb5
Remove bookmark button on analysis screen
julien4215 Jan 20, 2025
3a68579
Change color of slidable to match theme
julien4215 Jan 20, 2025
653a168
More work on bookmarks
julien4215 Jan 22, 2025
ea8dfc2
Merge upstream main branch into bookmark-games
julien4215 Jan 23, 2025
d1aa50e
Introduce a new bookmark provider to solve sync issues between providers
julien4215 Jan 23, 2025
f142db1
Add bookmark functionality to archived game screen and game screen
julien4215 Jan 23, 2025
95b6299
Rename provider and add a doc comment to the provider
julien4215 Jan 24, 2025
2c8bdfb
Tweak bookmarks
julien4215 Jan 24, 2025
b057274
Merge upstream into bookmark-games to get the cache removal of games
julien4215 Jan 29, 2025
762bfa2
Merge upstream to fix conflicts
julien4215 Feb 3, 2025
0c2f3a7
Use a drop down menu for bookmark on game screens
julien4215 Feb 3, 2025
a2e007b
Add bookmark to recent games widget
julien4215 Feb 5, 2025
2eb1d11
Merge upstream into bookmark-games for CI test
julien4215 Feb 5, 2025
01ba938
Tweak code
julien4215 Feb 5, 2025
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
6 changes: 6 additions & 0 deletions lib/src/model/auth/auth_session.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:lichess_mobile/src/model/auth/session_storage.dart';
import 'package:lichess_mobile/src/model/common/preloaded_data.dart';
Expand Down Expand Up @@ -34,3 +35,8 @@ class AuthSessionState with _$AuthSessionState {

factory AuthSessionState.fromJson(Map<String, dynamic> json) => _$AuthSessionStateFromJson(json);
}

@riverpod
bool isLoggedIn(Ref ref) {
return ref.watch(authSessionProvider.select((authSession) => authSession != null));
}
19 changes: 12 additions & 7 deletions lib/src/model/game/archived_game.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ class ArchivedGame with _$ArchivedGame, BaseGame, IndexableSteps implements Base
///
/// Currently, those endpoints are supported:
/// - GET /game/export/<id>
factory ArchivedGame.fromServerJson(Map<String, dynamic> json) {
return _archivedGameFromPick(pick(json).required());
factory ArchivedGame.fromServerJson(Map<String, dynamic> json, {bool withBookmarked = false}) {
return _archivedGameFromPick(pick(json).required(), withBookmarked: withBookmarked);
}

/// Create an archived game from a local storage JSON.
Expand Down Expand Up @@ -99,10 +99,14 @@ class LightArchivedGame with _$LightArchivedGame {
@MoveConverter() Move? lastMove,
Side? winner,
ClockData? clock,
bool? bookmarked,
}) = _ArchivedGameData;

factory LightArchivedGame.fromServerJson(Map<String, dynamic> json) {
return _lightArchivedGameFromPick(pick(json).required());
factory LightArchivedGame.fromServerJson(
Map<String, dynamic> json, {
bool withBookmarked = false,
}) {
return _lightArchivedGameFromPick(pick(json).required(), withBookmarked: withBookmarked);
}

factory LightArchivedGame.fromJson(Map<String, dynamic> json) =>
Expand All @@ -129,8 +133,8 @@ IList<ExternalEval>? gameEvalsFromPick(RequiredPick pick) {
?.lock;
}

ArchivedGame _archivedGameFromPick(RequiredPick pick) {
final data = _lightArchivedGameFromPick(pick);
ArchivedGame _archivedGameFromPick(RequiredPick pick, {bool withBookmarked = false}) {
final data = _lightArchivedGameFromPick(pick, withBookmarked: withBookmarked);
final clocks = pick(
'clocks',
).asListOrNull<Duration>((p0) => Duration(milliseconds: p0.asIntOrThrow() * 10));
Expand Down Expand Up @@ -202,7 +206,7 @@ ArchivedGame _archivedGameFromPick(RequiredPick pick) {
);
}

LightArchivedGame _lightArchivedGameFromPick(RequiredPick pick) {
LightArchivedGame _lightArchivedGameFromPick(RequiredPick pick, {bool withBookmarked = false}) {
return LightArchivedGame(
id: pick('id').asGameIdOrThrow(),
fullId: pick('fullId').asGameFullIdOrNull(),
Expand All @@ -223,6 +227,7 @@ LightArchivedGame _lightArchivedGameFromPick(RequiredPick pick) {
lastMove: pick('lastMove').asUciMoveOrNull(),
clock: pick('clock').letOrNull(_clockDataFromPick),
opening: pick('opening').letOrNull(_openingFromPick),
bookmarked: withBookmarked ? pick('bookmarked').asBoolOrFalse() : null,
);
}

Expand Down
14 changes: 14 additions & 0 deletions lib/src/model/game/game_bookmark_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import 'package:lichess_mobile/src/model/common/id.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'game_bookmark_provider.g.dart';

/// A provider to store the bookmark value of a game when it was changed by the user.
@Riverpod(keepAlive: true)
class GameBookmark extends _$GameBookmark {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This provider looks dubious, I think it is not necessary and this state can be store elsewhere (probably a transient widget state is enough).

@override
bool? build(GameId id) => null;

@override
set state(bool? newState) => super.state = newState;
}
9 changes: 7 additions & 2 deletions lib/src/model/game/game_history.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Future<IList<LightArchivedGameWithPov>> myRecentGames(Ref ref) async {
@riverpod
Future<IList<LightArchivedGameWithPov>> userRecentGames(Ref ref, {required UserId userId}) {
return ref.withClientCacheFor(
(client) => GameRepository(client).getUserGames(userId),
(client) => GameRepository(client).getUserGames(userId, withBookmarked: true),
// cache is important because the associated widget is in a [ListView] and
// the provider may be instanciated multiple times in a short period of time
// (e.g. when scrolling)
Expand Down Expand Up @@ -119,7 +119,10 @@ class UserGameHistory extends _$UserGameHistory {
final id = userId ?? session?.user.id;
final recentGames =
id != null && online
? ref.withClient((client) => GameRepository(client).getUserGames(id, filter: filter))
? ref.withClient(
(client) =>
GameRepository(client).getUserGames(id, filter: filter, withBookmarked: true),
)
: storage
.page(userId: id, filter: filter)
.then(
Expand Down Expand Up @@ -158,6 +161,7 @@ class UserGameHistory extends _$UserGameHistory {
max: _nbPerPage,
until: _list.last.game.createdAt,
filter: currentVal.filter,
withBookmarked: true,
),
)
: currentVal.online && currentVal.session != null
Expand All @@ -167,6 +171,7 @@ class UserGameHistory extends _$UserGameHistory {
max: _nbPerPage,
until: _list.last.game.createdAt,
filter: currentVal.filter,
withBookmarked: true,
),
)
: (await ref.watch(gameStorageProvider.future))
Expand Down
26 changes: 22 additions & 4 deletions lib/src/model/game/game_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,18 @@ class GameRepository {

final LichessClient client;

Future<ArchivedGame> getGame(GameId id) {
Future<ArchivedGame> getGame(GameId id, {bool withBookmarked = false}) {
return client.readJson(
Uri(path: '/game/export/$id', queryParameters: {'clocks': '1', 'accuracy': '1'}),
Uri(
path: '/game/export/$id',
queryParameters: {
'clocks': '1',
'accuracy': '1',
if (withBookmarked) 'withBookmarked': '1',
},
),
headers: {'Accept': 'application/json'},
mapper: ArchivedGame.fromServerJson,
mapper: (json) => ArchivedGame.fromServerJson(json, withBookmarked: withBookmarked),
);
}

Expand All @@ -34,6 +41,7 @@ class GameRepository {
int max = 20,
DateTime? until,
GameFilterState filter = const GameFilterState(),
bool withBookmarked = false,
}) {
assert(!filter.perfs.contains(Perf.fromPosition));
assert(!filter.perfs.contains(Perf.puzzle));
Expand All @@ -53,10 +61,11 @@ class GameRepository {
if (filter.perfs.isNotEmpty)
'perfType': filter.perfs.map((perf) => perf.name).join(','),
if (filter.side != null) 'color': filter.side!.name,
if (withBookmarked) 'withBookmarked': 'true',
},
),
headers: {'Accept': 'application/x-ndjson'},
mapper: LightArchivedGame.fromServerJson,
mapper: (json) => LightArchivedGame.fromServerJson(json, withBookmarked: withBookmarked),
)
.then(
(value) =>
Expand Down Expand Up @@ -91,4 +100,13 @@ class GameRepository {
mapper: LightArchivedGame.fromServerJson,
);
}

/// Bookmark the game for the given `id` if `bookmark` is true else unbookmark it
Future<void> bookmark(GameId id, {required bool bookmark}) async {
final uri = Uri(path: '/bookmark/$id', queryParameters: {'v': bookmark ? '1' : '0'});
final response = await client.post(uri);
if (response.statusCode >= 400) {
throw http.ClientException('Failed to bookmark game: ${response.statusCode}', uri);
}
}
}
4 changes: 3 additions & 1 deletion lib/src/model/game/game_repository_providers.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lichess_mobile/src/model/auth/auth_session.dart';
import 'package:lichess_mobile/src/model/common/id.dart';
import 'package:lichess_mobile/src/model/game/archived_game.dart';
import 'package:lichess_mobile/src/model/game/game_repository.dart';
Expand All @@ -12,11 +13,12 @@ part 'game_repository_providers.g.dart';
/// Fetches a game from the local storage if available, otherwise fetches it from the server.
@riverpod
Future<ArchivedGame> archivedGame(Ref ref, {required GameId id}) async {
final isLoggedIn = ref.watch(isLoggedInProvider);
final gameStorage = await ref.watch(gameStorageProvider.future);
final game = await gameStorage.fetch(gameId: id);
if (game != null) return game;
return ref.withClientCacheFor(
(client) => GameRepository(client).getGame(id),
(client) => GameRepository(client).getGame(id, withBookmarked: isLoggedIn),
const Duration(seconds: 10),
);
}
Expand Down
38 changes: 34 additions & 4 deletions lib/src/view/game/archived_game_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart';
import 'package:lichess_mobile/src/model/auth/auth_session.dart';
import 'package:lichess_mobile/src/model/common/id.dart';
import 'package:lichess_mobile/src/model/game/archived_game.dart';
import 'package:lichess_mobile/src/model/game/game.dart';
Expand Down Expand Up @@ -44,7 +45,12 @@ class ArchivedGameScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
if (gameData != null) {
return _Body(gameData: gameData, orientation: orientation, initialCursor: initialCursor);
return _Body(
gameData: gameData,
orientation: orientation,
isLoggedIn: ref.watch(isLoggedInProvider),
julien4215 marked this conversation as resolved.
Show resolved Hide resolved
initialCursor: initialCursor,
);
} else {
return _LoadGame(gameId: gameId!, orientation: orientation, initialCursor: initialCursor);
}
Expand All @@ -61,25 +67,40 @@ class _LoadGame extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final game = ref.watch(archivedGameProvider(id: gameId));
final isLoggedIn = ref.watch(isLoggedInProvider);

return game.when(
data: (game) {
return _Body(gameData: game.data, orientation: orientation, initialCursor: initialCursor);
return _Body(
gameData: game.data,
orientation: orientation,
isLoggedIn: isLoggedIn,
initialCursor: initialCursor,
);
},
loading: () => _Body(gameData: null, orientation: orientation, initialCursor: initialCursor),
loading:
() => _Body(
gameData: null,
orientation: orientation,
isLoggedIn: isLoggedIn,
initialCursor: initialCursor,
),
error: (error, stackTrace) {
debugPrint('SEVERE: [ArchivedGameScreen] could not load game; $error\n$stackTrace');
switch (error) {
case ServerException _ when error.statusCode == 404:
return _Body(
gameData: null,
orientation: orientation,
isLoggedIn: isLoggedIn,
initialCursor: initialCursor,
error: 'Game not found.',
);
default:
return _Body(
gameData: null,
orientation: orientation,
isLoggedIn: isLoggedIn,
initialCursor: initialCursor,
error: error,
);
Expand All @@ -90,10 +111,17 @@ class _LoadGame extends ConsumerWidget {
}

class _Body extends StatelessWidget {
const _Body({required this.gameData, required this.orientation, this.initialCursor, this.error});
const _Body({
required this.gameData,
required this.orientation,
required this.isLoggedIn,
this.initialCursor,
this.error,
});

final LightArchivedGame? gameData;
final Object? error;
final bool isLoggedIn;
final Side orientation;
final int? initialCursor;

Expand All @@ -104,6 +132,8 @@ class _Body extends StatelessWidget {
title: gameData != null ? _GameTitle(gameData: gameData!) : const SizedBox.shrink(),
actions: [
if (gameData == null && error == null) const PlatformAppBarLoadingIndicator(),
if (gameData != null && isLoggedIn)
BookmarkButton(id: gameData!.id, bookmarked: gameData!.bookmarked!),
const ToggleSoundButton(),
],
),
Expand Down
Loading
Loading