From 4e37663885c2cae17c64fdbf800bfd6aae4790f8 Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Sat, 22 Jun 2024 08:42:49 -0300 Subject: [PATCH] feat(new reviewer): auto advance This differs from the legacy reviewer in four things: 1. It implements `Show reminder` as an action (it is a tooltip in Anki, so I made it a snackbar here) 2. It doesn't follow the global switch in settings, as Anki desktop doesn't have one and users complained about finding the global setting unexpected 3. There is now a `Question action` setting 4. The legacy reviewer has an issue where the same configuration is carried through all cards in the review session. So if a card with a different deck config shows up, the config won't be respected. This is fixed in the new reviewer --- .../ui/windows/reviewer/ReviewerFragment.kt | 7 ++ .../ui/windows/reviewer/ReviewerViewModel.kt | 25 +++++ .../reviewer/autoadvance/AnswerAction.kt | 33 +++++++ .../reviewer/autoadvance/AutoAdvance.kt | 97 +++++++++++++++++++ .../autoadvance/AutoAdvanceSettings.kt | 49 ++++++++++ .../reviewer/autoadvance/QuestionAction.kt | 30 ++++++ 6 files changed, 241 insertions(+) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AnswerAction.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvance.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvanceSettings.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/QuestionAction.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt index e24b9b57d7bd..df419bd693ee 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt @@ -70,6 +70,13 @@ class ReviewerFragment : anchorView = this@ReviewerFragment.view?.findViewById(R.id.buttons_area) } + override fun onStop() { + super.onStop() + if (!requireActivity().isChangingConfigurations) { + viewModel.stopAutoAdvance() + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt index 35f823cca03f..e58fcc511b0a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt @@ -38,6 +38,7 @@ import com.ichi2.anki.servicelayer.MARKED_TAG import com.ichi2.anki.servicelayer.NoteService import com.ichi2.anki.servicelayer.isBuryNoteAvailable import com.ichi2.anki.servicelayer.isSuspendNoteAvailable +import com.ichi2.anki.ui.windows.reviewer.autoadvance.AutoAdvance import com.ichi2.libanki.ChangeManager import com.ichi2.libanki.hasTag import com.ichi2.libanki.note @@ -78,6 +79,8 @@ class ReviewerViewModel(cardMediaPlayer: CardMediaPlayer) : private val stateMutationKey = TimeManager.time.intTimeMS().toString() val statesMutationEval = MutableSharedFlow() + private val autoAdvance = AutoAdvance(this) + /** * A flag that determines if the SchedulingStates in CurrentQueueState are * safe to persist in the database when answering a card. This is used to @@ -98,6 +101,17 @@ class ReviewerViewModel(cardMediaPlayer: CardMediaPlayer) : launchCatchingIO { updateUndoAndRedoLabels() } + cardMediaPlayer.setOnSoundGroupCompletedListener { + launchCatchingIO { + if (!autoAdvance.shouldWaitForAudio()) return@launchCatchingIO + + if (showingAnswer.value) { + autoAdvance.onShowAnswer() + } else { + autoAdvance.onShowQuestion() + } + } + } } /* ********************************************************************************************* @@ -124,6 +138,9 @@ class ReviewerViewModel(cardMediaPlayer: CardMediaPlayer) : } showAnswerInternal() loadAndPlaySounds(CardSide.ANSWER) + if (!autoAdvance.shouldWaitForAudio()) { + autoAdvance.onShowAnswer() + } // else wait for onSoundGroupCompleted } } @@ -266,6 +283,10 @@ class ReviewerViewModel(cardMediaPlayer: CardMediaPlayer) : } } + fun stopAutoAdvance() { + autoAdvance.cancelQuestionAndAnswerActionJobs() + } + /* ********************************************************************************************* *************************************** Internal methods *************************************** ********************************************************************************************* */ @@ -285,6 +306,9 @@ class ReviewerViewModel(cardMediaPlayer: CardMediaPlayer) : override suspend fun showQuestion() { super.showQuestion() runStateMutationHook() + if (!autoAdvance.shouldWaitForAudio()) { + autoAdvance.onShowQuestion() + } // else run in onSoundGroupCompleted } private suspend fun runStateMutationHook() { @@ -356,6 +380,7 @@ class ReviewerViewModel(cardMediaPlayer: CardMediaPlayer) : val card = state.topCard currentCard = CompletableDeferred(card) + autoAdvance.onCardChange(card) showQuestion() loadAndPlaySounds(CardSide.QUESTION) updateMarkedStatus() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AnswerAction.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AnswerAction.kt new file mode 100644 index 000000000000..cc0d53fc76d1 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AnswerAction.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 Brayan Oliveira + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package com.ichi2.anki.ui.windows.reviewer.autoadvance + +import com.ichi2.libanki.DeckConfig + +enum class AnswerAction(val configValue: Int) { + BURY_CARD(0), + ANSWER_AGAIN(1), + ANSWER_GOOD(2), + ANSWER_HARD(3), + SHOW_REMINDER(4); + + companion object { + fun from(config: DeckConfig): AnswerAction { + val value = config.optInt("answerAction") + return entries.firstOrNull { it.configValue == value } ?: BURY_CARD + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvance.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvance.kt new file mode 100644 index 000000000000..4639ceaca7c0 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvance.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2024 Brayan Oliveira + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package com.ichi2.anki.ui.windows.reviewer.autoadvance + +import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.asyncIO +import com.ichi2.anki.launchCatchingIO +import com.ichi2.anki.ui.windows.reviewer.ReviewerViewModel +import com.ichi2.libanki.Card +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay + +/** + * Implementation of the `Auto Advance` deck options + * + * An user can set a timer to automatically trigger an action + * after showing a question ([QuestionAction]) or an answer ([AnswerAction]). + * + * If a timer is set to 0, nothing happens. + * + * @see AutoAdvanceSettings + */ +class AutoAdvance(val viewModel: ReviewerViewModel) { + private var questionActionJob: Job? = null + private var answerActionJob: Job? = null + + private var settings = viewModel.asyncIO { + val card = viewModel.currentCard.await() + AutoAdvanceSettings.createInstance(card.currentDeckId().did) + } + + private suspend fun durationToShowQuestionFor() = settings.await().durationToShowQuestionFor + private suspend fun durationToShowAnswerFor() = settings.await().durationToShowAnswerFor + private suspend fun questionAction() = settings.await().questionAction + private suspend fun answerAction() = settings.await().answerAction + suspend fun shouldWaitForAudio() = settings.await().waitForAudio + + fun cancelQuestionAndAnswerActionJobs() { + questionActionJob?.cancel() + answerActionJob?.cancel() + } + + fun onCardChange(card: Card) { + cancelQuestionAndAnswerActionJobs() + settings = viewModel.asyncIO { + AutoAdvanceSettings.createInstance(card.currentDeckId().did) + } + } + + suspend fun onShowQuestion() { + answerActionJob?.cancel() + if (!durationToShowQuestionFor().isPositive()) return + + questionActionJob = viewModel.launchCatchingIO { + delay(durationToShowQuestionFor()) + when (questionAction()) { + QuestionAction.SHOW_ANSWER -> viewModel.showAnswer() + QuestionAction.SHOW_REMINDER -> showReminder(TR.studyingQuestionTimeElapsed()) + } + } + } + + suspend fun onShowAnswer() { + questionActionJob?.cancel() + if (!durationToShowAnswerFor().isPositive()) return + + answerActionJob = viewModel.launchCatchingIO { + delay(durationToShowAnswerFor()) + when (answerAction()) { + AnswerAction.BURY_CARD -> viewModel.buryCard() + AnswerAction.ANSWER_AGAIN -> viewModel.answerAgain() + AnswerAction.ANSWER_HARD -> viewModel.answerHard() + AnswerAction.ANSWER_GOOD -> viewModel.answerGood() + AnswerAction.SHOW_REMINDER -> showReminder(TR.studyingAnswerTimeElapsed()) + } + } + } + + private fun showReminder(message: String) { + viewModel.launchCatchingIO { + viewModel.actionFeedbackFlow.emit(message) + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvanceSettings.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvanceSettings.kt new file mode 100644 index 000000000000..85419f307bb3 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvanceSettings.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 Brayan Oliveira + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package com.ichi2.anki.ui.windows.reviewer.autoadvance + +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.utils.ext.secondsToShowAnswer +import com.ichi2.anki.utils.ext.secondsToShowQuestion +import com.ichi2.libanki.DeckId +import kotlin.time.Duration +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +data class AutoAdvanceSettings( + val questionAction: QuestionAction, + val answerAction: AnswerAction, + val durationToShowQuestionFor: Duration, + val durationToShowAnswerFor: Duration, + val waitForAudio: Boolean +) { + companion object { + suspend fun createInstance(deckId: DeckId): AutoAdvanceSettings { + val config = withCol { decks.configDictForDeckId(deckId) } + val questionAction = QuestionAction.from(config) + val answerAction = AnswerAction.from(config) + val waitForAudio = config.optBoolean("waitForAudio", true) + + return AutoAdvanceSettings( + questionAction = questionAction, + answerAction = answerAction, + durationToShowQuestionFor = config.secondsToShowQuestion.toDuration(DurationUnit.SECONDS), + durationToShowAnswerFor = config.secondsToShowAnswer.toDuration(DurationUnit.SECONDS), + waitForAudio = waitForAudio + ) + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/QuestionAction.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/QuestionAction.kt new file mode 100644 index 000000000000..1d4210cab8f9 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/QuestionAction.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2024 Brayan Oliveira + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package com.ichi2.anki.ui.windows.reviewer.autoadvance + +import com.ichi2.libanki.DeckConfig + +enum class QuestionAction(val configValue: Int) { + SHOW_ANSWER(0), + SHOW_REMINDER(1); + + companion object { + fun from(config: DeckConfig): QuestionAction { + val value = config.optInt("questionAction") + return entries.firstOrNull { it.configValue == value } ?: SHOW_ANSWER + } + } +}