Skip to content

Commit f240e45

Browse files
committed
Fix second filter state save in filtered deck screen
Replaces the cards state AutoCompleteTextView(thanks Material theme) with a Spinner. Fixes a bug where the second filter state was not saved because the code considered it null when the deck being loaded didn't already had the second filter enabled.
1 parent 615a2fe commit f240e45

4 files changed

Lines changed: 110 additions & 47 deletions

File tree

AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsFragment.kt

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import android.view.View
2525
import android.widget.AdapterView
2626
import android.widget.ArrayAdapter
2727
import android.widget.CheckBox
28+
import android.widget.Spinner
2829
import androidx.appcompat.app.AlertDialog
2930
import androidx.core.os.bundleOf
3031
import androidx.core.view.isVisible
@@ -35,7 +36,6 @@ import androidx.lifecycle.Lifecycle
3536
import androidx.lifecycle.lifecycleScope
3637
import androidx.lifecycle.repeatOnLifecycle
3738
import com.google.android.material.materialswitch.MaterialSwitch
38-
import com.google.android.material.textfield.MaterialAutoCompleteTextView
3939
import com.google.android.material.textfield.TextInputEditText
4040
import com.google.android.material.textfield.TextInputLayout
4141
import com.ichi2.anki.CardBrowser
@@ -52,6 +52,8 @@ import com.ichi2.utils.show
5252
import com.ichi2.utils.title
5353
import dev.androidbroadcast.vbpd.viewBinding
5454
import kotlinx.coroutines.launch
55+
import java.util.Locale.getDefault
56+
import kotlin.text.replaceFirstChar
5557

5658
/**
5759
* Represents the screen where a filtered deck can be built or rebuilt after updating its properties.
@@ -171,7 +173,7 @@ class FilteredDeckOptionsFragment : Fragment(R.layout.fragment_filtered_deck_opt
171173
SearchInputError.NotANumber -> TR.errorsInvalidInputEmpty()
172174
null -> null
173175
}
174-
binding.filterCardsInput.setAdapterIfChanged(state.cardOptions, filter1State.index)
176+
binding.filterCards.setAdapterIfNeeded(state.cardOptions, filter1State.index)
175177
// rescheduling (done here because in filter 2 setup we might exit early)
176178
binding.checkBoxReschedule.setCheckedIfChanged(state.shouldReschedule)
177179
binding.rescheduleDelayAgainInput.setTextIfChanged(state.delayAgain)
@@ -188,7 +190,8 @@ class FilteredDeckOptionsFragment : Fragment(R.layout.fragment_filtered_deck_opt
188190
}
189191
binding.secondFilterSearchContainer.isVisible = state.isSecondFilterEnabled
190192
binding.secondFilterLimitInputLayout.isVisible = state.isSecondFilterEnabled
191-
binding.secondFilterCardsInputLayout.isVisible = state.isSecondFilterEnabled
193+
binding.secondFilterCardsLabel.isVisible = state.isSecondFilterEnabled
194+
binding.secondFilterCards.isVisible = state.isSecondFilterEnabled
192195
val filter2State = state.filter2State ?: return
193196
binding.secondFilterSearchInput.setTextIfChanged(filter2State.search)
194197
binding.secondFilterLimitInput.setTextIfChanged(filter2State.limit)
@@ -198,7 +201,7 @@ class FilteredDeckOptionsFragment : Fragment(R.layout.fragment_filtered_deck_opt
198201
SearchInputError.NotANumber -> TR.errorsInvalidInputEmpty()
199202
null -> null
200203
}
201-
binding.secondFilterCardsInput.setAdapterIfChanged(state.cardOptions, filter2State.index)
204+
binding.secondFilterCards.setAdapterIfNeeded(state.cardOptions, filter2State.index)
202205
}
203206

204207
private fun TextInputLayout.setupRescheduleDelay(
@@ -228,10 +231,8 @@ class FilteredDeckOptionsFragment : Fragment(R.layout.fragment_filtered_deck_opt
228231
binding.filterLimitInput.onTextChanged { text ->
229232
viewModel.onLimitChange(FilterIndex.First, text)
230233
}
231-
binding.filterCardsInput.onItemClickListener =
232-
AdapterView.OnItemClickListener { _, _, position, _ ->
233-
viewModel.onCardsOptionsChange(FilterIndex.First, position)
234-
}
234+
binding.filterCards.bindListener(FilterIndex.First)
235+
235236
// filter#2
236237
binding.switchSecondFilter.onCheckedChanged(viewModel::onSecondFilterStatusChange)
237238
binding.secondFilterSearchInput.onTextChanged { text ->
@@ -243,10 +244,8 @@ class FilteredDeckOptionsFragment : Fragment(R.layout.fragment_filtered_deck_opt
243244
binding.secondFilterLimitInput.onTextChanged { text ->
244245
viewModel.onLimitChange(FilterIndex.Second, text)
245246
}
246-
binding.secondFilterCardsInput.onItemClickListener =
247-
AdapterView.OnItemClickListener { _, _, position, _ ->
248-
viewModel.onCardsOptionsChange(FilterIndex.Second, position)
249-
}
247+
binding.secondFilterCards.bindListener(FilterIndex.Second)
248+
250249
// reschedule
251250
binding.checkBoxReschedule.onCheckedChanged(viewModel::onRescheduleChange)
252251
binding.rescheduleDelayAgainInput.onTextChanged { text ->
@@ -289,12 +288,13 @@ class FilteredDeckOptionsFragment : Fragment(R.layout.fragment_filtered_deck_opt
289288
// first filter
290289
binding.filterSearchInputLayout.hint = TR.actionsSearch()
291290
binding.filterLimitInputLayout.hint = TR.decksLimitTo()
292-
binding.filterCardsInputLayout.hint = TR.decksCardsSelectedBy()
291+
binding.filterCardsLabel.text = TR.decksCardsSelectedBy().capitalize()
293292
// second filter
294293
binding.switchSecondFilter.text = TR.decksEnableSecondFilter()
295294
binding.secondFilterSearchInputLayout.hint = TR.actionsSearch()
296295
binding.secondFilterLimitInputLayout.hint = TR.decksLimitTo()
297-
binding.secondFilterCardsInputLayout.hint = TR.decksCardsSelectedBy()
296+
binding.secondFilterCardsLabel.text = TR.decksCardsSelectedBy().capitalize()
297+
298298
// buttons
299299
binding.btnShowExcludedCards.text = TR.decksUnmovableCards()
300300
binding.checkBoxReschedule.text = TR.decksRescheduleCardsBasedOnMyAnswers()
@@ -316,10 +316,28 @@ class FilteredDeckOptionsFragment : Fragment(R.layout.fragment_filtered_deck_opt
316316
if (this.isChecked != newChecked) this.isChecked = newChecked
317317
}
318318

319-
/** Sets the adapter and selection for [MaterialAutoCompleteTextView] only if its items are different */
320-
private fun MaterialAutoCompleteTextView.setAdapterIfChanged(
319+
private fun Spinner.bindListener(index: FilterIndex) {
320+
onItemSelectedListener =
321+
object : AdapterView.OnItemSelectedListener {
322+
override fun onItemSelected(
323+
adapterView: AdapterView<*>?,
324+
view: View?,
325+
position: Int,
326+
id: Long,
327+
) {
328+
viewModel.onCardsOptionsChange(index, position)
329+
}
330+
331+
override fun onNothingSelected(adapterView: AdapterView<*>?) {
332+
// do nothing
333+
}
334+
}
335+
}
336+
337+
/** Sets the adapter and selection for cards [Spinner] only if options are available */
338+
private fun Spinner.setAdapterIfNeeded(
321339
cardOptions: List<String>,
322-
selectedIndex: Int,
340+
selectedPosition: Int,
323341
) {
324342
if (cardOptions.isNotEmpty()) {
325343
setAdapter(
@@ -329,10 +347,17 @@ class FilteredDeckOptionsFragment : Fragment(R.layout.fragment_filtered_deck_opt
329347
cardOptions,
330348
),
331349
)
332-
setText(cardOptions[selectedIndex], false)
350+
setSelection(selectedPosition)
333351
}
334352
}
335353

354+
/**
355+
* Needed because backend returns the string TR.decksCardsSelectedBy starting with a lowercase.
356+
* This was the recommended replacement implementation for the now deprecated Kotlin
357+
* capitalize() method.
358+
*/
359+
private fun String.capitalize() = replaceFirstChar { if (it.isLowerCase()) it.titlecase(getDefault()) else it.toString() }
360+
336361
companion object {
337362
const val ARG_DECK_ID = "arg_deck_id"
338363
const val ARG_SEARCH = "arg_search"

AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModel.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,12 @@ class FilteredDeckOptionsViewModel(
156156
fun onSecondFilterStatusChange(isEnabled: Boolean) {
157157
Timber.i("Second filter status is changing to $isEnabled")
158158
if (currentState().isSecondFilterEnabled == isEnabled) return
159-
updateCurrentState { copy(isSecondFilterEnabled = isEnabled) }
159+
updateCurrentState {
160+
copy(
161+
isSecondFilterEnabled = isEnabled,
162+
filter2State = filter2State ?: SearchTermState(),
163+
)
164+
}
160165
}
161166

162167
fun onRescheduleChange(isEnabled: Boolean) {

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

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -157,23 +157,21 @@
157157
/>
158158
</com.google.android.material.textfield.TextInputLayout>
159159

160-
<com.google.android.material.textfield.TextInputLayout
161-
android:id="@+id/filter_cards_input_layout"
162-
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.Dense.ExposedDropdownMenu"
160+
<TextView
161+
android:id="@+id/filter_cards_label"
163162
android:layout_width="match_parent"
164163
android:layout_height="wrap_content"
165-
android:layout_marginTop="4dp"
166-
tools:hint="Cards selected by">
164+
android:layout_marginTop="8dp"
165+
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
166+
tools:text="Cards selected by"
167+
/>
167168

168-
<com.google.android.material.textfield.MaterialAutoCompleteTextView
169-
android:id="@+id/filter_cards_input"
170-
android:layout_width="match_parent"
171-
android:layout_height="wrap_content"
172-
android:inputType="textFilter"
173-
app:dropDownBackgroundTint="?android:attr/colorBackground"
174-
tools:text="Random"
175-
/>
176-
</com.google.android.material.textfield.TextInputLayout>
169+
<Spinner
170+
android:id="@+id/filter_cards"
171+
android:layout_width="match_parent"
172+
android:layout_height="wrap_content"
173+
android:minHeight="48dp"
174+
/>
177175

178176
<!-- Material3Fix after colorSurface* are fixed look into using one here -->
179177
<com.google.android.material.materialswitch.MaterialSwitch
@@ -231,24 +229,23 @@
231229
/>
232230
</com.google.android.material.textfield.TextInputLayout>
233231

234-
<com.google.android.material.textfield.TextInputLayout
235-
android:id="@+id/second_filter_cards_input_layout"
236-
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.Dense.ExposedDropdownMenu"
232+
<TextView
233+
android:id="@+id/second_filter_cards_label"
237234
android:layout_width="match_parent"
238235
android:layout_height="wrap_content"
236+
android:layout_marginTop="8dp"
239237
android:visibility="gone"
240-
android:layout_marginTop="4dp"
241-
tools:hint="Cards selected by">
238+
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
239+
tools:text="Cards selected by"
240+
/>
242241

243-
<com.google.android.material.textfield.MaterialAutoCompleteTextView
244-
android:id="@+id/second_filter_cards_input"
245-
android:layout_width="match_parent"
246-
android:layout_height="wrap_content"
247-
android:inputType="textFilter"
248-
app:dropDownBackgroundTint="?android:attr/colorBackground"
249-
tools:text="Order due"
250-
/>
251-
</com.google.android.material.textfield.TextInputLayout>
242+
<Spinner
243+
android:id="@+id/second_filter_cards"
244+
android:layout_width="match_parent"
245+
android:layout_height="wrap_content"
246+
android:visibility="gone"
247+
android:minHeight="48dp"
248+
/>
252249

253250
<com.ichi2.ui.FixedTextView
254251
android:id="@+id/options_label"

AnkiDroid/src/test/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModelTest.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ package com.ichi2.anki.filtered
1818

1919
import androidx.lifecycle.SavedStateHandle
2020
import androidx.test.ext.junit.runners.AndroidJUnit4
21+
import anki.decks.Deck
22+
import anki.decks.DeckKt.FilteredKt.searchTerm
23+
import anki.decks.DeckKt.filtered
24+
import anki.decks.filteredDeckForUpdate
2125
import com.ichi2.anki.CollectionManager.withCol
2226
import com.ichi2.anki.Flag
2327
import com.ichi2.anki.RobolectricTest
@@ -253,6 +257,38 @@ class FilteredDeckOptionsViewModelTest : RobolectricTest() {
253257
}
254258
}
255259

260+
@Test
261+
fun `a deck without a second filter gets a new fresh filter state when second filter is enabled`() =
262+
runTest {
263+
addDeck("A", true)
264+
addNoteToDeckA { flagCardForNote(this, Flag.RED) }
265+
addNoteToDeckA { flagCardForNote(this, Flag.GREEN) }
266+
// setup only the first filter
267+
val data =
268+
filteredDeckForUpdate {
269+
id = 0
270+
name = "filtered"
271+
config =
272+
filtered {
273+
searchTerms.add(
274+
searchTerm {
275+
search = "flag:1"
276+
limit = 5
277+
order =
278+
Deck.Filtered.SearchTerm.Order
279+
.forNumber(1)
280+
},
281+
)
282+
}
283+
}
284+
val changes = withCol { sched.addOrUpdateFilteredDeck(data) }
285+
withViewModel(changes.id) {
286+
assertNull(current.filter2State)
287+
onSecondFilterStatusChange(true)
288+
assertNotNull(current.filter2State)
289+
}
290+
}
291+
256292
/** Returns the current state as a [FilteredDeckOptions] or throw otherwise */
257293
private val FilteredDeckOptionsViewModel.current: FilteredDeckOptions
258294
get() = state.value as FilteredDeckOptions

0 commit comments

Comments
 (0)