Skip to content

Commit

Permalink
Merge branch 'julien4215-bookmark-games'
Browse files Browse the repository at this point in the history
  • Loading branch information
veloce committed Feb 5, 2025
2 parents 408748b + 7900fcd commit 0f883d8
Show file tree
Hide file tree
Showing 29 changed files with 1,136 additions and 387 deletions.
11 changes: 2 additions & 9 deletions lib/src/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -215,13 +215,6 @@ class _AppState extends ConsumerState<Application> {
blendLevel: 20,
);

const iosMenuTheme = MenuThemeData(
style: MenuStyle(
elevation: WidgetStatePropertyAll(0),
shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: kCardBorderRadius)),
),
);

return AnnotatedRegion<SystemUiOverlayStyle>(
value: FlexColorScheme.themedSystemNavigationBar(
context,
Expand All @@ -241,7 +234,7 @@ class _AppState extends ConsumerState<Application> {
subtitleTextStyle: isIOS ? lightCupertino.textTheme.textStyle : null,
leadingAndTrailingTextStyle: isIOS ? lightCupertino.textTheme.textStyle : null,
),
menuTheme: isIOS ? iosMenuTheme : null,
menuTheme: isIOS ? Styles.cupertinoAnchorMenuTheme : null,
floatingActionButtonTheme: floatingActionButtonTheme,
navigationBarTheme: NavigationBarTheme.of(context).copyWith(
height: remainingHeight < kSmallRemainingHeightLeftBoardThreshold ? 60 : null,
Expand All @@ -260,7 +253,7 @@ class _AppState extends ConsumerState<Application> {
subtitleTextStyle: isIOS ? darkCupertino.textTheme.textStyle : null,
leadingAndTrailingTextStyle: isIOS ? darkCupertino.textTheme.textStyle : null,
),
menuTheme: isIOS ? iosMenuTheme : null,
menuTheme: isIOS ? Styles.cupertinoAnchorMenuTheme : null,
floatingActionButtonTheme: floatingActionButtonTheme,
navigationBarTheme: NavigationBarTheme.of(context).copyWith(
height: remainingHeight < kSmallRemainingHeightLeftBoardThreshold ? 60 : null,
Expand Down
29 changes: 29 additions & 0 deletions lib/src/model/account/account_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ Future<IList<OngoingGame>> ongoingGames(Ref ref) async {
);
}

@Riverpod(keepAlive: true)
AccountService accountService(Ref ref) {
return AccountService(ref);
}

class AccountService {
const AccountService(this._ref);

final Ref _ref;

Future<void> setGameBookmark(GameId id, {required bool bookmark}) async {
final session = _ref.read(authSessionProvider);
if (session == null) return;

await _ref.withClient((client) => AccountRepository(client).bookmark(id, bookmark: bookmark));

_ref.invalidate(accountProvider);
}
}

class AccountRepository {
AccountRepository(this.client);

Expand Down Expand Up @@ -108,6 +128,15 @@ class AccountRepository {
throw http.ClientException('Failed to set preference: ${response.statusCode}', uri);
}
}

/// 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);
}
}
}

AccountPrefState _accountPreferencesFromPick(RequiredPick pick) {
Expand Down
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));
}
21 changes: 14 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,15 +99,21 @@ 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) =>
_$LightArchivedGameFromJson(json);

bool get isBookmarked => bookmarked == true;

String get clockDisplay {
return TimeIncrement(clock?.initial.inSeconds ?? 0, clock?.increment.inSeconds ?? 0).display;
}
Expand All @@ -129,8 +135,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 +208,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 +229,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
115 changes: 115 additions & 0 deletions lib/src/model/game/game_bookmarks.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import 'dart:async';

import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:freezed_annotation/freezed_annotation.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';
import 'package:lichess_mobile/src/network/http.dart';
import 'package:result_extensions/result_extensions.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'game_bookmarks.freezed.dart';
part 'game_bookmarks.g.dart';

const _nbPerPage = 20;

/// A provider that paginates the game bookmarks for the current app user.
@riverpod
class GameBookmarksPaginator extends _$GameBookmarksPaginator {
final _list = <LightArchivedGameWithPov>[];

@override
Future<GameBookmarksPaginatorState> build() async {
ref.onDispose(() {
_list.clear();
});

final session = ref.watch(authSessionProvider);

if (session == null) {
return GameBookmarksPaginatorState(
gameList: <LightArchivedGameWithPov>[].toIList(),
isLoading: false,
hasMore: false,
hasError: false,
);
}

final games = ref.withClient((client) => GameRepository(client).getBookmarkedGames(session));

_list.addAll(await games);

return GameBookmarksPaginatorState(
gameList: _list.toIList(),
isLoading: false,
hasMore: true,
hasError: false,
);
}

/// Fetches the next page of games.
Future<void> getNext() async {
if (!state.hasValue) return;

final session = ref.read(authSessionProvider);

if (session == null) return;

final currentVal = state.requireValue;
state = AsyncData(currentVal.copyWith(isLoading: true));
Result.capture(
ref.withClient(
(client) => GameRepository(
client,
).getBookmarkedGames(session, max: _nbPerPage, until: _list.last.game.createdAt),
),
).fold(
(value) {
if (value.isEmpty) {
state = AsyncData(currentVal.copyWith(hasMore: false, isLoading: false));
return;
}

_list.addAll(value);

state = AsyncData(
currentVal.copyWith(
gameList: _list.toIList(),
isLoading: false,
hasMore: value.length == _nbPerPage,
),
);
},
(error, stackTrace) {
state = AsyncData(currentVal.copyWith(isLoading: false, hasError: true));
},
);
}

void removeBookmark(GameId id) {
if (!state.hasValue) return;

final gameList = state.requireValue.gameList;
final entry = gameList.firstWhereOrNull((e) => e.game.id == id);
if (entry == null) return;

final index = gameList.indexOf(entry);

state = AsyncData(state.requireValue.copyWith(gameList: gameList.removeAt(index)));
}
}

@freezed
class GameBookmarksPaginatorState with _$GameBookmarksPaginatorState {
const factory GameBookmarksPaginatorState({
required IList<LightArchivedGameWithPov> gameList,
required bool isLoading,
required bool hasMore,
required bool hasError,
}) = _UserGameHistoryState;
}
14 changes: 14 additions & 0 deletions lib/src/model/game/game_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,20 @@ class GameController extends _$GameController {
}
}

Future<void> toggleBookmark() async {
if (state.hasValue) {
final toggledBookmark = !(state.requireValue.game.bookmarked ?? false);
await ref
.read(accountServiceProvider)
.setGameBookmark(gameFullId.gameId, bookmark: toggledBookmark);
state = AsyncValue.data(
state.requireValue.copyWith(
game: state.requireValue.game.copyWith(bookmarked: toggledBookmark),
),
);
}
}

void toggleMoveConfirmation() {
final curState = state.requireValue;
state = AsyncValue.data(
Expand Down
20 changes: 11 additions & 9 deletions lib/src/model/game/game_filter.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:lichess_mobile/l10n/l10n.dart';
import 'package:lichess_mobile/src/model/common/perf.dart';
import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'game_filter.freezed.dart';
Expand All @@ -24,12 +23,15 @@ class GameFilter extends _$GameFilter {
class GameFilterState with _$GameFilterState {
const GameFilterState._();

const factory GameFilterState({@Default(ISet<Perf>.empty()) ISet<Perf> perfs, Side? side}) =
_GameFilterState;
const factory GameFilterState({
@Default(ISet<Perf>.empty()) ISet<Perf> perfs,
Side? side,
bool? rated,
}) = _GameFilterState;

/// Returns a translated label of the selected filters.
String selectionLabel(BuildContext context) {
final fields = [side, perfs];
String selectionLabel(AppLocalizations l10n) {
final fields = [side, perfs, rated];
final labels =
fields
.map(
Expand All @@ -38,8 +40,8 @@ class GameFilterState with _$GameFilterState {
? field.map((e) => e.shortTitle).join(', ')
: (field as Side?) != null
? field == Side.white
? context.l10n.white
: context.l10n.black
? l10n.white
: l10n.black
: null,
)
.where((label) => label != null && label.isNotEmpty)
Expand All @@ -48,7 +50,7 @@ class GameFilterState with _$GameFilterState {
}

int get count {
final fields = [perfs, side];
final fields = [perfs, side, rated];
return fields.where((field) => field is Iterable ? field.isNotEmpty : field != null).length;
}
}
Loading

0 comments on commit 0f883d8

Please sign in to comment.