Skip to content

Commit 353946e

Browse files
committed
feat(new reviewer): answer timer
localized below the Count numbers to avoid losing screen space
1 parent f90162c commit 353946e

File tree

6 files changed

+258
-29
lines changed

6 files changed

+258
-29
lines changed
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Copyright (c) 2025 Brayan Oliveira <[email protected]>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
package com.ichi2.anki.ui.windows.reviewer
17+
18+
import android.content.Context
19+
import android.os.Parcelable
20+
import android.os.SystemClock
21+
import android.util.AttributeSet
22+
import android.widget.Chronometer
23+
import androidx.appcompat.widget.ThemeUtils
24+
import com.ichi2.anki.R
25+
import kotlinx.parcelize.Parcelize
26+
27+
/**
28+
* Improved version of a [Chronometer] aimed at handling Anki Decks' `Timer` configurations.
29+
*
30+
* Compared to a default [Chronometer], it can:
31+
* - Restore its status after configuration changes
32+
* - Stop the timer after [limitInMs] is reached.
33+
*/
34+
class AnswerTimer(
35+
context: Context,
36+
attributeSet: AttributeSet?,
37+
) : Chronometer(context, attributeSet) {
38+
var limitInMs = Int.MAX_VALUE
39+
private var elapsedMillisBeforeStop = 0L
40+
private var isRunning = false
41+
42+
init {
43+
setOnChronometerTickListener {
44+
if (hasReachedLimit()) {
45+
setTextColor(ThemeUtils.getThemeAttrColor(context, R.attr.maxTimerColor))
46+
stop()
47+
}
48+
}
49+
}
50+
51+
override fun start() {
52+
super.start()
53+
isRunning = true
54+
}
55+
56+
override fun stop() {
57+
elapsedMillisBeforeStop = SystemClock.elapsedRealtime() - base
58+
super.stop()
59+
isRunning = false
60+
}
61+
62+
fun resume() {
63+
base = SystemClock.elapsedRealtime() - elapsedMillisBeforeStop
64+
start()
65+
}
66+
67+
fun restart() {
68+
elapsedMillisBeforeStop = 0
69+
base = SystemClock.elapsedRealtime()
70+
setTextColor(ThemeUtils.getThemeAttrColor(context, android.R.attr.textColor))
71+
start()
72+
}
73+
74+
private fun hasReachedLimit() = SystemClock.elapsedRealtime() - base >= limitInMs
75+
76+
override fun onSaveInstanceState(): Parcelable {
77+
val elapsedMillis = if (isRunning) SystemClock.elapsedRealtime() - base else elapsedMillisBeforeStop
78+
return SavedState(
79+
state = super.onSaveInstanceState(),
80+
elapsedMs = elapsedMillis,
81+
isRunning = isRunning,
82+
limitInMs = limitInMs,
83+
)
84+
}
85+
86+
override fun onRestoreInstanceState(state: Parcelable?) {
87+
if (state !is SavedState) {
88+
super.onRestoreInstanceState(state)
89+
return
90+
}
91+
super.onRestoreInstanceState(state.superState)
92+
93+
elapsedMillisBeforeStop = state.elapsedMs
94+
isRunning = state.isRunning
95+
limitInMs = state.limitInMs
96+
97+
base = SystemClock.elapsedRealtime() - elapsedMillisBeforeStop
98+
if (isRunning && !hasReachedLimit()) {
99+
super.start()
100+
}
101+
}
102+
103+
@Parcelize
104+
private data class SavedState(
105+
val state: Parcelable?,
106+
val elapsedMs: Long,
107+
val isRunning: Boolean,
108+
val limitInMs: Int,
109+
) : BaseSavedState(state)
110+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright (c) 2025 Brayan Oliveira <[email protected]>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
package com.ichi2.anki.ui.windows.reviewer
17+
18+
import kotlin.random.Random
19+
20+
sealed interface AnswerTimerStatus {
21+
data class Running(
22+
val limitInMs: Int,
23+
) : AnswerTimerStatus {
24+
// allows emitting the same value in MutableStateFlow
25+
override fun equals(other: Any?): Boolean = false
26+
27+
override fun hashCode(): Int = Random.nextInt()
28+
}
29+
30+
data object Stopped : AnswerTimerStatus
31+
}

AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ class ReviewerFragment :
119119
override val webView: WebView
120120
get() = requireView().findViewById(R.id.webview)
121121

122+
private val timer: AnswerTimer? get() = view?.findViewById(R.id.timer)
123+
122124
override val baseSnackbarBuilder: SnackbarBuilder = {
123125
val fragmentView = this@ReviewerFragment.view
124126
val typeAnswerContainer = fragmentView?.findViewById<View>(R.id.type_answer_container)
@@ -144,10 +146,20 @@ class ReviewerFragment :
144146
nightMode = Themes.currentTheme.isNightMode,
145147
)
146148

149+
override fun onStart() {
150+
super.onStart()
151+
if (!requireActivity().isChangingConfigurations) {
152+
if (viewModel.answerTimerStatusFlow.value is AnswerTimerStatus.Running) {
153+
timer?.resume()
154+
}
155+
}
156+
}
157+
147158
override fun onStop() {
148159
super.onStop()
149160
if (!requireActivity().isChangingConfigurations) {
150161
viewModel.stopAutoAdvance()
162+
timer?.stop()
151163
}
152164
}
153165

@@ -177,6 +189,7 @@ class ReviewerFragment :
177189
setupCounts(view)
178190
setupMenu(view)
179191
setupToolbarPosition(view)
192+
setupAnswerTimer(view)
180193

181194
viewModel.actionFeedbackFlow
182195
.flowWithLifecycle(lifecycle)
@@ -560,6 +573,27 @@ class ReviewerFragment :
560573
}
561574
}
562575

576+
private fun setupAnswerTimer(view: View) {
577+
val timer = view.findViewById<AnswerTimer>(R.id.timer)
578+
timer.isVisible = viewModel.answerTimerStatusFlow.value != null // necessary to handle configuration changes
579+
viewModel.answerTimerStatusFlow.collectIn(lifecycleScope) { status ->
580+
when (status) {
581+
is AnswerTimerStatus.Running -> {
582+
timer.isVisible = true
583+
timer.limitInMs = status.limitInMs
584+
timer.restart()
585+
}
586+
AnswerTimerStatus.Stopped -> {
587+
timer.isVisible = true
588+
timer.stop()
589+
}
590+
null -> {
591+
timer.isVisible = false
592+
}
593+
}
594+
}
595+
}
596+
563597
override fun onSelectedTags(
564598
selectedTags: List<String>,
565599
indeterminateTags: List<String>,

AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import com.ichi2.anki.ui.windows.reviewer.autoadvance.AutoAdvance
5959
import com.ichi2.anki.utils.Destination
6060
import com.ichi2.anki.utils.ext.flag
6161
import com.ichi2.anki.utils.ext.setUserFlagForCards
62+
import com.ichi2.libanki.Card
6263
import com.ichi2.libanki.CardId
6364
import com.ichi2.libanki.ChangeManager
6465
import com.ichi2.libanki.NoteId
@@ -103,6 +104,7 @@ class ReviewerViewModel(
103104
val destinationFlow = MutableSharedFlow<Destination>()
104105
val editNoteTagsFlow = MutableSharedFlow<NoteId>()
105106
val setDueDateFlow = MutableSharedFlow<CardId>()
107+
val answerTimerStatusFlow = MutableStateFlow<AnswerTimerStatus?>(null)
106108

107109
override val server: AnkiServer = AnkiServer(this, serverPort).also { it.start() }
108110
private val stateMutationKey = TimeManager.time.intTimeMS().toString()
@@ -186,6 +188,13 @@ class ReviewerViewModel(
186188
if (!autoAdvance.shouldWaitForAudio()) {
187189
autoAdvance.onShowAnswer()
188190
} // else wait for onMediaGroupCompleted
191+
192+
if (answerTimerStatusFlow.value == null) return@launchCatchingIO
193+
val did = currentCard.await().currentDeckId()
194+
val stopTimerOnAnswer = withCol { decks.configDictForDeckId(did) }.stopTimerOnAnswer
195+
if (stopTimerOnAnswer) {
196+
answerTimerStatusFlow.emit(AnswerTimerStatus.Stopped)
197+
}
189198
}
190199
}
191200

@@ -483,6 +492,7 @@ class ReviewerViewModel(
483492

484493
val card = state.topCard
485494
currentCard = CompletableDeferred(card)
495+
setupAnswerTimer(card)
486496
autoAdvance.onCardChange(card)
487497
showQuestion()
488498
loadAndPlayMedia(CardSide.QUESTION)
@@ -559,6 +569,16 @@ class ReviewerViewModel(
559569
setDueDateFlow.emit(cardId)
560570
}
561571

572+
private suspend fun setupAnswerTimer(card: Card) {
573+
val shouldShowTimer = withCol { card.shouldShowTimer(this@withCol) }
574+
if (!shouldShowTimer) {
575+
answerTimerStatusFlow.emit(null)
576+
return
577+
}
578+
val limitInMillis = withCol { card.timeLimit(this@withCol) }
579+
answerTimerStatusFlow.emit(AnswerTimerStatus.Running(limitInMillis))
580+
}
581+
562582
private fun executeAction(action: ViewerAction) {
563583
Timber.v("ReviewerViewModel::executeAction %s", action.name)
564584
launchCatchingIO {

AnkiDroid/src/main/res/layout-sw600dp/reviewer2.xml

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,6 @@
106106
android:textColor="?attr/newCountColor"
107107
android:paddingEnd="4dp"
108108
android:textSize="16sp"
109-
app:layout_constraintTop_toTopOf="@id/back_button"
110-
app:layout_constraintBottom_toBottomOf="@id/back_button"
111-
app:layout_constraintStart_toEndOf="@id/back_button"
112-
app:layout_constraintEnd_toStartOf="@id/lrn_count"
113109
tools:text="127"
114110
/>
115111

@@ -120,10 +116,6 @@
120116
android:textColor="?attr/learnCountColor"
121117
android:paddingHorizontal="4dp"
122118
android:textSize="16sp"
123-
app:layout_constraintTop_toTopOf="@id/back_button"
124-
app:layout_constraintBottom_toBottomOf="@id/back_button"
125-
app:layout_constraintStart_toEndOf="@id/new_count"
126-
app:layout_constraintEnd_toStartOf="@id/rev_count"
127119
tools:text="381"
128120
/>
129121

@@ -134,12 +126,32 @@
134126
android:textColor="?attr/reviewCountColor"
135127
android:paddingStart="4dp"
136128
android:textSize="16sp"
137-
app:layout_constraintTop_toTopOf="@id/back_button"
138-
app:layout_constraintBottom_toBottomOf="@id/back_button"
139-
app:layout_constraintStart_toEndOf="@id/lrn_count"
140129
tools:text="954"
141130
/>
142131

132+
<androidx.constraintlayout.helper.widget.Flow
133+
android:id="@+id/counts_flow"
134+
android:layout_width="wrap_content"
135+
android:layout_height="wrap_content"
136+
app:constraint_referenced_ids="new_count,lrn_count,rev_count"
137+
app:layout_constraintTop_toTopOf="parent"
138+
app:layout_constraintStart_toEndOf="@id/back_button"
139+
app:layout_constraintBottom_toTopOf="@id/timer"
140+
/>
141+
142+
143+
<com.ichi2.anki.ui.windows.reviewer.AnswerTimer
144+
android:id="@+id/timer"
145+
android:layout_width="wrap_content"
146+
android:layout_height="wrap_content"
147+
app:layout_constraintTop_toBottomOf="@id/counts_flow"
148+
app:layout_constraintBottom_toBottomOf="parent"
149+
app:layout_constraintEnd_toEndOf="@id/counts_flow"
150+
app:layout_constraintStart_toStartOf="@id/counts_flow"
151+
android:visibility="gone"
152+
tools:visibility="visible"
153+
/>
154+
143155
<FrameLayout
144156
android:id="@+id/answer_area"
145157
android:layout_width="0dp"

0 commit comments

Comments
 (0)