Skip to content
Open
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
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2026 David Allison <davidallisongithub@gmail.com>
*
* 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.content.SharedPreferences
import androidx.core.content.edit
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.model.CardsOrNotes
import com.ichi2.anki.utils.ext.defaultBrowserSearch
import com.ichi2.anki.utils.ext.ignoreAccentsInSearch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import timber.log.Timber

/**
* Source of truth for the values controlled by [com.ichi2.anki.dialogs.BrowserOptionsDialog]
*/
class BrowserOptionsRepository(
private val sharedPrefs: SharedPreferences,
) {
val cardsOrNotes: StateFlow<CardsOrNotes>
field = MutableStateFlow(CardsOrNotes.CARDS)

val isTruncated: StateFlow<Boolean>
field = MutableStateFlow(sharedPrefs.getBoolean(PREF_IS_TRUNCATED, false))

val ignoreAccentsInSearch: StateFlow<Boolean>
field = MutableStateFlow(false)

/** @see com.ichi2.anki.libanki.Config.defaultBrowserSearch */
val defaultBrowserSearch: StateFlow<String>
field = MutableStateFlow("")

/** Reads persisted values into the flows. Call once during ViewModel init. */
suspend fun load() {
cardsOrNotes.value = withCol { CardsOrNotes.fromCollection(this) }
ignoreAccentsInSearch.value = withCol { config.ignoreAccentsInSearch }
defaultBrowserSearch.value = withCol { config.defaultBrowserSearch }
}

suspend fun setCardsOrNotes(value: CardsOrNotes) {
if (cardsOrNotes.value == value) return
Timber.i("setting cards/notes mode to %s", value)
withCol { value.saveToCollection(this) }
cardsOrNotes.value = value
}

fun setIsTruncated(value: Boolean) {
if (isTruncated.value == value) return
Timber.d("setting truncated to %s", value)
sharedPrefs.edit { putBoolean(PREF_IS_TRUNCATED, value) }
isTruncated.value = value
}

suspend fun setIgnoreAccentsInSearch(value: Boolean) {
if (ignoreAccentsInSearch.value == value) return
Timber.d("setting ignore accents in search to %s", value)
withCol { config.ignoreAccentsInSearch = value }
ignoreAccentsInSearch.value = value
}

suspend fun setDefaultBrowserSearch(value: String) {
if (defaultBrowserSearch.value == value) return
Timber.d("setting default browser search to %s", value)
withCol { config.defaultBrowserSearch = value }
defaultBrowserSearch.value = value
}

companion object {
private const val PREF_IS_TRUNCATED = "isTruncated"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,11 @@ class CardBrowserFragment :
Timber.d("syncing searchview state from chip updates")
val filters = search.filters

// Handle default search text
if (search.query.isNotEmpty() && searchBar?.text.isNullOrEmpty()) {
launchCatchingTask { searchBar?.setText(search.toUserSpannable()) }
}

decksChip?.text = filters.decks.firstOrNull()?.name ?: TR.sentenceCase.allDecks
decksChip?.hasCheckedBackground = filters.decks.any()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import android.os.Bundle
import android.os.Parcel
import android.os.Parcelable
import androidx.annotation.CheckResult
import androidx.core.content.edit
import androidx.core.os.BundleCompat
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
Expand Down Expand Up @@ -82,7 +81,6 @@ import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.settings.PrefsRepository
import com.ichi2.anki.utils.ext.currentCardBrowse
import com.ichi2.anki.utils.ext.getCardOrNull
import com.ichi2.anki.utils.ext.ignoreAccentsInSearch
import com.ichi2.anki.utils.ext.setUserFlagForCards
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
Expand Down Expand Up @@ -141,6 +139,8 @@ class CardBrowserViewModel(
preferences: SharedPreferencesProvider,
val isFragmented: Boolean,
val savedStateHandle: SavedStateHandle,
private val browserOptionsRepository: BrowserOptionsRepository =
BrowserOptionsRepository(preferences.sharedPrefs()),
private val manualInit: Boolean = false,
) : ViewModel(),
SharedPreferencesProvider by preferences {
Expand Down Expand Up @@ -202,8 +202,8 @@ class CardBrowserViewModel(
* Whether the browser is working in Cards mode or Notes mode.
* default: [CARDS]
* */
private val flowOfCardsOrNotes = MutableStateFlow(CARDS)
val cardsOrNotes get() = flowOfCardsOrNotes.value
val flowOfCardsOrNotes: StateFlow<CardsOrNotes> = browserOptionsRepository.cardsOrNotes
val cardsOrNotes: CardsOrNotes get() = flowOfCardsOrNotes.value

/**
* Ensures [focusedRow] points to a row in the current [cards] list, falling back to the
Expand Down Expand Up @@ -263,11 +263,12 @@ class CardBrowserViewModel(
.map { it?.isNotEmpty() == true }
.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = false)

val flowOfIsTruncated: MutableStateFlow<Boolean> =
MutableStateFlow(sharedPrefs().getBoolean("isTruncated", false))
val isTruncated get() = flowOfIsTruncated.value
val flowOfIsTruncated: StateFlow<Boolean> = browserOptionsRepository.isTruncated
val isTruncated: Boolean get() = flowOfIsTruncated.value

var shouldIgnoreAccents: Boolean = false
val shouldIgnoreAccents: Boolean get() = browserOptionsRepository.ignoreAccentsInSearch.value

val defaultBrowserSearch: String get() = browserOptionsRepository.defaultBrowserSearch.value

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

Expand Down Expand Up @@ -550,16 +551,18 @@ class CardBrowserViewModel(
}.launchIn(viewModelScope)

viewModelScope.launch {
shouldIgnoreAccents = withCol { config.ignoreAccentsInSearch }
browserOptionsRepository.load()

// Apply the default search (if available)
if (Prefs.devUsingCardBrowserSearchView) {
searchTerms = searchTerms.ifEmpty { defaultBrowserSearch }
}

val initialDeckId = if (selectAllDecks) SelectableDeck.AllDecks else getInitialDeck()
// PERF: slightly inefficient if the source was lastDeckId
setSelectedDeck(initialDeckId)
refreshBackendColumns()

val cardsOrNotes = withCol { CardsOrNotes.fromCollection(this@withCol) }
flowOfCardsOrNotes.update { cardsOrNotes }

withCol {
sortTypeFlow.update { LegacySortType.fromCol(config, cardsOrNotes, prefs) }
reverseDirectionFlow.update { ReverseDirection.fromConfig(config) }
Expand Down Expand Up @@ -794,32 +797,17 @@ class CardBrowserViewModel(
}
}

fun setCardsOrNotes(newValue: CardsOrNotes) =
viewModelScope.launch {
Timber.i("setting mode to %s", newValue)
withCol {
// Change this to only change the preference on a state change
newValue.saveToCollection(this@withCol)
}
flowOfCardsOrNotes.update { newValue }
}
fun setCardsOrNotes(newValue: CardsOrNotes) = viewModelScope.launch { browserOptionsRepository.setCardsOrNotes(newValue) }

fun setTruncated(value: Boolean) {
viewModelScope.launch {
flowOfIsTruncated.emit(value)
}
sharedPrefs().edit {
putBoolean("isTruncated", value)
}
}
fun setTruncated(value: Boolean) = viewModelScope.launch { browserOptionsRepository.setIsTruncated(value) }

fun setIgnoreAccents(value: Boolean) = viewModelScope.launch { browserOptionsRepository.setIgnoreAccentsInSearch(value) }

fun setIgnoreAccents(value: Boolean) {
Timber.d("Setting ignore accent in search to: $value")
fun setDefaultSearchText(text: String) =
viewModelScope.launch {
shouldIgnoreAccents = value
withCol { config.ignoreAccentsInSearch = value }
if (!Prefs.devUsingCardBrowserSearchView) return@launch
browserOptionsRepository.setDefaultBrowserSearch(text)
}
}

fun selectAll(): Job? {
if (!_selectedRows.addAll(cards)) return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ package com.ichi2.anki.dialogs

import android.app.Dialog
import android.os.Bundle
import android.text.Editable
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.ichi2.anki.CollectionManager.TR
Expand All @@ -27,6 +29,7 @@ import com.ichi2.anki.browser.BrowserColumnSelectionFragment
import com.ichi2.anki.browser.CardBrowserViewModel
import com.ichi2.anki.databinding.DialogBrowserOptionsBinding
import com.ichi2.anki.model.CardsOrNotes
import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.ui.internationalization.sentenceCase
import com.ichi2.utils.create
import com.ichi2.utils.negativeButton
Expand All @@ -49,19 +52,10 @@ class BrowserOptionsDialog : AppCompatDialogFragment(R.layout.dialog_browser_opt

/** Persists updated options to the ViewModel */
fun saveChanges() {
if (cardsOrNotes != dialogCardsOrNotes) {
viewModel.setCardsOrNotes(dialogCardsOrNotes)
}
val newTruncate = binding.truncateCheckBox.isChecked

if (newTruncate != isTruncated) {
viewModel.setTruncated(newTruncate)
}

val newIgnoreAccent = binding.ignoreAccentsCheckBox.isChecked
if (newIgnoreAccent != viewModel.shouldIgnoreAccents) {
viewModel.setIgnoreAccents(newIgnoreAccent)
}
viewModel.setCardsOrNotes(dialogCardsOrNotes)
viewModel.setTruncated(binding.truncateCheckBox.isChecked)
viewModel.setIgnoreAccents(binding.ignoreAccentsCheckBox.isChecked)
viewModel.setDefaultSearchText(binding.defaultSearchText.text.orEmpty())
}

private val cardsOrNotes by lazy {
Expand Down Expand Up @@ -112,6 +106,15 @@ class BrowserOptionsDialog : AppCompatDialogFragment(R.layout.dialog_browser_opt
isChecked = viewModel.shouldIgnoreAccents
}

if (Prefs.devUsingCardBrowserSearchView) {
binding.defaultSearchInputLayout.apply {
isVisible = true
hint = TR.preferencesDefaultSearchText()
placeholderText = TR.preferencesDefaultSearchTextExample()
}
binding.defaultSearchText.setText(viewModel.defaultBrowserSearch)
}

binding.browsingTextView.text = TR.preferencesBrowsing()

return MaterialAlertDialogBuilder(requireContext()).create {
Expand Down Expand Up @@ -147,3 +150,6 @@ class BrowserOptionsDialog : AppCompatDialogFragment(R.layout.dialog_browser_opt
}
}
}

/** Returns the text content as a [String], or `""` if the receiver is `null`. */
private fun Editable?.orEmpty(): String = this?.toString().orEmpty()
9 changes: 9 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,12 @@ import com.ichi2.anki.libanki.Config
var Config.ignoreAccentsInSearch
get() = getBool(ConfigKey.Bool.IGNORE_ACCENTS_IN_SEARCH)
set(value) = setBool(ConfigKey.Bool.IGNORE_ACCENTS_IN_SEARCH, value)

/**
* Default search text for the card browser
*
* e.g. "deck:current"
*/
var Config.defaultBrowserSearch
get() = getString(ConfigKey.String.DEFAULT_SEARCH_TEXT)
set(value) = setString(ConfigKey.String.DEFAULT_SEARCH_TEXT, value)
20 changes: 20 additions & 0 deletions AnkiDroid/src/main/res/layout/dialog_browser_options.xml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,26 @@
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
tools:text="Ignore accents in search (slower)"/>

<com.google.android.material.textfield.TextInputLayout
android:id="@+id/default_search_input_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
app:expandedHintEnabled="false"
app:endIconMode="clear_text"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="8dp"
android:visibility="gone"
tools:visibility="visible"
tools:hint="Default search text">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/default_search_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
tools:hint="e.g. 'deck:current'"/>
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

<LinearLayout
Expand Down
1 change: 1 addition & 0 deletions AnkiDroid/src/main/res/values/theme_light.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<item name="colorPrimaryDark">@color/material_light_blue_700</item>
<item name="colorSurface">@color/white</item>
<item name="colorOnSurface">@color/black</item>
<item name="android:textColorHint">#716C76</item>
<item name="colorAccent">@color/material_blue_grey_700</item>
<item name="colorPrimaryContainer">@color/white</item> <!-- Switch thumb color when disabled and being pressed -->
<item name="colorOnPrimaryContainer">@color/white</item> <!-- FAB icons -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import com.ichi2.anki.noteeditor.NoteEditorLauncher
import com.ichi2.anki.servicelayer.NoteService
import com.ichi2.anki.setFlagFilterSync
import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.utils.ext.defaultBrowserSearch
import com.ichi2.anki.utils.ext.ignoreAccentsInSearch
import com.ichi2.testutils.IntentAssert
import com.ichi2.testutils.JvmTest
Expand Down Expand Up @@ -1852,6 +1853,47 @@ class CardBrowserViewModelTest : JvmTest() {
}
}

@Test
fun `default search text is applied on init`() {
col.config.defaultBrowserSearch = "deck:current"
try {
Prefs.devUsingCardBrowserSearchView = true
runViewModelTest {
assertThat(searchTerms, equalTo("deck:current"))
assertThat(defaultBrowserSearch, equalTo("deck:current"))
}
} finally {
Prefs.devUsingCardBrowserSearchView = false
}
}

@Test
fun `intent search wins over default search text`() {
col.config.defaultBrowserSearch = "deck:current"
try {
Prefs.devUsingCardBrowserSearchView = true
runViewModelTest(options = DeepLink("tag:foo")) {
assertThat(searchTerms, equalTo("tag:foo"))
}
} finally {
Prefs.devUsingCardBrowserSearchView = false
}
}

@Test
fun `setDefaultSearchText round-trips through collection config`() {
try {
Prefs.devUsingCardBrowserSearchView = true
runViewModelTest {
setDefaultSearchText("tag:foo").join()
assertThat(defaultBrowserSearch, equalTo("tag:foo"))
assertThat(col.config.defaultBrowserSearch, equalTo("tag:foo"))
}
} finally {
Prefs.devUsingCardBrowserSearchView = false
}
}

@Test
fun `updating sort type launches search`() =
runViewModelTest {
Expand Down
Loading