diff --git a/lib/src/model/puzzle/puzzle_controller.dart b/lib/src/model/puzzle/puzzle_controller.dart index 84a13e618f..a90ec1a012 100644 --- a/lib/src/model/puzzle/puzzle_controller.dart +++ b/lib/src/model/puzzle/puzzle_controller.dart @@ -35,6 +35,7 @@ class PuzzleController extends _$PuzzleController { late Branch _gameTree; Timer? _firstMoveTimer; Timer? _viewSolutionTimer; + // on streak, we pre-load the next puzzle to avoid a delay when the user // completes the current one FutureResult? _nextPuzzleFuture; @@ -43,6 +44,7 @@ class PuzzleController extends _$PuzzleController { Future get _service => ref.read(puzzleServiceFactoryProvider)(queueLength: kPuzzleLocalQueueLength); + @override PuzzleState build(PuzzleContext initialContext, {PuzzleStreak? initialStreak}) { final evaluationService = ref.read(evaluationServiceProvider); @@ -100,6 +102,7 @@ class PuzzleController extends _$PuzzleController { currentPath: UciPath.empty, node: _gameTree.view, pov: _gameTree.nodeAt(initialPath).position.ply.isEven ? Side.white : Side.black, + hintShown: false, resultSent: false, isChangingDifficulty: false, isLocalEvalEnabled: false, @@ -119,6 +122,7 @@ class PuzzleController extends _$PuzzleController { _addMove(move); if (state.mode == PuzzleMode.play) { + state = state.copyWith(hintSquare: null); final nodeList = _gameTree.branchesOn(state.currentPath).toList(); final movesToTest = nodeList.sublist(state.initialPath.size).map((e) => e.sanMove); @@ -195,12 +199,18 @@ class PuzzleController extends _$PuzzleController { }); } + void toggleHint() { + if (state.hintSquare == null) { + state = state.copyWith(hintShown: true, hintSquare: state._nextSolutionMove.from); + } else { + state = state.copyWith(hintSquare: null); + } + } + void skipMove() { if (state.streak != null) { state = state.copyWith.streak!(hasSkipped: true); - final moveIndex = state.currentPath.size - state.initialPath.size; - final solution = state.puzzle.puzzle.solution[moveIndex]; - onUserMove(NormalMove.fromUci(solution)); + onUserMove(state._nextSolutionMove); } } @@ -311,7 +321,10 @@ class PuzzleController extends _$PuzzleController { solution: PuzzleSolution( id: state.puzzle.puzzle.id, win: state.result == PuzzleResult.win, - rated: initialContext.userId != null && ref.read(puzzlePreferencesProvider).rated, + rated: + initialContext.userId != null && + !state.hintShown && + ref.read(puzzlePreferencesProvider).rated, ), ); @@ -511,6 +524,8 @@ class PuzzleState with _$PuzzleState { NormalMove? promotionMove, PuzzleResult? result, PuzzleFeedback? feedback, + required bool hintShown, + Square? hintSquare, required bool isLocalEvalEnabled, required bool resultSent, required bool isChangingDifficulty, @@ -531,9 +546,15 @@ class PuzzleState with _$PuzzleState { EvaluationContext(variant: Variant.standard, initialPosition: initialPosition); Position get position => node.position; + String get fen => node.position.fen; + bool get canGoNext => mode == PuzzleMode.view && node.children.isNotEmpty; + bool get canGoBack => mode == PuzzleMode.view && currentPath.size > initialPath.size; + NormalMove get _nextSolutionMove => + NormalMove.fromUci(puzzle.puzzle.solution[currentPath.size - initialPath.size]); + IMap> get validMoves => makeLegalMoves(position); } diff --git a/lib/src/view/puzzle/puzzle_screen.dart b/lib/src/view/puzzle/puzzle_screen.dart index c32e04398b..ffc041b4e8 100644 --- a/lib/src/view/puzzle/puzzle_screen.dart +++ b/lib/src/view/puzzle/puzzle_screen.dart @@ -271,6 +271,8 @@ class _Body extends ConsumerWidget { dest: evalBestMove.to, ), ]) + : puzzleState.hintSquare != null + ? ISet([Circle(color: ShapeColor.green.color, orig: puzzleState.hintSquare!)]) : null, engineGauge: puzzleState.isEngineEnabled @@ -404,6 +406,22 @@ class _BottomBarState extends ConsumerState<_BottomBar> { !isDailyPuzzle && puzzleState.mode != PuzzleMode.view) _DifficultySelector(initialPuzzleContext: widget.initialPuzzleContext), + if (puzzleState.mode != PuzzleMode.view) + FutureBuilder( + future: _viewSolutionCompleter.future, + builder: (context, snapshot) { + return BottomBarButton( + icon: Icons.info, + label: context.l10n.getAHint, + showLabel: true, + highlighted: puzzleState.hintSquare != null, + onTap: + snapshot.connectionState == ConnectionState.done + ? () => ref.read(ctrlProvider.notifier).toggleHint() + : null, + ); + }, + ), if (puzzleState.mode != PuzzleMode.view) FutureBuilder( future: _viewSolutionCompleter.future, diff --git a/test/view/puzzle/puzzle_screen_test.dart b/test/view/puzzle/puzzle_screen_test.dart index eb67c10d3b..fc9386e9bd 100644 --- a/test/view/puzzle/puzzle_screen_test.dart +++ b/test/view/puzzle/puzzle_screen_test.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/testing.dart'; import 'package:lichess_mobile/src/model/account/account_preferences.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_batch_storage.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_difficulty.dart'; @@ -200,7 +201,7 @@ void main() { const orientation = Side.black; - // await for first move to be played + // wait for first move to be played await tester.pump(const Duration(milliseconds: 1500)); // in play mode we don't see the continue button @@ -497,9 +498,10 @@ void main() { // wait for the puzzle to load await tester.pump(const Duration(milliseconds: 200)); - // await for first move to be played and view solution button to appear + // wait for first move to be played and view solution button to appear await tester.pump(const Duration(seconds: 5)); + // view solution expect(find.byIcon(Icons.help), findsOneWidget); await tester.tap(find.byIcon(Icons.help)); @@ -507,14 +509,114 @@ void main() { await tester.pump(const Duration(seconds: 1)); // check puzzle was saved as isRatedPreference - final captured = verify(saveDBReq).captured; + final captured = verify(saveDBReq).captured.map((e) => e as PuzzleBatch).toList(); expect(captured.length, 2); - expect((captured[1] as PuzzleBatch).solved.length, 0); - expect((captured[0] as PuzzleBatch).solved.length, 1); - expect((captured[0] as PuzzleBatch).solved[0].rated, isRatedPreference); + expect(captured[0].solved, [ + PuzzleSolution(id: puzzle2.puzzle.id, win: false, rated: isRatedPreference), + ]); + expect(captured[1].solved.length, 0); }, ); } + + testWidgets('puzzle rating is saved correctly when hint is used', variant: kPlatformVariant, ( + WidgetTester tester, + ) async { + final mockClient = MockClient((request) { + if (request.url.path == '/api/puzzle/batch/mix') { + return mockResponse(batchOf1, 200); + } + return mockResponse('', 404); + }); + + final app = await makeTestProviderScopeApp( + tester, + home: PuzzleScreen( + angle: const PuzzleTheme(PuzzleThemeKey.mix), + puzzleId: puzzle2.puzzle.id, + ), + overrides: [ + lichessClientProvider.overrideWith((ref) { + return LichessClient(mockClient, ref); + }), + puzzleBatchStorageProvider.overrideWith((ref) => mockBatchStorage), + puzzleStorageProvider.overrideWith((ref) => mockHistoryStorage), + puzzlePreferencesProvider.overrideWith(() => MockPuzzlePreferences(true)), + ], + userSession: fakeSession, + ); + + Future saveDBReq() => mockBatchStorage.save( + userId: fakeSession.user.id, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + data: captureAny(named: 'data'), + ); + when(saveDBReq).thenAnswer((_) async {}); + when( + () => mockBatchStorage.fetch( + userId: fakeSession.user.id, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + ), + ).thenAnswer((_) async => batch); + + when(() => mockHistoryStorage.save(puzzle: any(named: 'puzzle'))).thenAnswer((_) async {}); + + await tester.pumpWidget(app); + + // wait for the puzzle to load + await tester.pump(const Duration(milliseconds: 200)); + + // wait for first move to be played and hint/view solution buttons to appear + await tester.pump(const Duration(seconds: 5)); + + // check possible hint widgets before hint is set + final customPaintWidgetsBefore = find.byType(CustomPaint).evaluate().toSet(); + + // get hint and wait for it to show + expect(find.byIcon(Icons.info), findsOneWidget); + await tester.tap(find.byIcon(Icons.info)); + await tester.pump(const Duration(milliseconds: 100)); + + // check hint is set + final customPaintWidgetsAfter = find.byType(CustomPaint).evaluate(); + expect(customPaintWidgetsAfter.length, customPaintWidgetsBefore.length + 1); + final diff = customPaintWidgetsAfter.toSet().difference(customPaintWidgetsBefore); + expect(diff.length, 1); + expect((diff.first.widget as CustomPaint).painter.runtimeType.toString(), '_CirclePainter'); + + // view solution + expect(find.byIcon(Icons.help), findsOneWidget); + await tester.tap(find.byIcon(Icons.help)); + + // wait for solution replay animation to finish + await tester.pump(const Duration(seconds: 1)); + + // go to next puzzle + expect(find.byIcon(CupertinoIcons.play_arrow_solid), findsOneWidget); + await tester.tap(find.byIcon(CupertinoIcons.play_arrow_solid)); + + // wait for the puzzle to load + await tester.pump(const Duration(milliseconds: 200)); + + // wait for first move to be played and hint/view solution buttons to appear + await tester.pump(const Duration(seconds: 5)); + + // view solution + expect(find.byIcon(Icons.help), findsOneWidget); + await tester.tap(find.byIcon(Icons.help)); + + // wait for solution replay animation to finish + await tester.pump(const Duration(seconds: 1)); + + // check first puzzle was unrated due to hint + // and following puzzles are still rated when not using the hint + final captured = verify(saveDBReq).captured.map((e) => e as PuzzleBatch).toList(); + expect(captured.length, 4); + expect(captured[0].solved, [PuzzleSolution(id: puzzle2.puzzle.id, win: false, rated: false)]); + expect(captured[1].solved.length, 0); + expect(captured[2].solved, [PuzzleSolution(id: puzzle.puzzle.id, win: false, rated: true)]); + expect(captured[3].solved.length, 0); + }); }); }