Skip to content

Commit

Permalink
feat: Hierarchical deck selection
Browse files Browse the repository at this point in the history
Fixes 15646

refactor the code
remove DeckComparator class
remove DeckNameComparator class
remove DeckNameComparatorTest class

resolve ordering issue

resolve bug: All Decks appears in the note editor

address some comments
making deckNode name immutable
resolve sorting bug

Remove ExpandableDecks class and enhance the code

add saving state

refactor the code and add Expansion encapsulation

resolve filter bug

Resolve test cases

Refactor and enhance the code qualiy.

Resolve the bug of the toggle expand button

remove the Tree structure

Resolve bug: collapse the childrens using the root

add the hierarchy logic
  • Loading branch information
josephShokry authored and david-allison committed Apr 26, 2024
1 parent 54e2884 commit 9036f16
Show file tree
Hide file tree
Showing 6 changed files with 182 additions and 157 deletions.
166 changes: 118 additions & 48 deletions AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,24 @@ package com.ichi2.anki.dialogs

import android.app.Activity
import android.app.Dialog
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Filter
import android.widget.Filterable
import android.widget.ImageButton
import android.widget.TextView
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.os.BundleCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import anki.decks.DeckTreeNode
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.customview.customView
import com.ichi2.anki.CollectionManager.withCol
Expand All @@ -42,15 +46,15 @@ import com.ichi2.anki.dialogs.DeckSelectionDialog.SelectableDeck
import com.ichi2.anki.launchCatchingTask
import com.ichi2.anki.showThemedToast
import com.ichi2.annotations.NeedsTest
import com.ichi2.libanki.*
import com.ichi2.utils.DeckNameComparator
import com.ichi2.libanki.DeckId
import com.ichi2.libanki.DeckNameId
import com.ichi2.libanki.sched.DeckNode
import com.ichi2.utils.KotlinCleanup
import com.ichi2.utils.TypedFilter
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.util.*
import kotlin.collections.ArrayList
import java.util.Locale

/**
* "Deck Search": A dialog allowing the user to select a deck from a list of decks.
Expand All @@ -64,11 +68,27 @@ import kotlin.collections.ArrayList
* @see SelectableDeck The data that is displayed
*/
@NeedsTest("simulate 'don't keep activities'")
@NeedsTest("Test the ordering of the dialog")
@NeedsTest("test the ordering of decks in search page in the dialog")
@NeedsTest("test syncing the status of collapsing deck with teh deckPicker")
open class DeckSelectionDialog : AnalyticsDialogFragment() {
private var dialog: MaterialDialog? = null
private lateinit var expandImage: Drawable
private lateinit var collapseImage: Drawable
private lateinit var decksRoot: DeckNode
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
isCancelable = true

val attrs = intArrayOf(
R.attr.expandRef,
R.attr.collapseRef
)
val typedArray = requireContext().obtainStyledAttributes(attrs)
expandImage = typedArray.getDrawable(0)!!
expandImage.isAutoMirrored = true
collapseImage = typedArray.getDrawable(1)!!
collapseImage.isAutoMirrored = true
}

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
Expand Down Expand Up @@ -214,21 +234,27 @@ open class DeckSelectionDialog : AnalyticsDialogFragment() {
}

open inner class DecksArrayAdapter(deckNames: List<SelectableDeck>) : RecyclerView.Adapter<DecksArrayAdapter.ViewHolder>(), Filterable {
inner class ViewHolder(private val deckTextView: TextView) : RecyclerView.ViewHolder(deckTextView) {
inner class ViewHolder(deckHolder: View) : RecyclerView.ViewHolder(deckHolder) {
var deckName: String = ""
private var deckID: Long = -1L

private val deckTextView: TextView = deckHolder.findViewById(R.id.deckpicker_name)
val expander: ImageButton = deckHolder.findViewById(R.id.deckpicker_expander)
val indentView: ImageButton = deckHolder.findViewById(R.id.deckpicker_indent)
fun setDeck(deck: SelectableDeck) {
deckName = deck.name
deckTextView.text = deck.displayName
deckID = deck.deckId
}

init {
deckTextView.setOnClickListener {
selectDeckByNameAndClose(deckName)
deckHolder.setOnClickListener {
selectDeckByIdAndClose(deckID)
}
expander.setOnClickListener {
toggleExpansion(deckID)
}
deckTextView.setOnLongClickListener { // creating sub deck with parent deck path
deckHolder.setOnLongClickListener { // creating sub deck with parent deck path
if (deckID == DeckSpinnerSelection.ALL_DECKS_ID) {
context?.let { showThemedToast(it, R.string.cannot_create_subdeck_for_all_decks, true) }
} else {
Expand All @@ -237,28 +263,78 @@ open class DeckSelectionDialog : AnalyticsDialogFragment() {
true
}
}

private fun toggleExpansion(deckId: Long) {
decksRoot.find(deckId)?.apply {
collapsed = !collapsed
Timber.d("The deck with ID $id is currently expanded: ${!collapsed}.")
updateCurrentlyDisplayedDecks()
}
}
}
private fun updateCurrentlyDisplayedDecks() {
currentlyDisplayedDecks.clear()
currentlyDisplayedDecks.addAll(allDecksList.filter(::isViewable))
notifyDataSetChanged()
}

private val allDecksList = ArrayList<SelectableDeck>()
private val currentlyDisplayedDecks = ArrayList<SelectableDeck>()
protected fun selectDeckByNameAndClose(deckName: String) {
val deck = allDecksList.firstOrNull { it.name == deckName }
private val allDecksList = ArrayList<DeckNode>()
private val currentlyDisplayedDecks = ArrayList<DeckNode>()

protected fun selectDeckByIdAndClose(deckId: Long) {
val deck = decksRoot.find(deckId)
if (deck == null) {
displayErrorAndCancel()
return
}
selectDeckAndClose(deck)
selectDeckAndClose(SelectableDeck(deck.did, deck.fullDeckName))
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.deck_picker_dialog_list_item, parent, false)
return ViewHolder(v.findViewById(R.id.deck_picker_dialog_list_item_value))
return ViewHolder(v)
}

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val deck = currentlyDisplayedDecks[position]
holder.setDeck(deck)
val isDeckViewable = isViewable(deck)
holder.itemView.isVisible = isDeckViewable
if (isDeckViewable) {
holder.setDeck(SelectableDeck(deck.did, deck.fullDeckName))
}
setDeckExpander(holder.expander, holder.indentView, deck)
}

/**
* Sets the expander and indent views based on the properties of the provided DeckNode.
*
* @param expander The ImageButton used for expanding/collapsing the deck node.
* @param indent The ImageButton used for indenting the deck node.
* @param node The DeckNode representing the deck.
*/
private fun setDeckExpander(expander: ImageButton, indent: ImageButton, node: DeckNode) {
if (hasSubDecks(node)) {
expander.apply {
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
setImageDrawable(if (node.collapsed) expandImage else collapseImage)
contentDescription = context.getString(if (node.collapsed) R.string.expand else R.string.collapse)
visibility = View.VISIBLE
}
} else {
expander.apply {
visibility = View.INVISIBLE
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}
}
indent.minimumWidth = node.depth * expander.resources.getDimensionPixelSize(R.dimen.keyline_1)
}

private fun hasSubDecks(node: DeckNode): Boolean {
return node.children.isNotEmpty()
}
private fun isViewable(deck: DeckNode): Boolean {
val parentNode = deck.parent ?: return true
return !parentNode.get()?.collapsed!! && isViewable(parentNode.get()!!)
}

override fun getItemCount(): Int {
Expand All @@ -270,31 +346,42 @@ open class DeckSelectionDialog : AnalyticsDialogFragment() {
}

fun getCurrentlyDisplayedDecks(): List<SelectableDeck> {
return currentlyDisplayedDecks
return currentlyDisplayedDecks.map { SelectableDeck(it.did, it.fullDeckName) }
}

private inner class DecksFilter : TypedFilter<SelectableDeck>(allDecksList) {
override fun filterResults(constraint: CharSequence, items: List<SelectableDeck>): List<SelectableDeck> {
private inner class DecksFilter : TypedFilter<DeckNode>(allDecksList) {
override fun filterResults(constraint: CharSequence, items: List<DeckNode>): List<DeckNode> {
val filterPattern = constraint.toString().lowercase(Locale.getDefault()).trim { it <= ' ' }
return items.filter {
it.name.lowercase(Locale.getDefault()).contains(filterPattern)
it.fullDeckName.lowercase(Locale.getDefault()).contains(filterPattern)
}
}

override fun publishResults(constraint: CharSequence?, results: List<SelectableDeck>) {
override fun publishResults(constraint: CharSequence?, results: List<DeckNode>) {
results.forEach { it.collapsed = false }
currentlyDisplayedDecks.apply {
clear()
addAll(results)
sort()
}
notifyDataSetChanged()
}
}

init {
allDecksList.addAll(deckNames)
currentlyDisplayedDecks.addAll(deckNames)
currentlyDisplayedDecks.sort()
launchCatchingTask {
decksRoot = withCol { Pair(sched.deckDueTree(), this.isEmpty) }.first
val allDecksSet = deckNames.filter { it.deckId != 0L }.mapNotNull { decksRoot.find(it.deckId) }.toSet()
if (deckNames.any { it.deckId == ALL_DECKS_ID }) {
val newDeckNode = DeckTreeNode.newBuilder()
.setDeckId(ALL_DECKS_ID)
.setName("all")
.build()
allDecksList.add(DeckNode(newDeckNode, getString(R.string.card_browser_all_decks), null))
}

allDecksList.addAll(allDecksSet)
updateCurrentlyDisplayedDecks()
}
}
}

Expand All @@ -303,37 +390,20 @@ open class DeckSelectionDialog : AnalyticsDialogFragment() {
* @param name Name of the deck, or localization of "all decks"
*/
@Parcelize
class SelectableDeck(val deckId: DeckId, val name: String) : Comparable<SelectableDeck>, Parcelable {
class SelectableDeck(val deckId: DeckId, val name: String) : Parcelable {
/**
* The name to be displayed to the user. Contains
* only the sub-deck name with proper indentation
* rather than the entire deck name.
* Eg: foo::bar -> \t\tbar
* The name to be displayed to the user. Contains only
* the sub-deck name rather than the entire deck name.
* Eg: foo::bar -> bar
*/

@IgnoredOnParcel
val displayName: String by lazy {
val nameArr = name.split("::")
"\t\t".repeat(nameArr.size - 1) + nameArr[nameArr.size - 1]
nameArr[nameArr.size - 1]
}

constructor(d: DeckNameId) : this(d.id, d.name)

/** "All decks" comes first. Then usual deck name order. */
override fun compareTo(other: SelectableDeck): Int {
if (deckId == ALL_DECKS_ID) {
return if (other.deckId == ALL_DECKS_ID) {
0
} else {
-1
}
}
return if (other.deckId == ALL_DECKS_ID) {
1
} else {
DeckNameComparator.INSTANCE.compare(name, other.name)
}
}

companion object {
/**
* @param includeFiltered Whether to include filtered decks in the output
Expand Down
28 changes: 0 additions & 28 deletions AnkiDroid/src/main/java/com/ichi2/utils/DeckComparator.kt

This file was deleted.

38 changes: 0 additions & 38 deletions AnkiDroid/src/main/java/com/ichi2/utils/DeckNameComparator.kt

This file was deleted.

Loading

0 comments on commit 9036f16

Please sign in to comment.