From de0e25a7f041ae088ba552e4aed86585116ed233 Mon Sep 17 00:00:00 2001 From: hill Date: Mon, 2 Nov 2026 08:43:43 +0100 Subject: [PATCH 1/3] Show today's goal as an animated progress bar on Today and above the deck list --- .../main/java/com/ichi2/anki/DeckPicker.kt | 1 + .../ankiquest/AnkiquestDeckPickerHooks.kt | 65 +++ .../ichi2/anki/ankiquest/AnkiquestGoalBar.kt | 454 ++++++++++++++++++ .../ichi2/anki/ankiquest/AnkiquestGoalPlan.kt | 30 +- .../anki/ankiquest/AnkiquestGoalViews.kt | 287 +++++++++++ .../anki/ankiquest/AnkiquestHomeActivity.kt | 40 +- .../ichi2/anki/ankiquest/AnkiquestHomeData.kt | 6 +- .../main/res/values-de/ankiquest_goals.xml | 4 + .../main/res/values-es/ankiquest_goals.xml | 4 + .../main/res/values-fr/ankiquest_goals.xml | 4 + .../ankiquest_goal_bar_colors.xml | 13 + .../main/res/values-pt/ankiquest_goals.xml | 4 + .../res/values/ankiquest_goal_bar_colors.xml | 13 + .../src/main/res/values/ankiquest_goals.xml | 4 + .../anki/ankiquest/AnkiquestGoalBarTest.kt | 194 ++++++++ .../ankiquest/AnkiquestHomeScreenshotTest.kt | 25 +- 16 files changed, 1108 insertions(+), 40 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalBar.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalViews.kt create mode 100644 AnkiDroid/src/main/res/values-night/ankiquest_goal_bar_colors.xml create mode 100644 AnkiDroid/src/main/res/values/ankiquest_goal_bar_colors.xml create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestGoalBarTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 83f5ab1d5d93..a47b24f10564 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -1626,6 +1626,7 @@ open class DeckPicker : if (AnkiquestDeckPickerHooks.recreateIfNavigationChanged(this, ankiquestNavigationMode)) return if (navDrawerIsReady() && hasCollectionStoragePermissions()) { refreshState() + AnkiquestDeckPickerHooks.refreshGoal(this) } viewModel.onScreenResumed() message?.let { dialogHandler.sendStoredMessage(it) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckPickerHooks.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckPickerHooks.kt index 8219fb63858b..971f4a21f580 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckPickerHooks.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestDeckPickerHooks.kt @@ -7,17 +7,21 @@ import android.view.KeyEvent import android.view.Menu import android.view.MenuItem import android.view.View +import android.view.ViewGroup import android.widget.LinearLayout import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.AppCompatCheckBox import androidx.core.content.edit import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat.Type.navigationBars +import androidx.core.view.children import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.fragment.app.commit +import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomnavigation.BottomNavigationView import com.ichi2.anki.AbstractFlashcardViewer +import com.ichi2.anki.AnkiDroidApp import com.ichi2.anki.BottomNavController.NavigationItem import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CommonString @@ -28,6 +32,9 @@ import com.ichi2.anki.android.input.shortcut import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.snackbar.showSnackbar +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import timber.log.Timber /** AnkiQuest behaviour of [DeckPicker], kept here so the upstream activity only carries one-line calls. */ object AnkiquestDeckPickerHooks { @@ -141,6 +148,64 @@ object AnkiquestDeckPickerHooks { } } + /** + * Shows today's goal in one tappable line above the deck list, which opens Today, or hides it + * when no account is set, the goal is off or the collection cannot be read. + */ + fun refreshGoal(activity: DeckPicker) { + val parent = activity.binding.deckPickerPane.deckpickerView + if (AnkiquestHomeData.account() == null) { + showGoal(parent, null) + return + } + activity.lifecycleScope.launch { + val loaded = + try { + withCol { + AnkiquestDailyGoal.status(AnkiquestDailyGoal.load(this), AnkiDroidApp.sharedPrefs()) to + AnkiquestGoalPlan.secondsPerCard(this) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "AnkiQuest goal could not be loaded for the deck list") + null + } + showGoal(parent, loaded?.first, loaded?.second ?: AnkiquestGoalPlan.DEFAULT_SECONDS_PER_CARD) { + activity.startActivity(AnkiquestHomeActivity.intent(activity)) + } + } + } + + /** Puts the one-line goal for [status] at the top of [parent], or hides it without a goal; returns the line shown. */ + internal fun showGoal( + parent: LinearLayout, + status: AnkiquestDailyGoal.Status?, + secondsPerCard: Double = AnkiquestGoalPlan.DEFAULT_SECONDS_PER_CARD, + open: () -> Unit = {}, + ): AnkiquestCompactGoal? { + val existing = parent.children.filterIsInstance().firstOrNull() + val display = status?.let { AnkiquestGoalBar.display(parent.context, it, secondsPerCard) } + if (status == null || display == null) { + existing?.isVisible = false + return null + } + val line = + existing ?: AnkiquestCompactGoal(parent.context).also { + val margin = (12 * parent.resources.displayMetrics.density).toInt() + parent.addView( + it, + 0, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + setMargins(margin, margin / 2, margin, margin / 3) + }, + ) + } + line.isVisible = true + line.show(display, status, open) + return line + } + /** Recreates [activity] when AnkiQuest navigation was switched on or off while it was in the background. */ fun recreateIfNavigationChanged( activity: DeckPicker, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalBar.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalBar.kt new file mode 100644 index 000000000000..02083f5e65de --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalBar.kt @@ -0,0 +1,454 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.LinearGradient +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RadialGradient +import android.graphics.RectF +import android.graphics.Shader +import android.os.Build +import android.util.AttributeSet +import android.view.View +import android.view.animation.DecelerateInterpolator +import android.view.animation.OvershootInterpolator +import androidx.core.content.edit +import androidx.core.graphics.ColorUtils +import com.ichi2.anki.R +import com.ichi2.anki.common.android.Animations +import com.ichi2.anki.common.preferences.sharedPrefs +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.max + +/** + * A thick, fully rounded bar toward today's goal. The fill is a gradient in the AnkiQuest blues with a + * glossy stripe and a glowing knob at its leading edge; it grows from the value last shown today with a + * slight overshoot, pops the milestones at 25, 50 and 75 % as it passes them, and turns gold with a + * shine and a bounce the first time the goal is reached. Without animations it shows the final state at once. + */ +internal class AnkiquestGoalBar + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + ) : View(context, attrs) { + /** What the bar shows for one day's goal. */ + data class Display( + val day: Int, + val fraction: Float, + val reached: Boolean, + /** "48 of 140 cards", or the done text once the goal is reached. */ + val label: String, + /** "~9 min left", or null once the goal is reached. */ + val remaining: String?, + /** How a backlog goal is made up, or null for other goals. */ + val detail: String?, + val description: String, + ) + + /** A thinner bar for one-line summaries. */ + var compact: Boolean = false + set(value) { + field = value + requestLayout() + } + + /** The value the bar is heading to, from 0 to 1. */ + var fraction: Float = 0f + private set + + /** The value drawn right now, which trails [fraction] while the fill animates. */ + var shownFraction: Float = 0f + private set + + /** Whether the bar shows the goal as reached, in gold. */ + var gold: Boolean = false + private set + + /** The milestones the drawn fill has passed. */ + val passedMilestones: Int get() = MILESTONES.count { shownFraction >= it } + + private var goldBlend = 0f + private var shine = -1f + private val pops = FloatArray(MILESTONES.size) { 1f } + private val animators = mutableListOf() + + private val density = resources.displayMetrics.density + private val track = context.getColor(R.color.aq_goal_bar_track) + private val tick = context.getColor(R.color.aq_goal_bar_tick) + private val fillStart = context.getColor(R.color.aq_goal_bar_start) + private val fillEnd = context.getColor(R.color.aq_goal_bar_end) + private val goldStart = context.getColor(R.color.aq_goal_gold_start) + private val goldEnd = context.getColor(R.color.aq_goal_gold_end) + + private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = track } + private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val goldPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val glossPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(90, 255, 255, 255) } + private val tickPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val knobPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE } + private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val shinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(150, 255, 255, 255) } + private val glowMatrix = Matrix() + private val rect = RectF() + private val fillPath = Path() + private val shinePath = Path() + + private val barHeight: Float get() = (if (compact) COMPACT_HEIGHT_DP else HEIGHT_DP) * density + private val glowPadding: Float get() = (if (compact) 2 else 5) * density + private val glowRadius: Float get() = barHeight / 2f + glowPadding + + init { + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES + } + + /** + * Shows [display], animating from the value this [slot] last showed on the same day; the gold + * celebration plays once per slot and day. + */ + fun show( + display: Display, + slot: String, + ) { + if (animators.isNotEmpty()) { + stop() + remember() + } + this.slot = slot + day = display.day + contentDescription = display.description + val remembered = + context + .sharedPrefs() + .getString(PREF_SHOWN_PREFIX + slot, null) + ?.split(':') + ?.takeIf { it.size == 3 && it[0].toIntOrNull() == display.day } + val from = remembered?.get(1)?.toFloatOrNull()?.coerceIn(0f, 1f) ?: 0f + val celebrated = remembered?.get(2) == "1" + fraction = display.fraction.coerceIn(0f, 1f) + reached = display.reached + if (!animationsEnabled(context) || (from == fraction && (!reached || celebrated))) { + settle() + return + } + shownFraction = from + gold = false + celebrationDone = false + goldBlend = 0f + shine = -1f + pops.fill(1f) + invalidate() + if (from == fraction) { + celebrate() + return + } + ValueAnimator + .ofFloat(from, fraction) + .apply { + startDelay = START_DELAY_MS + duration = (FILL_BASE_MS + FILL_PER_BAR_MS * abs(fraction - from)).toLong() + interpolator = OvershootInterpolator(1.3f) + addUpdateListener { + val value = (it.animatedValue as Float).coerceIn(0f, 1f) + MILESTONES.forEachIndexed { index, milestone -> + if (shownFraction < milestone && value >= milestone && from < milestone) pop(index) + } + shownFraction = value + invalidate() + } + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + animators.remove(animation) + shownFraction = fraction + invalidate() + if (reached && !celebrated) celebrate() else settle() + } + }, + ) + }.also { play(it) } + } + + private var slot: String? = null + private var day = 0 + private var reached = false + private var celebrationDone = false + + private fun remember() { + val key = slot ?: return + val value = "$day:$shownFraction:${if (gold && celebrationDone) 1 else 0}" + val prefs = context.sharedPrefs() + if (prefs.getString(PREF_SHOWN_PREFIX + key, null) != value) prefs.edit { putString(PREF_SHOWN_PREFIX + key, value) } + } + + private fun stop() { + val running = animators.toList() + animators.clear() + running.forEach { + it.removeAllListeners() + it.cancel() + } + } + + private fun settle() { + shownFraction = fraction + gold = reached + celebrationDone = reached + goldBlend = if (reached) 1f else 0f + shine = -1f + pops.fill(1f) + scaleX = 1f + scaleY = 1f + invalidate() + remember() + } + + private fun pop(index: Int) { + ValueAnimator + .ofFloat(1f, 1.9f, 1f) + .apply { + duration = POP_MS + interpolator = DecelerateInterpolator() + addUpdateListener { + pops[index] = it.animatedValue as Float + invalidate() + } + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + animators.remove(animation) + } + }, + ) + }.also { play(it) } + } + + private fun celebrate() { + gold = true + ValueAnimator + .ofFloat(1f, 1.25f, 1f) + .apply { + duration = BOUNCE_MS + interpolator = OvershootInterpolator(2f) + addUpdateListener { + val value = it.animatedValue as Float + scaleY = value + scaleX = 1f + (value - 1f) * 0.08f + } + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + animators.remove(animation) + } + }, + ) + }.also { play(it) } + ValueAnimator + .ofFloat(0f, 1f) + .apply { + duration = SHINE_MS + addUpdateListener { + val value = it.animatedValue as Float + goldBlend = (value * 3f).coerceAtMost(1f) + shine = value + invalidate() + } + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + animators.remove(animation) + settle() + } + }, + ) + }.also { play(it) } + } + + private fun play(animator: Animator) { + animators += animator + animator.start() + } + + override fun onDetachedFromWindow() { + if (animators.isNotEmpty()) { + stop() + remember() + } + super.onDetachedFromWindow() + } + + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + val height = ceil(barHeight + 2 * glowPadding).toInt() + setMeasuredDimension( + getDefaultSize(suggestedMinimumWidth, widthMeasureSpec), + resolveSize(height, heightMeasureSpec), + ) + } + + override fun onSizeChanged( + w: Int, + h: Int, + oldw: Int, + oldh: Int, + ) { + super.onSizeChanged(w, h, oldw, oldh) + val width = max(w, 1).toFloat() + fillPaint.shader = LinearGradient(0f, 0f, width, 0f, fillStart, fillEnd, Shader.TileMode.CLAMP) + goldPaint.shader = LinearGradient(0f, 0f, width, 0f, goldStart, goldEnd, Shader.TileMode.CLAMP) + glowPaint.shader = + RadialGradient( + 0f, + 0f, + max(glowRadius, 1f), + intArrayOf(ColorUtils.setAlphaComponent(Color.WHITE, 150), Color.TRANSPARENT), + null, + Shader.TileMode.CLAMP, + ) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val full = width.toFloat() + val bar = barHeight + val top = (height - bar) / 2f + val radius = bar / 2f + val center = top + radius + canvas.save() + if (layoutDirection == LAYOUT_DIRECTION_RTL) canvas.scale(-1f, 1f, full / 2f, 0f) + rect.set(0f, top, full, top + bar) + canvas.drawRoundRect(rect, radius, radius, trackPaint) + val shown = shownFraction.coerceIn(0f, 1f) + val end = if (shown <= 0f) 0f else max(bar, shown * full) + if (end > 0f) { + rect.set(0f, top, end, top + bar) + if (goldBlend < 1f) canvas.drawRoundRect(rect, radius, radius, fillPaint) + if (goldBlend > 0f) { + goldPaint.alpha = (goldBlend * 255).toInt() + canvas.drawRoundRect(rect, radius, radius, goldPaint) + } + if (end > bar) { + rect.set(radius * 0.7f, top + bar * 0.16f, end - radius * 0.7f, top + bar * 0.38f) + canvas.drawRoundRect(rect, bar * 0.11f, bar * 0.11f, glossPaint) + } + if (shine in 0f..1f) { + fillPath.reset() + rect.set(0f, top, end, top + bar) + fillPath.addRoundRect(rect, radius, radius, Path.Direction.CW) + val band = bar * 1.2f + val x = -band * 2 + shine * (end + band * 3) + shinePath.reset() + shinePath.moveTo(x + band, top) + shinePath.lineTo(x + band * 2, top) + shinePath.lineTo(x + band, top + bar) + shinePath.lineTo(x, top + bar) + shinePath.close() + canvas.save() + canvas.clipPath(fillPath) + canvas.drawPath(shinePath, shinePaint) + canvas.restore() + } + if (!gold && shown < 1f) { + val knob = end - radius + glowMatrix.setTranslate(knob, center) + glowPaint.shader?.setLocalMatrix(glowMatrix) + canvas.drawCircle(knob, center, glowRadius, glowPaint) + canvas.drawCircle(knob, center, radius * 0.42f, knobPaint) + } + } + MILESTONES.forEachIndexed { index, milestone -> + val x = milestone * full + if (x <= radius || x >= full - radius) return@forEachIndexed + val passed = shown >= milestone + tickPaint.color = if (passed) Color.argb(220, 255, 255, 255) else tick + canvas.drawCircle(x, center, bar * TICK_RADIUS * pops[index], tickPaint) + } + canvas.restore() + } + + companion object { + /** Prefix of the per-slot memory of the value last shown and the day it was shown on. */ + const val PREF_SHOWN_PREFIX = "ankiquestGoalBarShown." + + val MILESTONES = floatArrayOf(0.25f, 0.5f, 0.75f) + + private const val HEIGHT_DP = 18 + private const val COMPACT_HEIGHT_DP = 10 + private const val TICK_RADIUS = 0.14f + private const val START_DELAY_MS = 150L + private const val FILL_BASE_MS = 600f + private const val FILL_PER_BAR_MS = 700f + private const val POP_MS = 320L + private const val SHINE_MS = 900L + private const val BOUNCE_MS = 420L + + /** + * Whether to animate: the system and AnkiDroid's own settings allow animations, and the + * animator duration scale is not 0, which battery saver can also set. + */ + fun animationsEnabled(context: Context): Boolean = + Animations.areAnimationsEnabled(context) && + (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || ValueAnimator.areAnimatorsEnabled()) + + /** The share of [target] that [progress] makes, from 0 to 1; a reached goal is always full. */ + fun fraction( + progress: Int, + target: Int, + reached: Boolean, + ): Float = + when { + reached -> 1f + target <= 0 -> 0f + else -> (progress.toFloat() / target).coerceIn(0f, 1f) + } + + /** The bar for [status], or null when no daily goal is set. */ + fun display( + context: Context, + status: AnkiquestDailyGoal.Status, + secondsPerCard: Double, + ): Display? { + if (status.mode == AnkiquestDailyGoal.Mode.OFF) return null + val reached = status.goalDone + val progress = AnkiquestDailyGoal.progress(context, status) + val left = (status.goal.target - status.progress).coerceAtLeast(0) + val minutes = + when { + reached || left == 0 -> null + status.mode == AnkiquestDailyGoal.Mode.MINUTES -> left + else -> ceil(left * secondsPerCard.coerceAtLeast(1.0) / 60.0).toInt().coerceAtLeast(1) + } + val detail = + if (status.mode == AnkiquestDailyGoal.Mode.BACKLOG && status.goal.backlog > 0) { + context.getString(R.string.aq_goal_backlog_explained, status.goal.extra, status.goal.backlog, status.goal.daysLeft) + } else { + null + } + return Display( + day = status.day, + fraction = fraction(status.progress, status.goal.target, reached), + reached = reached, + label = if (reached) context.getString(R.string.aq_goal_done_title) else progress, + remaining = minutes?.let { context.getString(R.string.aq_goal_time_left, it) }, + detail = detail, + description = + if (reached) { + context.getString(R.string.aq_goal_done_title) + } else { + context.getString(R.string.aq_goal_daily, progress) + }, + ) + } + } + } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalPlan.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalPlan.kt index 0b01be16de28..ada603749ef3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalPlan.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalPlan.kt @@ -127,9 +127,7 @@ internal object AnkiquestGoalPlan { * date as an Anki day number, so this is one indexed count. New cards and daily limits are left * out, like Anki's own forecast graph. * - * `secondsPerCard` is the average answer time in the review log over the last [ANSWER_TIME_DAYS] - * days (`revlog.time` is in milliseconds), leaving out manual reschedules, or - * [DEFAULT_SECONDS_PER_CARD] without any history. + * `secondsPerCard` is [secondsPerCard]. */ fun forecast( col: Collection, @@ -144,19 +142,27 @@ internal object AnkiquestGoalPlan { today + 1, today + FORECAST_DAYS, ) - val seconds = - col.db - .query( - "select avg(time) from revlog where id >= ? and time > 0 and type < 4", - now - ANSWER_TIME_DAYS * DAY_MS, - ).use { cursor -> - if (!cursor.moveToFirst() || cursor.isNull(0)) null else cursor.getDouble(0) / 1000.0 - } return Forecast( due = load.dueToday, backlog = load.backlog, dailyDue = upcoming.toDouble() / FORECAST_DAYS, - secondsPerCard = seconds?.takeIf { it > 0 } ?: DEFAULT_SECONDS_PER_CARD, + secondsPerCard = secondsPerCard(col, now), ) } + + /** + * The average answer time in the review log over the last [ANSWER_TIME_DAYS] days (`revlog.time` + * is in milliseconds), leaving out manual reschedules, or [DEFAULT_SECONDS_PER_CARD] without any history. + */ + fun secondsPerCard( + col: Collection, + now: Long = TimeManager.time.intTimeMS(), + ): Double = + col.db + .query( + "select avg(time) from revlog where id >= ? and time > 0 and type < 4", + now - ANSWER_TIME_DAYS * DAY_MS, + ).use { cursor -> + if (!cursor.moveToFirst() || cursor.isNull(0)) null else cursor.getDouble(0) / 1000.0 + }?.takeIf { it > 0 } ?: DEFAULT_SECONDS_PER_CARD } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalViews.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalViews.kt new file mode 100644 index 000000000000..112cf76d18d3 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalViews.kt @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import androidx.core.view.isVisible +import com.ichi2.anki.R +import kotlin.math.min + +/** Today's goal on the Today screen: what is done and the time left above an [AnkiquestGoalBar], and how a backlog goal is made up. */ +internal class AnkiquestGoalProgress + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + ) : LinearLayout(context, attrs) { + val label: TextView = goalText(context, 16f, R.color.aq_home_text, bold = true) + val remaining: TextView = goalText(context, 14f, R.color.aq_home_muted, bold = true) + val bar = AnkiquestGoalBar(context) + val detail: TextView = goalText(context, 14f, R.color.aq_home_muted) + + init { + orientation = VERTICAL + label.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + remaining.gravity = Gravity.END + addView( + LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.BOTTOM + addView(label, LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + addView(remaining, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + }, + LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT), + ) + addView( + bar, + LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = dp(4) }, + ) + addView( + detail, + LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = dp(4) }, + ) + } + + fun show( + display: AnkiquestGoalBar.Display, + slot: String, + ) { + label.text = display.label + remaining.text = display.remaining + remaining.isVisible = display.remaining != null + detail.text = display.detail + detail.isVisible = display.detail != null + bar.show(display, slot) + } + } + +/** A small ring around a flame that fills with today's streak minutes. */ +internal class AnkiquestStreakRing + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + ) : View(context, attrs) { + /** How much of the streak minimum is done, from 0 to 1. */ + var fraction: Float = 0f + set(value) { + field = value.coerceIn(0f, 1f) + invalidate() + } + + var safe: Boolean = false + set(value) { + field = value + invalidate() + } + + private val streak = context.getColor(R.color.aq_goal_streak) + private val stroke = 2.5f * resources.displayMetrics.density + private val trackPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = stroke + color = context.getColor(R.color.aq_goal_bar_track) + } + private val arcPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = stroke + strokeCap = Paint.Cap.ROUND + color = streak + } + private val safePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ColorUtils.setAlphaComponent(streak, 60) } + private val flamePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER } + private val bounds = RectF() + + init { + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val size = min(width, height).toFloat() + val cx = width / 2f + val cy = height / 2f + val radius = size / 2f - stroke + bounds.set(cx - radius, cy - radius, cx + radius, cy + radius) + if (safe) canvas.drawCircle(cx, cy, radius, safePaint) + canvas.drawCircle(cx, cy, radius, trackPaint) + val sweep = if (safe) 360f else 360f * fraction + if (sweep > 0f) canvas.drawArc(bounds, -90f, sweep, false, arcPaint) + flamePaint.textSize = size * 0.5f + canvas.drawText(FLAME, cx, cy - (flamePaint.descent() + flamePaint.ascent()) / 2f, flamePaint) + } + + private companion object { + const val FLAME = "🔥" + } + } + +/** The streak minimum as a pill: a [AnkiquestStreakRing] and "7 / 10 min", or "Streak safe" once today counts. */ +internal class AnkiquestStreakChip + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + ) : LinearLayout(context, attrs) { + /** What the chip shows for one day. */ + data class Streak( + val text: String, + val description: String, + val fraction: Float, + val safe: Boolean, + ) + + val ring = AnkiquestStreakRing(context) + val label: TextView = goalText(context, 14f, R.color.aq_goal_chip_text, bold = true) + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(6), dp(4), dp(12), dp(4)) + background = + GradientDrawable().apply { + cornerRadius = dp(18).toFloat() + setColor(context.getColor(R.color.aq_goal_chip_background)) + } + label.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES + addView(ring, LayoutParams(dp(26), dp(26))) + addView( + label, + LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { marginStart = dp(6) }, + ) + } + + fun show(status: AnkiquestDailyGoal.Status) { + val streak = streak(context, status) + label.text = streak.text + contentDescription = streak.description + ring.fraction = streak.fraction + ring.safe = streak.safe + } + + companion object { + fun streak( + context: Context, + status: AnkiquestDailyGoal.Status, + ): Streak { + val minimum = status.streakMinutes + return when { + status.minimumDone -> + Streak( + context.getString(R.string.aq_goal_streak_safe), + if (minimum > 0) { + context.getString(R.string.aq_goal_minimum_minutes_done, minimum) + } else { + context.getString(R.string.aq_goal_minimum_any_done) + }, + 1f, + true, + ) + minimum > 0 -> { + val minutes = status.minutes.coerceIn(0, minimum) + Streak( + context.getString(R.string.aq_goal_streak_minutes, minutes, minimum), + context.getString(R.string.aq_goal_minimum_minutes, minimum) + ". " + + context.getString(R.string.ankiquest_streak_short, minutes, minimum), + minutes.toFloat() / minimum, + false, + ) + } + else -> + Streak( + context.getString(R.string.aq_goal_streak_any), + context.getString(R.string.aq_goal_minimum_any), + 0f, + false, + ) + } + } + } + } + +/** Today's goal in one tappable line above the deck list: what is done, a compact [AnkiquestGoalBar] and the streak ring. */ +internal class AnkiquestCompactGoal + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + ) : LinearLayout(context, attrs) { + val label: TextView = goalText(context, 14f, R.color.aq_home_text, bold = true) + val bar = AnkiquestGoalBar(context).apply { compact = true } + val ring = AnkiquestStreakRing(context) + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(14), dp(8), dp(10), dp(8)) + minimumHeight = dp(48) + isClickable = true + isFocusable = true + background = + GradientDrawable().apply { + cornerRadius = dp(16).toFloat() + setColor(context.getColor(R.color.aq_home_surface)) + setStroke(dp(1), context.getColor(R.color.aq_home_outline)) + } + val ripple = TypedValue() + if (context.theme.resolveAttribute(android.R.attr.selectableItemBackground, ripple, true)) { + foreground = ContextCompat.getDrawable(context, ripple.resourceId) + } + label.maxLines = 1 + label.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + bar.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO + addView(label, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + addView(bar, LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { marginStart = dp(12) }) + addView(ring, LayoutParams(dp(28), dp(28)).apply { marginStart = dp(10) }) + } + + fun show( + display: AnkiquestGoalBar.Display, + status: AnkiquestDailyGoal.Status, + open: () -> Unit, + ) { + val streak = AnkiquestStreakChip.streak(context, status) + label.text = display.label + ring.fraction = streak.fraction + ring.safe = streak.safe + contentDescription = display.description + ". " + streak.description + setOnClickListener { open() } + bar.show(display, SLOT_DECKS) + } + + companion object { + const val SLOT_DECKS = "decks" + } + } + +private fun goalText( + context: Context, + size: Float, + @ColorRes color: Int, + bold: Boolean = false, +): TextView = + TextView(context).apply { + textSize = size + setTextColor(context.getColor(color)) + if (bold) typeface = Typeface.DEFAULT_BOLD + } + +private fun View.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() 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 324c7792921e..cfd7f792936d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeActivity.kt @@ -326,34 +326,21 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { private fun renderGoal(status: AnkiquestDailyGoal.Status) { val panel = card() text(panel, getString(R.string.aq_goal_today), heading = true) - when { - status.minimumDone && status.streakMinutes > 0 -> - text(panel, getString(R.string.aq_goal_minimum_minutes_done, status.streakMinutes), small = true) - status.minimumDone -> text(panel, getString(R.string.aq_goal_minimum_any_done), small = true) - status.streakMinutes > 0 -> { - text(panel, getString(R.string.aq_goal_minimum_minutes, status.streakMinutes), small = true) - meter(panel, status.minutes.toLong(), status.streakMinutes.toLong()) - } - else -> text(panel, getString(R.string.aq_goal_minimum_any), small = true) + val secondsPerCard = local?.secondsPerCard ?: AnkiquestGoalPlan.DEFAULT_SECONDS_PER_CARD + AnkiquestGoalBar.display(this, status, secondsPerCard)?.let { display -> + panel.addView( + AnkiquestGoalProgress(this).apply { show(display, SLOT_TODAY) }, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + topMargin = dp(6) + }, + ) } - if (status.mode == AnkiquestDailyGoal.Mode.OFF) return - text( - panel, - if (status.goalDone) { - getString(R.string.aq_goal_done_title) - } else { - getString(R.string.aq_goal_daily, AnkiquestDailyGoal.progress(this, status)) + panel.addView( + AnkiquestStreakChip(this).apply { show(status) }, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + topMargin = dp(10) }, - small = true, ) - meter(panel, status.progress.toLong(), status.goal.target.toLong()) - if (status.mode == AnkiquestDailyGoal.Mode.BACKLOG && status.goal.backlog > 0) { - text( - panel, - getString(R.string.aq_goal_backlog_explained, status.goal.extra, status.goal.backlog, status.goal.daysLeft), - small = true, - ) - } } private fun renderDeckCopies() { @@ -673,6 +660,9 @@ class AnkiquestHomeActivity : AnkiActivity(R.layout.activity_ankiquest_home) { const val EXTRA_FOCUS = "ankiquest.focus" const val FOCUS_QUESTS = "quests" + /** The goal bar memory of the Today screen, see [AnkiquestGoalBar.show]. */ + const val SLOT_TODAY = "today" + fun intent(context: Context): Intent = Intent(context, AnkiquestHomeActivity::class.java) } } 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 d7fb46f8c38c..cc48a10a8ea5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeData.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestHomeData.kt @@ -132,6 +132,8 @@ internal data class HomeLocal( val selected: Long, val nextUpdateAt: Long? = null, val goal: AnkiquestDailyGoal.Status? = null, + /** The average answer time, for the time left toward [goal]. */ + val secondsPerCard: Double = AnkiquestGoalPlan.DEFAULT_SECONDS_PER_CARD, ) { val focus: HomeDeck? get() = decks.firstOrNull { it.id == selected && it.daily.status == DailyDeckStatus.REVIEW_NOW } @@ -201,6 +203,8 @@ internal object AnkiquestHomeData { val cutoff = sched.dayCutoff * 1000L val next = (daily.values.mapNotNull { it.nextLearningAt } + cutoff).filter { it > now }.minOrNull() val goal = runCatching { AnkiquestDailyGoal.status(AnkiquestDailyGoal.load(this), AnkiDroidApp.sharedPrefs()) }.getOrNull() - HomeLocal(rows, decks.selected(), next, goal) + val secondsPerCard = + runCatching { AnkiquestGoalPlan.secondsPerCard(this, now) }.getOrDefault(AnkiquestGoalPlan.DEFAULT_SECONDS_PER_CARD) + HomeLocal(rows, decks.selected(), next, goal, secondsPerCard) } } diff --git a/AnkiDroid/src/main/res/values-de/ankiquest_goals.xml b/AnkiDroid/src/main/res/values-de/ankiquest_goals.xml index b6820d217005..8e39f2c7490a 100644 --- a/AnkiDroid/src/main/res/values-de/ankiquest_goals.xml +++ b/AnkiDroid/src/main/res/values-de/ankiquest_goals.xml @@ -11,6 +11,10 @@ Tagesziel: %1$s Die heute fälligen Karten plus %1$d deiner %2$d überfälligen Karten. Verbleibende Tage im Plan: %3$d. Tagesziel erreicht! 🎉 + Noch ~%1$d Min. + %1$d / %2$d Min. + Lernserie sicher + Einmal wiederholen rettet deine Serie Heute %1$d von %2$d Minuten Karten pro Tag Minuten pro Tag diff --git a/AnkiDroid/src/main/res/values-es/ankiquest_goals.xml b/AnkiDroid/src/main/res/values-es/ankiquest_goals.xml index f441f7a3a24b..ab27ec41a946 100644 --- a/AnkiDroid/src/main/res/values-es/ankiquest_goals.xml +++ b/AnkiDroid/src/main/res/values-es/ankiquest_goals.xml @@ -11,6 +11,10 @@ Objetivo diario: %1$s Las tarjetas de hoy más %1$d de tus %2$d tarjetas atrasadas. Días que quedan en tu plan: %3$d. ¡Objetivo diario logrado! 🎉 + Te quedan ~%1$d min + %1$d / %2$d min + Racha a salvo + Repasa una vez y salvas tu racha %1$d de %2$d minutos hoy Tarjetas por día Minutos por día diff --git a/AnkiDroid/src/main/res/values-fr/ankiquest_goals.xml b/AnkiDroid/src/main/res/values-fr/ankiquest_goals.xml index a02b19269eef..975d13684580 100644 --- a/AnkiDroid/src/main/res/values-fr/ankiquest_goals.xml +++ b/AnkiDroid/src/main/res/values-fr/ankiquest_goals.xml @@ -11,6 +11,10 @@ Objectif du jour : %1$s Les cartes du jour plus %1$d de tes %2$d cartes en retard. Jours restants dans ton plan : %3$d. Objectif du jour atteint ! 🎉 + Encore ~%1$d min + %1$d / %2$d min + Série assurée + Une révision et ta série est sauvée %1$d minutes sur %2$d aujourd’hui Cartes par jour Minutes par jour diff --git a/AnkiDroid/src/main/res/values-night/ankiquest_goal_bar_colors.xml b/AnkiDroid/src/main/res/values-night/ankiquest_goal_bar_colors.xml new file mode 100644 index 000000000000..a8d5c4156470 --- /dev/null +++ b/AnkiDroid/src/main/res/values-night/ankiquest_goal_bar_colors.xml @@ -0,0 +1,13 @@ + + + + #2C3A47 + #4A5D70 + #6DB8FF + #4A7FE6 + #FFD54A + #F29A00 + #FF9A3D + #3D2B1C + #FFC690 + diff --git a/AnkiDroid/src/main/res/values-pt/ankiquest_goals.xml b/AnkiDroid/src/main/res/values-pt/ankiquest_goals.xml index 418072cb89b3..6d98a82313cc 100644 --- a/AnkiDroid/src/main/res/values-pt/ankiquest_goals.xml +++ b/AnkiDroid/src/main/res/values-pt/ankiquest_goals.xml @@ -11,6 +11,10 @@ Meta diária: %1$s Os cartões de hoje mais %1$d dos teus %2$d cartões atrasados. Dias que faltam no plano: %3$d. Meta diária alcançada! 🎉 + Faltam ~%1$d min + %1$d / %2$d min + Sequência garantida + Revise uma vez para manter sua sequência %1$d de %2$d minutos hoje Cartões por dia Minutos por dia diff --git a/AnkiDroid/src/main/res/values/ankiquest_goal_bar_colors.xml b/AnkiDroid/src/main/res/values/ankiquest_goal_bar_colors.xml new file mode 100644 index 000000000000..5fbfe95c67cd --- /dev/null +++ b/AnkiDroid/src/main/res/values/ankiquest_goal_bar_colors.xml @@ -0,0 +1,13 @@ + + + + #E2E9F2 + #B9C8D8 + #4FA8FF + #2B66D9 + #FFD84D + #FFA200 + #FF7A1A + #FFF0E0 + #8F3E00 + diff --git a/AnkiDroid/src/main/res/values/ankiquest_goals.xml b/AnkiDroid/src/main/res/values/ankiquest_goals.xml index 5d41e2772899..2f1ed21da68b 100644 --- a/AnkiDroid/src/main/res/values/ankiquest_goals.xml +++ b/AnkiDroid/src/main/res/values/ankiquest_goals.xml @@ -11,6 +11,10 @@ Daily goal: %1$s Today’s due cards plus %1$d of your %2$d overdue cards. Days left in your plan: %3$d. Daily goal reached! 🎉 + ~%1$d min left + %1$d / %2$d min + Streak safe + Review once to keep your streak %1$d of %2$d minutes today Cards per day Minutes per day diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestGoalBarTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestGoalBarTest.kt new file mode 100644 index 000000000000..cf3ccbe959f7 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestGoalBarTest.kt @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import android.os.Looper +import android.provider.Settings +import android.widget.LinearLayout +import androidx.core.content.edit +import androidx.core.view.isVisible +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.EmptyApplicationCategory +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.ankiquest.AnkiquestDailyGoal.Goal +import com.ichi2.anki.ankiquest.AnkiquestDailyGoal.Mode +import com.ichi2.anki.ankiquest.AnkiquestDailyGoal.Status +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.testutils.EmptyApplication +import org.junit.After +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.time.Duration +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class, sdk = [33]) +@Category(EmptyApplicationCategory::class) +class AnkiquestGoalBarTest : RobolectricTest() { + @After + fun restoreAnimations() { + animatorScale(1f) + } + + private fun animatorScale(scale: Float) { + Settings.Global.putFloat(targetContext.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, scale) + } + + private fun cards( + studied: Int, + target: Int, + day: Int = 100, + ) = Status(day, minutes = 4, streakMinutes = 10, studiedCards = studied, mode = Mode.CARDS, goal = Goal(target)) + + private fun display(status: Status) = assertNotNull(AnkiquestGoalBar.display(targetContext, status, 8.0)) + + @Test + fun `the fraction stays between empty and full`() { + assertEquals(0f, AnkiquestGoalBar.fraction(-5, 10, reached = false)) + assertEquals(0.5f, AnkiquestGoalBar.fraction(5, 10, reached = false)) + assertEquals(1f, AnkiquestGoalBar.fraction(150, 140, reached = false)) + assertEquals(0f, AnkiquestGoalBar.fraction(3, 0, reached = false)) + assertEquals(1f, AnkiquestGoalBar.fraction(0, 10, reached = true)) + } + + @Test + fun `a card goal shows the cards done, the time left and a spoken description`() { + val shown = display(cards(48, 140)) + val progress = targetContext.getString(R.string.aq_goal_progress_cards, 48, 140) + assertEquals(progress, shown.label) + assertEquals(targetContext.getString(R.string.aq_goal_time_left, 13), shown.remaining) + assertEquals(targetContext.getString(R.string.aq_goal_daily, progress), shown.description) + assertEquals(48f / 140, shown.fraction) + assertFalse(shown.reached) + assertNull(shown.detail) + } + + @Test + fun `a minute goal counts the minutes left`() { + val shown = display(Status(100, 12, 10, 30, Mode.MINUTES, Goal(20))) + assertEquals(targetContext.getString(R.string.aq_goal_progress_minutes, 12, 20), shown.label) + assertEquals(targetContext.getString(R.string.aq_goal_time_left, 8), shown.remaining) + } + + @Test + fun `studying past the target shows the goal reached and full`() { + val shown = display(cards(150, 140)) + assertTrue(shown.reached) + assertEquals(1f, shown.fraction) + assertEquals(targetContext.getString(R.string.aq_goal_done_title), shown.label) + assertEquals(targetContext.getString(R.string.aq_goal_done_title), shown.description) + assertNull(shown.remaining) + } + + @Test + fun `a backlog goal explains how it is made up and an off goal shows no bar`() { + val backlog = display(Status(100, 3, 0, 4, Mode.BACKLOG, Goal(30, backlog = 50, extra = 5, daysLeft = 10))) + assertEquals(targetContext.getString(R.string.aq_goal_backlog_explained, 5, 50, 10), backlog.detail) + assertNull(AnkiquestGoalBar.display(targetContext, Status(100, 3, 0, 4, Mode.OFF, Goal(0)), 8.0)) + } + + @Test + fun `without animations the bar shows the final value and gold at once`() { + animatorScale(0f) + assertFalse(AnkiquestGoalBar.animationsEnabled(targetContext)) + val bar = AnkiquestGoalBar(targetContext) + bar.show(display(cards(84, 140)), "test") + assertEquals(0.6f, bar.shownFraction, 0.001f) + assertEquals(2, bar.passedMilestones) + assertFalse(bar.gold) + val progress = targetContext.getString(R.string.aq_goal_progress_cards, 84, 140) + assertEquals(targetContext.getString(R.string.aq_goal_daily, progress), bar.contentDescription) + + bar.show(display(cards(140, 140)), "test") + assertEquals(1f, bar.shownFraction) + assertTrue(bar.gold) + assertEquals(3, bar.passedMilestones) + } + + @Test + fun `the fill animates from the value shown earlier the same day`() { + val first = AnkiquestGoalBar(targetContext) + first.show(display(cards(28, 140)), "test") + assertEquals(0f, first.shownFraction) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(5)) + assertEquals(0.2f, first.shownFraction, 0.001f) + + val again = AnkiquestGoalBar(targetContext) + again.show(display(cards(70, 140)), "test") + assertEquals(0.2f, again.shownFraction, 0.001f) + assertEquals(0.5f, again.fraction, 0.001f) + + val tomorrow = AnkiquestGoalBar(targetContext) + tomorrow.show(display(cards(70, 140, day = 101)), "test") + assertEquals(0f, tomorrow.shownFraction) + } + + @Test + fun `reaching the goal turns the bar gold once the celebration ends`() { + targetContext.sharedPrefs().edit { putString(AnkiquestGoalBar.PREF_SHOWN_PREFIX + "test", "100:0.9:0") } + val bar = AnkiquestGoalBar(targetContext) + bar.show(display(cards(140, 140)), "test") + assertFalse(bar.gold) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(5)) + assertTrue(bar.gold) + assertEquals(1f, bar.shownFraction) + + val later = AnkiquestGoalBar(targetContext) + later.show(display(cards(140, 140)), "test") + assertTrue(later.gold) + } + + @Test + fun `the streak chip counts minutes until today is safe`() { + val counting = AnkiquestStreakChip(targetContext).apply { show(Status(100, 7, 10, 20, Mode.CARDS, Goal(100))) } + assertEquals(targetContext.getString(R.string.aq_goal_streak_minutes, 7, 10), counting.label.text.toString()) + assertEquals(0.7f, counting.ring.fraction, 0.001f) + assertFalse(counting.ring.safe) + assertTrue(counting.contentDescription.startsWith(targetContext.getString(R.string.aq_goal_minimum_minutes, 10))) + + val safe = AnkiquestStreakChip(targetContext).apply { show(Status(100, 12, 10, 20, Mode.CARDS, Goal(100))) } + assertEquals(targetContext.getString(R.string.aq_goal_streak_safe), safe.label.text.toString()) + assertTrue(safe.ring.safe) + assertEquals(targetContext.getString(R.string.aq_goal_minimum_minutes_done, 10), safe.contentDescription) + + val any = AnkiquestStreakChip(targetContext).apply { show(Status(100, 0, 0, 0, Mode.OFF, Goal(0))) } + assertEquals(targetContext.getString(R.string.aq_goal_streak_any), any.label.text.toString()) + assertEquals(targetContext.getString(R.string.aq_goal_minimum_any), any.contentDescription) + + val anyDone = AnkiquestStreakChip(targetContext).apply { show(Status(100, 0, 0, 1, Mode.OFF, Goal(0))) } + assertEquals(targetContext.getString(R.string.aq_goal_minimum_any_done), anyDone.contentDescription) + } + + @Test + fun `the deck list shows a tappable goal line and hides it without a goal`() { + animatorScale(0f) + val parent = LinearLayout(targetContext).apply { addView(LinearLayout(targetContext)) } + assertNull(AnkiquestDeckPickerHooks.showGoal(parent, Status(100, 3, 0, 4, Mode.OFF, Goal(0)))) + assertEquals(1, parent.childCount) + + var opened = 0 + val line = assertNotNull(AnkiquestDeckPickerHooks.showGoal(parent, cards(48, 140)) { opened++ }) + assertSame(line, parent.getChildAt(0)) + assertTrue(line.isVisible) + assertEquals(targetContext.getString(R.string.aq_goal_progress_cards, 48, 140), line.label.text.toString()) + assertEquals(48f / 140, line.bar.shownFraction, 0.001f) + line.performClick() + assertEquals(1, opened) + + assertNull(AnkiquestDeckPickerHooks.showGoal(parent, null)) + assertFalse(line.isVisible) + + assertSame(line, AnkiquestDeckPickerHooks.showGoal(parent, cards(60, 140))) + assertTrue(line.isVisible) + assertEquals(2, parent.childCount) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestHomeScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestHomeScreenshotTest.kt index 1f0d5b4ce89c..ac67d84ba7b6 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestHomeScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestHomeScreenshotTest.kt @@ -2,6 +2,7 @@ package com.ichi2.anki.ankiquest +import android.provider.Settings import com.ichi2.anki.ScreenshotTest import io.mockk.coEvery import io.mockk.every @@ -60,11 +61,31 @@ class AnkiquestHomeScreenshotTest : ScreenshotTest() { @Test fun todayLargeText() = captureToday(largeText = true) - private fun captureToday(largeText: Boolean = false) { + @Test + fun todayGoal() { + Settings.Global.putFloat(targetContext.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 0f) + val goal = + AnkiquestDailyGoal.Status( + day = 1, + minutes = 7, + streakMinutes = 10, + studiedCards = 48, + mode = AnkiquestDailyGoal.Mode.CARDS, + goal = AnkiquestDailyGoal.Goal(140), + ) + coEvery { AnkiquestHomeData.local() } returns + HomeLocal(listOf(HomeDeck(12, "Spanish", 3, 2, 19)), 12, goal = goal, secondsPerCard = 6.0) + captureToday(prefix = "today_goal") + } + + private fun captureToday( + largeText: Boolean = false, + prefix: String = "today", + ) { RuntimeEnvironment.setFontScale(if (largeText) 2f else 1f) startActivityNormallyOpenCollectionWithIntent(AnkiquestHomeActivity::class.java, AnkiquestHomeActivity.intent(targetContext)) advanceRobolectricLooper() val size = if (device == DeviceConfig.PHONE) "320dp" else "default" - captureScreen("today_${size}${if (largeText) "_200percent" else ""}") + captureScreen("${prefix}_${size}${if (largeText) "_200percent" else ""}") } } From f30435d6382965848cd3c72e0c35cea03c10d17b Mon Sep 17 00:00:00 2001 From: hill Date: Mon, 2 Nov 2026 09:16:01 +0100 Subject: [PATCH 2/3] Clip the goal bar's shine with Canvas.withClip --- .../main/java/com/ichi2/anki/ankiquest/AnkiquestGoalBar.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalBar.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalBar.kt index 02083f5e65de..e209b276c7c6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalBar.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalBar.kt @@ -22,6 +22,7 @@ import android.view.animation.DecelerateInterpolator import android.view.animation.OvershootInterpolator import androidx.core.content.edit import androidx.core.graphics.ColorUtils +import androidx.core.graphics.withClip import com.ichi2.anki.R import com.ichi2.anki.common.android.Animations import com.ichi2.anki.common.preferences.sharedPrefs @@ -354,10 +355,7 @@ internal class AnkiquestGoalBar shinePath.lineTo(x + band, top + bar) shinePath.lineTo(x, top + bar) shinePath.close() - canvas.save() - canvas.clipPath(fillPath) - canvas.drawPath(shinePath, shinePaint) - canvas.restore() + canvas.withClip(fillPath) { drawPath(shinePath, shinePaint) } } if (!gold && shown < 1f) { val knob = end - radius From 21c9a9f5af1200e26ffeb47d2e9aa79f39217a70 Mon Sep 17 00:00:00 2001 From: hill Date: Wed, 4 Nov 2026 02:53:36 +0100 Subject: [PATCH 3/3] Draw the goal bar inside Canvas.withSave Lint's UseKtx check flags the manual save()/restore() pair. --- .../ichi2/anki/ankiquest/AnkiquestGoalBar.kt | 87 ++++++++++--------- 1 file changed, 44 insertions(+), 43 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalBar.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalBar.kt index e209b276c7c6..905873993093 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalBar.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestGoalBar.kt @@ -23,6 +23,7 @@ import android.view.animation.OvershootInterpolator import androidx.core.content.edit import androidx.core.graphics.ColorUtils import androidx.core.graphics.withClip +import androidx.core.graphics.withSave import com.ichi2.anki.R import com.ichi2.anki.common.android.Animations import com.ichi2.anki.common.preferences.sharedPrefs @@ -326,53 +327,53 @@ internal class AnkiquestGoalBar val top = (height - bar) / 2f val radius = bar / 2f val center = top + radius - canvas.save() - if (layoutDirection == LAYOUT_DIRECTION_RTL) canvas.scale(-1f, 1f, full / 2f, 0f) - rect.set(0f, top, full, top + bar) - canvas.drawRoundRect(rect, radius, radius, trackPaint) - val shown = shownFraction.coerceIn(0f, 1f) - val end = if (shown <= 0f) 0f else max(bar, shown * full) - if (end > 0f) { - rect.set(0f, top, end, top + bar) - if (goldBlend < 1f) canvas.drawRoundRect(rect, radius, radius, fillPaint) - if (goldBlend > 0f) { - goldPaint.alpha = (goldBlend * 255).toInt() - canvas.drawRoundRect(rect, radius, radius, goldPaint) - } - if (end > bar) { - rect.set(radius * 0.7f, top + bar * 0.16f, end - radius * 0.7f, top + bar * 0.38f) - canvas.drawRoundRect(rect, bar * 0.11f, bar * 0.11f, glossPaint) - } - if (shine in 0f..1f) { - fillPath.reset() + canvas.withSave { + if (layoutDirection == LAYOUT_DIRECTION_RTL) canvas.scale(-1f, 1f, full / 2f, 0f) + rect.set(0f, top, full, top + bar) + canvas.drawRoundRect(rect, radius, radius, trackPaint) + val shown = shownFraction.coerceIn(0f, 1f) + val end = if (shown <= 0f) 0f else max(bar, shown * full) + if (end > 0f) { rect.set(0f, top, end, top + bar) - fillPath.addRoundRect(rect, radius, radius, Path.Direction.CW) - val band = bar * 1.2f - val x = -band * 2 + shine * (end + band * 3) - shinePath.reset() - shinePath.moveTo(x + band, top) - shinePath.lineTo(x + band * 2, top) - shinePath.lineTo(x + band, top + bar) - shinePath.lineTo(x, top + bar) - shinePath.close() - canvas.withClip(fillPath) { drawPath(shinePath, shinePaint) } + if (goldBlend < 1f) canvas.drawRoundRect(rect, radius, radius, fillPaint) + if (goldBlend > 0f) { + goldPaint.alpha = (goldBlend * 255).toInt() + canvas.drawRoundRect(rect, radius, radius, goldPaint) + } + if (end > bar) { + rect.set(radius * 0.7f, top + bar * 0.16f, end - radius * 0.7f, top + bar * 0.38f) + canvas.drawRoundRect(rect, bar * 0.11f, bar * 0.11f, glossPaint) + } + if (shine in 0f..1f) { + fillPath.reset() + rect.set(0f, top, end, top + bar) + fillPath.addRoundRect(rect, radius, radius, Path.Direction.CW) + val band = bar * 1.2f + val x = -band * 2 + shine * (end + band * 3) + shinePath.reset() + shinePath.moveTo(x + band, top) + shinePath.lineTo(x + band * 2, top) + shinePath.lineTo(x + band, top + bar) + shinePath.lineTo(x, top + bar) + shinePath.close() + canvas.withClip(fillPath) { drawPath(shinePath, shinePaint) } + } + if (!gold && shown < 1f) { + val knob = end - radius + glowMatrix.setTranslate(knob, center) + glowPaint.shader?.setLocalMatrix(glowMatrix) + canvas.drawCircle(knob, center, glowRadius, glowPaint) + canvas.drawCircle(knob, center, radius * 0.42f, knobPaint) + } } - if (!gold && shown < 1f) { - val knob = end - radius - glowMatrix.setTranslate(knob, center) - glowPaint.shader?.setLocalMatrix(glowMatrix) - canvas.drawCircle(knob, center, glowRadius, glowPaint) - canvas.drawCircle(knob, center, radius * 0.42f, knobPaint) + MILESTONES.forEachIndexed { index, milestone -> + val x = milestone * full + if (x <= radius || x >= full - radius) return@forEachIndexed + val passed = shown >= milestone + tickPaint.color = if (passed) Color.argb(220, 255, 255, 255) else tick + canvas.drawCircle(x, center, bar * TICK_RADIUS * pops[index], tickPaint) } } - MILESTONES.forEachIndexed { index, milestone -> - val x = milestone * full - if (x <= radius || x >= full - radius) return@forEachIndexed - val passed = shown >= milestone - tickPaint.color = if (passed) Color.argb(220, 255, 255, 255) else tick - canvas.drawCircle(x, center, bar * TICK_RADIUS * pops[index], tickPaint) - } - canvas.restore() } companion object {