Skip to content

Commit 66ca462

Browse files
Haz3-joltlukstbit
authored andcommitted
feat(card-browser): implement "See more" for search history
Issue 20816 Assisted-by: Claude Opus 4.7 - discussion, rubber duck
1 parent f1a3305 commit 66ca462

6 files changed

Lines changed: 144 additions & 22 deletions

File tree

AnkiDroid/src/main/java/com/ichi2/anki/browser/search/CardBrowserSearchViewModel.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import com.ichi2.anki.libanki.NoteTypeNameID
3131
import kotlinx.coroutines.flow.MutableSharedFlow
3232
import kotlinx.coroutines.flow.MutableStateFlow
3333
import kotlinx.coroutines.flow.SharingStarted
34+
import kotlinx.coroutines.flow.StateFlow
3435
import kotlinx.coroutines.flow.combine
3536
import kotlinx.coroutines.flow.emptyFlow
3637
import kotlinx.coroutines.flow.filterNotNull
@@ -78,12 +79,28 @@ class CardBrowserSearchViewModel(
7879
initialValue = "",
7980
)
8081

82+
val isHistoryExpandedFlow = savedStateHandle.getMutableStateFlow(STATE_HISTORY_EXPANDED, false)
83+
8184
val searchHistoryFlow =
8285
MutableStateFlow<SearchHistoryItems>(
8386
SearchHistoryItems.Loading(searchHistoryManager.entries),
8487
)
8588
val searchHistoryAvailableFlow = searchHistoryFlow.map { it.isNotEmpty() }
8689

90+
val displayedSearchHistoryFlow: StateFlow<SearchHistoryItems> =
91+
combine(searchHistoryFlow, isHistoryExpandedFlow) { items, expanded ->
92+
if (expanded) items else items.truncated(MAX_SEARCH_HISTORY_ENTRIES)
93+
}.stateIn(
94+
scope = viewModelScope,
95+
started = SharingStarted.Eagerly,
96+
initialValue = searchHistoryFlow.value,
97+
)
98+
99+
val showHistoryToggleFlow =
100+
searchHistoryFlow.map {
101+
it.entryToSearchString.size > MAX_SEARCH_HISTORY_ENTRIES
102+
}
103+
87104
val closeSearchViewFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1, replay = 1)
88105

89106
val submittedSearchFlow = MutableSharedFlow<SearchRequest?>(extraBufferCapacity = 1, replay = 1)
@@ -194,6 +211,10 @@ class CardBrowserSearchViewModel(
194211
advancedSearchFlow.value = !advancedSearchFlow.value
195212
}
196213

214+
fun toggleHistoryExpanded() {
215+
isHistoryExpandedFlow.value = !isHistoryExpandedFlow.value
216+
}
217+
197218
/**
198219
* Appends [searchText] to the current temporary advances search text
199220
*/
@@ -340,6 +361,12 @@ class CardBrowserSearchViewModel(
340361
abstract val entryToSearchString: List<Pair<SearchHistoryEntry, SearchString?>>
341362

342363
fun isNotEmpty() = entryToSearchString.isNotEmpty()
364+
365+
fun truncated(max: Int): SearchHistoryItems =
366+
when (this) {
367+
is Loading -> Loading(input.take(max))
368+
is Loaded -> Loaded(entryToSearchString.take(max))
369+
}
343370
}
344371

345372
/** Wraps [SearchFilters] so sync updates do not trigger a search */
@@ -360,6 +387,10 @@ class CardBrowserSearchViewModel(
360387
private const val STATE_ADVANCED_SEARCH_ENABLED = "advancedSearch"
361388
private const val STATE_BASIC_SEARCH_TEXT = "basicSearchText"
362389
private const val STATE_ADVANCED_SEARCH_TEXT = "advancedSearchText"
390+
private const val STATE_HISTORY_EXPANDED = "historyExpanded"
391+
392+
/** Limits the number of history items, so controls appear below without scrolling */
393+
const val MAX_SEARCH_HISTORY_ENTRIES = 5
363394
}
364395
}
365396

AnkiDroid/src/main/java/com/ichi2/anki/browser/search/StandardSearchFragment.kt

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ import com.ichi2.anki.launchCatchingTask
5252
import com.ichi2.anki.libanki.DeckNameId
5353
import com.ichi2.anki.model.CardStateFilter
5454
import com.ichi2.anki.model.SelectableDeck
55-
import com.ichi2.anki.snackbar.showSnackbar
5655
import com.ichi2.anki.utils.ext.hasCheckedBackground
5756
import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope
5857
import com.ichi2.anki.utils.ext.showDialogFragment
@@ -208,15 +207,8 @@ class StandardSearchFragment :
208207
fun toSpannable() = searchString?.let { entry.toUserSpannable(it) } ?: SpannableString("")
209208
}
210209

211-
fun buildUiModelList(historyItems: SearchHistoryItems): List<SearchHistoryItemUiModel> =
212-
historyItems.entryToSearchString
213-
.take(MAX_SEARCH_HISTORY_ENTRIES)
214-
.map {
215-
SearchHistoryItemUiModel(
216-
entry = it.first,
217-
searchString = it.second,
218-
)
219-
}
210+
fun toUiModels(items: SearchHistoryItems): List<SearchHistoryItemUiModel> =
211+
items.entryToSearchString.map { SearchHistoryItemUiModel(entry = it.first, searchString = it.second) }
220212

221213
class SearchHistoryAdapter(
222214
private val context: Context,
@@ -254,7 +246,7 @@ class StandardSearchFragment :
254246
val searchHistoryAdapter =
255247
SearchHistoryAdapter(
256248
context = requireContext(),
257-
searches = buildUiModelList(viewModel.searchHistoryFlow.value),
249+
searches = toUiModels(viewModel.displayedSearchHistoryFlow.value),
258250
)
259251
binding.searchHistory.apply {
260252
adapter = searchHistoryAdapter
@@ -263,14 +255,22 @@ class StandardSearchFragment :
263255
}
264256
}
265257

266-
binding.seeMore.apply {
267-
setOnClickListener { showSnackbar("TODO") }
258+
binding.toggleSearchHistory.setOnClickListener {
259+
viewModel.toggleHistoryExpanded()
268260
}
269261

270-
// replace the data when the search history is updated
271-
viewModel.searchHistoryFlow.launchCollectionInLifecycleScope {
262+
// replace the data when the displayed search history is updated
263+
viewModel.displayedSearchHistoryFlow.launchCollectionInLifecycleScope {
272264
searchHistoryAdapter.clear()
273-
searchHistoryAdapter.addAll(buildUiModelList(it))
265+
searchHistoryAdapter.addAll(toUiModels(it))
266+
}
267+
268+
viewModel.showHistoryToggleFlow.launchCollectionInLifecycleScope {
269+
binding.toggleSearchHistory.isVisible = it
270+
}
271+
272+
viewModel.isHistoryExpandedFlow.launchCollectionInLifecycleScope {
273+
binding.toggleSearchHistory.setText(if (it) R.string.card_browser_see_less else R.string.card_browser_see_more)
274274
}
275275

276276
viewModel.searchHistoryAvailableFlow.launchCollectionInLifecycleScope {
@@ -335,9 +335,6 @@ class StandardSearchFragment :
335335

336336
companion object {
337337
const val TAG = "STANDARD"
338-
339-
/** Limits the number of history items, so controls appear below without scrolling */
340-
const val MAX_SEARCH_HISTORY_ENTRIES = 5
341338
}
342339
}
343340

AnkiDroid/src/main/res/layout/fragment_standard_search.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,13 @@
129129
/>
130130

131131
<Button
132-
android:id="@+id/see_more"
132+
android:id="@+id/toggle_search_history"
133133
android:layout_width="match_parent"
134134
android:layout_height="wrap_content"
135135
android:layout_gravity="end"
136136
android:paddingStart="16dp"
137137
style="@style/Widget.Material3.Button.TextButton.Icon"
138-
android:text="See more"
138+
android:text="@string/card_browser_see_more"
139139
app:layout_constraintTop_toBottomOf="@id/search_history"
140140
/>
141141

AnkiDroid/src/main/res/values/07-cardbrowser.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,8 @@
106106
<string name="save_changes_message">Please save your changes first</string>
107107

108108
<string name="browser_find_replace_loading_fields">Retrieving fields names&#8230;</string>
109+
110+
<!-- Search history -->
111+
<string name="card_browser_see_more">See more</string>
112+
<string name="card_browser_see_less">See less</string>
109113
</resources>

AnkiDroid/src/test/java/com/ichi2/anki/browser/search/CardBrowserSearchViewModelTest.kt

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import app.cash.turbine.test
2222
import com.ichi2.anki.RobolectricTest
2323
import com.ichi2.anki.browser.SearchHistory
2424
import com.ichi2.anki.browser.SearchHistory.SearchHistoryEntry
25+
import com.ichi2.anki.browser.search.CardBrowserSearchViewModel.Companion.MAX_SEARCH_HISTORY_ENTRIES
2526
import com.ichi2.anki.browser.search.CardBrowserSearchViewModel.UserMessage
2627
import com.ichi2.testutils.assertFalse
2728
import kotlinx.coroutines.flow.map
@@ -359,6 +360,70 @@ class CardBrowserSearchViewModelTest : RobolectricTest() {
359360
}
360361
}
361362

363+
@Test
364+
fun `search history - initially collapsed`() =
365+
withViewModel {
366+
assertThat("initially not expanded", isHistoryExpandedFlow.value, equalTo(false))
367+
}
368+
369+
@Test
370+
fun `search history - toggle expands and collapses`() =
371+
withViewModel {
372+
isHistoryExpandedFlow.test {
373+
assertThat(expectMostRecentItem(), equalTo(false))
374+
toggleHistoryExpanded()
375+
assertThat(expectMostRecentItem(), equalTo(true))
376+
toggleHistoryExpanded()
377+
assertThat(expectMostRecentItem(), equalTo(false))
378+
}
379+
}
380+
381+
@Test
382+
fun `search history - displayed list is truncated when collapsed`() =
383+
withViewModel {
384+
repeat(MAX_SEARCH_HISTORY_ENTRIES + 3) { i ->
385+
submitSearch("search $i")
386+
}
387+
displayedSearchHistoryFlow.test {
388+
val items = expectMostRecentItem()
389+
assertThat(items.entryToSearchString, hasSize(MAX_SEARCH_HISTORY_ENTRIES))
390+
}
391+
}
392+
393+
@Test
394+
fun `search history - displayed list shows all when expanded`() =
395+
withViewModel {
396+
val totalItems = MAX_SEARCH_HISTORY_ENTRIES + 3
397+
repeat(totalItems) { i ->
398+
submitSearch("search $i")
399+
}
400+
toggleHistoryExpanded()
401+
displayedSearchHistoryFlow.test {
402+
val items = expectMostRecentItem()
403+
assertThat(items.entryToSearchString, hasSize(totalItems))
404+
}
405+
}
406+
407+
@Test
408+
fun `search history - toggle button visible only when items exceed max`() =
409+
withViewModel {
410+
showHistoryToggleFlow.test {
411+
assertThat(expectMostRecentItem(), equalTo(false))
412+
}
413+
414+
repeat(MAX_SEARCH_HISTORY_ENTRIES) { i ->
415+
submitSearch("search $i")
416+
}
417+
showHistoryToggleFlow.test {
418+
assertThat(expectMostRecentItem(), equalTo(false))
419+
}
420+
421+
submitSearch("one more search")
422+
showHistoryToggleFlow.test {
423+
assertThat(expectMostRecentItem(), equalTo(true))
424+
}
425+
}
426+
362427
fun withViewModel(
363428
cardCount: Int = 1,
364429
block: suspend CardBrowserSearchViewModel.() -> Unit,

AnkiDroid/src/test/java/com/ichi2/anki/browser/search/StandardSearchFragmentTest.kt

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.ichi2.anki.browser.search
1818

19+
import androidx.core.view.isVisible
1920
import androidx.fragment.app.Fragment
2021
import androidx.test.ext.junit.runners.AndroidJUnit4
2122
import com.ichi2.anki.RobolectricTest
@@ -26,6 +27,8 @@ import org.junit.Before
2627
import org.junit.Test
2728
import org.junit.runner.RunWith
2829
import kotlin.test.assertEquals
30+
import kotlin.test.assertFalse
31+
import kotlin.test.assertTrue
2932

3033
/** Test of [StandardSearchFragment] */
3134
@RunWith(AndroidJUnit4::class)
@@ -48,7 +51,7 @@ class StandardSearchFragmentTest : RobolectricTest() {
4851
@Test
4952
fun `history entries are truncated`() {
5053
val expectedMaxEntries = 5
51-
assertEquals(expectedMaxEntries, StandardSearchFragment.MAX_SEARCH_HISTORY_ENTRIES)
54+
assertEquals(expectedMaxEntries, CardBrowserSearchViewModel.MAX_SEARCH_HISTORY_ENTRIES)
5255

5356
val history = SearchHistory()
5457
repeat(expectedMaxEntries + 1) {
@@ -61,6 +64,28 @@ class StandardSearchFragmentTest : RobolectricTest() {
6164
}
6265
}
6366

67+
@Test
68+
fun `see more button is hidden when entries do not exceed limit`() {
69+
val history = SearchHistory()
70+
repeat(3) { history.addRecent(SearchHistoryEntry(it.toString())) }
71+
72+
withFragment {
73+
assertFalse(binding.toggleSearchHistory.isVisible)
74+
}
75+
}
76+
77+
@Test
78+
fun `see more button is visible when entries exceed limit`() {
79+
val history = SearchHistory()
80+
repeat(CardBrowserSearchViewModel.MAX_SEARCH_HISTORY_ENTRIES + 1) {
81+
history.addRecent(SearchHistoryEntry(it.toString()))
82+
}
83+
84+
withFragment {
85+
assertTrue(binding.toggleSearchHistory.isVisible)
86+
}
87+
}
88+
6489
fun withFragment(block: StandardSearchFragment.() -> Unit) =
6590
withCardBrowserFragment(useSearchView = true) {
6691
val targetFragment = requireChildFragment<StandardSearchFragment>(StandardSearchFragment.TAG)

0 commit comments

Comments
 (0)