diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleReminders.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleReminders.kt index 247ba1fe0b24..dd7a7ac67e95 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleReminders.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleReminders.kt @@ -21,7 +21,6 @@ import android.content.Intent import android.os.Bundle import android.view.View import android.widget.LinearLayout -import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.core.os.BundleCompat import androidx.core.view.isVisible @@ -33,9 +32,11 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton +import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CrashReportData.Companion.toCrashReportData import com.ichi2.anki.R import com.ichi2.anki.SingleFragmentActivity +import com.ichi2.anki.canUserAccessDeck import com.ichi2.anki.dialogs.DeckSelectionDialog import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.libanki.DeckId @@ -84,7 +85,7 @@ class ScheduleReminders : private lateinit var reminders: HashMap /** - * Retrieving deck names for a given deck ID in [setDeckNameFromScopeForView] requires a call to the collection. + * Retrieving deck names for a given deck ID in [retrieveDeckNameFromID] requires a call to the collection. * However, most reminders in the RecyclerView will often be from the same deck (and are guaranteed to be if * this fragment is opened in [ReviewReminderScope.DeckSpecific] mode). Hence, we cache deck names. */ @@ -114,7 +115,8 @@ class ScheduleReminders : // Set up adapter, pass functionality to it adapter = ScheduleRemindersAdapter( - ::setDeckNameFromScopeForView, + ::retrieveDeckNameFromID, + ::retrieveCanUserAccessDeck, ::toggleReminderEnabled, ::editReminder, ) @@ -281,22 +283,32 @@ class ScheduleReminders : } /** - * Sets a TextView's text based on a [ReviewReminderScope]. - * The text is either the scope's associated deck's name, or "All Decks" if the scope is global. - * For example, this is used to display the [ScheduleRemindersAdapter]'s deck name column. + * Retrieves a deck name from the collection for a given deck ID and passes it to the provided callback. + * Used by the [ScheduleRemindersAdapter] because it cannot access the collection directly. */ - private fun setDeckNameFromScopeForView( - scope: ReviewReminderScope, - view: TextView, + private fun retrieveDeckNameFromID( + did: DeckId, + callback: (deckName: String) -> Unit, ) { - when (scope) { - is ReviewReminderScope.Global -> view.text = getString(R.string.card_browser_all_decks) - is ReviewReminderScope.DeckSpecific -> { - launchCatchingTask { - val deckName = cachedDeckNames.getOrPut(scope.did) { scope.getDeckName() } - view.text = deckName - } - } + launchCatchingTask { + val deckName = cachedDeckNames.getOrPut(did) { withCol { decks.name(did) } } + callback(deckName) + } + } + + /** + * Retrieves whether the user can access the deck with the given ID and passes the result to the provided callback. + * Basically, checks whether the deck exists, with some exceptions: see [canUserAccessDeck]. + * Used by the [ScheduleRemindersAdapter] because it cannot access the collection directly. + */ + private fun retrieveCanUserAccessDeck( + did: DeckId, + callback: (isDeckAccessible: Boolean) -> Unit, + ) { + launchCatchingTask { + val isDeckAccessible = canUserAccessDeck(did) + Timber.d("Checked for whether deck with id %s can be accessed: %s", did, isDeckAccessible) + callback(isDeckAccessible) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt index d154b7e69faa..e9d093881727 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt @@ -17,6 +17,9 @@ package com.ichi2.anki.reviewreminders import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Paint +import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -26,9 +29,11 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.google.android.material.materialswitch.MaterialSwitch import com.ichi2.anki.R +import com.ichi2.anki.libanki.DeckId class ScheduleRemindersAdapter( - private val setDeckNameFromScopeForView: (ReviewReminderScope, TextView) -> Unit, + private val retrieveDeckNameFromID: (DeckId, callback: (deckName: String) -> Unit) -> Unit, + private val retrieveCanUserAccessDeck: (DeckId, callback: (isDeckAccessible: Boolean) -> Unit) -> Unit, private val toggleReminderEnabled: (ReviewReminderId, ReviewReminderScope) -> Unit, private val editReminder: (ReviewReminder) -> Unit, ) : ListAdapter(diffCallback) { @@ -60,16 +65,133 @@ class ScheduleRemindersAdapter( val reminder = getItem(position) holder.reminder = reminder - setDeckNameFromScopeForView(reminder.scope, holder.deckTextView) holder.timeTextView.text = reminder.time.toFormattedString(holder.context) holder.itemView.setOnClickListener { editReminder(reminder) } holder.switchView.isChecked = reminder.enabled holder.switchView.setOnClickListener { toggleReminderEnabled(reminder.id, reminder.scope) } + + errorReminderIfDeckNotFound(reminder.scope, holder) + } + + /** + * Marks a review reminder's ViewHolder in the UI as errored-out if its corresponding deck cannot be found. + * Otherwise, sets its ViewHolder's style to a normal state and sets the deck name text. + * Never errors-out a global review reminder. + * + * We do this instead of immediately deleting the reminder because there are many reasons why a deck ID might + * be unavailable (ex. the user might undo a deletion, restore their collection from a backup, etc.), + * and we don't want to delete the user's reminder without their consent. Reminder deletion should + * be an explicit action; leaving errored-out reminders in the UI allows the user to explicitly decide + * what to do with them. + */ + private fun errorReminderIfDeckNotFound( + scope: ReviewReminderScope, + holder: ViewHolder, + ) { + val activeTextColor = getThemeColor(holder.context, normalTextThemeAttribute) + val activeTrackColor = getThemeColor(holder.context, normalPrimaryColorThemeAttribute) + val inactiveTextColor = holder.context.getColor(erroredReviewReminderColor) + val inactiveTrackColor = holder.context.getColor(erroredReviewReminderColor) + + when (scope) { + is ReviewReminderScope.Global -> { + holder.deckTextView.text = holder.context.getString(R.string.card_browser_all_decks) + setTextViewStrikethrough(holder.timeTextView, false) + setViewHolderColors(holder, activeTextColor, activeTrackColor) + } + is ReviewReminderScope.DeckSpecific -> + retrieveCanUserAccessDeck(scope.did) { isDeckAccessible -> + if (isDeckAccessible) { + retrieveDeckNameFromID(scope.did) { holder.deckTextView.text = it } + setTextViewStrikethrough(holder.timeTextView, false) + setViewHolderColors(holder, activeTextColor, activeTrackColor) + } else { + holder.deckTextView.text = "Deck not found" + setTextViewStrikethrough(holder.timeTextView, true) + setViewHolderColors(holder, inactiveTextColor, inactiveTrackColor) + } + } + } + } + + /** + * Sets the text color and switch track color of a ViewHolder. + */ + private fun setViewHolderColors( + holder: ViewHolder, + textColor: Int, + trackColor: Int, + ) { + with(holder) { + deckTextView.setTextColor(textColor) + timeTextView.setTextColor(textColor) + switchView.trackTintList = + ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked)), + intArrayOf(trackColor), + ) + } + } + + /** + * Sets or unsets strikethrough on a TextView. + */ + private fun setTextViewStrikethrough( + textView: TextView, + setStrikethrough: Boolean, + ) { + textView.paintFlags = + if (setStrikethrough) { + textView.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG + } else { + textView.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() + } } + /** + * Caches theme color values by resource id. + * Maps resource id to resolved color int. + */ + private val cachedThemeColors = mutableMapOf() + + /** + * Returns the theme color for the given resource id, with caching. + * Used for resetting the text color of a reminder in the UI if it was previously errored-out. + */ + private fun getThemeColor( + context: Context, + resId: Int, + ): Int = + cachedThemeColors.getOrPut(resId) { + TypedValue() + .apply { + context.theme.resolveAttribute(resId, this, true) + }.data + } + companion object { + /** + * Theme attribute for the primary color used in the normal (non-errored-out) state of a review reminder. + * Used for the switch track color of a reminder in the UI if it is not errored-out. + * The corresponding color resource can be obtained via [getThemeColor]. + */ + private val normalPrimaryColorThemeAttribute: Int = android.R.attr.colorPrimary + + /** + * Theme attribute for the text color used in the normal (non-errored-out) state of a review reminder. + * Used for the text color of a reminder in the UI if it is not errored-out. + * The corresponding color resource can be obtained via [getThemeColor]. + */ + private val normalTextThemeAttribute: Int = com.google.android.material.R.attr.colorOnSurface + + /** + * Color of the activated switch and text of an element in the review reminder UI list when its review reminder + * is errored-out. A deck-specific review reminder can become errored-out if its corresponding deck cannot be found. + */ + private val erroredReviewReminderColor: Int = R.color.material_grey_500 + private val diffCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(