Skip to content

Commit 990fb2f

Browse files
david-allisonmikehardy
authored andcommitted
feat(card-browser): Grade Now
Allows a user to grade a card without the use of the study screen Issue 18604
1 parent a23770a commit 990fb2f

File tree

6 files changed

+197
-1
lines changed

6 files changed

+197
-1
lines changed

AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import com.ichi2.anki.dialogs.DeckSelectionDialog.Companion.newInstance
8888
import com.ichi2.anki.dialogs.DeckSelectionDialog.DeckSelectionListener
8989
import com.ichi2.anki.dialogs.DeckSelectionDialog.SelectableDeck
9090
import com.ichi2.anki.dialogs.DiscardChangesDialog
91+
import com.ichi2.anki.dialogs.GradeNowDialog
9192
import com.ichi2.anki.dialogs.SimpleMessageDialog
9293
import com.ichi2.anki.dialogs.tags.TagsDialog
9394
import com.ichi2.anki.dialogs.tags.TagsDialogFactory
@@ -722,6 +723,13 @@ open class CardBrowser :
722723
return true
723724
}
724725
}
726+
KeyEvent.KEYCODE_G -> {
727+
if (event.isCtrlPressed && event.isShiftPressed) {
728+
Timber.i("Ctrl+Shift+G - Grade Now")
729+
openGradeNow()
730+
return true
731+
}
732+
}
725733
KeyEvent.KEYCODE_FORWARD_DEL, KeyEvent.KEYCODE_DEL -> {
726734
if (searchView?.isIconified == false) {
727735
Timber.i("Delete pressed - Search active, deleting character")
@@ -1025,6 +1033,9 @@ open class CardBrowser :
10251033
actionBarMenu?.findItem(R.id.action_reschedule_cards)?.title =
10261034
TR.actionsSetDueDate().toSentenceCase(this, R.string.sentence_set_due_date)
10271035

1036+
actionBarMenu?.findItem(R.id.action_grade_now)?.title =
1037+
TR.actionsGradeNow().toSentenceCase(this, R.string.sentence_grade_now)
1038+
10281039
val isFindReplaceEnabled = sharedPrefs().getBoolean(getString(R.string.pref_browser_find_replace), false)
10291040
menu.findItem(R.id.action_find_replace)?.apply {
10301041
isVisible = isFindReplaceEnabled
@@ -1112,6 +1123,7 @@ open class CardBrowser :
11121123
}
11131124
actionBarMenu.findItem(R.id.action_change_deck).isVisible = viewModel.hasSelectedAnyRows()
11141125
actionBarMenu.findItem(R.id.action_reposition_cards).isVisible = viewModel.hasSelectedAnyRows()
1126+
actionBarMenu.findItem(R.id.action_grade_now).isVisible = viewModel.hasSelectedAnyRows()
11151127
actionBarMenu.findItem(R.id.action_reschedule_cards).isVisible = viewModel.hasSelectedAnyRows()
11161128
actionBarMenu.findItem(R.id.action_edit_tags).isVisible = viewModel.hasSelectedAnyRows()
11171129
actionBarMenu.findItem(R.id.action_reset_cards_progress).isVisible = viewModel.hasSelectedAnyRows()
@@ -1278,6 +1290,11 @@ open class CardBrowser :
12781290
onResetProgress()
12791291
return true
12801292
}
1293+
R.id.action_grade_now -> {
1294+
Timber.i("CardBrowser:: Grade now button pressed")
1295+
openGradeNow()
1296+
return true
1297+
}
12811298
R.id.action_reschedule_cards -> {
12821299
Timber.i("CardBrowser:: Reschedule button pressed")
12831300
rescheduleSelectedCards()
@@ -1398,6 +1415,12 @@ open class CardBrowser :
13981415
)
13991416
}
14001417

1418+
fun openGradeNow() =
1419+
launchCatchingTask {
1420+
val cardIds = viewModel.queryAllSelectedCardIds()
1421+
GradeNowDialog.showDialog(this@CardBrowser, cardIds)
1422+
}
1423+
14011424
private fun repositionSelectedCards(): Boolean {
14021425
Timber.i("CardBrowser:: Reposition button pressed")
14031426
if (warnUserIfInNotesOnlyMode()) return false
@@ -1885,6 +1908,7 @@ open class CardBrowser :
18851908
shortcut("Ctrl+Alt+S", R.string.card_browser_list_my_searches),
18861909
shortcut("Ctrl+S", R.string.card_browser_list_my_searches_save),
18871910
shortcut("Alt+S", R.string.card_browser_show_suspended),
1911+
shortcut("Ctrl+Shift+G", Translations::actionsGradeNow),
18881912
shortcut("Ctrl+Shift+J", Translations::browsingToggleBury),
18891913
shortcut("Ctrl+J", Translations::browsingToggleSuspend),
18901914
shortcut("Ctrl+Shift+I", Translations::actionsCardInfo),
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Copyright (c) 2025 David Allison <davidallisongithub@gmail.com>
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+
17+
package com.ichi2.anki.dialogs
18+
19+
import android.content.Context
20+
import android.view.LayoutInflater
21+
import android.view.View
22+
import android.view.ViewGroup
23+
import android.widget.ArrayAdapter
24+
import android.widget.TextView
25+
import androidx.annotation.DrawableRes
26+
import androidx.appcompat.app.AppCompatActivity
27+
import anki.scheduler.CardAnswer
28+
import com.google.android.material.dialog.MaterialAlertDialogBuilder
29+
import com.ichi2.anki.CollectionManager.TR
30+
import com.ichi2.anki.R
31+
import com.ichi2.anki.common.annotations.NeedsTest
32+
import com.ichi2.anki.launchCatchingTask
33+
import com.ichi2.anki.libanki.CardId
34+
import com.ichi2.anki.observability.undoableOp
35+
import com.ichi2.anki.snackbar.showSnackbar
36+
import com.ichi2.anki.ui.internationalization.toSentenceCase
37+
import com.ichi2.anki.undoAndShowSnackbar
38+
import com.ichi2.anki.utils.ext.setCompoundDrawablesRelativeWithIntrinsicBoundsKt
39+
import com.ichi2.anki.withProgress
40+
import com.ichi2.utils.negativeButton
41+
import com.ichi2.utils.show
42+
import com.ichi2.utils.title
43+
import timber.log.Timber
44+
45+
/**
46+
* Allows a user to grade a card without the use of the study screen
47+
*
48+
* For example, a forgotten card can be marked as 'again' before it's due
49+
*
50+
* Discussion: https://github.com/ankitects/anki/pull/3840
51+
*
52+
* @see net.ankiweb.rsdroid.Backend.gradeNow
53+
*/
54+
@NeedsTest("UI test for this dialog")
55+
@NeedsTest("Menu only displayed if cards selected")
56+
@NeedsTest("Suspended card handling")
57+
object GradeNowDialog {
58+
fun showDialog(
59+
context: AppCompatActivity,
60+
cardIds: List<CardId>,
61+
) {
62+
if (!cardIds.any()) {
63+
Timber.w("no selected cards")
64+
return
65+
}
66+
67+
Timber.i("Opening 'Grade Now'")
68+
69+
val adapter = GradeNowListAdapter(context, Grade.entries)
70+
71+
MaterialAlertDialogBuilder(context).show {
72+
title(text = TR.actionsGradeNow().toSentenceCase(context, R.string.sentence_grade_now))
73+
negativeButton(R.string.dialog_cancel)
74+
setAdapter(adapter, { dialog, which ->
75+
val selectedGrade = adapter.getItem(which)!!
76+
Timber.i("selected '%s'", selectedGrade.name)
77+
// dismiss the dialog before the operation completes to stop duplicate clicks
78+
context.gradeNow(cardIds, selectedGrade)
79+
dialog.dismiss()
80+
})
81+
}
82+
}
83+
84+
private fun AppCompatActivity.gradeNow(
85+
ids: List<CardId>,
86+
grade: Grade,
87+
) = launchCatchingTask {
88+
Timber.d("Grading %d cards as %s", ids.size, grade.name)
89+
withProgress {
90+
undoableOp { this.backend.gradeNow(ids, grade.rating) }
91+
}
92+
showSnackbar(TR.schedulingGradedCardsDone(ids.size)) {
93+
setAction(R.string.undo) { launchCatchingTask { undoAndShowSnackbar() } }
94+
}
95+
}
96+
}
97+
98+
private class GradeNowListAdapter(
99+
context: Context,
100+
val grades: List<Grade>,
101+
) : ArrayAdapter<Grade>(context, R.layout.grade_now_list_item, grades) {
102+
override fun getView(
103+
position: Int,
104+
convertView: View?,
105+
parent: ViewGroup,
106+
): View =
107+
convertView ?: LayoutInflater.from(context).inflate(R.layout.grade_now_list_item, parent, false).apply {
108+
val grade = getItem(position)!!
109+
findViewById<TextView>(R.id.grade_view).apply {
110+
text = grade.getLabel()
111+
setCompoundDrawablesRelativeWithIntrinsicBoundsKt(start = grade.iconRes)
112+
}
113+
}
114+
}
115+
116+
private enum class Grade(
117+
val rating: CardAnswer.Rating,
118+
@DrawableRes val iconRes: Int,
119+
val getLabel: () -> String,
120+
) {
121+
Again(CardAnswer.Rating.AGAIN, R.drawable.ic_ease_again, { TR.studyingAgain() }),
122+
Hard(CardAnswer.Rating.HARD, R.drawable.ic_ease_hard, { TR.studyingHard() }),
123+
Good(CardAnswer.Rating.GOOD, R.drawable.ic_ease_good, { TR.studyingGood() }),
124+
Easy(CardAnswer.Rating.EASY, R.drawable.ic_ease_easy, { TR.studyingEasy() }),
125+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?xml version="1.0" encoding="utf-8"?><!--
2+
~ Copyright (c) 2025 David Allison <davidallisongithub@gmail.com>
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+
17+
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
18+
xmlns:tools="http://schemas.android.com/tools"
19+
android:orientation="horizontal"
20+
android:layout_width="match_parent"
21+
android:layout_height="wrap_content"
22+
>
23+
24+
<com.ichi2.ui.FixedTextView
25+
android:id="@+id/grade_view"
26+
android:layout_width="match_parent"
27+
android:layout_height="wrap_content"
28+
android:minHeight="?attr/listPreferredItemHeight"
29+
android:text="@string/binding_add_gesture"
30+
android:gravity="center_vertical"
31+
32+
android:drawablePadding="16dp"
33+
android:paddingStart="24dp"
34+
35+
android:background="?android:attr/selectableItemBackground"
36+
android:textAppearance="?attr/textAppearanceBodyLarge"
37+
38+
tools:drawableStart="@drawable/ic_ease_again"
39+
tools:text="Again"
40+
/>
41+
42+
</LinearLayout>

AnkiDroid/src/main/res/menu/card_browser_multiselect.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@
6565
android:id="@+id/action_edit_tags"
6666
android:title="@string/menu_edit_tags"/>
6767

68+
<item
69+
android:id="@+id/action_grade_now"
70+
tools:title="Grade now" />
71+
6872
<item
6973
android:id="@+id/action_reset_cards_progress"
7074
android:title="@string/card_editor_reset_card"/>

AnkiDroid/src/main/res/values/sentence-case.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,5 @@ undoActionUndone()
5151
<string name="sentence_sync_media_log">Media sync log</string>
5252
<string name="sentence_empty_trash">Empty trash</string>
5353
<string name="sentence_restore_deleted">Restore deleted</string>
54-
54+
<string name="sentence_grade_now">Grade now</string>
5555
</resources>

AnkiDroid/src/test/java/com/ichi2/anki/ui/internationalization/SentenceCaseTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class SentenceCaseTest : RobolectricTest() {
4141
assertThat(TR.emptyCardsWindowTitle().toSentenceCase(this, R.string.sentence_empty_cards), equalTo("Empty cards"))
4242
assertThat(TR.mediaCheckEmptyTrash().toSentenceCase(this, R.string.sentence_empty_trash), equalTo("Empty trash"))
4343
assertThat(TR.mediaCheckRestoreTrash().toSentenceCase(this, R.string.sentence_restore_deleted), equalTo("Restore deleted"))
44+
assertThat(TR.actionsGradeNow().toSentenceCase(this, R.string.sentence_grade_now), equalTo("Grade now"))
4445

4546
assertThat("syncMediaLogTitle", TR.syncMediaLogTitle(), equalTo("Media Sync Log"))
4647
assertThat(

0 commit comments

Comments
 (0)