diff --git a/lib/src/model/game/archived_game.dart b/lib/src/model/game/archived_game.dart index 1a34f67a59..1e6dc894d9 100644 --- a/lib/src/model/game/archived_game.dart +++ b/lib/src/model/game/archived_game.dart @@ -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 json) { @@ -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(), ); } diff --git a/lib/src/model/game/game_history.dart b/lib/src/model/game/game_history.dart index 9191de56bb..9a18cbe3b3 100644 --- a/lib/src/model/game/game_history.dart +++ b/lib/src/model/game/game_history.dart @@ -60,8 +60,10 @@ Future> myRecentGames(Ref ref) async { /// A provider that fetches the recent games from the server for a given user. @riverpod Future> 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) @@ -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( @@ -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 diff --git a/lib/src/model/game/game_repository.dart b/lib/src/model/game/game_repository.dart index 8d6fc24b59..c320134d07 100644 --- a/lib/src/model/game/game_repository.dart +++ b/lib/src/model/game/game_repository.dart @@ -13,9 +13,16 @@ class GameRepository { final LichessClient client; - Future getGame(GameId id) { + Future 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, ); @@ -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)); @@ -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'}, @@ -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 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); + } + } } diff --git a/lib/src/model/game/game_repository_providers.dart b/lib/src/model/game/game_repository_providers.dart index 9c89f34760..60c99c77c0 100644 --- a/lib/src/model/game/game_repository_providers.dart +++ b/lib/src/model/game/game_repository_providers.dart @@ -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'; @@ -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(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), ); } diff --git a/lib/src/view/analysis/analysis_screen.dart b/lib/src/view/analysis/analysis_screen.dart index 9be60c3b76..e88c0e6a4b 100644 --- a/lib/src/view/analysis/analysis_screen.dart +++ b/lib/src/view/analysis/analysis_screen.dart @@ -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'; @@ -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'; @@ -72,11 +74,13 @@ class _AnalysisScreenState extends ConsumerState 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( diff --git a/lib/src/view/game/archived_game_screen.dart b/lib/src/view/game/archived_game_screen.dart index aaa45253b5..f5d8963b60 100644 --- a/lib/src/view/game/archived_game_screen.dart +++ b/lib/src/view/game/archived_game_screen.dart @@ -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'; @@ -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); } @@ -61,11 +69,24 @@ 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) { @@ -73,6 +94,7 @@ class _LoadGame extends ConsumerWidget { return _Body( gameData: null, orientation: orientation, + isLoggedIn: isLoggedIn, initialCursor: initialCursor, error: 'Game not found.', ); @@ -80,6 +102,7 @@ class _LoadGame extends ConsumerWidget { return _Body( gameData: null, orientation: orientation, + isLoggedIn: isLoggedIn, initialCursor: initialCursor, error: error, ); @@ -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; @@ -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(), ], ), diff --git a/lib/src/view/game/game_common_widgets.dart b/lib/src/view/game/game_common_widgets.dart index b526edb7ac..8582c23b61 100644 --- a/lib/src/view/game/game_common_widgets.dart +++ b/lib/src/view/game/game_common_widgets.dart @@ -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'; @@ -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( + 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 { @@ -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( @@ -60,20 +103,10 @@ class GameAppBar extends ConsumerWidget { : const SizedBox.shrink(), actions: [ const ToggleSoundButton(), - if (id != null) - AppBarIconButton( - onPressed: - () => showAdaptiveBottomSheet( - 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!), + ], ], ); } diff --git a/lib/src/view/game/game_list_tile.dart b/lib/src/view/game/game_list_tile.dart index ecf75f2250..b037c26493 100644 --- a/lib/src/view/game/game_list_tile.dart +++ b/lib/src/view/game/game_list_tile.dart @@ -4,8 +4,9 @@ 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/common/id.dart'; +import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/model/game/archived_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/game/game_status.dart'; import 'package:lichess_mobile/src/network/http.dart'; @@ -111,6 +112,8 @@ class _ContextMenu extends ConsumerWidget { final customColors = Theme.of(context).extension(); + final isLoggedIn = ref.watch(authSessionProvider) != null; + return BottomSheetScrollableContainer( children: [ Padding( @@ -234,6 +237,15 @@ class _ContextMenu extends ConsumerWidget { closeOnPressed: false, child: Text(context.l10n.mobileShareGameURL), ), + if (isLoggedIn) + BottomSheetContextMenuAction( + onPressed: () { + ref.withClient((client) => GameRepository(client).bookmark(game.id, bookmark: true)); + }, + icon: (game.bookmarked!) ? Icons.star : Icons.star_outline_rounded, + closeOnPressed: false, + child: Text(context.l10n.bookmarkThisGame), + ), // Builder is used to retrieve the context immediately surrounding the // BottomSheetContextMenuAction // This is necessary to get the correct context for the iPad share dialog @@ -359,11 +371,9 @@ class _ContextMenu extends ConsumerWidget { /// A list tile that shows extended game info including a result icon and analysis icon. class ExtendedGameListTile extends StatelessWidget { - const ExtendedGameListTile({required this.item, this.userId, this.padding}); + const ExtendedGameListTile({required this.item, this.padding}); final LightArchivedGameWithPov item; - final UserId? userId; - final EdgeInsetsGeometry? padding; @override diff --git a/lib/src/view/user/game_history_screen.dart b/lib/src/view/user/game_history_screen.dart index 5ed1dc176d..1b7df69300 100644 --- a/lib/src/view/user/game_history_screen.dart +++ b/lib/src/view/user/game_history_screen.dart @@ -1,6 +1,7 @@ import 'package:dartchess/dartchess.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/model/common/perf.dart'; import 'package:lichess_mobile/src/model/game/game_filter.dart'; @@ -135,9 +136,13 @@ class _BodyState extends ConsumerState<_Body> { @override Widget build(BuildContext context) { final gameFilterState = ref.watch(gameFilterProvider(filter: widget.gameFilter)); - final gameListState = ref.watch( - userGameHistoryProvider(widget.user?.id, isOnline: widget.isOnline, filter: gameFilterState), + final gameListProvider = userGameHistoryProvider( + widget.user?.id, + isOnline: widget.isOnline, + filter: gameFilterState, ); + final gameListState = ref.watch(gameListProvider); + final isLoggedIn = ref.watch(authSessionProvider) != null; return gameListState.when( data: (state) { @@ -170,15 +175,34 @@ class _BodyState extends ConsumerState<_Body> { ); } - return ExtendedGameListTile( + final gameTile = ExtendedGameListTile( item: list[index], - userId: widget.user?.id, // see: https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/cupertino/list_tile.dart#L30 for horizontal padding value padding: Theme.of(context).platform == TargetPlatform.iOS ? const EdgeInsets.symmetric(horizontal: 14.0, vertical: 12.0) : null, ); + + final game = list[index].game; + + return isLoggedIn + ? Slidable( + endActionPane: ActionPane( + motion: const ScrollMotion(), + children: [ + SlidableAction( + onPressed: (BuildContext context) { + ref.read(gameListProvider.notifier).toggleBookmark(index); + }, + icon: (game.bookmarked!) ? Icons.star : Icons.star_outline_rounded, + label: context.l10n.bookmarkThisGame, + ), + ], + ), + child: gameTile, + ) + : gameTile; }, ); }, diff --git a/lib/src/view/user/recent_games.dart b/lib/src/view/user/recent_games.dart index ecae5bcb05..cab5de57e6 100644 --- a/lib/src/view/user/recent_games.dart +++ b/lib/src/view/user/recent_games.dart @@ -1,6 +1,5 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/model/game/game_history.dart'; import 'package:lichess_mobile/src/model/user/user.dart'; import 'package:lichess_mobile/src/network/connectivity.dart'; @@ -25,8 +24,6 @@ class RecentGamesWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final connectivity = ref.watch(connectivityChangesProvider); - final session = ref.watch(authSessionProvider); - final userId = user?.id ?? session?.user.id; final recentGames = user != null @@ -67,7 +64,7 @@ class RecentGamesWidget extends ConsumerWidget { : null, children: data.map((item) { - return ExtendedGameListTile(item: item, userId: userId); + return ExtendedGameListTile(item: item); }).toList(), ); },