Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: interactive studies #1128

Merged
merged 2 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion lib/src/model/study/study_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ class StudyController extends _$StudyController implements PgnTreeNotifier {

Timer? _startEngineEvalTimer;

Timer? _opponentFirstMoveTimer;

@override
Future<StudyState> build(StudyId id) async {
final evaluationService = ref.watch(evaluationServiceProvider);
ref.onDispose(() {
_startEngineEvalTimer?.cancel();
_opponentFirstMoveTimer?.cancel();
_engineEvalDebounce.dispose();
evaluationService.disposeEngine();
});
Expand All @@ -62,6 +65,7 @@ class StudyController extends _$StudyController implements PgnTreeNotifier {
chapterId: chapterId,
),
);
_ensureItsOurTurnIfGamebook();
}

Future<StudyState> _fetchChapter(
Expand Down Expand Up @@ -95,6 +99,7 @@ class StudyController extends _$StudyController implements PgnTreeNotifier {
pov: orientation,
isLocalEvaluationAllowed: false,
isLocalEvaluationEnabled: false,
gamebookActive: false,
pgn: pgn,
);
}
Expand All @@ -119,6 +124,7 @@ class StudyController extends _$StudyController implements PgnTreeNotifier {
isLocalEvaluationAllowed:
study.chapter.features.computer && !study.chapter.gamebook,
isLocalEvaluationEnabled: prefs.enableLocalEvaluation,
gamebookActive: study.chapter.gamebook,
pgn: pgn,
);

Expand All @@ -144,6 +150,19 @@ class StudyController extends _$StudyController implements PgnTreeNotifier {
return studyState;
}

// The PGNs of some gamebook studies start with the opponent's turn, so trigger their move after a delay
void _ensureItsOurTurnIfGamebook() {
_opponentFirstMoveTimer?.cancel();
if (state.requireValue.isAtStartOfChapter &&
state.requireValue.gamebookActive &&
state.requireValue.gamebookComment == null &&
state.requireValue.position!.turn != state.requireValue.pov) {
_opponentFirstMoveTimer = Timer(const Duration(milliseconds: 750), () {
userNext();
});
}
}

EvaluationContext _evaluationContext(Variant variant) => EvaluationContext(
variant: variant,
initialPosition: _root.position,
Expand All @@ -168,6 +187,20 @@ class StudyController extends _$StudyController implements PgnTreeNotifier {
shouldForceShowVariation: true,
);
}

if (state.requireValue.gamebookActive) {
final comment = state.requireValue.gamebookComment;
// If there's no explicit comment why the move was good/bad, trigger next/previous move automatically
if (comment == null) {
Timer(const Duration(milliseconds: 750), () {
if (state.requireValue.isOnMainline) {
userNext();
} else {
userPrevious();
}
});
}
}
}

void onPromotionSelection(Role? role) {
Expand Down Expand Up @@ -237,6 +270,7 @@ class StudyController extends _$StudyController implements PgnTreeNotifier {
void reset() {
if (state.hasValue) {
_setPath(UciPath.empty);
_ensureItsOurTurnIfGamebook();
}
}

Expand Down Expand Up @@ -486,6 +520,14 @@ class StudyController extends _$StudyController implements PgnTreeNotifier {
}
}

enum GamebookState {
startLesson,
findTheMove,
correctMove,
incorrectMove,
lessonComplete
}

@freezed
class StudyState with _$StudyState {
const StudyState._();
Expand Down Expand Up @@ -519,6 +561,9 @@ class StudyState with _$StudyState {
/// Whether local evaluation is allowed for this study.
required bool isLocalEvaluationAllowed,

/// Whether we're currently in gamebook mode, where the user has to find the right moves.
required bool gamebookActive,

/// Whether the user has enabled local evaluation.
required bool isLocalEvaluationEnabled,

Expand Down Expand Up @@ -567,6 +612,37 @@ class StudyState with _$StudyState {

bool get isAtStartOfChapter => currentPath.isEmpty;

String? get gamebookComment {
final comment =
(currentNode.isRoot ? pgnRootComments : currentNode.comments)
?.map((comment) => comment.text)
.nonNulls
.join('\n');
return comment?.isNotEmpty == true
? comment
: gamebookState == GamebookState.incorrectMove
? gamebookDeviationComment
: null;
}

String? get gamebookHint => study.hints.getOrNull(currentPath.size);

String? get gamebookDeviationComment =>
study.deviationComments.getOrNull(currentPath.size);

GamebookState get gamebookState {
if (isAtEndOfChapter) return GamebookState.lessonComplete;

final bool myTurn = currentNode.position!.turn == pov;
if (isAtStartOfChapter && !myTurn) return GamebookState.startLesson;

return myTurn
? GamebookState.findTheMove
: isOnMainline
? GamebookState.correctMove
: GamebookState.incorrectMove;
}

bool get isIntroductoryChapter =>
currentNode.isRoot && currentNode.children.isEmpty;

Expand All @@ -576,7 +652,9 @@ class StudyState with _$StudyState {
.flattened,
);

PlayerSide get playerSide => PlayerSide.both;
PlayerSide get playerSide => gamebookActive
? (pov == Side.white ? PlayerSide.white : PlayerSide.black)
: PlayerSide.both;
}

@freezed
Expand Down
116 changes: 116 additions & 0 deletions lib/src/view/study/study_bottom_bar.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart';
import 'package:lichess_mobile/src/model/common/id.dart';
import 'package:lichess_mobile/src/model/study/study_controller.dart';
import 'package:lichess_mobile/src/utils/l10n_context.dart';
import 'package:lichess_mobile/src/utils/navigation.dart';
import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart';
import 'package:lichess_mobile/src/widgets/bottom_bar.dart';
import 'package:lichess_mobile/src/widgets/bottom_bar_button.dart';
import 'package:lichess_mobile/src/widgets/buttons.dart';
Expand All @@ -15,6 +18,25 @@ class StudyBottomBar extends ConsumerWidget {

final StudyId id;

@override
Widget build(BuildContext context, WidgetRef ref) {
final gamebook = ref.watch(
studyControllerProvider(id).select(
(s) => s.requireValue.gamebookActive,
),
);

return gamebook ? _GamebookBottomBar(id: id) : _AnalysisBottomBar(id: id);
}
}

class _AnalysisBottomBar extends ConsumerWidget {
const _AnalysisBottomBar({
required this.id,
});

final StudyId id;

@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(studyControllerProvider(id)).valueOrNull;
Expand Down Expand Up @@ -68,3 +90,97 @@ class StudyBottomBar extends ConsumerWidget {
);
}
}

class _GamebookBottomBar extends ConsumerWidget {
const _GamebookBottomBar({
required this.id,
});

final StudyId id;

@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(studyControllerProvider(id)).requireValue;

return BottomBar(
children: [
...switch (state.gamebookState) {
GamebookState.findTheMove => [
if (!state.currentNode.isRoot)
BottomBarButton(
onTap: ref.read(studyControllerProvider(id).notifier).reset,
icon: Icons.skip_previous,
label: 'Back',
showLabel: true,
),
BottomBarButton(
icon: Icons.help,
label: context.l10n.viewTheSolution,
showLabel: true,
onTap: ref
.read(studyControllerProvider(id).notifier)
.showGamebookSolution,
),
],
GamebookState.startLesson || GamebookState.correctMove => [
BottomBarButton(
onTap: ref.read(studyControllerProvider(id).notifier).userNext,
icon: Icons.play_arrow,
label: context.l10n.studyNext,
showLabel: true,
blink: state.gamebookComment != null &&
!state.isIntroductoryChapter,
),
],
GamebookState.incorrectMove => [
BottomBarButton(
onTap:
ref.read(studyControllerProvider(id).notifier).userPrevious,
label: context.l10n.retry,
showLabel: true,
icon: Icons.refresh,
blink: state.gamebookComment != null,
),
],
GamebookState.lessonComplete => [
if (!state.isIntroductoryChapter)
BottomBarButton(
onTap: ref.read(studyControllerProvider(id).notifier).reset,
icon: Icons.refresh,
label: context.l10n.studyPlayAgain,
showLabel: true,
),
BottomBarButton(
onTap: state.hasNextChapter
? ref.read(studyControllerProvider(id).notifier).nextChapter
: null,
icon: Icons.play_arrow,
label: context.l10n.studyNextChapter,
showLabel: true,
blink: !state.isIntroductoryChapter && state.hasNextChapter,
),
if (!state.isIntroductoryChapter)
BottomBarButton(
onTap: () => pushPlatformRoute(
context,
rootNavigator: true,
builder: (context) => AnalysisScreen(
pgnOrId: state.pgn,
options: AnalysisOptions(
isLocalEvaluationAllowed: true,
variant: state.variant,
orientation: state.pov,
id: standaloneAnalysisId,
),
),
),
icon: Icons.biotech,
label: context.l10n.analysis,
showLabel: true,
),
],
},
],
);
}
}
Loading