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

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
4 changes: 4 additions & 0 deletions lib/src/model/game/archived_game.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ class LightArchivedGame with _$LightArchivedGame {
@MoveConverter() Move? lastMove,
Side? winner,
ClockData? clock,

/// If the game is bookmarked, can only be null if the user is not logged in
bool? bookmarked,
}) = _ArchivedGameData;

factory LightArchivedGame.fromServerJson(Map<String, dynamic> json) {
Expand Down Expand Up @@ -223,6 +226,7 @@ LightArchivedGame _lightArchivedGameFromPick(RequiredPick pick) {
lastMove: pick('lastMove').asUciMoveOrNull(),
clock: pick('clock').letOrNull(_clockDataFromPick),
opening: pick('opening').letOrNull(_openingFromPick),
bookmarked: pick('bookmarked').asBoolOrFalse(),
);
}

Expand Down
31 changes: 29 additions & 2 deletions lib/src/model/game/game_history.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ Future<IList<LightArchivedGameWithPov>> myRecentGames(Ref ref) async {
/// A provider that fetches the recent games from the server for a given user.
@riverpod
Future<IList<LightArchivedGameWithPov>> userRecentGames(Ref ref, {required UserId userId}) {
final isLoggedIn = ref.watch(authSessionProvider.select((session) => session != null));

return ref.withClientCacheFor(
(client) => GameRepository(client).getUserGames(userId),
(client) => GameRepository(client).getUserGames(userId, withBookmarked: isLoggedIn),
// 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 +121,11 @@ 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: session != null),
)
: storage
.page(userId: id, filter: filter)
.then(
Expand Down Expand Up @@ -201,6 +207,27 @@ class UserGameHistory extends _$UserGameHistory {
},
);
}

void toggleBookmark(int index) {
if (!state.hasValue) return;

final gameList = state.requireValue.gameList;
final game = gameList[index].game;
final pov = gameList[index].pov;

ref.withClient(
(client) => GameRepository(client).bookmark(game.id, bookmark: !game.bookmarked!),
);

state = AsyncData(
state.requireValue.copyWith(
gameList: gameList.replace(index, (
game: game.copyWith(bookmarked: !game.bookmarked!),
pov: pov,
)),
),
);
}
}

@freezed
Expand Down
26 changes: 24 additions & 2 deletions lib/src/model/game/game_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,16 @@ 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,
);
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,6 +61,7 @@ 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'},
Expand Down Expand Up @@ -91,4 +100,17 @@ class GameRepository {
mapper: LightArchivedGame.fromServerJson,
);
}

/// If bookmark is not set, toggle the bookmark value.
/// Otherwise it will explicitly set the bookmark value.
Future<void> bookmark(GameId id, {bool? bookmark}) async {
final uri =
bookmark == null
? Uri(path: '/bookmark/$id')
: 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(authSessionProvider.select((session) => session != null));
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
4 changes: 4 additions & 0 deletions lib/src/view/analysis/analysis_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart';
import 'package:lichess_mobile/src/model/analysis/analysis_preferences.dart';
import 'package:lichess_mobile/src/model/analysis/opening_service.dart';
import 'package:lichess_mobile/src/model/auth/auth_session.dart';
import 'package:lichess_mobile/src/model/common/chess.dart';
import 'package:lichess_mobile/src/model/game/game_share_service.dart';
import 'package:lichess_mobile/src/network/http.dart';
Expand All @@ -21,6 +22,7 @@ import 'package:lichess_mobile/src/view/board_editor/board_editor_screen.dart';
import 'package:lichess_mobile/src/view/engine/engine_depth.dart';
import 'package:lichess_mobile/src/view/engine/engine_gauge.dart';
import 'package:lichess_mobile/src/view/engine/engine_lines.dart';
import 'package:lichess_mobile/src/view/game/game_common_widgets.dart';
import 'package:lichess_mobile/src/view/opening_explorer/opening_explorer_view.dart';
import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart';
import 'package:lichess_mobile/src/widgets/bottom_bar.dart';
Expand Down Expand Up @@ -72,11 +74,13 @@ class _AnalysisScreenState extends ConsumerState<AnalysisScreen>
final ctrlProvider = analysisControllerProvider(widget.options);
final asyncState = ref.watch(ctrlProvider);
final prefs = ref.watch(analysisPreferencesProvider);
final isLoggedIn = ref.watch(authSessionProvider) != null;

final appBarActions = [
if (prefs.enableComputerAnalysis)
EngineDepth(defaultEval: asyncState.valueOrNull?.currentNode.eval),
AppBarAnalysisTabIndicator(tabs: tabs, controller: _tabController),
if (widget.options.gameId != null && isLoggedIn) BookmarkButton(id: widget.options.gameId!),
AppBarIconButton(
onPressed: () {
pushPlatformRoute(
Expand Down
40 changes: 36 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 @@ -43,8 +44,15 @@ class ArchivedGameScreen extends ConsumerWidget {

@override
Widget build(BuildContext context, WidgetRef ref) {
final isLoggedIn = ref.watch(authSessionProvider) != null;

if (gameData != null) {
return _Body(gameData: gameData, orientation: orientation, initialCursor: initialCursor);
return _Body(
gameData: gameData,
orientation: orientation,
isLoggedIn: isLoggedIn,
initialCursor: initialCursor,
);
} else {
return _LoadGame(gameId: gameId!, orientation: orientation, initialCursor: initialCursor);
}
Expand All @@ -61,25 +69,40 @@ class _LoadGame extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final game = ref.watch(archivedGameProvider(id: gameId));
final isLoggedIn = ref.watch(authSessionProvider) != null;

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 +113,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 +134,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
61 changes: 47 additions & 14 deletions lib/src/view/game/game_common_widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:lichess_mobile/src/model/auth/auth_session.dart';
import 'package:lichess_mobile/src/model/challenge/challenge.dart';
import 'package:lichess_mobile/src/model/common/id.dart';
import 'package:lichess_mobile/src/model/common/time_increment.dart';
import 'package:lichess_mobile/src/model/game/game.dart';
import 'package:lichess_mobile/src/model/game/game_repository.dart';
import 'package:lichess_mobile/src/model/game/game_share_service.dart';
import 'package:lichess_mobile/src/model/lobby/game_seek.dart';
import 'package:lichess_mobile/src/network/http.dart';
Expand All @@ -23,6 +25,46 @@ import 'package:lichess_mobile/src/widgets/buttons.dart';
import 'package:lichess_mobile/src/widgets/feedback.dart';
import 'package:lichess_mobile/src/widgets/platform_scaffold.dart';

class BookmarkButton extends ConsumerWidget {
const BookmarkButton({required this.id, this.bookmarked = false});

final GameId id;
final bool bookmarked;

@override
Widget build(BuildContext context, WidgetRef ref) {
return AppBarIconButton(
onPressed: () {
ref.withClient((client) => GameRepository(client).bookmark(id, bookmark: true));
},
semanticsLabel: context.l10n.bookmarkThisGame,
icon: Icon(bookmarked ? Icons.star : Icons.star_outline_rounded),
);
}
}

class _SettingButton extends ConsumerWidget {
const _SettingButton({required this.id});

final GameFullId id;

@override
Widget build(BuildContext context, WidgetRef ref) {
return AppBarIconButton(
onPressed:
() => showAdaptiveBottomSheet<void>(
context: context,
isDismissible: true,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => GameSettings(id: id),
),
semanticsLabel: context.l10n.settingsSettings,
icon: const Icon(Icons.settings),
);
}
}

final _gameTitledateFormat = DateFormat.yMMMd();

class GameAppBar extends ConsumerWidget {
Expand All @@ -44,6 +86,7 @@ class GameAppBar extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final shouldPreventGoingBackAsync =
id != null ? ref.watch(shouldPreventGoingBackProvider(id!)) : const AsyncValue.data(true);
final isLoggedIn = ref.watch(authSessionProvider) != null;

return PlatformAppBar(
leading: shouldPreventGoingBackAsync.maybeWhen<Widget?>(
Expand All @@ -60,20 +103,10 @@ class GameAppBar extends ConsumerWidget {
: const SizedBox.shrink(),
actions: [
const ToggleSoundButton(),
if (id != null)
AppBarIconButton(
onPressed:
() => showAdaptiveBottomSheet<void>(
context: context,
isDismissible: true,
isScrollControlled: true,
showDragHandle: true,
constraints: BoxConstraints(minHeight: MediaQuery.sizeOf(context).height * 0.5),
builder: (_) => GameSettings(id: id!),
),
semanticsLabel: context.l10n.settingsSettings,
icon: const Icon(Icons.settings),
),
if (id != null) ...[
if (isLoggedIn) BookmarkButton(id: id!.gameId),
_SettingButton(id: id!),
],
],
);
}
Expand Down
Loading
Loading