Skip to content

feat(new reviewer): answer timer #18510

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Copyright (c) 2025 Brayan Oliveira <[email protected]>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.ui.windows.reviewer

import android.content.Context
import android.os.Parcelable
import android.os.SystemClock
import android.util.AttributeSet
import android.widget.Chronometer
import androidx.appcompat.widget.ThemeUtils
import com.ichi2.anki.R
import kotlinx.parcelize.Parcelize

/**
* Improved version of a [Chronometer] aimed at handling Anki Decks' `Timer` configurations.
*
* Compared to a default [Chronometer], it can:
* - Restore its status after configuration changes
* - Stop the timer after [limitInMs] is reached.
*/
class AnswerTimer(
context: Context,
attributeSet: AttributeSet?,
) : Chronometer(context, attributeSet) {
var limitInMs = Int.MAX_VALUE
private var elapsedMillisBeforeStop = 0L
private var isRunning = false

init {
setOnChronometerTickListener {
if (hasReachedLimit()) {
setTextColor(ThemeUtils.getThemeAttrColor(context, R.attr.maxTimerColor))
stop()
}
}
}

override fun start() {
super.start()
isRunning = true
}

override fun stop() {
elapsedMillisBeforeStop = SystemClock.elapsedRealtime() - base
super.stop()
isRunning = false
}

fun resume() {
base = SystemClock.elapsedRealtime() - elapsedMillisBeforeStop
start()
}

fun restart() {
elapsedMillisBeforeStop = 0
base = SystemClock.elapsedRealtime()
setTextColor(ThemeUtils.getThemeAttrColor(context, android.R.attr.textColor))
start()
}

private fun hasReachedLimit() = SystemClock.elapsedRealtime() - base >= limitInMs

override fun onSaveInstanceState(): Parcelable {
val elapsedMillis = if (isRunning) SystemClock.elapsedRealtime() - base else elapsedMillisBeforeStop
return SavedState(
state = super.onSaveInstanceState(),
elapsedMs = elapsedMillis,
isRunning = isRunning,
limitInMs = limitInMs,
)
}

override fun onRestoreInstanceState(state: Parcelable?) {
if (state !is SavedState) {
super.onRestoreInstanceState(state)
return
}
super.onRestoreInstanceState(state.superState)

elapsedMillisBeforeStop = state.elapsedMs
isRunning = state.isRunning
limitInMs = state.limitInMs

base = SystemClock.elapsedRealtime() - elapsedMillisBeforeStop
if (isRunning && !hasReachedLimit()) {
super.start()
}
}

@Parcelize
private data class SavedState(
val state: Parcelable?,
val elapsedMs: Long,
val isRunning: Boolean,
val limitInMs: Int,
) : BaseSavedState(state)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Brayan Oliveira <[email protected]>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.ui.windows.reviewer

import kotlin.random.Random

sealed interface AnswerTimerStatus {
data class Running(
val limitInMs: Int,
) : AnswerTimerStatus {
// allows emitting the same value in MutableStateFlow
override fun equals(other: Any?): Boolean = false

override fun hashCode(): Int = Random.nextInt()
}

data object Stopped : AnswerTimerStatus
}
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ class ReviewerFragment :
override val webView: WebView
get() = requireView().findViewById(R.id.webview)

private val timer: AnswerTimer? get() = view?.findViewById(R.id.timer)

override val baseSnackbarBuilder: SnackbarBuilder = {
val fragmentView = [email protected]
val typeAnswerContainer = fragmentView?.findViewById<View>(R.id.type_answer_container)
Expand All @@ -144,10 +146,20 @@ class ReviewerFragment :
nightMode = Themes.currentTheme.isNightMode,
)

override fun onStart() {
super.onStart()
if (!requireActivity().isChangingConfigurations) {
if (viewModel.answerTimerStatusFlow.value is AnswerTimerStatus.Running) {
timer?.resume()
}
}
}

override fun onStop() {
super.onStop()
if (!requireActivity().isChangingConfigurations) {
viewModel.stopAutoAdvance()
timer?.stop()
}
}

Expand Down Expand Up @@ -177,6 +189,7 @@ class ReviewerFragment :
setupCounts(view)
setupMenu(view)
setupToolbarPosition(view)
setupAnswerTimer(view)

viewModel.actionFeedbackFlow
.flowWithLifecycle(lifecycle)
Expand Down Expand Up @@ -560,6 +573,27 @@ class ReviewerFragment :
}
}

private fun setupAnswerTimer(view: View) {
val timer = view.findViewById<AnswerTimer>(R.id.timer)
timer.isVisible = viewModel.answerTimerStatusFlow.value != null // necessary to handle configuration changes
viewModel.answerTimerStatusFlow.collectIn(lifecycleScope) { status ->
when (status) {
is AnswerTimerStatus.Running -> {
timer.isVisible = true
timer.limitInMs = status.limitInMs
timer.restart()
}
AnswerTimerStatus.Stopped -> {
timer.isVisible = true
timer.stop()
}
null -> {
timer.isVisible = false
}
}
}
}

override fun onSelectedTags(
selectedTags: List<String>,
indeterminateTags: List<String>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import com.ichi2.anki.ui.windows.reviewer.autoadvance.AutoAdvance
import com.ichi2.anki.utils.Destination
import com.ichi2.anki.utils.ext.flag
import com.ichi2.anki.utils.ext.setUserFlagForCards
import com.ichi2.libanki.Card
import com.ichi2.libanki.CardId
import com.ichi2.libanki.ChangeManager
import com.ichi2.libanki.NoteId
Expand Down Expand Up @@ -103,6 +104,7 @@ class ReviewerViewModel(
val destinationFlow = MutableSharedFlow<Destination>()
val editNoteTagsFlow = MutableSharedFlow<NoteId>()
val setDueDateFlow = MutableSharedFlow<CardId>()
val answerTimerStatusFlow = MutableStateFlow<AnswerTimerStatus?>(null)

override val server: AnkiServer = AnkiServer(this, serverPort).also { it.start() }
private val stateMutationKey = TimeManager.time.intTimeMS().toString()
Expand Down Expand Up @@ -186,6 +188,13 @@ class ReviewerViewModel(
if (!autoAdvance.shouldWaitForAudio()) {
autoAdvance.onShowAnswer()
} // else wait for onMediaGroupCompleted

if (answerTimerStatusFlow.value == null) return@launchCatchingIO
val did = currentCard.await().currentDeckId()
val stopTimerOnAnswer = withCol { decks.configDictForDeckId(did) }.stopTimerOnAnswer
if (stopTimerOnAnswer) {
answerTimerStatusFlow.emit(AnswerTimerStatus.Stopped)
}
}
}

Expand Down Expand Up @@ -483,6 +492,7 @@ class ReviewerViewModel(

val card = state.topCard
currentCard = CompletableDeferred(card)
setupAnswerTimer(card)
autoAdvance.onCardChange(card)
showQuestion()
loadAndPlayMedia(CardSide.QUESTION)
Expand Down Expand Up @@ -559,6 +569,16 @@ class ReviewerViewModel(
setDueDateFlow.emit(cardId)
}

private suspend fun setupAnswerTimer(card: Card) {
val shouldShowTimer = withCol { card.shouldShowTimer(this@withCol) }
if (!shouldShowTimer) {
answerTimerStatusFlow.emit(null)
return
}
val limitInMillis = withCol { card.timeLimit(this@withCol) }
answerTimerStatusFlow.emit(AnswerTimerStatus.Running(limitInMillis))
}

private fun executeAction(action: ViewerAction) {
Timber.v("ReviewerViewModel::executeAction %s", action.name)
launchCatchingIO {
Expand Down
34 changes: 23 additions & 11 deletions AnkiDroid/src/main/res/layout-sw600dp/reviewer2.xml
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,6 @@
android:textColor="?attr/newCountColor"
android:paddingEnd="4dp"
android:textSize="16sp"
app:layout_constraintTop_toTopOf="@id/back_button"
app:layout_constraintBottom_toBottomOf="@id/back_button"
app:layout_constraintStart_toEndOf="@id/back_button"
app:layout_constraintEnd_toStartOf="@id/lrn_count"
tools:text="127"
/>

Expand All @@ -120,10 +116,6 @@
android:textColor="?attr/learnCountColor"
android:paddingHorizontal="4dp"
android:textSize="16sp"
app:layout_constraintTop_toTopOf="@id/back_button"
app:layout_constraintBottom_toBottomOf="@id/back_button"
app:layout_constraintStart_toEndOf="@id/new_count"
app:layout_constraintEnd_toStartOf="@id/rev_count"
tools:text="381"
/>

Expand All @@ -134,12 +126,32 @@
android:textColor="?attr/reviewCountColor"
android:paddingStart="4dp"
android:textSize="16sp"
app:layout_constraintTop_toTopOf="@id/back_button"
app:layout_constraintBottom_toBottomOf="@id/back_button"
app:layout_constraintStart_toEndOf="@id/lrn_count"
tools:text="954"
/>

<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/counts_flow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="new_count,lrn_count,rev_count"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toEndOf="@id/back_button"
app:layout_constraintBottom_toTopOf="@id/timer"
/>


<com.ichi2.anki.ui.windows.reviewer.AnswerTimer
android:id="@+id/timer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/counts_flow"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/counts_flow"
app:layout_constraintStart_toStartOf="@id/counts_flow"
android:visibility="gone"
tools:visibility="visible"
/>

<FrameLayout
android:id="@+id/answer_area"
android:layout_width="0dp"
Expand Down
Loading