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 Dec 8, 2024
1 parent df8472e commit e34147f
Show file tree
Hide file tree
Showing 10 changed files with 358 additions and 59 deletions.
38 changes: 9 additions & 29 deletions AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,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 @@ -426,7 +427,7 @@ open class CardBrowser :
dialogFragment.dismiss()
}
}

setupChips(findViewById(R.id.filtering_chips_group))
setupFlows()
registerOnForgetHandler { viewModel.queryAllSelectedCardIds() }
}
Expand Down Expand Up @@ -950,10 +951,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 @@ -1008,9 +1005,8 @@ open class CardBrowser :
} else {
// 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)
menu.findItem(R.id.action_flag).subMenu?.let { subMenu ->
setupFlags(subMenu)
}
showBackIcon()
increaseHorizontalPaddingOfOverflowMenuIcons(menu)
Expand All @@ -1029,23 +1025,10 @@ 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()) {
subMenu.add(groupId, flag.code, Menu.NONE, displayName)
subMenu.add(MULTI_SELECT_FLAG, flag.code, Menu.NONE, displayName)
.setIcon(flag.drawableRes)
}
}
Expand Down Expand Up @@ -1160,8 +1143,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 @@ -1744,10 +1726,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 @@ -2435,6 +2413,8 @@ open class CardBrowser :
private const val CHANGE_DECK_KEY = "CHANGE_DECK"
private const val DEFAULT_FONT_SIZE_RATIO = 100

private const val MULTI_SELECT_FLAG = 1001

@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
const val LINES_VISIBLE_WHEN_COLLAPSED = 3

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 @@ -108,6 +108,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,115 @@
/*
* 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<TextView>(R.id.clear_selection)
.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)
view.findViewById<ImageView>(R.id.icon).setImageResource(flag.drawableRes)
view.findViewById<TextView>(R.id.text).apply {
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 @@ -680,18 +680,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) {
val sb = StringBuilder(cardState.toSearch)
// join selectedTags as "tag:$tag" with " or " between them
Expand Down
33 changes: 26 additions & 7 deletions AnkiDroid/src/main/java/com/ichi2/anki/browser/Chips.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,44 @@ 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
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")
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
44 changes: 44 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,44 @@
<?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: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:padding="16dp"
tools:visibility="visible">

<ImageView
android:id="@+id/clear_icon"
android:layout_width="24dp"
android:layout_height="wrap_content"
app:srcCompat="@drawable/ic_clear_white" />

<TextView
android:id="@+id/clear_selection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/clear_filter"
android:textAppearance="?textAppearanceListItem" />
</LinearLayout>

<LinearLayout
android:id="@+id/flags_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
1 change: 1 addition & 0 deletions AnkiDroid/src/main/res/values/07-cardbrowser.xml
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,5 @@
Example shown to use: 'Red +2'
When displaying a chip control for a filter, if one element is selected, we display it
If more than one element is selected, we also show the count of extra filters">%1$s +%2$d</string>
<string name="clear_filter">Clear filter</string>
</resources>
Loading

0 comments on commit e34147f

Please sign in to comment.