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
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -84,7 +85,7 @@ class ScheduleReminders :
private lateinit var reminders: HashMap<ReviewReminderId, ReviewReminder>

/**
* 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.
*/
Expand Down Expand Up @@ -114,7 +115,8 @@ class ScheduleReminders :
// Set up adapter, pass functionality to it
adapter =
ScheduleRemindersAdapter(
::setDeckNameFromScopeForView,
::retrieveDeckNameFromID,
::retrieveCanUserAccessDeck,
::toggleReminderEnabled,
::editReminder,
)
Expand Down Expand Up @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ReviewReminder, ScheduleRemindersAdapter.ViewHolder>(diffCallback) {
Expand Down Expand Up @@ -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<Int, Int>()

/**
* 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<ReviewReminder>() {
override fun areItemsTheSame(
Expand Down