Skip to content
Merged
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,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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long, DailyDeckState> {
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<Long, Pair<Long, Long>>()
val detachedDue = mutableMapOf<Long, Long>()
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,
Expand All @@ -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<Long, Long>()
col.db
.query(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
Expand Down Expand Up @@ -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() }
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,19 @@ 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
}

internal data class HomeLocal(
val decks: List<HomeDeck>,
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()
}
Expand Down Expand Up @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,21 +96,24 @@ class DeckPickerViewModel :
*/
private val flowOfRefreshDeckList = MutableSharedFlow<Unit>(extraBufferCapacity = 1)

private val dailyDeckStates = MutableStateFlow<Map<DeckId, com.ichi2.anki.ankiquest.DailyDeckState>>(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
val currentDeckId = withCol { decks.current().getLong("id") }
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)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/widgets/DeckAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading