diff --git a/lib/src/model/game/game_filter.dart b/lib/src/model/game/game_filter.dart new file mode 100644 index 0000000000..d0ec1abcd6 --- /dev/null +++ b/lib/src/model/game/game_filter.dart @@ -0,0 +1,38 @@ +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/common/perf.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'game_filter.freezed.dart'; +part 'game_filter.g.dart'; + +@riverpod +class GameFilter extends _$GameFilter { + @override + GameFilterState build({GameFilterState? filter}) { + return filter ?? const GameFilterState(); + } + + void setFilter(GameFilterState filter) => state = state.copyWith( + perfs: filter.perfs, + side: filter.side, + ); +} + +@freezed +class GameFilterState with _$GameFilterState { + const GameFilterState._(); + + const factory GameFilterState({ + @Default(ISet.empty()) ISet perfs, + Side? side, + }) = _GameFilterState; + + int get count { + final fields = [perfs, side]; + return fields + .where((field) => field is Iterable ? field.isNotEmpty : field != null) + .length; + } +} diff --git a/lib/src/model/game/game_history.dart b/lib/src/model/game/game_history.dart index 940bd99edd..0ba4a51b5f 100644 --- a/lib/src/model/game/game_history.dart +++ b/lib/src/model/game/game_history.dart @@ -8,8 +8,8 @@ import 'package:lichess_mobile/src/model/account/account_repository.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/model/common/http.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; -import 'package:lichess_mobile/src/model/common/perf.dart'; import 'package:lichess_mobile/src/model/game/archived_game.dart'; +import 'package:lichess_mobile/src/model/game/game_filter.dart'; import 'package:lichess_mobile/src/model/game/game_repository.dart'; import 'package:lichess_mobile/src/model/game/game_storage.dart'; import 'package:lichess_mobile/src/model/user/user.dart'; @@ -64,10 +64,9 @@ Future> myRecentGames( Future> userRecentGames( UserRecentGamesRef ref, { required UserId userId, - Perf? perf, }) { return ref.withClientCacheFor( - (client) => GameRepository(client).getUserGames(userId, perfType: perf), + (client) => GameRepository(client).getUserGames(userId), // 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) @@ -116,7 +115,7 @@ class UserGameHistory extends _$UserGameHistory { /// server. If this is false, the provider will fetch the games from the /// local storage. required bool isOnline, - Perf? perf, + GameFilterState filter = const GameFilterState(), }) async { ref.cacheFor(const Duration(minutes: 5)); ref.onDispose(() { @@ -124,15 +123,25 @@ class UserGameHistory extends _$UserGameHistory { }); final session = ref.watch(authSessionProvider); + final online = await ref + .watch(connectivityChangesProvider.selectAsync((c) => c.isOnline)); + final storage = ref.watch(gameStorageProvider); - final recentGames = userId != null - ? ref.read( - userRecentGamesProvider( - userId: userId, - perf: perf, - ).future, + final id = userId ?? session?.user.id; + final recentGames = id != null && online + ? ref.withClient( + (client) => GameRepository(client).getUserGames(id, filter: filter), ) - : ref.read(myRecentGamesProvider.future); + : storage.page(userId: id, filter: filter).then( + (value) => value + // we can assume that `youAre` is not null either for logged + // in users or for anonymous users + .map( + (e) => + (game: e.game.data, pov: e.game.youAre ?? Side.white), + ) + .toIList(), + ); _list.addAll(await recentGames); @@ -142,7 +151,7 @@ class UserGameHistory extends _$UserGameHistory { hasMore: true, hasError: false, online: isOnline, - perfType: perf, + filter: filter, session: session, ); } @@ -160,7 +169,7 @@ class UserGameHistory extends _$UserGameHistory { userId!, max: _nbPerPage, until: _list.last.game.createdAt, - perfType: currentVal.perfType, + filter: currentVal.filter, ), ) : currentVal.online && currentVal.session != null @@ -169,7 +178,7 @@ class UserGameHistory extends _$UserGameHistory { currentVal.session!.user.id, max: _nbPerPage, until: _list.last.game.createdAt, - perfType: currentVal.perfType, + filter: currentVal.filter, ), ) : ref @@ -219,7 +228,7 @@ class UserGameHistoryState with _$UserGameHistoryState { const factory UserGameHistoryState({ required IList gameList, required bool isLoading, - Perf? perfType, + required GameFilterState filter, required bool hasMore, required bool hasError, required bool online, diff --git a/lib/src/model/game/game_repository.dart b/lib/src/model/game/game_repository.dart index b6041eaca2..7d342ef6c6 100644 --- a/lib/src/model/game/game_repository.dart +++ b/lib/src/model/game/game_repository.dart @@ -5,6 +5,7 @@ import 'package:lichess_mobile/src/model/common/http.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/common/perf.dart'; import 'package:lichess_mobile/src/model/game/archived_game.dart'; +import 'package:lichess_mobile/src/model/game/game_filter.dart'; import 'package:lichess_mobile/src/model/game/playable_game.dart'; class GameRepository { @@ -40,12 +41,12 @@ class GameRepository { UserId userId, { int max = 20, DateTime? until, - Perf? perfType, + GameFilterState filter = const GameFilterState(), }) { - assert( - ![Perf.fromPosition, Perf.puzzle, Perf.storm, Perf.streak] - .contains(perfType), - ); + assert(!filter.perfs.contains(Perf.fromPosition)); + assert(!filter.perfs.contains(Perf.puzzle)); + assert(!filter.perfs.contains(Perf.storm)); + assert(!filter.perfs.contains(Perf.streak)); return client .readNdJsonList( Uri( @@ -54,11 +55,13 @@ class GameRepository { 'max': max.toString(), if (until != null) 'until': until.millisecondsSinceEpoch.toString(), - if (perfType != null) 'perfType': perfType.name, 'moves': 'false', 'lastFen': 'true', 'accuracy': 'true', 'opening': 'true', + if (filter.perfs.isNotEmpty) + 'perfType': filter.perfs.map((perf) => perf.name).join(','), + if (filter.side != null) 'color': filter.side!.name, }, ), headers: {'Accept': 'application/x-ndjson'}, diff --git a/lib/src/model/game/game_storage.dart b/lib/src/model/game/game_storage.dart index 7d957682d0..b28ff6728b 100644 --- a/lib/src/model/game/game_storage.dart +++ b/lib/src/model/game/game_storage.dart @@ -4,6 +4,7 @@ import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:lichess_mobile/src/db/database.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_filter.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:sqflite/sqflite.dart'; @@ -44,6 +45,7 @@ class GameStorage { UserId? userId, DateTime? until, int max = 20, + GameFilterState filter = const GameFilterState(), }) async { final list = await _db.query( kGameStorageTable, @@ -59,20 +61,27 @@ class GameStorage { limit: max, ); - return list.map((e) { - final raw = e['data']! as String; - final json = jsonDecode(raw); - if (json is! Map) { - throw const FormatException( - '[GameStorage] cannot fetch game: expected an object', - ); - } - return ( - userId: UserId(e['userId']! as String), - lastModified: DateTime.parse(e['lastModified']! as String), - game: ArchivedGame.fromJson(json), - ); - }).toIList(); + return list + .map((e) { + final raw = e['data']! as String; + final json = jsonDecode(raw); + if (json is! Map) { + throw const FormatException( + '[GameStorage] cannot fetch game: expected an object', + ); + } + return ( + userId: UserId(e['userId']! as String), + lastModified: DateTime.parse(e['lastModified']! as String), + game: ArchivedGame.fromJson(json), + ); + }) + .where( + (e) => + filter.perfs.isEmpty || filter.perfs.contains(e.game.meta.perf), + ) + .where((e) => filter.side == null || filter.side == e.game.youAre) + .toIList(); } Future fetch({ diff --git a/lib/src/view/user/game_history_screen.dart b/lib/src/view/user/game_history_screen.dart index 6586a343ef..a345b9e7fd 100644 --- a/lib/src/view/user/game_history_screen.dart +++ b/lib/src/view/user/game_history_screen.dart @@ -1,66 +1,142 @@ +import 'package:dartchess/dartchess.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.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/perf.dart'; +import 'package:lichess_mobile/src/model/game/game_filter.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/model/user/user_repository_providers.dart'; +import 'package:lichess_mobile/src/styles/lichess_icons.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/view/game/game_list_tile.dart'; +import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; -import 'package:lichess_mobile/src/widgets/platform.dart'; class GameHistoryScreen extends ConsumerWidget { const GameHistoryScreen({ required this.user, required this.isOnline, - this.perf, - this.games, + this.gameFilter = const GameFilterState(), super.key, }); final LightUser? user; final bool isOnline; - final Perf? perf; - final int? games; + final GameFilterState gameFilter; @override Widget build(BuildContext context, WidgetRef ref) { - return ConsumerPlatformWidget( - ref: ref, - androidBuilder: _buildAndroid, - iosBuilder: _buildIos, + final session = ref.read(authSessionProvider); + final username = user?.name ?? session?.user.name; + final filtersInUse = ref.watch( + gameFilterProvider(filter: gameFilter).select( + (state) => state.count, + ), ); - } - - Widget _buildIos(BuildContext context, WidgetRef ref) { final nbGamesAsync = ref.watch( userNumberOfGamesProvider(user, isOnline: isOnline), ); + final title = filtersInUse == 0 + ? nbGamesAsync.when( + data: (nbGames) => Text(context.l10n.nbGames(nbGames)), + loading: () => const ButtonLoadingIndicator(), + error: (e, s) => Text(context.l10n.mobileAllGames), + ) + : Text( + username != null + ? '$username ${context.l10n.games.toLowerCase()}' + : context.l10n.games, + ); + final filterBtn = Stack( + alignment: Alignment.center, + children: [ + IconButton( + icon: const Icon(Icons.tune), + tooltip: context.l10n.filterGames, + onPressed: () => showAdaptiveBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => _FilterGames( + filter: ref.read(gameFilterProvider(filter: gameFilter)), + user: user, + ), + ).then((value) { + if (value != null) { + ref + .read(gameFilterProvider(filter: gameFilter).notifier) + .setFilter(value); + } + }), + ), + if (filtersInUse > 0) + Positioned( + top: 2.0, + right: 2.0, + child: Stack( + alignment: Alignment.center, + children: [ + Icon( + Icons.brightness_1, + size: 20.0, + color: Theme.of(context).colorScheme.secondary, + ), + FittedBox( + fit: BoxFit.contain, + child: DefaultTextStyle.merge( + style: TextStyle( + color: Theme.of(context).colorScheme.onSecondary, + fontWeight: FontWeight.bold, + ), + child: Text(filtersInUse.toString()), + ), + ), + ], + ), + ), + ], + ); + + switch (Theme.of(context).platform) { + case TargetPlatform.android: + return _buildAndroid(context, ref, title: title, filterBtn: filterBtn); + case TargetPlatform.iOS: + return _buildIos(context, ref, title: title, filterBtn: filterBtn); + default: + assert(false, 'Unexpected platform ${Theme.of(context).platform}'); + return const SizedBox.shrink(); + } + } + + Widget _buildIos( + BuildContext context, + WidgetRef ref, { + required Widget title, + required Widget filterBtn, + }) { return CupertinoPageScaffold( navigationBar: CupertinoNavigationBar( - middle: nbGamesAsync.when( - data: (nbGames) => Text(context.l10n.nbGames(games ?? nbGames)), - loading: () => const CupertinoActivityIndicator(), - error: (e, s) => Text(context.l10n.mobileAllGames), - ), + middle: title, + trailing: filterBtn, ), - child: _Body(user: user, isOnline: isOnline, perf: perf), + child: _Body(user: user, isOnline: isOnline, gameFilter: gameFilter), ); } - Widget _buildAndroid(BuildContext context, WidgetRef ref) { - final nbGamesAsync = ref.watch( - userNumberOfGamesProvider(user, isOnline: isOnline), - ); + Widget _buildAndroid( + BuildContext context, + WidgetRef ref, { + required Widget title, + required Widget filterBtn, + }) { return Scaffold( appBar: AppBar( - title: nbGamesAsync.when( - data: (nbGames) => Text(context.l10n.nbGames(games ?? nbGames)), - loading: () => const ButtonLoadingIndicator(), - error: (e, s) => Text(context.l10n.mobileAllGames), - ), + title: title, + actions: [filterBtn], ), - body: _Body(user: user, isOnline: isOnline, perf: perf), + body: _Body(user: user, isOnline: isOnline, gameFilter: gameFilter), ); } } @@ -69,12 +145,12 @@ class _Body extends ConsumerStatefulWidget { const _Body({ required this.user, required this.isOnline, - required this.perf, + required this.gameFilter, }); final LightUser? user; final bool isOnline; - final Perf? perf; + final GameFilterState gameFilter; @override ConsumerState<_Body> createState() => _BodyState(); @@ -103,7 +179,7 @@ class _BodyState extends ConsumerState<_Body> { userGameHistoryProvider( widget.user?.id, isOnline: widget.isOnline, - perf: widget.perf, + filter: ref.read(gameFilterProvider(filter: widget.gameFilter)), ), ); @@ -120,7 +196,7 @@ class _BodyState extends ConsumerState<_Body> { userGameHistoryProvider( widget.user?.id, isOnline: widget.isOnline, - perf: widget.perf, + filter: ref.read(gameFilterProvider(filter: widget.gameFilter)), ).notifier, ) .getNext(); @@ -130,11 +206,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, - perf: widget.perf, + filter: gameFilterState, ), ); @@ -143,46 +221,61 @@ class _BodyState extends ConsumerState<_Body> { final list = state.gameList; return SafeArea( - child: ListView.separated( - controller: _scrollController, - separatorBuilder: (context, index) => Theme.of(context).platform == - TargetPlatform.iOS - ? const PlatformDivider(height: 1, cupertinoHasLeading: true) - : const PlatformDivider(height: 1, color: Colors.transparent), - itemCount: list.length + (state.isLoading ? 1 : 0), - itemBuilder: (context, index) { - if (state.isLoading && index == list.length) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 32.0), - child: CenterLoadingIndicator(), - ); - } else if (state.hasError && - state.hasMore && - index == list.length) { - // TODO: add a retry button - return const Padding( + child: list.isEmpty + ? const Padding( padding: EdgeInsets.symmetric(vertical: 32.0), child: Center( child: Text( - 'Could not load more games', + 'No games found', ), ), - ); - } - - return 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, - ); - }, - ), + ) + : ListView.separated( + controller: _scrollController, + separatorBuilder: (context, index) => + Theme.of(context).platform == TargetPlatform.iOS + ? const PlatformDivider( + height: 1, + cupertinoHasLeading: true, + ) + : const PlatformDivider( + height: 1, + color: Colors.transparent, + ), + itemCount: list.length + (state.isLoading ? 1 : 0), + itemBuilder: (context, index) { + if (state.isLoading && index == list.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 32.0), + child: CenterLoadingIndicator(), + ); + } else if (state.hasError && + state.hasMore && + index == list.length) { + // TODO: add a retry button + return const Padding( + padding: EdgeInsets.symmetric(vertical: 32.0), + child: Center( + child: Text( + 'Could not load more games', + ), + ), + ); + } + + return 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, + ); + }, + ), ); }, error: (e, s) { @@ -195,3 +288,203 @@ class _BodyState extends ConsumerState<_Body> { ); } } + +class _FilterGames extends ConsumerStatefulWidget { + const _FilterGames({ + required this.filter, + required this.user, + }); + + final GameFilterState filter; + final LightUser? user; + + @override + ConsumerState<_FilterGames> createState() => _FilterGamesState(); +} + +class _FilterGamesState extends ConsumerState<_FilterGames> { + late GameFilterState filter; + + @override + void initState() { + super.initState(); + filter = widget.filter; + } + + @override + Widget build(BuildContext context) { + const gamePerfs = [ + Perf.ultraBullet, + Perf.bullet, + Perf.blitz, + Perf.rapid, + Perf.classical, + Perf.correspondence, + Perf.chess960, + Perf.antichess, + Perf.kingOfTheHill, + Perf.threeCheck, + Perf.atomic, + Perf.horde, + Perf.racingKings, + Perf.crazyhouse, + ]; + const filterGroupSpace = SizedBox(height: 10.0); + + final session = ref.read(authSessionProvider); + final userId = widget.user?.id ?? session?.user.id; + + List availablePerfs(User user) { + final perfs = gamePerfs.where((perf) { + final p = user.perfs[perf]; + return p != null && p.numberOfGamesOrRuns > 0; + }).toList(growable: false); + perfs.sort( + (p1, p2) => user.perfs[p2]!.numberOfGamesOrRuns + .compareTo(user.perfs[p1]!.numberOfGamesOrRuns), + ); + return perfs; + } + + Widget perfFilter(List choices) => _Filter( + filterName: context.l10n.variant, + icon: const Icon(LichessIcons.classical), + filterType: FilterType.multipleChoice, + choices: choices, + choiceSelected: (choice) => filter.perfs.contains(choice), + choiceLabel: (t) => t.title, + onSelected: (value, selected) => setState( + () { + filter = filter.copyWith( + perfs: selected + ? filter.perfs.add(value) + : filter.perfs.remove(value), + ); + }, + ), + ); + + return Padding( + padding: const EdgeInsets.all(16), + child: DraggableScrollableSheet( + initialChildSize: .7, + expand: false, + snap: true, + snapSizes: const [.7], + builder: (context, scrollController) => ListView( + controller: scrollController, + children: [ + if (userId != null) + ref.watch(userProvider(id: userId)).when( + data: (user) => perfFilter(availablePerfs(user)), + loading: () => const Center( + child: CircularProgressIndicator.adaptive(), + ), + error: (_, __) => perfFilter(gamePerfs), + ) + else + perfFilter(gamePerfs), + const Divider(), + filterGroupSpace, + _Filter( + filterName: context.l10n.side, + icon: const Icon(LichessIcons.chess_pawn), + filterType: FilterType.singleChoice, + choices: Side.values, + choiceSelected: (choice) => filter.side == choice, + choiceLabel: (t) => switch (t) { + Side.white => context.l10n.white, + Side.black => context.l10n.black, + }, + onSelected: (value, selected) => setState( + () { + filter = filter.copyWith(side: selected ? value : null); + }, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(filter), + child: const Text('OK'), + ), + ], + ), + ], + ), + ), + ); + } +} + +enum FilterType { + singleChoice, + multipleChoice, +} + +class _Filter extends StatelessWidget { + const _Filter({ + required this.filterName, + required this.icon, + required this.filterType, + required this.choices, + required this.choiceSelected, + required this.choiceLabel, + required this.onSelected, + }); + + final String filterName; + final Icon icon; + final FilterType filterType; + final Iterable choices; + final bool Function(T choice) choiceSelected; + final String Function(T choice) choiceLabel; + final void Function(T value, bool selected) onSelected; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + margin: const EdgeInsets.only(right: 10), + child: icon, + ), + Text(filterName, style: const TextStyle(fontSize: 18)), + ], + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: Wrap( + spacing: 8.0, + children: choices + .map( + (choice) => switch (filterType) { + FilterType.singleChoice => ChoiceChip( + label: Text(choiceLabel(choice)), + selected: choiceSelected(choice), + onSelected: (value) => onSelected(choice, value), + ), + FilterType.multipleChoice => FilterChip( + label: Text(choiceLabel(choice)), + selected: choiceSelected(choice), + onSelected: (value) => onSelected(choice, value), + ), + }, + ) + .toList(growable: false), + ), + ), + ], + ); + } +} diff --git a/lib/src/view/user/perf_stats_screen.dart b/lib/src/view/user/perf_stats_screen.dart index cad1bf787e..8b447d669c 100644 --- a/lib/src/view/user/perf_stats_screen.dart +++ b/lib/src/view/user/perf_stats_screen.dart @@ -13,6 +13,7 @@ import 'package:lichess_mobile/src/constants.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/model/common/http.dart'; import 'package:lichess_mobile/src/model/common/perf.dart'; +import 'package:lichess_mobile/src/model/game/game_filter.dart'; import 'package:lichess_mobile/src/model/game/game_repository.dart'; import 'package:lichess_mobile/src/model/user/user.dart'; import 'package:lichess_mobile/src/model/user/user_repository_providers.dart'; @@ -282,8 +283,7 @@ class _Body extends ConsumerWidget { builder: (context) => GameHistoryScreen( user: user.lightUser, isOnline: true, - perf: perf, - games: data.totalGames, + gameFilter: GameFilterState(perfs: ISet({perf})), ), ); }, diff --git a/test/model/game/mock_game_storage.dart b/test/model/game/mock_game_storage.dart index 0ed719063f..c2fd45f075 100644 --- a/test/model/game/mock_game_storage.dart +++ b/test/model/game/mock_game_storage.dart @@ -1,6 +1,7 @@ import 'package:fast_immutable_collections/fast_immutable_collections.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_filter.dart'; import 'package:lichess_mobile/src/model/game/game_storage.dart'; class MockGameStorage implements GameStorage { @@ -19,6 +20,7 @@ class MockGameStorage implements GameStorage { UserId? userId, DateTime? until, int max = 10, + GameFilterState filter = const GameFilterState(), }) { return Future.value(IList()); }