Skip to content

Commit

Permalink
feat(browser): search 'flag' using chip controls
Browse files Browse the repository at this point in the history
This is the first of many chips which we will
introduce to modernize the browser
  • Loading branch information
david-allison committed Aug 18, 2024
1 parent 6565924 commit 4254250
Show file tree
Hide file tree
Showing 9 changed files with 214 additions and 84 deletions.
61 changes: 28 additions & 33 deletions AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,10 @@ 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
import com.ichi2.anki.common.utils.android.isRobolectric
import com.ichi2.anki.dialogs.BrowserOptionsDialog
import com.ichi2.anki.dialogs.CardBrowserMySearchesDialog
Expand Down Expand Up @@ -146,6 +148,8 @@ import com.ichi2.widget.WidgetStatus.updateInBackground
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -430,6 +434,7 @@ open class CardBrowser :
}
onboarding.onCreate()

setupChips(findViewById(R.id.chip_group))
setupFlows()
}

Expand Down Expand Up @@ -460,10 +465,16 @@ open class CardBrowser :
.setSelection(COLUMN2_KEYS.indexOf(column))
}

fun onFilterQueryChanged(filterQuery: SearchParameters) {
// setQuery before expand does not set the view's value
searchItem!!.expandActionView()
searchView!!.setQuery(filterQuery.userInput, submit = false)
suspend fun onFilterQueryChanged(params: Pair<SearchParameters, SearchParameters>) {
val (oldParameters, newParameters) = params
// TODO; Confirm this logic
// don't open the actionView if a chip was pressed
if (newParameters.userInput.isNotEmpty()) {
// setQuery before expand does not set the view's value
searchItem?.expandActionView()
searchView?.setQuery(newParameters.userInput, submit = false)
}
updateChips(findViewById(R.id.chip_group), oldParameters, newParameters)
}
suspend fun onDeckIdChanged(deckId: DeckId?) {
if (deckId == null) return
Expand Down Expand Up @@ -572,7 +583,12 @@ open class CardBrowser :
viewModel.flowOfSelectedRows.launchCollectionInLifecycleScope(::onSelectedRowsChanged)
viewModel.flowOfColumn1.launchCollectionInLifecycleScope(::onColumn1Changed)
viewModel.flowOfColumn2.launchCollectionInLifecycleScope(::onColumn2Changed)
viewModel.flowOfFilterQuery.launchCollectionInLifecycleScope(::onFilterQueryChanged)
viewModel.flowOfFilterQuery.runningFold(
initial = Pair(SearchParameters.EMPTY, SearchParameters.EMPTY),
operation = { accumulator, new -> Pair(accumulator.second, new) }
)
.filterNotNull()
.launchCollectionInLifecycleScope(::onFilterQueryChanged)
viewModel.flowOfDeckId.launchCollectionInLifecycleScope(::onDeckIdChanged)
viewModel.flowOfCanSearch.launchCollectionInLifecycleScope(::onCanSaveChanged)
viewModel.flowOfIsInMultiSelectMode.launchCollectionInLifecycleScope(::isInMultiSelectModeChanged)
Expand Down Expand Up @@ -926,10 +942,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)
}
saveSearchItem = menu.findItem(R.id.action_save_search)
saveSearchItem?.isVisible = false // the searchview's query always starts empty.
mySearchesItem = menu.findItem(R.id.action_list_my_searches)
Expand Down Expand Up @@ -983,9 +995,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 @@ -1004,23 +1015,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.ordinal, Menu.NONE, displayName)
subMenu.add(MULTI_SELECT_FLAG, flag.ordinal, Menu.NONE, displayName)
.setIcon(flag.drawableRes)
}
}
Expand Down Expand Up @@ -1142,8 +1140,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 @@ -1711,10 +1708,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 @@ -2383,6 +2376,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 @@ -80,6 +80,11 @@ enum class Flag(
}

/**
* 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
Expand Up @@ -163,7 +163,8 @@ class CardBrowserViewModel(
MutableStateFlow(sharedPrefs().getBoolean("isTruncated", false))
val isTruncated get() = flowOfIsTruncated.value

private val _selectedRows: MutableSet<CardBrowser.CardCache> = Collections.synchronizedSet(LinkedHashSet())
private val _selectedRows: MutableSet<CardBrowser.CardCache> =
Collections.synchronizedSet(LinkedHashSet())

// immutable accessor for _selectedRows
val selectedRows: Set<CardBrowser.CardCache> get() = _selectedRows
Expand Down Expand Up @@ -262,10 +263,11 @@ class CardBrowserViewModel(
*
* @see launchSearchForCards
*/
private val performSearchFlow = flowOfInitCompleted.combineTransform(searchRequested) { init, _ ->
if (!init) return@combineTransform
emit(Unit)
}
private val performSearchFlow =
flowOfInitCompleted.combineTransform(searchRequested) { init, _ ->
if (!init) return@combineTransform
emit(Unit)
}

init {
Timber.d("CardBrowserViewModel::init")
Expand All @@ -275,13 +277,16 @@ class CardBrowserViewModel(
is CardBrowserLaunchOptions.SystemContextMenu -> {
searchTerms = searchTerms.copy(userInput = options.search.toString())
}

is CardBrowserLaunchOptions.SearchQueryJs -> {
searchTerms = searchTerms.copy(userInput = options.search)
selectAllDecks = options.allDecks
}

is CardBrowserLaunchOptions.DeepLink -> {
searchTerms = searchTerms.copy(userInput = options.search)
}

null -> {}
}

Expand Down Expand Up @@ -317,8 +322,10 @@ class CardBrowserViewModel(
flowOfCardsOrNotes.update { cardsOrNotes }

val allColumns = withCol { allBrowserColumns() }.associateBy { it.key }
column1Candidates = CardBrowserColumn.COLUMN1_KEYS.map { allColumns[it.ankiColumnKey]!! }
column2Candidates = CardBrowserColumn.COLUMN2_KEYS.map { allColumns[it.ankiColumnKey]!! }
column1Candidates =
CardBrowserColumn.COLUMN1_KEYS.map { allColumns[it.ankiColumnKey]!! }
column2Candidates =
CardBrowserColumn.COLUMN2_KEYS.map { allColumns[it.ankiColumnKey]!! }

setupColumns(cardsOrNotes)

Expand Down Expand Up @@ -481,6 +488,7 @@ class CardBrowserViewModel(
reverseDirectionFlow.update { ReverseDirection(orderAsc = false) }
launchSearchForCards()
}

ChangeCardOrder.DirectionChange -> {
reverseDirectionFlow.update { ReverseDirection(orderAsc = !orderAsc) }
cards.reverse()
Expand Down Expand Up @@ -616,6 +624,7 @@ class CardBrowserViewModel(
withCol { config.set("savedFilters", filters) }
return filters
}

suspend fun savedSearches(): HashMap<String, String> =
withCol { config.get("savedFilters") } ?: hashMapOf()

Expand Down Expand Up @@ -647,18 +656,13 @@ class CardBrowserViewModel(
private fun <T> Flow<T>.ignoreValuesFromViewModelLaunch(): Flow<T> =
this.filter { initCompleted }

suspend fun setFilterQuery(filterQuery: SearchParameters) {
this.flowOfFilterQuery.emit(filterQuery)
launchSearchForCards(filterQuery)
}

/**
* Searches for all marked notes and replaces the current search results with these marked notes.
*/
suspend fun searchForMarkedNotes() {
// only intended to be used if the user has no selection
if (hasSelectedAnyRows()) return
setFilterQuery(searchTerms.copy(userInput = "tag:marked"))
launchSearchForCards(searchTerms.copy(userInput = "tag:marked"))
}

/**
Expand All @@ -667,19 +671,7 @@ class CardBrowserViewModel(
suspend fun searchForSuspendedCards() {
// only intended to be used if the user has no selection
if (hasSelectedAnyRows()) return
setFilterQuery(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
}
setFilterQuery(searchTerms.copy(userInput = updatedInput))
launchSearchForCards(searchTerms.copy(userInput = "is:suspended"))
}

suspend fun filterByTags(selectedTags: List<String>, cardState: CardStateFilter) {
Expand All @@ -689,14 +681,17 @@ class CardBrowserViewModel(
if (selectedTags.isNotEmpty()) {
sb.append("($tagsConcat)") // Only if we added anything to the tag list
}
setFilterQuery(searchTerms.copy(userInput = sb.toString()))
launchSearchForCards(searchTerms.copy(userInput = sb.toString()))
}

/** Previewing */
suspend fun queryPreviewIntentData(): PreviewerDestination {
// If in NOTES mode, we show one Card per Note, as this matches Anki Desktop
return if (selectedRowCount() > 1) {
PreviewerDestination(currentIndex = 0, PreviewerIdsFile(cacheDir, queryAllSelectedCardIds()))
PreviewerDestination(
currentIndex = 0,
PreviewerIdsFile(cacheDir, queryAllSelectedCardIds())
)
} else {
// Preview all cards, starting from the one that is currently selected
val startIndex = indexOfFirstCheckedCard() ?: 0
Expand Down Expand Up @@ -735,12 +730,13 @@ class CardBrowserViewModel(
searchQueryInputFlow.update { null }
}

fun moveSelectedCardsToDeck(deckId: DeckId): Deferred<OpChangesWithCount> = viewModelScope.async {
val selectedCardIds = queryAllSelectedCardIds()
return@async undoableOp {
setDeck(selectedCardIds, deckId)
fun moveSelectedCardsToDeck(deckId: DeckId): Deferred<OpChangesWithCount> =
viewModelScope.async {
val selectedCardIds = queryAllSelectedCardIds()
return@async undoableOp {
setDeck(selectedCardIds, deckId)
}
}
}

suspend fun updateSelectedCardsFlag(flag: Flag): List<CardId> {
val idsToChange = queryAllSelectedCardIds()
Expand All @@ -755,6 +751,7 @@ class CardBrowserViewModel(

suspend fun launchSearchForCards(searchQuery: SearchParameters): Job? {
searchTerms = searchQuery
flowOfFilterQuery.emit(searchTerms)
return launchSearchForCards()
}

Expand Down
68 changes: 68 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/browser/Chips.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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 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

fun CardBrowser.setupChips(chips: ChipGroup) {
chips.findViewById<Chip>(R.id.chip_flag)
.setOnClickListener { chip ->
(chip as Chip).isChecked = !chip.isChecked
FlagsSheetFragment().show(supportFragmentManager, FlagsSheetFragment.TAG)
}
}

// TODO: Context Parameter
suspend fun CardBrowser.updateChips(
chips: ChipGroup,
oldSearchParameters: SearchParameters,
newSearchParameters: SearchParameters
) {
if (oldSearchParameters.flags != newSearchParameters.flags) {
val flagNames = Flag.queryDisplayNames()
chips.findViewById<Chip>(R.id.chip_flag).let { chip ->
chip.update(
activeItems = newSearchParameters.flags,
inactiveText = TR.browsingFlag(),
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)
}
}
}

private fun <T> Chip.update(activeItems: Collection<T>, inactiveText: String, activeTextGetter: (T) -> String) {
if (activeItems.isEmpty()) {
isChecked = false
text = inactiveText
} else {
isChecked = true
val firstSelectedItemName = activeTextGetter(activeItems.first())
text = if (activeItems.size == 1) {
firstSelectedItemName
} else {
// Display the first filter, along with the count of additional applied filters:
// e.g. ["Tag1", "Tag2", "Tag3"] -> "Tag1 +2"
context.getString(R.string.chip_filter_multiple_selections, firstSelectedItemName, (activeItems.size - 1))
}
}
}
Loading

0 comments on commit 4254250

Please sign in to comment.