Skip to content

Commit

Permalink
feat: interactive studies
Browse files Browse the repository at this point in the history
  • Loading branch information
tom-anders committed Nov 12, 2024
1 parent ff98f16 commit 7ace51c
Show file tree
Hide file tree
Showing 5 changed files with 611 additions and 3 deletions.
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

0 comments on commit 7ace51c

Please sign in to comment.