diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt new file mode 100644 index 000000000000..45de95ea3933 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026 David Allison + * + * 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 . + */ + +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 + field = MutableStateFlow(CardsOrNotes.CARDS) + + val isTruncated: StateFlow + field = MutableStateFlow(sharedPrefs.getBoolean(PREF_IS_TRUNCATED, false)) + + val ignoreAccentsInSearch: StateFlow + field = MutableStateFlow(false) + + /** @see com.ichi2.anki.libanki.Config.defaultBrowserSearch */ + val defaultBrowserSearch: StateFlow + 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" + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt index c9eb1ac81354..0bfc40ba859a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -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() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt index 18870e6c1b66..af6c08770e62 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -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 @@ -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 @@ -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 { @@ -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 = browserOptionsRepository.cardsOrNotes + val cardsOrNotes: CardsOrNotes get() = flowOfCardsOrNotes.value /** * Ensures [focusedRow] points to a row in the current [cards] list, falling back to the @@ -263,11 +263,12 @@ class CardBrowserViewModel( .map { it?.isNotEmpty() == true } .stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = false) - val flowOfIsTruncated: MutableStateFlow = - MutableStateFlow(sharedPrefs().getBoolean("isTruncated", false)) - val isTruncated get() = flowOfIsTruncated.value + val flowOfIsTruncated: StateFlow = 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 = Collections.synchronizedSet(LinkedHashSet()) @@ -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) } @@ -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 diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BrowserOptionsDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BrowserOptionsDialog.kt index 0e2b912ded44..bf918f08f19d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BrowserOptionsDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BrowserOptionsDialog.kt @@ -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 @@ -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 @@ -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 { @@ -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 { @@ -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() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Config.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Config.kt index 35c2f8fbef3b..f0f12008ca43 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Config.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Config.kt @@ -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) diff --git a/AnkiDroid/src/main/res/layout/dialog_browser_options.xml b/AnkiDroid/src/main/res/layout/dialog_browser_options.xml index 6f99851e8eaf..488fadba1c4b 100644 --- a/AnkiDroid/src/main/res/layout/dialog_browser_options.xml +++ b/AnkiDroid/src/main/res/layout/dialog_browser_options.xml @@ -93,6 +93,26 @@ android:layout_height="wrap_content" android:layout_marginHorizontal="16dp" tools:text="Ignore accents in search (slower)"/> + + + + @color/material_light_blue_700 @color/white @color/black + #716C76 @color/material_blue_grey_700 @color/white @color/white diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt index c485bffe8b74..c5238c188309 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt @@ -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 @@ -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 {