Skip to content

Commit 7c11d01

Browse files
lukstbitdavid-allison
authored andcommitted
Implement multiple deletion of note types
The user can now select multiple note types and delete them with one click. While multi selecting the rename and delete options are disabled and a check indicator is shown. Filtering still works allowing the user to search for specific note types and selecting them, while preserving previous selected items. A selection summary is displayed through a floating toolbar. This was built manually as the material themes provided one was buggy(and we don't use proper theme colors). BACK while there's a multiple selection clears it.
1 parent e678e8d commit 7c11d01

11 files changed

Lines changed: 717 additions & 146 deletions

File tree

AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesState.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ data class ManageNoteTypesState(
4444
* and after marked as consumed.
4545
*/
4646
val destination: Destination? = null,
47+
/**
48+
* Flag to indicate if we are selecting multiple items. This being true implies that at least
49+
* one item in the list of [com.ichi2.anki.notetype.NoteTypeItemState] has its isSelected
50+
* property set to true.
51+
*/
52+
val isInMultiSelectMode: Boolean = false,
4753
) {
4854
/** Simple message to be shown to the user, usually in a [Snackbar] or [Toast] */
4955
enum class UserMessage {
@@ -87,6 +93,16 @@ data class NoteTypeItemState(
8793
val id: NoteTypeId,
8894
val name: String,
8995
val useCount: Int,
96+
/**
97+
* Only set and used in multiple selection mode, true if this entry is currently selected,
98+
* false otherwise.
99+
*/
100+
val isSelected: Boolean = false,
101+
/**
102+
* Flag to indicate if the ui should show this item or not, used for filtering items when we
103+
* want to hide entries but still holding on to them for their state.
104+
*/
105+
val shouldBeDisplayed: Boolean = true,
90106
) {
91107
companion object {
92108
fun asModel(source: NotetypeNameIdUseCount) = NoteTypeItemState(source.id, source.name, source.useCount)

AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesViewModel.kt

Lines changed: 168 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@
1616

1717
package com.ichi2.anki.notetype
1818

19+
import androidx.annotation.VisibleForTesting
20+
import androidx.annotation.VisibleForTesting.Companion.PRIVATE
1921
import androidx.lifecycle.ViewModel
2022
import androidx.lifecycle.viewModelScope
2123
import anki.collection.OpChanges
2224
import anki.notetypes.Notetype
2325
import anki.notetypes.copy
2426
import com.ichi2.anki.CollectionManager.withCol
27+
import com.ichi2.anki.exception.CombinedException
2528
import com.ichi2.anki.libanki.Collection
2629
import com.ichi2.anki.libanki.NoteTypeId
2730
import com.ichi2.anki.libanki.getNotetype
@@ -40,17 +43,18 @@ import kotlinx.coroutines.flow.asStateFlow
4043
import kotlinx.coroutines.flow.update
4144
import kotlinx.coroutines.launch
4245
import net.ankiweb.rsdroid.BackendException
46+
import timber.log.Timber
4347

4448
class ManageNoteTypesViewModel : ViewModel() {
4549
private val _state = MutableStateFlow(ManageNoteTypesState())
4650
val state: StateFlow<ManageNoteTypesState> = _state.asStateFlow()
47-
private lateinit var initialNoteTypes: List<NoteTypeItemState>
4851

4952
init {
5053
refreshNoteTypes()
5154
}
5255

5356
fun refreshNoteTypes() {
57+
Timber.i("Refreshing list of notetypes")
5458
_state.update { oldState -> oldState.copy(isLoading = true) }
5559
viewModelScope.launch {
5660
withCol { safeGetNotetypeNameIdUseCount() }
@@ -59,7 +63,6 @@ class ManageNoteTypesViewModel : ViewModel() {
5963
oldState.copy(isLoading = false, error = ReportableException(it))
6064
}
6165
}.onSuccess {
62-
initialNoteTypes = it
6366
_state.update { oldState ->
6467
oldState.copy(isLoading = false, noteTypes = it)
6568
}
@@ -68,11 +71,12 @@ class ManageNoteTypesViewModel : ViewModel() {
6871
}
6972

7073
fun filter(query: String) {
71-
val matchedNoteTypes =
72-
initialNoteTypes.filter { entry ->
73-
entry.name.contains(query)
74-
}
74+
Timber.i("Filtering list of notetypes with query=$query")
7575
_state.update { oldState ->
76+
val matchedNoteTypes =
77+
oldState.noteTypes.map {
78+
it.copy(shouldBeDisplayed = it.name.contains(query))
79+
}
7680
oldState.copy(isLoading = false, noteTypes = matchedNoteTypes, searchQuery = query)
7781
}
7882
}
@@ -81,21 +85,15 @@ class ManageNoteTypesViewModel : ViewModel() {
8185
nid: NoteTypeId,
8286
name: String,
8387
) {
88+
Timber.i("Renaming notetype with id $nid")
8489
_state.update { oldState -> oldState.copy(isLoading = true) }
8590
viewModelScope.launch {
8691
undoableOp<OpChanges> {
8792
safeRenameNoteType(nid, name)
8893
.onSuccess { changes ->
8994
_state.update { oldState ->
9095
val updatedNoteTypes =
91-
oldState.noteTypes
92-
.map { noteTypeState ->
93-
if (noteTypeState.id == nid) {
94-
noteTypeState.copy(name = name)
95-
} else {
96-
noteTypeState
97-
}
98-
}.also { initialNoteTypes = it }
96+
oldState.noteTypes.withUpdatedItem(nid) { old -> old.copy(name = name) }
9997
oldState.copy(isLoading = false, noteTypes = updatedNoteTypes)
10098
}
10199
return@undoableOp changes
@@ -116,7 +114,9 @@ class ManageNoteTypesViewModel : ViewModel() {
116114
}
117115
}
118116

117+
/** Deletes the note type with [nid] and also updates the multi select mode status if needed */
119118
fun delete(nid: NoteTypeId) {
119+
Timber.i("Deleting notetype with id $nid")
120120
_state.update { oldState -> oldState.copy(isLoading = true) }
121121
val noteTypesCount = _state.value.noteTypes.size
122122
viewModelScope.launch {
@@ -130,11 +130,12 @@ class ManageNoteTypesViewModel : ViewModel() {
130130
safeRemoveNoteType(nid)
131131
.onSuccess { changes ->
132132
_state.update { oldState ->
133-
val updatedNoteTypes =
134-
oldState.noteTypes
135-
.filter { it.id != nid }
136-
.also { initialNoteTypes = it }
137-
oldState.copy(isLoading = false, noteTypes = updatedNoteTypes)
133+
val updatedNoteTypes = oldState.noteTypes.filterNot { it.id == nid }
134+
oldState.copy(
135+
isLoading = false,
136+
noteTypes = updatedNoteTypes,
137+
isInMultiSelectMode = updatedNoteTypes.multiSelectModeStatus,
138+
)
138139
}
139140
return@undoableOp changes
140141
}.onFailure {
@@ -149,30 +150,177 @@ class ManageNoteTypesViewModel : ViewModel() {
149150
}
150151

151152
fun onItemClick(entry: NoteTypeItemState) {
153+
if (_state.value.isInMultiSelectMode) {
154+
Timber.i("onItemClick: already in multiple selection mode, toggling selection for notetype with id: ${entry.id} ")
155+
_state.update { oldState ->
156+
val updatedNoteTypes =
157+
oldState.noteTypes.withUpdatedItem(entry.id) { noteType ->
158+
noteType.copy(isSelected = !noteType.isSelected)
159+
}
160+
oldState.copy(
161+
noteTypes = updatedNoteTypes,
162+
isInMultiSelectMode = updatedNoteTypes.multiSelectModeStatus,
163+
)
164+
}
165+
} else {
166+
Timber.i("onItemClick: not in multiple selection mode, sending show fields editor request")
167+
_state.update { oldState ->
168+
oldState.copy(destination = FieldsEditor(entry.id, entry.name))
169+
}
170+
}
171+
}
172+
173+
fun onItemLongClick(entry: NoteTypeItemState) {
174+
if (_state.value.isInMultiSelectMode) {
175+
Timber.i("onItemLongClick: already in multiple selection mode, toggling selection for notetype with id: ${entry.id} ")
176+
_state.update { oldState ->
177+
val updatedNoteTypes =
178+
oldState.noteTypes.withUpdatedItem(entry.id) { noteType ->
179+
noteType.copy(isSelected = !noteType.isSelected)
180+
}
181+
oldState.copy(
182+
noteTypes = updatedNoteTypes,
183+
isInMultiSelectMode = updatedNoteTypes.multiSelectModeStatus,
184+
)
185+
}
186+
} else {
187+
Timber.i("onItemLongClick: no previous selection, starting multi select mode with notetype(${entry.id}) selected")
188+
_state.update { oldState ->
189+
val updatedNoteTypes =
190+
oldState.noteTypes.withUpdatedItem(entry.id) { noteType ->
191+
noteType.copy(isSelected = true)
192+
}
193+
oldState.copy(noteTypes = updatedNoteTypes, isInMultiSelectMode = true)
194+
}
195+
}
196+
}
197+
198+
/** Updates the check status for a selected note type also updates the multi select mode status if needed */
199+
fun onItemChecked(
200+
entry: NoteTypeItemState,
201+
isChecked: Boolean,
202+
) {
203+
Timber.i("onItemCheck: update selection for notetype(${entry.id}) with new status: $isChecked")
204+
_state.update { oldState ->
205+
val updatedNoteTypes =
206+
_state.value.noteTypes.withUpdatedItem(entry.id) { old -> old.copy(isSelected = isChecked) }
207+
oldState.copy(
208+
noteTypes = updatedNoteTypes,
209+
isInMultiSelectMode = updatedNoteTypes.multiSelectModeStatus,
210+
)
211+
}
212+
}
213+
214+
/** Clears any selected note types and also exits the multi select mode */
215+
fun clearSelection() {
216+
Timber.i("Clearing selected notetypes")
217+
_state.update { oldState ->
218+
val updatedNoteTypes =
219+
oldState.noteTypes.map { noteTypeItemState ->
220+
noteTypeItemState.copy(isSelected = false)
221+
}
222+
oldState.copy(
223+
noteTypes = updatedNoteTypes,
224+
isInMultiSelectMode = updatedNoteTypes.multiSelectModeStatus,
225+
)
226+
}
227+
}
228+
229+
/**
230+
* Deletes all the [NoteTypeItemState] which are currently selected. Any errors when deleting
231+
* the [Notetype]s will be combined into a single [CombinedException] to present to the user.
232+
*/
233+
fun deleteSelectedNoteTypes() {
234+
val noteTypesToDelete = selectedNoteTypes.toMutableList()
235+
Timber.i("Deleting currently selected notetypes: ${noteTypesToDelete.map { it.id }}")
236+
// show loading and clear selection
152237
_state.update { oldState ->
153-
oldState.copy(destination = FieldsEditor(entry.id, entry.name))
238+
val updateNotetypes =
239+
oldState.noteTypes.map { noteType ->
240+
noteType.copy(isSelected = false)
241+
}
242+
oldState.copy(
243+
isLoading = true,
244+
noteTypes = updateNotetypes,
245+
isInMultiSelectMode = false,
246+
)
247+
}
248+
viewModelScope.launch {
249+
val errors = mutableMapOf<NoteTypeItemState, Throwable>()
250+
noteTypesToDelete.forEach { noteType ->
251+
undoableOp<OpChanges> {
252+
safeRemoveNoteType(noteType.id)
253+
.onFailure { exception ->
254+
errors[noteType] = exception
255+
OpChanges.getDefaultInstance()
256+
}.onSuccess { return@undoableOp it }
257+
OpChanges.getDefaultInstance()
258+
}
259+
}
260+
// look through any errors we might have and remove from our list of note types the ones
261+
// that were in noteTypesToDelete but not in errors map(which presumably weren't deleted)
262+
val removedIds = noteTypesToDelete.map { it.id } - errors.keys.map { it.id }.toSet()
263+
val updatedNoteTypes = _state.value.noteTypes.filterNot { it.id in removedIds }
264+
val combinedException =
265+
CombinedException.from(
266+
errors.map { (state, throwable) ->
267+
"${state.name} - $throwable" to throwable
268+
},
269+
)
270+
_state.update { oldState ->
271+
oldState.copy(
272+
isLoading = false,
273+
noteTypes = updatedNoteTypes,
274+
error = combinedException?.let { throwable -> ReportableException(throwable) },
275+
)
276+
}
154277
}
155278
}
156279

157280
fun onCardEditorRequested(entry: NoteTypeItemState) {
281+
Timber.i("Sending open card editor request")
158282
_state.update { oldState ->
159283
oldState.copy(destination = CardEditor(entry.id))
160284
}
161285
}
162286

287+
/** Clears any previous user messages from the state */
163288
fun clearMessage() {
289+
Timber.i("Clearing user message from state")
164290
_state.update { oldState -> oldState.copy(message = null) }
165291
}
166292

293+
/** Clears any previous errors from the state */
167294
fun clearError() {
295+
Timber.i("Clearing errors from state")
168296
_state.update { oldState -> oldState.copy(error = null) }
169297
}
170298

299+
/** Clears any previous destinations requested by the user from the state */
171300
fun clearDestination() {
301+
Timber.i("Clearing requested destinations from state")
172302
_state.update { oldState -> oldState.copy(destination = null) }
173303
}
304+
305+
/**
306+
* Returns a new list where all items are unchanged with the exception of the [NoteTypeItemState]
307+
* identified by [nid] which is replaced by the result of invoking the [update] lambda.
308+
*/
309+
private fun List<NoteTypeItemState>.withUpdatedItem(
310+
nid: NoteTypeId,
311+
update: (NoteTypeItemState) -> NoteTypeItemState,
312+
): List<NoteTypeItemState> = map { noteType -> if (noteType.id == nid) update(noteType) else noteType }
313+
314+
/** True if we are selecting multiple items(implies at least one item is currently selected), false otherwise. */
315+
@VisibleForTesting(otherwise = PRIVATE)
316+
val List<NoteTypeItemState>.multiSelectModeStatus: Boolean
317+
get() = any { it.isSelected }
174318
}
175319

320+
/** The list of [NoteTypeItemState] that are currently selected */
321+
val ManageNoteTypesViewModel.selectedNoteTypes: List<NoteTypeItemState>
322+
get() = state.value.noteTypes.filter { it.isSelected }
323+
176324
private fun Collection.safeRenameNoteType(
177325
nid: NoteTypeId,
178326
newName: String,

0 commit comments

Comments
 (0)