From 3ddd9eb80114517166fec434b5486a4d2f6c6a2b Mon Sep 17 00:00:00 2001 From: Aldana Belen Rodriguez Date: Mon, 28 Sep 2026 01:55:17 +0200 Subject: [PATCH] Show distinct daily study states for decks --- .../anki/ankiquest/AnkiquestDailyDeckState.kt | 54 +++++++++++++++++++ .../ichi2/anki/ankiquest/AnkiquestDecks.kt | 53 +++++++++++++++++- .../anki/ankiquest/AnkiquestHomeActivity.kt | 43 +++++++++++++-- .../ichi2/anki/ankiquest/AnkiquestHomeData.kt | 30 +++++++++-- .../anki/deckpicker/DeckPickerViewModel.kt | 16 +++++- .../ichi2/anki/deckpicker/DisplayDeckNode.kt | 4 ++ .../com/ichi2/anki/widgets/DeckAdapter.kt | 25 +++++++++ AnkiDroid/src/main/res/layout/item_deck.xml | 36 +++++++++---- .../res/values-es/ankiquest_daily_decks.xml | 10 ++++ .../src/main/res/values-es/ankiquest_home.xml | 1 - .../ankiquest_daily_deck_colors.xml | 6 +++ .../values/ankiquest_daily_deck_colors.xml | 6 +++ .../main/res/values/ankiquest_daily_decks.xml | 10 ++++ .../src/main/res/values/ankiquest_home.xml | 1 - .../anki/ankiquest/AnkiquestDecksTest.kt | 12 +++++ .../ankiquest/AnkiquestHomeActivityTest.kt | 31 +++++++++++ .../anki/ankiquest/AnkiquestHomeDataTest.kt | 5 +- 17 files changed, 317 insertions(+), 26 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDailyDeckState.kt create mode 100644 AnkiDroid/src/main/res/values-es/ankiquest_daily_decks.xml create mode 100644 AnkiDroid/src/main/res/values-night/ankiquest_daily_deck_colors.xml create mode 100644 AnkiDroid/src/main/res/values/ankiquest_daily_deck_colors.xml create mode 100644 AnkiDroid/src/main/res/values/ankiquest_daily_decks.xml diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDailyDeckState.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDailyDeckState.kt new file mode 100644 index 000000000000..a9a8b8402bb9 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDailyDeckState.kt @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package com.ichi2.anki.ankiquest + +import android.content.Context +import android.text.format.DateFormat +import com.ichi2.anki.R +import java.util.Date + +enum class DailyDeckStatus { REVIEW_NOW, LEARNING_LATER, DONE, NOTHING_SCHEDULED } + +/** Daily remaining includes learning beyond Anki's short learn-ahead window. */ +data class DailyDeckState( + val ready: Long, + val remaining: Long, + val reviewed: Long, + val nextLearningAt: Long? = null, +) { + val status: DailyDeckStatus get() = + when { + ready > 0 -> DailyDeckStatus.REVIEW_NOW + remaining > 0 && nextLearningAt != null -> DailyDeckStatus.LEARNING_LATER + remaining > 0 -> DailyDeckStatus.REVIEW_NOW + reviewed > 0 -> DailyDeckStatus.DONE + else -> DailyDeckStatus.NOTHING_SCHEDULED + } + + val readyCount: Long get() = ready.coerceAtLeast(remaining.takeIf { nextLearningAt == null } ?: 0) + + fun label(context: Context): String = + when (status) { + DailyDeckStatus.REVIEW_NOW -> context.resources.getQuantityString(R.plurals.aq_deck_ready, readyCount.toInt(), readyCount) + DailyDeckStatus.LEARNING_LATER -> + context.getString( + R.string.aq_deck_later, + DateFormat.getTimeFormat(context).format(Date(requireNotNull(nextLearningAt))), + ) + DailyDeckStatus.DONE -> context.getString(R.string.aq_deck_done) + DailyDeckStatus.NOTHING_SCHEDULED -> context.getString(R.string.aq_deck_nothing) + } + + val color: Int get() = + when (status) { + DailyDeckStatus.LEARNING_LATER -> R.color.aq_deck_amber + DailyDeckStatus.DONE -> R.color.aq_deck_green + else -> R.color.aq_home_muted + } + + val icon: Int get() = + when (status) { + DailyDeckStatus.LEARNING_LATER -> R.drawable.ic_running_clock + DailyDeckStatus.DONE -> R.drawable.ic_done + else -> 0 + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDecks.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDecks.kt index 20cef26c562b..920e3222ab82 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDecks.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDecks.kt @@ -10,6 +10,56 @@ import org.json.JSONObject internal object AnkiquestDecks { private const val DAY_MS = 86_400_000L + fun dailyStates( + col: Collection, + now: Long, + dueTree: com.ichi2.anki.libanki.sched.DeckNode? = null, + ): Map { + val tree = dueTree ?: col.sched.deckDueTree() + val rows = snapshots(col, now, 0, 0, tree) + val indexed = tree.associateBy { it.did } + val names = col.decks.allNamesAndIds(includeFiltered = false) + val next = mutableMapOf>() + val detachedDue = mutableMapOf() + col.db + .query( + "select case when odid != 0 then odid else did end, count(*) from cards " + + "where (queue in (1,4) and due < ?) " + + "or (odid != 0 and (queue = 0 or (queue in (2,3) and due <= ?))) group by 1", + col.sched.dayCutoff, + col.sched.today, + ).use { cursor -> + while (cursor.moveToNext()) detachedDue[cursor.getLong(0)] = cursor.getLong(1) + } + col.db + .query( + "select case when odid != 0 then odid else did end, count(*), min(due) from cards where queue in (1,4) and due > ? and due < ? group by 1", + now / 1000, + col.sched.dayCutoff, + ).use { cursor -> + while (cursor.moveToNext()) next[cursor.getLong(0)] = cursor.getLong(1) to cursor.getLong(2) * 1000 + } + val ordinaryIds = names.map { it.id }.toSet() + return (0 until rows.length()).associate { index -> + val row = rows.getJSONObject(index) + val id = row.getString("id").toLong() + val name = row.getString("name") + val node = indexed[id] + val descendants = names.filter { it.id == id || it.name.startsWith("$name::") } + val nextAt = descendants.mapNotNull { next[it.id]?.second }.minOrNull() + // snapshots uses a sentinel for a deck omitted by Anki's tree. + // Use actual learning and filtered cards for display instead. + val remaining = if (node == null) descendants.sumOf { detachedDue[it.id] ?: 0L } else row.getLong("remaining") + val later = descendants.sumOf { next[it.id]?.first ?: 0L } + val due = node?.let { (it.newCount + it.lrnCount + it.revCount).toLong() } ?: 0L + id to DailyDeckState(maxOf(due, remaining - later), remaining, row.getLong("reviewed_today"), nextAt) + } + + indexed.filterKeys { id -> id !in ordinaryIds && id != 0L }.mapValues { (_, node) -> + val due = (node.newCount + node.lrnCount + node.revCount).toLong() + DailyDeckState(due, due, 0) + } + } + fun day( now: Long, offsetWestMinutes: Int, @@ -21,12 +71,13 @@ internal object AnkiquestDecks { now: Long, offsetWestMinutes: Int, rolloverHour: Int, + dueTree: com.ichi2.anki.libanki.sched.DeckNode? = null, ): JSONArray { val day = day(now, offsetWestMinutes, rolloverHour) val end = col.sched.dayCutoff * 1000 val start = end - DAY_MS val names = col.decks.allNamesAndIds(includeFiltered = false) - val tree = col.sched.deckDueTree().associateBy { it.did } + val tree = (dueTree ?: col.sched.deckDueTree()).associateBy { it.did } val reviewed = mutableMapOf() col.db .query( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivity.kt index 066f1e5eafd2..dbbc76635840 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivity.kt @@ -20,6 +20,7 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.core.widget.NestedScrollView +import androidx.core.widget.TextViewCompat import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.button.MaterialButton @@ -85,6 +86,29 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { render() } + private var localRefreshJob: kotlinx.coroutines.Job? = null + + override fun onPause() { + localRefreshJob?.cancel() + super.onPause() + } + + private fun scheduleLocalRefresh(nextAt: Long?) { + localRefreshJob?.cancel() + if (nextAt == null) return + localRefreshJob = + lifecycleScope.launch { + kotlinx.coroutines.delay( + ( + nextAt - + com.ichi2.anki.common.time.TimeManager.time + .intTimeMS() + ).coerceAtLeast(1_000L), + ) + if (lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) refresh() + } + } + override fun onResume() { super.onResume() if (!::content.isInitialized) return @@ -104,6 +128,7 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { val result = AnkiquestHomeData.local() if (turn == generation) { local = result + scheduleLocalRefresh(result.nextUpdateAt) localFailed = false render() } @@ -171,10 +196,17 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { focus != null -> { text(study, focus.name, heading = true) text(study, getString(R.string.aq_home_counts, focus.review, focus.learning, focus.new), small = true) - if (focus.due > 0) { - button(study, getString(R.string.aq_home_study_due, focus.due), primary = true) { openDecks(focus.id) } - } else { - text(study, getString(R.string.aq_home_nothing_due), small = true) + val daily = focus.daily + val label = text(study, daily.label(this), small = true) + label.setTextColor(getColor(daily.color)) + if (daily.icon != 0) { + label.setCompoundDrawablesRelativeWithIntrinsicBounds(daily.icon, 0, 0, 0) + TextViewCompat.setCompoundDrawableTintList(label, ColorStateList.valueOf(getColor(daily.color))) + label.compoundDrawablePadding = dp(6) + } + if (daily.status == DailyDeckStatus.DONE) study.background = shape(R.color.aq_deck_done_background) + if (daily.status == DailyDeckStatus.REVIEW_NOW) { + button(study, getString(R.string.aq_home_study_due, daily.readyCount.toInt()), primary = true) { openDecks(focus.id) } } button(study, R.string.aq_home_change_deck) { chooseDeck() } } @@ -216,13 +248,14 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { val rows = local?.decks.orEmpty() MaterialAlertDialogBuilder(this) .setTitle(R.string.aq_home_change_deck) - .setItems(rows.map { "${it.name} · ${it.due}" }.toTypedArray()) { _, index -> + .setItems(rows.map { "${it.name} · ${it.daily.label(this)}" }.toTypedArray()) { _, index -> lifecycleScope.launch { try { CollectionManager.withCol { if (decks.get(rows[index].id) != null) decks.select(rows[index].id) } local = AnkiquestHomeData.local() + scheduleLocalRefresh(local?.nextUpdateAt) localFailed = false } catch (e: CancellationException) { throw e diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeData.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeData.kt index 9530d2a92168..ec2fe8780b26 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeData.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeData.kt @@ -63,6 +63,7 @@ internal data class HomeDeck( val new: Int, val learning: Int, val review: Int, + val daily: DailyDeckState = DailyDeckState((new + learning + review).toLong(), (new + learning + review).toLong(), 0), ) { val due: Int get() = new + learning + review } @@ -70,9 +71,11 @@ internal data class HomeDeck( internal data class HomeLocal( val decks: List, val selected: Long, + val nextUpdateAt: Long? = null, ) { val focus: HomeDeck? get() = - decks.firstOrNull { it.id == selected && it.due > 0 } ?: decks.firstOrNull { it.due > 0 } + decks.firstOrNull { it.id == selected && it.daily.status == DailyDeckStatus.REVIEW_NOW } + ?: decks.firstOrNull { it.daily.status == DailyDeckStatus.REVIEW_NOW } ?: decks.firstOrNull { it.id == selected } ?: decks.firstOrNull() } @@ -109,11 +112,30 @@ internal object AnkiquestHomeData { suspend fun local(): HomeLocal = CollectionManager.withCol { + val now = + com.ichi2.anki.common.time.TimeManager.time + .intTimeMS() + val tree = sched.deckDueTree() + val daily = AnkiquestDecks.dailyStates(this, now, tree) val rows = - sched.deckDueTree().filter { it.did != 0L }.map { - HomeDeck(it.did, it.fullDeckName, it.newCount, it.lrnCount, it.revCount) + tree.filter { it.did != 0L }.map { + HomeDeck( + it.did, + it.fullDeckName, + it.newCount, + it.lrnCount, + it.revCount, + daily[it.did] + ?: DailyDeckState( + (it.newCount + it.lrnCount + it.revCount).toLong(), + (it.newCount + it.lrnCount + it.revCount).toLong(), + 0, + ), + ) } - HomeLocal(rows, decks.selected()) + val cutoff = sched.dayCutoff * 1000L + val next = (daily.values.mapNotNull { it.nextLearningAt } + cutoff).filter { it > now }.minOrNull() + HomeLocal(rows, decks.selected(), next) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt index 274f78dcabb4..d249a9900246 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt @@ -96,13 +96,16 @@ class DeckPickerViewModel : */ private val flowOfRefreshDeckList = MutableSharedFlow(extraBufferCapacity = 1) + private val dailyDeckStates = MutableStateFlow>(emptyMap()) + val flowOfDeckList = combine( flowOfDeckDueTree, flowOfCurrentDeckFilter, flowOfFocusedDeck, flowOfRefreshDeckList.onStart { emit(Unit) }, - ) { tree, filter, _, _ -> + dailyDeckStates, + ) { tree, filter, _, _, daily -> if (tree == null) return@combine FlattenedDeckList.empty // TODO: use flowOfFocusedDeck once it's set on all instances @@ -110,7 +113,7 @@ class DeckPickerViewModel : Timber.i("currentDeckId: %d", currentDeckId) FlattenedDeckList( - data = tree.filterAndFlattenDisplay(filter, currentDeckId), + data = tree.filterAndFlattenDisplay(filter, currentDeckId).map { it.withDailyState(daily[it.did]) }, hasSubDecks = tree.children.any { it.children.any() }, ) }.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = FlattenedDeckList.empty) @@ -383,6 +386,15 @@ class DeckPickerViewModel : withCol { Pair(sched.deckDueTree(), isEmpty) } + dailyDeckStates.value = + withCol { + com.ichi2.anki.ankiquest.AnkiquestDecks.dailyStates( + this, + com.ichi2.anki.common.time.TimeManager.time + .intTimeMS(), + deckDueTree, + ) + } dueTree = deckDueTree flowOfCollectionHasNoCards.value = collectionHasNoCards diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DisplayDeckNode.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DisplayDeckNode.kt index 6d6a7c7a0f92..15af23992b51 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DisplayDeckNode.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DisplayDeckNode.kt @@ -40,6 +40,7 @@ data class DisplayDeckNode private constructor( val lrnCount: Int, val revCount: Int, val isSelected: Boolean, + val dailyState: com.ichi2.anki.ankiquest.DailyDeckState? = null, ) { // DeckNode is mutable, so use a lateinit var so '==' doesn't include it in the comparison lateinit var deckNode: DeckNode @@ -49,6 +50,9 @@ data class DisplayDeckNode private constructor( updated.deckNode = this.deckNode } + fun withDailyState(state: com.ichi2.anki.ankiquest.DailyDeckState?): DisplayDeckNode = + copy(dailyState = state).also { it.deckNode = deckNode } + companion object { fun from( node: DeckNode, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/widgets/DeckAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/widgets/DeckAdapter.kt index 82ef538c521a..4e298d187109 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/widgets/DeckAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/widgets/DeckAdapter.kt @@ -14,6 +14,7 @@ import androidx.core.content.withStyledAttributes import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.core.widget.TextViewCompat import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView @@ -167,6 +168,30 @@ class DeckAdapter( binding.deckName.text = node.lastDeckNameComponent binding.deckName.setTextColor(if (node.filtered) deckNameDynColor else deckNameDefaultColor) + binding.deckDailyStatus.isVisible = node.dailyState != null + node.dailyState?.let { daily -> + val status = binding.deckDailyStatus + status.text = daily.label(status.context) + status.setTextColor(status.context.getColor(daily.color)) + status.setCompoundDrawablesRelativeWithIntrinsicBounds(daily.icon, 0, 0, 0) + TextViewCompat.setCompoundDrawableTintList( + status, + android.content.res.ColorStateList + .valueOf(status.context.getColor(daily.color)), + ) + status.compoundDrawablePadding = 4.dp.toPx(status.context) + if (daily.status == com.ichi2.anki.ankiquest.DailyDeckStatus.DONE && !node.isSelected) { + binding.deckLayout.backgroundTintList = + android.content.res.ColorStateList + .valueOf(status.context.getColor(R.color.aq_deck_done_background)) + } + } + if (node.dailyState?.status != com.ichi2.anki.ankiquest.DailyDeckStatus.DONE || + node.isSelected + ) { + binding.deckLayout.backgroundTintList = null + } + // Set the card counts and their colors binding.deckNew.text = node.newCount.toString() binding.deckNew.setTextColor(if (node.newCount == 0) zeroCountColor else newCountColor) diff --git a/AnkiDroid/src/main/res/layout/item_deck.xml b/AnkiDroid/src/main/res/layout/item_deck.xml index 6302c3411e4e..143d2e182f7f 100644 --- a/AnkiDroid/src/main/res/layout/item_deck.xml +++ b/AnkiDroid/src/main/res/layout/item_deck.xml @@ -38,19 +38,33 @@ android:minWidth="48dp" android:padding="12dp" android:background="?attr/selectableItemBackgroundBorderless" /> - + android:orientation="vertical"> + + + + + Aprendizaje pendiente hoy · siguiente a las %1$s + Terminado por hoy + Nada programado para hoy + + %d tarjeta lista + %d tarjetas listas + + diff --git a/AnkiDroid/src/main/res/values-es/ankiquest_home.xml b/AnkiDroid/src/main/res/values-es/ankiquest_home.xml index fdff0029bc74..b13a7bb24770 100644 --- a/AnkiDroid/src/main/res/values-es/ankiquest_home.xml +++ b/AnkiDroid/src/main/res/values-es/ankiquest_home.xml @@ -8,7 +8,6 @@ Tu próximo paso Estudiar %1$d tarjetas pendientes %1$d repasos · %2$d aprendiendo · %3$d nuevas - No hay tarjetas listas en este mazo. El próximo repaso aparecerá cuando corresponda. Cambiar mazo de estudio Abrir mazos Tu estudio empieza con un mazo. diff --git a/AnkiDroid/src/main/res/values-night/ankiquest_daily_deck_colors.xml b/AnkiDroid/src/main/res/values-night/ankiquest_daily_deck_colors.xml new file mode 100644 index 000000000000..c92f57fe75cf --- /dev/null +++ b/AnkiDroid/src/main/res/values-night/ankiquest_daily_deck_colors.xml @@ -0,0 +1,6 @@ + + + #F0BC57 + #8CD79E + #1B3426 + diff --git a/AnkiDroid/src/main/res/values/ankiquest_daily_deck_colors.xml b/AnkiDroid/src/main/res/values/ankiquest_daily_deck_colors.xml new file mode 100644 index 000000000000..b6f649246e59 --- /dev/null +++ b/AnkiDroid/src/main/res/values/ankiquest_daily_deck_colors.xml @@ -0,0 +1,6 @@ + + + #916200 + #247743 + #EAF3E9 + diff --git a/AnkiDroid/src/main/res/values/ankiquest_daily_decks.xml b/AnkiDroid/src/main/res/values/ankiquest_daily_decks.xml new file mode 100644 index 000000000000..ea80d1adee16 --- /dev/null +++ b/AnkiDroid/src/main/res/values/ankiquest_daily_decks.xml @@ -0,0 +1,10 @@ + + + Learning later today · next at %1$s + Done today + Nothing scheduled today + + %d card ready + %d cards ready + + diff --git a/AnkiDroid/src/main/res/values/ankiquest_home.xml b/AnkiDroid/src/main/res/values/ankiquest_home.xml index 863264d94c57..c52363af43bf 100644 --- a/AnkiDroid/src/main/res/values/ankiquest_home.xml +++ b/AnkiDroid/src/main/res/values/ankiquest_home.xml @@ -16,7 +16,6 @@ Your next step Study %1$d due %1$d reviews · %2$d learning · %3$d new - Nothing ready in this deck. Your next review will appear when due. Change study focus Open decks Your study starts with a deck. diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestDecksTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestDecksTest.kt index 47d6cbfdd5eb..adedf6233a4e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestDecksTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestDecksTest.kt @@ -45,6 +45,9 @@ class AnkiquestDecksTest : RobolectricTest() { assertEquals(0L, snapshot(1).getLong("remaining")) assertEquals(1L, snapshot(1).getLong("reviewed_today")) assertEquals(1L, snapshot(other).getLong("remaining")) + val states = AnkiquestDecks.dailyStates(col, TimeManager.time.intTimeMS()) + assertEquals(DailyDeckStatus.DONE, states[1]?.status) + assertEquals(DailyDeckStatus.REVIEW_NOW, states[other]?.status) } @Test @@ -74,6 +77,10 @@ class AnkiquestDecksTest : RobolectricTest() { assertEquals(1L, snapshot(child).getLong("remaining")) assertEquals(1L, snapshot(parent).getLong("remaining")) + val states = AnkiquestDecks.dailyStates(col, TimeManager.time.intTimeMS()) + assertEquals(DailyDeckStatus.LEARNING_LATER, states[child]?.status) + assertEquals(DailyDeckStatus.LEARNING_LATER, states[parent]?.status) + assertEquals(card.due * 1000L, states[child]?.nextLearningAt) } @Test @@ -85,6 +92,7 @@ class AnkiquestDecksTest : RobolectricTest() { col.updateCards(listOf(card)) assertEquals(0L, snapshot(1).getLong("remaining")) + assertEquals(DailyDeckStatus.NOTHING_SCHEDULED, AnkiquestDecks.dailyStates(col, TimeManager.time.intTimeMS())[1]?.status) } @Test @@ -112,6 +120,9 @@ class AnkiquestDecksTest : RobolectricTest() { assertEquals(1L, snapshot(1).getLong("remaining")) assertTrue(snapshots().none { it.getString("id") == filtered.toString() }) + val state = AnkiquestDecks.dailyStates(col, TimeManager.time.intTimeMS())[1] + assertEquals(DailyDeckStatus.REVIEW_NOW, state?.status) + assertEquals(1L, state?.readyCount) } @Test @@ -119,6 +130,7 @@ class AnkiquestDecksTest : RobolectricTest() { col.decks.id("Spanish") assertTrue(snapshots().any { it.getString("id") == "1" }) assertEquals(0L, snapshot(1).getLong("reviewed_today")) + assertEquals(DailyDeckStatus.NOTHING_SCHEDULED, AnkiquestDecks.dailyStates(col, TimeManager.time.intTimeMS())[1]?.status) } private fun snapshot(id: Long): JSONObject = snapshots().single { it.getString("id") == id.toString() } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivityTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivityTest.kt index 5d63d6f53c5a..c1377a390889 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivityTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivityTest.kt @@ -57,6 +57,34 @@ class AnkiquestHomeActivityTest : RobolectricTest() { unmockkObject(AnkiquestHomeData) } + @Test + fun `an empty unstudied deck is clearly labelled nothing scheduled today`() { + coEvery { AnkiquestHomeData.local() } returns HomeLocal(listOf(HomeDeck(12, "Spanish", 0, 0, 0)), 12) + val home = launch() + assertTrue(home.hasText("Nothing scheduled today")) + } + + @Test + fun `learning later shows its next time without inviting an empty study session`() { + val next = + com.ichi2.anki.common.time.TimeManager.time + .intTimeMS() + 3_600_000L + val daily = DailyDeckState(0, 1, 1, next) + coEvery { AnkiquestHomeData.local() } returns HomeLocal(listOf(HomeDeck(12, "Spanish", 0, 0, 0, daily)), 12) + val home = launch() + assertTrue(home.hasTextContaining("Learning later today")) + assertFalse(home.hasText(home.getString(R.string.aq_home_study_due, 0))) + } + + @Test + fun `completed deck has a done state without an empty study action`() { + val daily = DailyDeckState(0, 0, 1) + coEvery { AnkiquestHomeData.local() } returns HomeLocal(listOf(HomeDeck(12, "Spanish", 0, 0, 0, daily)), 12) + val home = launch() + assertTrue(home.hasText("Done today")) + assertFalse(home.hasText(home.getString(R.string.aq_home_study_due, 0))) + } + @Test fun `Today studies the local focus deck and shows server quests`() { val home = launch() @@ -129,6 +157,9 @@ class AnkiquestHomeActivityTest : RobolectricTest() { private fun AnkiquestHomeActivity.hasText(value: String): Boolean = findViewById(R.id.aq_home_content).allViews.filterIsInstance().any { it.text.toString() == value } + private fun AnkiquestHomeActivity.hasTextContaining(value: String): Boolean = + findViewById(R.id.aq_home_content).allViews.filterIsInstance().any { it.text.toString().contains(value) } + private fun AnkiquestHomeActivity.click(label: String) { val control = findViewById(R.id.aq_home_content).allViews.filterIsInstance().first { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestHomeDataTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestHomeDataTest.kt index a943c9d9087d..7f9f81942a5e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestHomeDataTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestHomeDataTest.kt @@ -145,6 +145,9 @@ class AnkiquestHomeDataTest : RobolectricTest() { assertEquals(24, selected.due) assertEquals(selected, HomeLocal(listOf(other, selected), 12).focus) assertEquals(other, HomeLocal(listOf(other, selected), 999).focus) - assertEquals(other, HomeLocal(listOf(selected.copy(new = 0, learning = 0, review = 0), other), 12).focus) + assertEquals( + other, + HomeLocal(listOf(selected.copy(new = 0, learning = 0, review = 0, daily = DailyDeckState(0, 0, 0)), other), 12).focus, + ) } }