Skip to content

Commit a02de1f

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

File tree

6 files changed

+234
-30
lines changed

6 files changed

+234
-30
lines changed
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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 restart() {
63+
elapsedMillisBeforeStop = 0
64+
base = SystemClock.elapsedRealtime()
65+
setTextColor(ThemeUtils.getThemeAttrColor(context, android.R.attr.textColor))
66+
start()
67+
}
68+
69+
private fun hasReachedLimit() = SystemClock.elapsedRealtime() - base >= limitInMs
70+
71+
override fun onSaveInstanceState(): Parcelable {
72+
val elapsedMillis = if (isRunning) SystemClock.elapsedRealtime() - base else elapsedMillisBeforeStop
73+
return SavedState(
74+
state = super.onSaveInstanceState(),
75+
elapsedMs = elapsedMillis,
76+
isRunning = isRunning,
77+
limitInMs = limitInMs,
78+
)
79+
}
80+
81+
override fun onRestoreInstanceState(state: Parcelable?) {
82+
if (state !is SavedState) {
83+
super.onRestoreInstanceState(state)
84+
return
85+
}
86+
super.onRestoreInstanceState(state.superState)
87+
88+
elapsedMillisBeforeStop = state.elapsedMs
89+
isRunning = state.isRunning
90+
limitInMs = state.limitInMs
91+
92+
base = SystemClock.elapsedRealtime() - elapsedMillisBeforeStop
93+
if (isRunning && !hasReachedLimit()) {
94+
super.start()
95+
}
96+
}
97+
98+
@Parcelize
99+
private data class SavedState(
100+
val state: Parcelable?,
101+
val elapsedMs: Long,
102+
val isRunning: Boolean,
103+
val limitInMs: Int,
104+
) : BaseSavedState(state)
105+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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+
sealed interface AnswerTimerStatus {
19+
data class Running(
20+
val limitInMs: Int,
21+
) : AnswerTimerStatus
22+
23+
data object Stopped : AnswerTimerStatus
24+
}

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ class ReviewerFragment :
177177
setupCounts(view)
178178
setupMenu(view)
179179
setupToolbarPosition(view)
180+
setupAnswerTimer(view)
180181

181182
viewModel.actionFeedbackFlow
182183
.flowWithLifecycle(lifecycle)
@@ -560,6 +561,27 @@ class ReviewerFragment :
560561
}
561562
}
562563

564+
private fun setupAnswerTimer(view: View) {
565+
val timer = view.findViewById<AnswerTimer>(R.id.timer)
566+
timer.isVisible = viewModel.answerTimerStatusFlow.value != null // necessary to handle configuration changes
567+
viewModel.answerTimerStatusFlow.collectIn(lifecycleScope) { state ->
568+
when (state) {
569+
is AnswerTimerStatus.Running -> {
570+
timer.isVisible = true
571+
timer.limitInMs = state.limitInMs
572+
timer.restart()
573+
}
574+
AnswerTimerStatus.Stopped -> {
575+
timer.isVisible = true
576+
timer.stop()
577+
}
578+
null -> {
579+
timer.isVisible = false
580+
}
581+
}
582+
}
583+
}
584+
563585
override fun onSelectedTags(
564586
selectedTags: List<String>,
565587
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"

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

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,59 +18,80 @@
1818
android:layout_height="match_parent"
1919
android:orientation="vertical">
2020

21-
22-
<LinearLayout
23-
android:id="@+id/tools_layout"
21+
<androidx.constraintlayout.widget.ConstraintLayout
2422
android:layout_width="match_parent"
2523
android:layout_height="wrap_content"
26-
android:gravity="center_vertical"
27-
>
24+
android:minHeight="?actionBarSize">
2825

2926
<androidx.appcompat.widget.AppCompatImageButton
3027
android:id="@+id/back_button"
3128
style="?actionButtonStyle"
3229
android:layout_width="?minTouchTargetSize"
3330
android:layout_height="?actionBarSize"
34-
android:src="?attr/homeAsUpIndicator"
3531
android:layout_marginStart="4dp"
3632
android:contentDescription="@string/abc_action_bar_up_description"
37-
android:tooltipText="@string/abc_action_bar_up_description"
33+
android:src="?attr/homeAsUpIndicator"
34+
app:layout_constraintStart_toStartOf="parent"
35+
app:layout_constraintBottom_toBottomOf="parent"
36+
app:layout_constraintTop_toTopOf="parent" />
37+
38+
<androidx.constraintlayout.helper.widget.Flow
39+
android:id="@+id/counts_flow"
40+
android:layout_width="wrap_content"
41+
android:layout_height="wrap_content"
42+
app:constraint_referenced_ids="new_count,lrn_count,rev_count"
43+
app:layout_constraintTop_toTopOf="parent"
44+
app:layout_constraintStart_toEndOf="@id/back_button"
45+
app:layout_constraintBottom_toTopOf="@id/timer"
3846
/>
3947

4048
<com.google.android.material.textview.MaterialTextView
4149
android:id="@+id/new_count"
4250
android:layout_width="wrap_content"
4351
android:layout_height="wrap_content"
44-
android:textColor="?attr/newCountColor"
4552
android:paddingEnd="4dp"
46-
tools:text="127"
47-
/>
53+
android:textColor="?attr/newCountColor"
54+
tools:text="127" />
4855

4956
<com.google.android.material.textview.MaterialTextView
5057
android:id="@+id/lrn_count"
5158
android:layout_width="wrap_content"
5259
android:layout_height="wrap_content"
53-
android:textColor="?attr/learnCountColor"
5460
android:paddingHorizontal="4dp"
55-
tools:text="381"
56-
/>
61+
android:textColor="?attr/learnCountColor"
62+
tools:text="381" />
5763

5864
<com.google.android.material.textview.MaterialTextView
5965
android:id="@+id/rev_count"
6066
android:layout_width="wrap_content"
6167
android:layout_height="wrap_content"
62-
android:textColor="?attr/reviewCountColor"
6368
android:paddingStart="4dp"
64-
tools:text="954"
69+
android:textColor="?attr/reviewCountColor"
70+
tools:text="954" />
71+
72+
<com.ichi2.anki.ui.windows.reviewer.AnswerTimer
73+
android:id="@+id/timer"
74+
android:layout_width="wrap_content"
75+
android:layout_height="wrap_content"
76+
app:layout_constraintTop_toBottomOf="@id/counts_flow"
77+
app:layout_constraintBottom_toBottomOf="parent"
78+
app:layout_constraintEnd_toEndOf="@id/counts_flow"
79+
app:layout_constraintStart_toStartOf="@id/counts_flow"
80+
android:visibility="gone"
81+
tools:visibility="visible"
6582
/>
6683

6784
<com.ichi2.anki.preferences.reviewer.ReviewerMenuView
6885
android:id="@+id/reviewer_menu_view"
69-
android:layout_width="match_parent"
70-
android:layout_height="?actionBarSize"
86+
android:layout_width="0dp"
87+
android:layout_height="0dp"
7188
android:layout_marginStart="12dp"
72-
/>
73-
</LinearLayout>
89+
app:layout_constraintBottom_toBottomOf="parent"
90+
app:layout_constraintEnd_toEndOf="parent"
91+
app:layout_constraintStart_toEndOf="@id/rev_count"
92+
app:layout_constraintTop_toTopOf="parent" />
93+
94+
</androidx.constraintlayout.widget.ConstraintLayout>
7495

7596
<com.google.android.material.card.MaterialCardView
7697
android:id="@+id/webview_container"

0 commit comments

Comments
 (0)