Skip to content

Commit d5cfc83

Browse files
lukstbitBrayanDSO
authored andcommitted
Enable discard changes dialog for new filtered deck options screen
1 parent a11471e commit d5cfc83

3 files changed

Lines changed: 170 additions & 6 deletions

File tree

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import android.widget.AdapterView
2626
import android.widget.ArrayAdapter
2727
import android.widget.CheckBox
2828
import android.widget.Spinner
29+
import androidx.activity.OnBackPressedCallback
2930
import androidx.appcompat.app.AlertDialog
3031
import androidx.core.os.bundleOf
3132
import androidx.core.view.isVisible
@@ -42,6 +43,7 @@ import com.ichi2.anki.CardBrowser
4243
import com.ichi2.anki.CollectionManager.TR
4344
import com.ichi2.anki.R
4445
import com.ichi2.anki.databinding.FragmentFilteredDeckOptionsBinding
46+
import com.ichi2.anki.dialogs.DiscardChangesDialog
4547
import com.ichi2.anki.libanki.DeckId
4648
import com.ichi2.anki.utils.ConfigAwareSingleFragmentActivity
4749
import com.ichi2.anki.utils.openUrl
@@ -53,7 +55,6 @@ import com.ichi2.utils.title
5355
import dev.androidbroadcast.vbpd.viewBinding
5456
import kotlinx.coroutines.launch
5557
import java.util.Locale.getDefault
56-
import kotlin.text.replaceFirstChar
5758

5859
/**
5960
* Represents the screen where a filtered deck can be built or rebuilt after updating its properties.
@@ -64,6 +65,14 @@ import kotlin.text.replaceFirstChar
6465
class FilteredDeckOptionsFragment : Fragment(R.layout.fragment_filtered_deck_options) {
6566
private val binding by viewBinding(FragmentFilteredDeckOptionsBinding::bind)
6667
private val viewModel by viewModels<FilteredDeckOptionsViewModel>()
68+
private val discardBackHandler =
69+
object : OnBackPressedCallback(false) {
70+
override fun handleOnBackPressed() {
71+
DiscardChangesDialog.showDialog(context = requireActivity()) {
72+
requireActivity().finish()
73+
}
74+
}
75+
}
6776

6877
override fun onViewCreated(
6978
view: View,
@@ -91,6 +100,15 @@ class FilteredDeckOptionsFragment : Fragment(R.layout.fragment_filtered_deck_opt
91100
binding.secondFilterLimitInput.filters = arrayOf(InputFilter.LengthFilter(5))
92101
bindLabels()
93102
bindListeners()
103+
requireActivity().onBackPressedDispatcher.addCallback(
104+
viewLifecycleOwner,
105+
discardBackHandler,
106+
)
107+
viewLifecycleOwner.lifecycleScope.launch {
108+
viewModel.hasUnsavedChanges.collect { status ->
109+
discardBackHandler.isEnabled = status
110+
}
111+
}
94112
viewLifecycleOwner.lifecycleScope.launch {
95113
repeatOnLifecycle(Lifecycle.State.STARTED) {
96114
viewModel.state.collect { state ->

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

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ class FilteredDeckOptionsViewModel(
5555

5656
private var decksNames = emptyList<String>()
5757

58+
val hasUnsavedChanges: StateFlow<Boolean>
59+
field = MutableStateFlow<Boolean>(false)
60+
private var initialState: FilteredDeckOptions? = null
61+
5862
/** The [DeckId] of a filtered deck to edit or 0 if we are creating a new filtered deck. */
5963
private val did: DeckId
6064
get() = savedStateHandle.get<Long>(ARG_DECK_ID) ?: 0L
@@ -79,12 +83,15 @@ class FilteredDeckOptionsViewModel(
7983
return@launch
8084
}
8185
decksNames = withCol { safeGetDecksNames() }
82-
savedStateHandle[ARG_DATA] =
83-
filteredDeckData.asInitialState(
86+
filteredDeckData
87+
.asInitialState(
8488
cardsOptions = cardsOptions,
8589
defaultSearch1 = search,
8690
defaultSearch2 = search2,
87-
)
91+
).apply {
92+
savedStateHandle[ARG_DATA] = this
93+
initialState = this
94+
}
8895
state.update { currentState() }
8996
}
9097
}
@@ -99,6 +106,7 @@ class FilteredDeckOptionsViewModel(
99106
}
100107
if (currentState().name == name) return
101108
updateCurrentState { copy(name = name, nameInputError = error) }
109+
hasUnsavedChanges.update { wasStateModified() }
102110
}
103111

104112
fun onSearchChange(
@@ -109,6 +117,7 @@ class FilteredDeckOptionsViewModel(
109117
val targetFilterState = currentFilterState(index)
110118
if (targetFilterState?.search == searchQuery) return
111119
updateCurrentFilterState(index) { copy(search = searchQuery) }
120+
hasUnsavedChanges.update { wasStateModified() }
112121
}
113122

114123
fun onLimitChange(
@@ -124,6 +133,7 @@ class FilteredDeckOptionsViewModel(
124133
}
125134
if (limit == currentFilterState(index)?.limit) return
126135
updateCurrentFilterState(index) { copy(limit = limit, error = inputError) }
136+
hasUnsavedChanges.update { wasStateModified() }
127137
}
128138

129139
fun onCardsOptionsChange(
@@ -133,6 +143,7 @@ class FilteredDeckOptionsViewModel(
133143
Timber.i("Filtered deck filter($filterIndex) cards options index changing to $cardOptionIndex")
134144
if (currentFilterState(filterIndex)?.index == cardOptionIndex) return
135145
updateCurrentFilterState(filterIndex) { copy(index = cardOptionIndex) }
146+
hasUnsavedChanges.update { wasStateModified() }
136147
}
137148

138149
fun onSearchInBrowser(filterIndex: FilterIndex) {
@@ -170,6 +181,7 @@ class FilteredDeckOptionsViewModel(
170181
filter2State = filter2State ?: SearchTermState(),
171182
)
172183
}
184+
hasUnsavedChanges.update { wasStateModified() }
173185
}
174186

175187
fun onRescheduleChange(isEnabled: Boolean) {
@@ -214,7 +226,8 @@ class FilteredDeckOptionsViewModel(
214226
fun onShowExcludedCards() {
215227
Timber.i("Building unmovable cards search query to show in browser")
216228
viewModelScope.launch {
217-
val manualFilters = mutableListOf(currentFilterState(FilterIndex.First)?.search ?: return@launch)
229+
val manualFilters =
230+
mutableListOf(currentFilterState(FilterIndex.First)?.search ?: return@launch)
218231
if (currentState().isSecondFilterEnabled && currentFilterState(FilterIndex.Second) != null) {
219232
manualFilters.add(currentFilterState(FilterIndex.Second)?.search ?: return@launch)
220233
}
@@ -227,7 +240,8 @@ class FilteredDeckOptionsViewModel(
227240
val manualFilter = withCol { groupSearches(manualFilters, SearchJoiner.OR) }
228241
val implicitFilter = withCol { groupSearches(implicitFilters, SearchJoiner.OR) }
229242
try {
230-
val browserSearch = withCol { buildSearchString(listOf(manualFilter, implicitFilter)) }
243+
val browserSearch =
244+
withCol { buildSearchString(listOf(manualFilter, implicitFilter)) }
231245
updateCurrentState { copy(browserQuery = browserSearch) }
232246
} catch (ex: Exception) {
233247
updateCurrentState { copy(throwable = ex) }
@@ -280,6 +294,38 @@ class FilteredDeckOptionsViewModel(
280294
updateCurrentState { copy(throwable = null) }
281295
}
282296

297+
/**
298+
* Checks the current state with the state that was loaded initially.
299+
*/
300+
private fun wasStateModified(): Boolean {
301+
val initial = initialState ?: return false
302+
val current = (state.value as? FilteredDeckOptions) ?: return false
303+
return initial.name != current.name ||
304+
isFilter1Changed(initial, current) ||
305+
initial.isSecondFilterEnabled != current.isSecondFilterEnabled ||
306+
isFilter2Changed(initial, current)
307+
}
308+
309+
private fun isFilter1Changed(
310+
initial: FilteredDeckOptions,
311+
current: FilteredDeckOptions,
312+
): Boolean =
313+
initial.filter1State.search != current.filter1State.search ||
314+
initial.filter1State.limit != current.filter1State.limit ||
315+
initial.filter1State.index != current.filter1State.index
316+
317+
private fun isFilter2Changed(
318+
initial: FilteredDeckOptions,
319+
current: FilteredDeckOptions,
320+
): Boolean =
321+
if (!current.isSecondFilterEnabled || !initial.isSecondFilterEnabled) {
322+
false
323+
} else {
324+
initial.filter2State?.search != current.filter2State?.search ||
325+
initial.filter2State?.limit != current.filter2State?.limit ||
326+
initial.filter2State?.index != current.filter2State?.index
327+
}
328+
283329
/** Get the current state as it's found in the associated [SavedStateHandle]. Throws if state is not found. */
284330
private fun currentState(): FilteredDeckOptions = requireNotNull(savedStateHandle[ARG_DATA])
285331

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

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,71 @@ class FilteredDeckOptionsViewModelTest : RobolectricTest() {
310310
}
311311
}
312312

313+
@Test
314+
fun `changing user input updates changed status`() =
315+
runTest {
316+
val testDid = createTestFilteredDeck()
317+
withViewModel(did = testDid) {
318+
assertThat(current.name, equalTo("Filtered"))
319+
assertThat(current.filter1State.search, equalTo("flag:1"))
320+
assertThat(current.filter1State.limit, equalTo("5"))
321+
assertTrue(current.isSecondFilterEnabled)
322+
assertThat(current.filter2State?.search, equalTo("flag:2"))
323+
assertThat(current.filter2State?.limit, equalTo("5"))
324+
assertFalse(hasUnsavedChanges.value)
325+
// check status for name changes
326+
onDeckNameChange("Not")
327+
assertTrue(hasUnsavedChanges.value)
328+
onDeckNameChange("Filtered")
329+
assertFalse(hasUnsavedChanges.value)
330+
// check status for filter 1 content changes
331+
onSearchChange(FilterIndex.First, "flags:7")
332+
assertTrue(hasUnsavedChanges.value)
333+
onSearchChange(FilterIndex.First, "flag:1")
334+
assertFalse(hasUnsavedChanges.value)
335+
onLimitChange(FilterIndex.First, "100")
336+
assertTrue(hasUnsavedChanges.value)
337+
onLimitChange(FilterIndex.First, "5")
338+
assertFalse(hasUnsavedChanges.value)
339+
onCardsOptionsChange(FilterIndex.First, 10)
340+
assertTrue(hasUnsavedChanges.value)
341+
onCardsOptionsChange(FilterIndex.First, 1)
342+
assertFalse(hasUnsavedChanges.value)
343+
// check status if the second filter availability is changed
344+
onSecondFilterStatusChange(false)
345+
assertTrue(hasUnsavedChanges.value)
346+
onSecondFilterStatusChange(true)
347+
assertFalse(hasUnsavedChanges.value)
348+
// check status for filter 2 content changes
349+
onSearchChange(FilterIndex.Second, "flags:7")
350+
assertTrue(hasUnsavedChanges.value)
351+
onSearchChange(FilterIndex.Second, "flag:2")
352+
assertFalse(hasUnsavedChanges.value)
353+
onLimitChange(FilterIndex.Second, "100")
354+
assertTrue(hasUnsavedChanges.value)
355+
onLimitChange(FilterIndex.Second, "5")
356+
assertFalse(hasUnsavedChanges.value)
357+
onCardsOptionsChange(FilterIndex.Second, 10)
358+
assertTrue(hasUnsavedChanges.value)
359+
onCardsOptionsChange(FilterIndex.Second, 1)
360+
assertFalse(hasUnsavedChanges.value)
361+
}
362+
}
363+
364+
@Test
365+
fun `changed status is not updated for properties that are not observed`() =
366+
runTest {
367+
val testDid = createTestFilteredDeck()
368+
withViewModel(did = testDid) {
369+
assertFalse(current.allowEmpty)
370+
assertTrue(current.shouldReschedule)
371+
assertFalse(hasUnsavedChanges.value)
372+
onAllowEmptyChange(true)
373+
onRescheduleChange(false)
374+
assertFalse(hasUnsavedChanges.value)
375+
}
376+
}
377+
313378
/** Returns the current state as a [FilteredDeckOptions] or throw otherwise */
314379
private val FilteredDeckOptionsViewModel.current: FilteredDeckOptions
315380
get() = state.value as FilteredDeckOptions
@@ -338,6 +403,41 @@ class FilteredDeckOptionsViewModelTest : RobolectricTest() {
338403
setup()
339404
}
340405

406+
/** Note: created filtered deck has the second filter enabled by default */
407+
private suspend fun createTestFilteredDeck(): DeckId {
408+
addDeck("A", true)
409+
addNoteToDeckA { flagCardForNote(this, Flag.RED) }
410+
addNoteToDeckA { flagCardForNote(this, Flag.GREEN) }
411+
val data =
412+
filteredDeckForUpdate {
413+
id = 0
414+
name = "Filtered"
415+
config =
416+
filtered {
417+
reschedule = true
418+
searchTerms.add(
419+
searchTerm {
420+
search = "flag:1"
421+
limit = 5
422+
order =
423+
Deck.Filtered.SearchTerm.Order
424+
.forNumber(1)
425+
},
426+
)
427+
searchTerms.add(
428+
searchTerm {
429+
search = "flag:2"
430+
limit = 5
431+
order =
432+
Deck.Filtered.SearchTerm.Order
433+
.forNumber(1)
434+
},
435+
)
436+
}
437+
}
438+
return withCol { sched.addOrUpdateFilteredDeck(data) }.id
439+
}
440+
341441
companion object {
342442
private const val TEST_DECK_NAME = "TestFiltered"
343443
}

0 commit comments

Comments
 (0)