From 2c36fee63abc9a8179984e90b4ae90b32fda411d Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 5 May 2026 18:02:47 +0100 Subject: [PATCH 1/4] refactor(card-browser): move options to repository * Simplifies the ViewModel, it's getting large Assisted-by: Claude Opus 4.7 - all --- .../anki/browser/BrowserOptionsRepository.kt | 70 +++++++++++++++++++ .../anki/browser/CardBrowserViewModel.kt | 47 +++---------- 2 files changed, 81 insertions(+), 36 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt 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..9aa135bf5be5 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt @@ -0,0 +1,70 @@ +/* + * 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.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) + + /** 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 } + } + + suspend fun setCardsOrNotes(value: CardsOrNotes) { + Timber.i("setting cards/notes mode to %s", value) + withCol { value.saveToCollection(this) } + cardsOrNotes.value = value + } + + fun setIsTruncated(value: Boolean) { + Timber.d("setting truncated to %s", value) + sharedPrefs.edit { putBoolean(PREF_IS_TRUNCATED, value) } + isTruncated.value = value + } + + suspend fun setIgnoreAccentsInSearch(value: Boolean) { + Timber.d("setting ignore accents in search to %s", value) + withCol { config.ignoreAccentsInSearch = value } + ignoreAccentsInSearch.value = value + } + + companion object { + private const val PREF_IS_TRUNCATED = "isTruncated" + } +} 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..6574b1c8deee 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,10 @@ 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 private val _selectedRows: MutableSet = Collections.synchronizedSet(LinkedHashSet()) @@ -550,16 +549,13 @@ class CardBrowserViewModel( }.launchIn(viewModelScope) viewModelScope.launch { - shouldIgnoreAccents = withCol { config.ignoreAccentsInSearch } + browserOptionsRepository.load() 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 +790,11 @@ 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) { - Timber.d("Setting ignore accent in search to: $value") - viewModelScope.launch { - shouldIgnoreAccents = value - withCol { config.ignoreAccentsInSearch = value } - } - } + fun setIgnoreAccents(value: Boolean) = viewModelScope.launch { browserOptionsRepository.setIgnoreAccentsInSearch(value) } fun selectAll(): Job? { if (!_selectedRows.addAll(cards)) return null From 56a9cee4eefe40ac7e4a88fba0fd79555015a1fa Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 5 May 2026 18:06:30 +0100 Subject: [PATCH 2/4] refactor(card-browser): simplify no-op setters Assisted-by: Claude Opus 4.7 - all --- .../anki/browser/BrowserOptionsRepository.kt | 3 +++ .../ichi2/anki/dialogs/BrowserOptionsDialog.kt | 16 +++------------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt index 9aa135bf5be5..c40a898dfc15 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt @@ -47,18 +47,21 @@ class BrowserOptionsRepository( } 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 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..6b6d329ec9ca 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BrowserOptionsDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BrowserOptionsDialog.kt @@ -49,19 +49,9 @@ 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) } private val cardsOrNotes by lazy { From 2ac1300b984e77020c9e75c1f46bfd91675c1c8d Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 5 May 2026 18:43:42 +0100 Subject: [PATCH 3/4] feat(card-browser): implement 'Default search text' Fixes 18618 Assisted-by: Claude Opus 4.7 - all --- .../anki/browser/BrowserOptionsRepository.kt | 13 ++++++ .../ichi2/anki/browser/CardBrowserFragment.kt | 5 +++ .../anki/browser/CardBrowserViewModel.kt | 13 ++++++ .../anki/dialogs/BrowserOptionsDialog.kt | 16 +++++++ .../java/com/ichi2/anki/utils/ext/Config.kt | 9 ++++ .../res/layout/dialog_browser_options.xml | 20 +++++++++ .../anki/browser/CardBrowserViewModelTest.kt | 42 +++++++++++++++++++ 7 files changed, 118 insertions(+) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt index c40a898dfc15..45de95ea3933 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt @@ -20,6 +20,7 @@ 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 @@ -40,10 +41,15 @@ class BrowserOptionsRepository( 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) { @@ -67,6 +73,13 @@ class BrowserOptionsRepository( 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 6574b1c8deee..af6c08770e62 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -268,6 +268,8 @@ class CardBrowserViewModel( val shouldIgnoreAccents: Boolean get() = browserOptionsRepository.ignoreAccentsInSearch.value + val defaultBrowserSearch: String get() = browserOptionsRepository.defaultBrowserSearch.value + private val _selectedRows: MutableSet = Collections.synchronizedSet(LinkedHashSet()) // immutable accessor for _selectedRows @@ -551,6 +553,11 @@ class CardBrowserViewModel( viewModelScope.launch { 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) @@ -796,6 +803,12 @@ class CardBrowserViewModel( fun setIgnoreAccents(value: Boolean) = viewModelScope.launch { browserOptionsRepository.setIgnoreAccentsInSearch(value) } + fun setDefaultSearchText(text: String) = + viewModelScope.launch { + if (!Prefs.devUsingCardBrowserSearchView) return@launch + browserOptionsRepository.setDefaultBrowserSearch(text) + } + fun selectAll(): Job? { if (!_selectedRows.addAll(cards)) return null Timber.d("selecting all: %d item(s)", cards.size) 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 6b6d329ec9ca..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 @@ -52,6 +55,7 @@ class BrowserOptionsDialog : AppCompatDialogFragment(R.layout.dialog_browser_opt viewModel.setCardsOrNotes(dialogCardsOrNotes) viewModel.setTruncated(binding.truncateCheckBox.isChecked) viewModel.setIgnoreAccents(binding.ignoreAccentsCheckBox.isChecked) + viewModel.setDefaultSearchText(binding.defaultSearchText.text.orEmpty()) } private val cardsOrNotes by lazy { @@ -102,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 { @@ -137,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)"/> + + + + Date: Tue, 5 May 2026 19:37:09 +0100 Subject: [PATCH 4/4] fix(accessibility): android:textColorHint android:textColorHint was #79747E. On #E4F5FD this led to a contrast of 4.07:1 #716C76 is 4.57:1 - acceptable for text Assisted-by: Claude Opus 4.7 - color --- AnkiDroid/src/main/res/values/theme_light.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/AnkiDroid/src/main/res/values/theme_light.xml b/AnkiDroid/src/main/res/values/theme_light.xml index 9975f032ef37..110f4bbc0d55 100644 --- a/AnkiDroid/src/main/res/values/theme_light.xml +++ b/AnkiDroid/src/main/res/values/theme_light.xml @@ -7,6 +7,7 @@ @color/material_light_blue_700 @color/white @color/black + #716C76 @color/material_blue_grey_700 @color/white @color/white