Skip to content

Commit

Permalink
Enable new UI for flags filtering
Browse files Browse the repository at this point in the history
The flags filtering was moved from a menu option to an embedded chip
view triggering a separate bottom sheet dialog.

Contains some code from commit 4254250 that was
about flags filtering.

Note: see the changes in CardBrowserTest.checkIfLongSelectChecksAllCardsInBetween().
For some reason after adding the tests related to flag filtering, this test
started to throw NullPointerExceptions which also triggered another test
to crash as well due to unhandled exception from another test. Using a
position that should be displayed seems to fix this.
  • Loading branch information
lukstbit committed Jan 6, 2025
1 parent db84344 commit 3012fb7
Show file tree
Hide file tree
Showing 12 changed files with 391 additions and 79 deletions.
40 changes: 8 additions & 32 deletions AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import com.ichi2.anki.browser.SaveSearchResult
import com.ichi2.anki.browser.SearchParameters
import com.ichi2.anki.browser.SharedPreferencesLastDeckIdRepository
import com.ichi2.anki.browser.getLabel
import com.ichi2.anki.browser.setupChips
import com.ichi2.anki.browser.toCardBrowserLaunchOptions
import com.ichi2.anki.browser.toQuery
import com.ichi2.anki.browser.updateChips
Expand Down Expand Up @@ -418,7 +419,7 @@ open class CardBrowser :
dialogFragment.dismiss()
}
}

setupChips(findViewById(R.id.filtering_chips_group))
setupFlows()
registerOnForgetHandler { viewModel.queryAllSelectedCardIds() }
}
Expand Down Expand Up @@ -922,9 +923,6 @@ open class CardBrowser :
// restore drawer click listener and icon
restoreDrawerIcon()
menuInflater.inflate(R.menu.card_browser, menu)
menu.findItem(R.id.action_search_by_flag).subMenu?.let { subMenu ->
setupFlags(subMenu, Mode.SINGLE_SELECT)
}
menu.findItem(R.id.action_create_filtered_deck).title = TR.qtMiscCreateFilteredDeck()
saveSearchItem = menu.findItem(R.id.action_save_search)
saveSearchItem?.isVisible = false // the searchview's query always starts empty.
Expand Down Expand Up @@ -985,7 +983,7 @@ open class CardBrowser :
// multi-select mode
menuInflater.inflate(R.menu.card_browser_multiselect, menu)
menu.findItem(R.id.action_flag).subMenu?.let { subMenu ->
setupFlags(subMenu, Mode.MULTI_SELECT)
setupFlags(subMenu)
}
showBackIcon()
increaseHorizontalPaddingOfOverflowMenuIcons(menu)
Expand All @@ -1010,31 +1008,12 @@ open class CardBrowser :
return super.onCreateOptionsMenu(menu)
}

/**
* Representing different selection modes.
*/
enum class Mode(
val value: Int,
) {
SINGLE_SELECT(1000),
MULTI_SELECT(1001),
}

private fun setupFlags(
subMenu: SubMenu,
mode: Mode,
) {
private fun setupFlags(subMenu: SubMenu) {
lifecycleScope.launch {
val groupId =
when (mode) {
Mode.SINGLE_SELECT -> mode.value
Mode.MULTI_SELECT -> mode.value
}

for ((flag, displayName) in Flag.queryDisplayNames()) {
val item =
subMenu
.add(groupId, flag.code, Menu.NONE, displayName)
.add(MULTI_SELECT_FLAG, flag.code, Menu.NONE, displayName)
.setIcon(flag.drawableRes)
if (flag == Flag.NONE) {
val color = ThemeUtils.getThemeAttrColor(this@CardBrowser, android.R.attr.colorControlNormal)
Expand Down Expand Up @@ -1163,8 +1142,7 @@ open class CardBrowser :

Flag.entries.find { it.ordinal == item.itemId }?.let { flag ->
when (item.groupId) {
Mode.SINGLE_SELECT.value -> filterByFlag(flag)
Mode.MULTI_SELECT.value -> updateFlagForSelectedRows(flag)
MULTI_SELECT_FLAG -> updateFlagForSelectedRows(flag)
else -> return@let
}
return true
Expand Down Expand Up @@ -1738,10 +1716,6 @@ open class CardBrowser :
viewModel.filterByTags(selectedTags, cardState)
}

/** Updates search terms to only show cards with selected flag. */
@VisibleForTesting
fun filterByFlag(flag: Flag) = launchCatchingTask { viewModel.setFlagFilter(flag) }

/**
* Loads/Reloads (Updates the Q, A & etc) of cards in the [cardIds] list
* @param cardIds Card IDs that were changed
Expand Down Expand Up @@ -1918,6 +1892,8 @@ open class CardBrowser :
*/
private const val CHANGE_DECK_KEY = "CHANGE_DECK"

private const val MULTI_SELECT_FLAG = 1001

// Values related to persistent state data
private const val ALL_DECKS_ID = 0L

Expand Down
5 changes: 5 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/Flag.kt
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ enum class Flag(
fun fromCode(code: Int) = Flag.entries.first { it.code == code }

/**
* Usage:
* ```kotlin
* Flag.queryDisplayNames().map { (flag, displayName) -> ... }
* ```
*
* @return A mapping from each [Flag] to its display name (optionally user-defined)
*/
suspend fun queryDisplayNames(): Map<Flag, String> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.browser

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import com.ichi2.anki.Flag
import com.ichi2.anki.R
import com.ichi2.anki.launchCatchingTask
import com.ichi2.anki.workarounds.BottomSheetDialogFragmentFix
import kotlinx.coroutines.launch

class BrowserFlagsFilteringFragment : BottomSheetDialogFragmentFix() {
private val viewModel: CardBrowserViewModel by activityViewModels()

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
val content = inflater.inflate(R.layout.fragment_flags_filter_sheet, container, false)
content
.findViewById<LinearLayout>(R.id.clear_filter_container)
.setOnClickListener {
searchFor(emptySet())
dismiss()
}
return content
}

override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
super.onViewCreated(view, savedInstanceState)
val container = view.findViewById<LinearLayout>(R.id.flags_container)
val currentSelection =
if (savedInstanceState != null) {
savedInstanceState
.getString(KEY_SELECTED_FLAGS)
?.split("|")
?.map { Flag.fromCode(it.toInt()) }
?.toSet() ?: viewModel.searchTerms.flags
} else {
viewModel.searchTerms.flags
}
val clearContainer = view.findViewById<LinearLayout>(R.id.clear_filter_container)
clearContainer.isVisible = currentSelection.isNotEmpty()
viewLifecycleOwner.lifecycleScope.launch {
Flag.queryDisplayNames().forEach { (flag, displayName) ->
buildFlagFilterView(container, clearContainer, flag, displayName, currentSelection.contains(flag))
}
}
}

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(
KEY_SELECTED_FLAGS,
viewModel.searchTerms.flags
.map { it.code }
.joinToString("|"),
)
}

private fun buildFlagFilterView(
container: LinearLayout,
clearContainer: LinearLayout,
flag: Flag,
displayName: String,
isAlreadySelected: Boolean,
) {
val view =
requireActivity().layoutInflater.inflate(R.layout.item_browser_flag_filter, container, false).apply {
findViewById<ImageView>(R.id.icon).setImageResource(flag.drawableRes)
findViewById<TextView>(R.id.text).text = displayName
setOnClickListener {
searchFor(setOf(flag))
dismiss() // direct selection clears everything and closes the filter
}
}
view.findViewById<CheckBox>(R.id.checkbox).apply {
setChecked(isAlreadySelected)
setOnCheckedChangeListener { _, isChecked ->
val newSelection =
viewModel.searchTerms.flags.toMutableSet().apply {
if (isChecked) add(flag) else remove(flag)
}
clearContainer.isVisible = container.children.any { it.findViewById<CheckBox>(R.id.checkbox).isChecked }
searchFor(newSelection)
}
}
container.addView(view)
}

private fun searchFor(flagsSelection: Set<Flag>) {
requireActivity().launchCatchingTask {
viewModel.launchSearchForCards(
viewModel.searchTerms.copy(flags = flagsSelection),
)
}
}

companion object {
const val TAG = "BrowserFlagsFilteringFragment"
private const val KEY_SELECTED_FLAGS = "key_selected_flags"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -695,19 +695,6 @@ class CardBrowserViewModel(
launchSearchForCards(searchTerms.copy(userInput = "is:suspended"))
}

suspend fun setFlagFilter(flag: Flag) {
Timber.i("filtering to flag: %s", flag)
val flagSearchTerm = "flag:${flag.code}"
val userInput = searchTerms.userInput
val updatedInput =
when {
userInput.contains("flag:") -> userInput.replaceFirst("flag:.".toRegex(), flagSearchTerm)
userInput.isNotEmpty() -> "$flagSearchTerm $userInput"
else -> flagSearchTerm
}
launchSearchForCards(searchTerms.copy(userInput = updatedInput))
}

suspend fun filterByTags(
selectedTags: List<String>,
cardState: CardStateFilter,
Expand Down
37 changes: 28 additions & 9 deletions AnkiDroid/src/main/java/com/ichi2/anki/browser/Chips.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,27 +17,46 @@ package com.ichi2.anki.browser
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.ichi2.anki.CardBrowser
import com.ichi2.anki.CollectionManager.TR
import com.ichi2.anki.Flag
import com.ichi2.anki.R

@Suppress("UnusedReceiverParameter")
fun CardBrowser.setupChips(
@Suppress("UNUSED_PARAMETER") chips: ChipGroup,
) {
// TODO add here code to initialize each of the filtering chips
// move to context parameters when available
fun CardBrowser.setupChips(chips: ChipGroup) {
chips.findViewById<Chip>(R.id.chip_flag).apply {
text = TR.browsingSidebarFlags()
setOnClickListener { chip ->
(chip as Chip).isChecked = !chip.isChecked
BrowserFlagsFilteringFragment().show(
supportFragmentManager,
BrowserFlagsFilteringFragment.TAG,
)
}
}
}

@Suppress("RedundantSuspendModifier", "UnusedReceiverParameter")
@Suppress("UnusedReceiverParameter")
// move to context parameters when available
suspend fun CardBrowser.updateChips(
@Suppress("UNUSED_PARAMETER") chips: ChipGroup,
chips: ChipGroup,
oldSearchParameters: SearchParameters,
newSearchParameters: SearchParameters,
) {
if (oldSearchParameters.flags != newSearchParameters.flags) {
// TODO add code for each chip to update its status
val flagNames = Flag.queryDisplayNames()
chips.findViewById<Chip>(R.id.chip_flag).let { chip ->
chip.update(
activeItems = newSearchParameters.flags,
inactiveText = TR.browsingSidebarFlags(),
activeTextGetter = { flag -> flagNames[flag]!! },
)
// text shows "Red + 1" if there are multiple flags, so show the first flag icon
val firstFlagOrDefault = newSearchParameters.flags.firstOrNull() ?: Flag.NONE
chip.setChipIconResource(firstFlagOrDefault.drawableRes)
}
}
}

@Suppress("unused")
private fun <T> Chip.update(
activeItems: Collection<T>,
inactiveText: String,
Expand Down
8 changes: 7 additions & 1 deletion AnkiDroid/src/main/res/layout/card_browser.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@
android:layout_height="wrap_content"
android:elevation="0dp"
app:singleLine="true">

<com.google.android.material.chip.Chip
android:id="@+id/chip_flag"
style="@style/Chip.WithIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:chipIcon="@drawable/ic_flag_transparent"
app:chipIconTint="@null" />
</com.google.android.material.chip.ChipGroup>
</HorizontalScrollView>

Expand Down
48 changes: 48 additions & 0 deletions AnkiDroid/src/main/res/layout/fragment_flags_filter_sheet.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingVertical="12dp"
android:orientation="vertical">

<LinearLayout
android:id="@+id/clear_filter_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:visibility="gone"
android:minHeight="?attr/listPreferredItemHeightSmall"
android:background="?attr/selectableItemBackground"
tools:visibility="visible">

<ImageView
android:id="@+id/clear_icon"
android:layout_width="24dp"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_marginStart="16dp"
android:layout_marginEnd="12dp"
app:srcCompat="@drawable/ic_clear_white" />

<com.ichi2.ui.FixedTextView
android:id="@+id/clear_selection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/clear_filter"
android:textAppearance="?attr/textAppearanceBodyMedium"
tools:text="Clear filter"/>
</LinearLayout>

<LinearLayout
android:id="@+id/flags_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
Loading

0 comments on commit 3012fb7

Please sign in to comment.