diff --git a/lib/src/model/puzzle/puzzle_providers.dart b/lib/src/model/puzzle/puzzle_providers.dart index 7923986f87..312a379b18 100644 --- a/lib/src/model/puzzle/puzzle_providers.dart +++ b/lib/src/model/puzzle/puzzle_providers.dart @@ -34,6 +34,19 @@ Future nextPuzzle(Ref ref, PuzzleAngle angle) async { return puzzleService.nextPuzzle(userId: session?.user.id, angle: angle); } +@riverpod +Future nextReplayPuzzle(Ref ref, int daysToReplay) async { + final session = ref.watch(authSessionProvider); + final puzzleService = await ref.read(puzzleServiceFactoryProvider)( + queueLength: kPuzzleLocalQueueLength, + ); + // useful for for preview puzzle list in puzzle tab (providers in a list can + // be invalidated multiple times when the user scrolls the list) + // ref.cacheFor(const Duration(minutes: 1)); + + return puzzleService.nextReplayPuzzle(userId: session?.user.id, daysToReplay: daysToReplay); +} + typedef InitialStreak = ({PuzzleStreak streak, Puzzle puzzle}); /// Fetches the active streak from the local storage if available, otherwise fetches it from the server. diff --git a/lib/src/model/puzzle/puzzle_service.dart b/lib/src/model/puzzle/puzzle_service.dart index 6ad6607cd2..8a987def76 100644 --- a/lib/src/model/puzzle/puzzle_service.dart +++ b/lib/src/model/puzzle/puzzle_service.dart @@ -1,6 +1,7 @@ import 'dart:math' show max; import 'package:async/async.dart'; +import 'package:collection/collection.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -60,6 +61,8 @@ class PuzzleContext with _$PuzzleContext { /// List of solved puzzle results if available. IList? rounds, + + @Default(false) bool replaying, }) = _PuzzleContext; } @@ -101,6 +104,52 @@ class PuzzleService { ); } + /// Loads the next puzzle to replay from history + Future nextReplayPuzzle({ + required UserId? userId, + required int daysToReplay, + }) async { + final checkPuzzlesAfter = DateTime.now().subtract(Duration(days: daysToReplay)); + PuzzleId? puzzleIdToReplay; + var lastPuzzleCheckedTime = DateTime.now(); + + while (puzzleIdToReplay == null && lastPuzzleCheckedTime.isAfter(checkPuzzlesAfter)) { + final puzzleHistory = await _ref.withClientCacheFor( + (client) => PuzzleRepository(client).puzzleActivity(20, before: lastPuzzleCheckedTime), + const Duration(hours: 3), + ); + if (puzzleHistory.isEmpty) { + break; + } + puzzleIdToReplay = + puzzleHistory + .firstWhereOrNull( + (entry) => + !entry.win && + entry.date.isAfter(DateTime.now().subtract(Duration(days: daysToReplay))), + ) + ?.id; + lastPuzzleCheckedTime = puzzleHistory.last.date; + } + + if (puzzleIdToReplay != null) { + return _ref + .withClient((client) => PuzzleRepository(client).fetch(puzzleIdToReplay!)) + .then( + (puzzle) => PuzzleContext( + puzzle: puzzle, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + userId: userId, + glicko: null, + rounds: null, + replaying: true, + ), + ); + } else { + return null; + } + } + /// Update puzzle queue with the solved puzzle, sync with server and returns /// the next puzzle with the glicko rating if available. /// diff --git a/lib/src/view/puzzle/dashboard_screen.dart b/lib/src/view/puzzle/dashboard_screen.dart index 40de636fb5..4d7cbef651 100644 --- a/lib/src/view/puzzle/dashboard_screen.dart +++ b/lib/src/view/puzzle/dashboard_screen.dart @@ -5,12 +5,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' show ClientException; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle.dart'; +import 'package:lichess_mobile/src/model/puzzle/puzzle_angle.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_providers.dart'; +import 'package:lichess_mobile/src/model/puzzle/puzzle_theme.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/utils/screen.dart'; import 'package:lichess_mobile/src/utils/string.dart'; +import 'package:lichess_mobile/src/view/puzzle/puzzle_screen.dart'; import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; @@ -46,62 +49,16 @@ class _Body extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return ListView(children: [PuzzleDashboardWidget()]); - } -} - -class PuzzleDashboardWidget extends ConsumerWidget { - @override - Widget build(BuildContext context, WidgetRef ref) { - final puzzleDashboard = ref.watch(puzzleDashboardProvider(ref.watch(daysProvider).days)); - final cardColor = Theme.of(context).platform == TargetPlatform.iOS ? Colors.transparent : null; + final days = ref.watch(daysProvider).days; + final puzzleDashboard = ref.watch(puzzleDashboardProvider(days)); return puzzleDashboard.when( data: (dashboard) { if (dashboard == null) { return const SizedBox.shrink(); } - final chartData = dashboard.themes.take(9).sortedBy((e) => e.theme.name).toList(); - return ListSection( - header: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.puzzlePuzzleDashboard), - Text( - context.l10n.puzzlePuzzleDashboardDescription, - style: Styles.subtitle.copyWith(color: textShade(context, Styles.subtitleOpacity)), - ), - ], - ), - // hack to make the divider take full length or row - cupertinoAdditionalDividerMargin: -14, - children: [ - StatCardRow([ - StatCard( - context.l10n.performance, - value: dashboard.global.performance.toString(), - backgroundColor: cardColor, - elevation: 0, - ), - StatCard( - context.l10n - .puzzleNbPlayed(dashboard.global.nb) - .replaceAll(RegExp(r'\d+'), '') - .trim() - .capitalize(), - value: dashboard.global.nb.toString().localizeNumbers(), - backgroundColor: cardColor, - elevation: 0, - ), - StatCard( - context.l10n.puzzleSolved.capitalize(), - value: '${((dashboard.global.firstWins / dashboard.global.nb) * 100).round()}%', - backgroundColor: cardColor, - elevation: 0, - ), - ]), - if (chartData.length >= 3) PuzzleChart(chartData), - ], + return ListView( + children: [PuzzleDashboardWidget(dashboard), ReplayButton(dashboard, days)], ); }, error: (e, s) { @@ -163,6 +120,59 @@ class PuzzleDashboardWidget extends ConsumerWidget { } } +class PuzzleDashboardWidget extends ConsumerWidget { + const PuzzleDashboardWidget(this.dashboard); + final PuzzleDashboard dashboard; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cardColor = Theme.of(context).platform == TargetPlatform.iOS ? Colors.transparent : null; + + final chartData = dashboard.themes.take(9).sortedBy((e) => e.theme.name).toList(); + return ListSection( + header: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.puzzlePuzzleDashboard), + Text( + context.l10n.puzzlePuzzleDashboardDescription, + style: Styles.subtitle.copyWith(color: textShade(context, Styles.subtitleOpacity)), + ), + ], + ), + // hack to make the divider take full length or row + cupertinoAdditionalDividerMargin: -14, + children: [ + StatCardRow([ + StatCard( + context.l10n.performance, + value: dashboard.global.performance.toString(), + backgroundColor: cardColor, + elevation: 0, + ), + StatCard( + context.l10n + .puzzleNbPlayed(dashboard.global.nb) + .replaceAll(RegExp(r'\d+'), '') + .trim() + .capitalize(), + value: dashboard.global.nb.toString().localizeNumbers(), + backgroundColor: cardColor, + elevation: 0, + ), + StatCard( + context.l10n.puzzleSolved.capitalize(), + value: '${((dashboard.global.firstWins / dashboard.global.nb) * 100).round()}%', + backgroundColor: cardColor, + elevation: 0, + ), + ]), + if (chartData.length >= 3) PuzzleChart(chartData), + ], + ); + } +} + class PuzzleChart extends StatelessWidget { const PuzzleChart(this.puzzleData); @@ -206,6 +216,49 @@ class PuzzleChart extends StatelessWidget { } } +class ReplayButton extends ConsumerWidget { + const ReplayButton(this.dashboard, this.daysToReplay); + + final PuzzleDashboard dashboard; + final int daysToReplay; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final puzzlesToReplay = + dashboard.global.nb - dashboard.global.firstWins - dashboard.global.replayWins; + final onPressed = + (puzzlesToReplay <= 0) + ? null + : () { + Navigator.of(context, rootNavigator: true).push( + PuzzleScreen.buildRoute( + context, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + puzzleId: null, + daysToReplay: daysToReplay, + ), + ); + }; + + return Row( + children: [ + const Spacer(), + FatButton( + semanticsLabel: context.l10n.puzzleNbToReplay(puzzlesToReplay), + onPressed: onPressed, + child: Row( + children: [ + const Icon(Icons.play_arrow), + Text(context.l10n.puzzleNbToReplay(puzzlesToReplay)), + ], + ), + ), + const Spacer(), + ], + ); + } +} + class DaysSelector extends ConsumerWidget { const DaysSelector(); diff --git a/lib/src/view/puzzle/puzzle_screen.dart b/lib/src/view/puzzle/puzzle_screen.dart index c32e04398b..630f46a9c9 100644 --- a/lib/src/view/puzzle/puzzle_screen.dart +++ b/lib/src/view/puzzle/puzzle_screen.dart @@ -50,17 +50,22 @@ class PuzzleScreen extends ConsumerStatefulWidget { /// Creates a new puzzle screen. /// /// If [puzzleId] is provided, the screen will load the puzzle with that id. Otherwise, it will load the next puzzle from the queue. - const PuzzleScreen({required this.angle, this.puzzleId, super.key}); + const PuzzleScreen({required this.angle, this.puzzleId, this.daysToReplay = 0, super.key}); final PuzzleAngle angle; final PuzzleId? puzzleId; + final int daysToReplay; static Route buildRoute( BuildContext context, { required PuzzleAngle angle, PuzzleId? puzzleId, + int daysToReplay = 0, }) { - return buildScreenRoute(context, screen: PuzzleScreen(angle: angle, puzzleId: puzzleId)); + return buildScreenRoute( + context, + screen: PuzzleScreen(angle: angle, puzzleId: puzzleId, daysToReplay: daysToReplay), + ); } @override @@ -100,6 +105,8 @@ class _PuzzleScreenState extends ConsumerState with RouteAware { body: widget.puzzleId != null ? _LoadPuzzleFromId(angle: widget.angle, id: widget.puzzleId!) + : widget.daysToReplay > 0 + ? _LoadNextReplayPuzzle(daysToReplay: widget.daysToReplay) : _LoadNextPuzzle(angle: widget.angle), ), ); @@ -163,6 +170,40 @@ class _LoadNextPuzzle extends ConsumerWidget { } } +class _LoadNextReplayPuzzle extends ConsumerWidget { + const _LoadNextReplayPuzzle({required this.daysToReplay}); + + final int daysToReplay; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final nextPuzzle = ref.watch(nextReplayPuzzleProvider(daysToReplay)); + + return nextPuzzle.when( + data: (data) { + if (data == null) { + return const Center( + child: BoardTable( + fen: kEmptyFen, + orientation: Side.white, + errorMessage: 'No more puzzles to replay.', + ), + ); + } else { + return _Body(initialPuzzleContext: data); + } + }, + loading: () => const Center(child: CircularProgressIndicator.adaptive()), + error: (e, s) { + debugPrint('SEVERE: [PuzzleScreen] could not load next replay puzzle; $e\n$s'); + return Center( + child: BoardTable(fen: kEmptyFen, orientation: Side.white, errorMessage: e.toString()), + ); + }, + ); + } +} + class _LoadPuzzleFromId extends ConsumerWidget { const _LoadPuzzleFromId({required this.angle, required this.id}); @@ -400,7 +441,8 @@ class _BottomBarState extends ConsumerState<_BottomBar> { return PlatformBottomBar( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - if (widget.initialPuzzleContext.userId != null && + if (!widget.initialPuzzleContext.replaying && + widget.initialPuzzleContext.userId != null && !isDailyPuzzle && puzzleState.mode != PuzzleMode.view) _DifficultySelector(initialPuzzleContext: widget.initialPuzzleContext),