Skip to content
75 changes: 58 additions & 17 deletions lib/src/model/puzzle/puzzle_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart';
import 'package:lichess_mobile/src/model/common/chess.dart';
import 'package:lichess_mobile/src/model/common/id.dart';
import 'package:lichess_mobile/src/model/common/node.dart';
import 'package:lichess_mobile/src/model/common/service/move_feedback.dart';
import 'package:lichess_mobile/src/model/common/service/sound_service.dart';
Expand Down Expand Up @@ -37,6 +38,7 @@ class PuzzleController extends Notifier<PuzzleState> {
late Branch _gameTree;
Timer? _firstMoveTimer;
Timer? _viewSolutionTimer;
IList<PuzzleId>? _replayRemaining;

Future<PuzzleService> get _service =>
ref.read(puzzleServiceFactoryProvider)(queueLength: kPuzzleLocalQueueLength);
Expand All @@ -54,6 +56,8 @@ class PuzzleController extends Notifier<PuzzleState> {
_updateUserRating();
}

_replayRemaining = initialContext.replayRemaining;

return _loadNewContext(initialContext);
}

Expand All @@ -73,6 +77,9 @@ class PuzzleController extends Notifier<PuzzleState> {
final root = Root.fromPgnMoves(context.puzzle.game.pgn);
_gameTree = root.nodeAt(root.mainlinePath.penultimate) as Branch;

// update puzzles that are remaining in replay
_replayRemaining = context.replayRemaining;

// play first move after 1 second
_firstMoveTimer = Timer(const Duration(seconds: 1), () {
_setPath(state.initialPath, firstMove: true);
Expand Down Expand Up @@ -215,6 +222,22 @@ class PuzzleController extends Notifier<PuzzleState> {
state = _loadNewContext(nextContext);
}

Future<PuzzleContext?> _nextReplayPuzzle() async {
final remaining = _replayRemaining;
if (remaining == null || remaining.isEmpty) return null;
try {
final nextPuzzle = await _repository.fetch(remaining.first);
return PuzzleContext(
puzzle: nextPuzzle,
angle: initialContext.angle,
userId: initialContext.userId,
replayRemaining: remaining.removeAt(0),
);
} catch (_) {
return null;
}
}

void _goToNextNode({bool isNavigating = false}) {
if (state.node.children.isEmpty) return;
_setPath(state.currentPath + state.node.children.first.id, isNavigating: isNavigating);
Expand Down Expand Up @@ -260,23 +283,41 @@ class PuzzleController extends Notifier<PuzzleState> {
.addAttempt(state.puzzle.puzzle.id, win: result == PuzzleResult.win);

final currentPuzzle = state.puzzle.puzzle;
final service = await _service;
final next =
currentPuzzle.id == initialContext.puzzle.puzzle.id && initialContext.casual == true
? await service.nextPuzzle(userId: initialContext.userId, angle: initialContext.angle)
: await service.solve(
userId: initialContext.userId,
angle: initialContext.angle,
puzzle: state.puzzle,
solution: PuzzleSolution(
id: state.puzzle.puzzle.id,
win: state.result == PuzzleResult.win,
rated:
initialContext.userId != null &&
!state.hintShown &&
ref.read(puzzlePreferencesProvider).rated,
),
);
final PuzzleContext? next;
if (initialContext.replayRemaining != null) {
final service = await _service;
await service.solve(
userId: initialContext.userId,
angle: initialContext.angle,
puzzle: state.puzzle,
solution: PuzzleSolution(
id: state.puzzle.puzzle.id,
win: state.result == PuzzleResult.win,
rated:
initialContext.userId != null &&
!state.hintShown &&
ref.read(puzzlePreferencesProvider).rated,
),
);
next = await _nextReplayPuzzle();
} else {
final service = await _service;
next = currentPuzzle.id == initialContext.puzzle.puzzle.id && initialContext.casual == true
? await service.nextPuzzle(userId: initialContext.userId, angle: initialContext.angle)
: await service.solve(
userId: initialContext.userId,
angle: initialContext.angle,
puzzle: state.puzzle,
solution: PuzzleSolution(
id: state.puzzle.puzzle.id,
win: state.result == PuzzleResult.win,
rated:
initialContext.userId != null &&
!state.hintShown &&
ref.read(puzzlePreferencesProvider).rated,
),
);
}

if (!ref.mounted) return;

Expand Down
20 changes: 20 additions & 0 deletions lib/src/model/puzzle/puzzle_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,26 @@ final nextPuzzleProvider = FutureProvider.autoDispose.family<PuzzleContext?, Puz
return puzzleService.nextPuzzle(userId: authUser?.user.id, angle: angle);
}, name: 'NextPuzzleProvider');

/// Fetches the list of puzzles to replay for the given number of [days] and [theme].
final puzzleReplayProvider = FutureProvider.autoDispose
.family<PuzzleContext?, ({int days, String theme})>((
Ref ref,
({int days, String theme}) params,
) async {
final authUser = ref.watch(authControllerProvider);
if (authUser == null) return null;
final repo = ref.read(puzzleRepositoryProvider);
final remaining = await repo.puzzleReplay(params.days, params.theme);
if (remaining.isEmpty) return null;
final puzzle = await repo.fetch(remaining.first);
return PuzzleContext(
puzzle: puzzle,
angle: const PuzzleTheme(PuzzleThemeKey.mix),
userId: authUser.user.id,
replayRemaining: remaining.removeAt(0),
);
}, name: 'PuzzleReplayProvider');

/// Fetches a storm of puzzles.
final stormProvider = FutureProvider.autoDispose<PuzzleStormResponse>((Ref ref) {
return ref.read(puzzleRepositoryProvider).storm();
Expand Down
15 changes: 15 additions & 0 deletions lib/src/model/puzzle/puzzle_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,13 @@ class PuzzleRepository {
);
}

Future<IList<PuzzleId>> puzzleReplay(int days, String theme) {
return client.readJson(
Uri(path: '/api/puzzle/replay/$days/$theme'),
mapper: _puzzleReplayFromJson,
);
}

PuzzleBatchResponse _decodeBatchResponse(Map<String, dynamic> json) {
final puzzles = json['puzzles'];
if (puzzles is! List<dynamic>) {
Expand Down Expand Up @@ -242,6 +249,14 @@ sealed class PuzzleStormResponse with _$PuzzleStormResponse {

// --

IList<PuzzleId> _puzzleReplayFromJson(Map<String, dynamic> json) {
return pick(
json,
'replay',
'remaining',
).asListOrThrow((p) => PuzzleId(p.asStringOrThrow())).toIList();
}

PuzzleHistoryEntry _puzzleActivityFromJson(Map<String, dynamic> json) =>
_historyPuzzleFromPick(pick(json).required());

Expand Down
3 changes: 3 additions & 0 deletions lib/src/model/puzzle/puzzle_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ sealed class PuzzleContext with _$PuzzleContext {
/// If true, the result won't be recorded on the server for this puzzle.
bool? casual,
bool? isPuzzleStreak,

/// Remaining puzzle IDs to replay after the current one.
IList<PuzzleId>? replayRemaining,
}) = _PuzzleContext;
}

Expand Down
30 changes: 28 additions & 2 deletions lib/src/view/puzzle/dashboard_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:lichess_mobile/src/model/auth/auth_controller.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';
Expand Down Expand Up @@ -56,13 +57,15 @@ class PuzzleDashboardWidget extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final puzzleDashboard = ref.watch(puzzleDashboardProvider(ref.watch(daysProvider).days));

final days = ref.watch(daysProvider).days;

return puzzleDashboard.when(
data: (dashboard) {
if (dashboard == null) return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_ChartSection(dashboard: dashboard, showDaysSelector: showDaysSelector),
_ChartSection(dashboard: dashboard, showDaysSelector: showDaysSelector, days: days),
_PerformanceSection(dashboard: dashboard, metric: Metric.improvementArea),
_PerformanceSection(dashboard: dashboard, metric: Metric.strength),
],
Expand Down Expand Up @@ -128,14 +131,21 @@ class PuzzleDashboardWidget extends ConsumerWidget {
}

class _ChartSection extends StatelessWidget {
const _ChartSection({required this.dashboard, required this.showDaysSelector});
const _ChartSection({
required this.dashboard,
required this.showDaysSelector,
required this.days,
});

final PuzzleDashboard dashboard;
final bool showDaysSelector;
final int days;

@override
Widget build(BuildContext context) {
final chartData = dashboard.themes.take(9).sortedBy((e) => e.theme.name).toList();
final puzzlesToReplay =
dashboard.global.nb - dashboard.global.firstWins - dashboard.global.replayWins;
return ListSection(
header: Column(
crossAxisAlignment: CrossAxisAlignment.start,
Expand Down Expand Up @@ -179,6 +189,22 @@ class _ChartSection extends StatelessWidget {
),
]),
if (chartData.length >= 3) PuzzleChart(chartData),
if (puzzlesToReplay > 0)
Center(
child: TextButton.icon(
onPressed: () {
Navigator.of(context, rootNavigator: true).push(
PuzzleScreen.buildRoute(
context,
angle: const PuzzleTheme(PuzzleThemeKey.mix),
replayDays: days,
),
);
},
icon: const Icon(Icons.play_arrow),
label: Text(context.l10n.puzzleNbToReplay(puzzlesToReplay)),
),
),
],
);
}
Expand Down
59 changes: 58 additions & 1 deletion lib/src/view/puzzle/puzzle_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ class PuzzleScreen extends ConsumerStatefulWidget {
this.puzzle,
this.puzzleId,
this.openCasual = false,
this.replayDays,
super.key,
});

Expand All @@ -70,12 +71,16 @@ class PuzzleScreen extends ConsumerStatefulWidget {
/// If true, the result won't be recorded on the server for the provider [puzzleId].
final bool openCasual;

/// If set, load puzzles to replay from the given number of days.
final int? replayDays;

static Route<dynamic> buildRoute(
BuildContext context, {
required PuzzleAngle angle,
PuzzleId? puzzleId,
Puzzle? puzzle,
bool openCasual = false,
int? replayDays,
}) {
return buildScreenRoute(
context,
Expand All @@ -84,6 +89,7 @@ class PuzzleScreen extends ConsumerStatefulWidget {
puzzleId: puzzleId,
puzzle: puzzle,
openCasual: openCasual,
replayDays: replayDays,
),
);
}
Expand Down Expand Up @@ -120,6 +126,9 @@ class _PuzzleScreenState extends ConsumerState<PuzzleScreen> with RouteAware {

@override
Widget build(BuildContext context) {
if (widget.replayDays != null) {
return _LoadReplayPuzzle(boardKey: _boardKey, days: widget.replayDays!);
}
return widget.puzzle != null
? _LoadPuzzleFromPuzzle(boardKey: _boardKey, angle: widget.angle, puzzle: widget.puzzle!)
: widget.puzzleId != null
Expand Down Expand Up @@ -212,6 +221,53 @@ class _LoadNextPuzzle extends ConsumerWidget {
}
}

class _LoadReplayPuzzle extends ConsumerWidget {
const _LoadReplayPuzzle({required this.boardKey, required this.days});

final GlobalKey boardKey;
final int days;

static const _angle = PuzzleTheme(PuzzleThemeKey.mix);

@override
Widget build(BuildContext context, WidgetRef ref) {
final replayPuzzle = ref.watch(puzzleReplayProvider((days: days, theme: _angle.key)));

switch (replayPuzzle) {
case AsyncData(:final value):
if (value == null) {
return const _PuzzleScaffold(
angle: _angle,
initialPuzzleContext: null,
body: PuzzleErrorBoardWidget(errorMessage: 'No more puzzles to replay.'),
);
} else {
return _PuzzleScaffold(
angle: _angle,
initialPuzzleContext: value,
body: _Body(boardKey: boardKey, initialPuzzleContext: value),
);
}
case AsyncError(:final error, :final stackTrace):
debugPrint('SEVERE: [PuzzleScreen] could not load replay puzzles; $error\n$stackTrace');
final errorMsg = error.toString().contains('404')
? 'No puzzles to replay.'
: error.toString();
return _PuzzleScaffold(
angle: _angle,
initialPuzzleContext: null,
body: PuzzleErrorBoardWidget(errorMessage: errorMsg),
);
case _:
return const _PuzzleScaffold(
angle: _angle,
initialPuzzleContext: null,
body: Center(child: CircularProgressIndicator.adaptive()),
);
}
}
}

class _LoadPuzzleFromPuzzle extends ConsumerWidget {
const _LoadPuzzleFromPuzzle({required this.boardKey, required this.angle, required this.puzzle});

Expand Down Expand Up @@ -859,6 +915,7 @@ class _PuzzleSettingsBottomSheet extends ConsumerWidget {
materialFilledCard: true,
children: [
if (initialPuzzleContext.userId != null &&
initialPuzzleContext.replayRemaining == null &&
puzzleState.mode != PuzzleMode.view &&
isOnline)
StatefulBuilder(
Expand Down Expand Up @@ -904,7 +961,7 @@ class _PuzzleSettingsBottomSheet extends ConsumerWidget {
ref.read(puzzlePreferencesProvider.notifier).setAutoNext(value);
},
),
if (authUser != null)
if (authUser != null && initialPuzzleContext.replayRemaining == null)
SwitchSettingTile(
title: Text(context.l10n.rated),
// ignore: avoid_bool_literals_in_conditional_expressions
Expand Down
Loading