Skip to content

Commit d8de96b

Browse files
lukstbitmikehardy
authored andcommitted
Add ViewModel implementation for ManageNoteTypes
This commit adds the ViewModel code(unused at this point) and the "state" class holding the information required by the ManageNoteTypes screen.
1 parent 7af0a14 commit d8de96b

File tree

4 files changed

+302
-3
lines changed

4 files changed

+302
-3
lines changed

AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1483,7 +1483,7 @@ open class CardTemplateEditor :
14831483
private const val TAB_TO_CURSOR_POSITION_KEY = "tabToCursorPosition"
14841484
private const val EDITOR_VIEW_ID_KEY = "editorViewId"
14851485
private const val TAB_TO_VIEW_ID = "tabToViewId"
1486-
private const val EDITOR_NOTE_TYPE_ID = "noteTypeId"
1486+
const val EDITOR_NOTE_TYPE_ID = "noteTypeId"
14871487
private const val EDITOR_NOTE_ID = "noteId"
14881488
private const val EDITOR_START_ORD_ID = "ordId"
14891489
private const val CARD_INDEX = "card_ord"

AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ class NoteTypeFieldEditor : AnkiActivity(R.layout.note_type_field_editor) {
9696
super.onCreate(savedInstanceState)
9797
setContentView(R.layout.note_type_field_editor)
9898
enableToolbar()
99-
binding.notetypeName.text = intent.getStringExtra("title")
99+
binding.notetypeName.text = intent.getStringExtra(EXTRA_NOTETYPE_NAME)
100100
startLoadingCollection()
101101
setFragmentResultListener(REQUEST_HINT_LOCALE_SELECTION) { _, bundle ->
102102
val selectedLocale =
@@ -130,7 +130,7 @@ class NoteTypeFieldEditor : AnkiActivity(R.layout.note_type_field_editor) {
130130
* to finish the activity.
131131
*/
132132
private fun initialize() {
133-
val noteTypeID = intent.getLongExtra("noteTypeID", 0)
133+
val noteTypeID = intent.getLongExtra(EXTRA_NOTETYPE_ID, 0)
134134
val collectionModel = getColUnsafe.notetypes.get(noteTypeID)
135135
if (collectionModel == null) {
136136
showThemedToast(this, R.string.field_editor_model_not_available, true)
@@ -548,6 +548,11 @@ class NoteTypeFieldEditor : AnkiActivity(R.layout.note_type_field_editor) {
548548
if (index == notetype.sortf) NodetypeKind.SORT else NodetypeKind.UNDEFINED,
549549
)
550550
}
551+
552+
companion object {
553+
const val EXTRA_NOTETYPE_NAME = "extra_notetype_name"
554+
const val EXTRA_NOTETYPE_ID = "extra_notetype_id"
555+
}
551556
}
552557

553558
enum class NodetypeKind {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/****************************************************************************************
2+
* Copyright (c) 2025 lukstbit <[email protected]> *
3+
* *
4+
* This program is free software; you can redistribute it and/or modify it under *
5+
* the terms of the GNU General Public License as published by the Free Software *
6+
* Foundation; either version 3 of the License, or (at your option) any later *
7+
* version. *
8+
* *
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY *
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A *
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details. *
12+
* *
13+
* You should have received a copy of the GNU General Public License along with *
14+
* this program. If not, see <http://www.gnu.org/licenses/>. *
15+
****************************************************************************************/
16+
17+
package com.ichi2.anki.notetype
18+
19+
import android.content.Context
20+
import android.content.Intent
21+
import android.widget.Toast
22+
import anki.notetypes.Notetype
23+
import anki.notetypes.NotetypeNameIdUseCount
24+
import com.google.android.material.snackbar.Snackbar
25+
import com.ichi2.anki.CardTemplateEditor
26+
import com.ichi2.anki.NoteTypeFieldEditor
27+
import com.ichi2.anki.libanki.NoteTypeId
28+
import com.ichi2.anki.utils.Destination
29+
30+
/** Encapsulates the entire state for [ManageNotetypes] */
31+
data class ManageNoteTypesState(
32+
/** Indicator if the UI should show a "loading" view to the user */
33+
val isLoading: Boolean = true,
34+
/** List of [Notetype] to show */
35+
val noteTypes: List<NoteTypeItemState> = emptyList(),
36+
/** User entered string to use for filtering the [noteTypes] list */
37+
val searchQuery: String = "",
38+
/** Error that occurred or null for no error */
39+
val error: ReportableException? = null,
40+
/** Simple transient messages in response to user actions or null for no message */
41+
val message: UserMessage? = null,
42+
/**
43+
* If not null the user requested to go to this destination. Should to be handled immediately
44+
* and after marked as consumed.
45+
*/
46+
val destination: Destination? = null,
47+
) {
48+
/** Simple message to be shown to the user, usually in a [Snackbar] or [Toast] */
49+
enum class UserMessage {
50+
/** Message to inform that the last [Notetype] can't be removed */
51+
DeletingLastModel,
52+
}
53+
54+
/**
55+
* Wrapper around an exception produced in [ManageNotetypes] with an extra flag about the
56+
* exception being reportable or not.
57+
*/
58+
data class ReportableException(
59+
val source: Throwable,
60+
/** true if this exception should be sent to [com.ichi2.anki.CrashReportService] */
61+
val isReportable: Boolean = true,
62+
)
63+
64+
data class CardEditor(
65+
val nid: NoteTypeId,
66+
) : Destination {
67+
override fun toIntent(context: Context): Intent =
68+
Intent(context, CardTemplateEditor::class.java).apply {
69+
putExtra(CardTemplateEditor.EDITOR_NOTE_TYPE_ID, nid)
70+
}
71+
}
72+
73+
data class FieldsEditor(
74+
val nid: NoteTypeId,
75+
val name: String,
76+
) : Destination {
77+
override fun toIntent(context: Context): Intent =
78+
Intent(context, NoteTypeFieldEditor::class.java).apply {
79+
putExtra(NoteTypeFieldEditor.EXTRA_NOTETYPE_NAME, name)
80+
putExtra(NoteTypeFieldEditor.EXTRA_NOTETYPE_ID, nid)
81+
}
82+
}
83+
}
84+
85+
/** Holds information about a single [Notetype] */
86+
data class NoteTypeItemState(
87+
val id: NoteTypeId,
88+
val name: String,
89+
val useCount: Int,
90+
) {
91+
companion object {
92+
fun asModel(source: NotetypeNameIdUseCount) = NoteTypeItemState(source.id, source.name, source.useCount)
93+
}
94+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/****************************************************************************************
2+
* Copyright (c) 2025 lukstbit <[email protected]> *
3+
* *
4+
* This program is free software; you can redistribute it and/or modify it under *
5+
* the terms of the GNU General Public License as published by the Free Software *
6+
* Foundation; either version 3 of the License, or (at your option) any later *
7+
* version. *
8+
* *
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY *
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A *
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details. *
12+
* *
13+
* You should have received a copy of the GNU General Public License along with *
14+
* this program. If not, see <http://www.gnu.org/licenses/>. *
15+
****************************************************************************************/
16+
17+
package com.ichi2.anki.notetype
18+
19+
import androidx.lifecycle.ViewModel
20+
import androidx.lifecycle.viewModelScope
21+
import anki.collection.OpChanges
22+
import anki.notetypes.Notetype
23+
import anki.notetypes.copy
24+
import com.ichi2.anki.CollectionManager.withCol
25+
import com.ichi2.anki.libanki.Collection
26+
import com.ichi2.anki.libanki.NoteTypeId
27+
import com.ichi2.anki.libanki.getNotetype
28+
import com.ichi2.anki.libanki.getNotetypeNameIdUseCount
29+
import com.ichi2.anki.libanki.removeNotetype
30+
import com.ichi2.anki.libanki.updateNotetype
31+
import com.ichi2.anki.notetype.ManageNoteTypesState.CardEditor
32+
import com.ichi2.anki.notetype.ManageNoteTypesState.FieldsEditor
33+
import com.ichi2.anki.notetype.ManageNoteTypesState.ReportableException
34+
import com.ichi2.anki.notetype.ManageNoteTypesState.UserMessage
35+
import com.ichi2.anki.notetype.NoteTypeItemState.Companion.asModel
36+
import com.ichi2.anki.observability.undoableOp
37+
import kotlinx.coroutines.flow.MutableStateFlow
38+
import kotlinx.coroutines.flow.StateFlow
39+
import kotlinx.coroutines.flow.asStateFlow
40+
import kotlinx.coroutines.flow.update
41+
import kotlinx.coroutines.launch
42+
import net.ankiweb.rsdroid.BackendException
43+
44+
class ManageNoteTypesViewModel : ViewModel() {
45+
private val _state = MutableStateFlow(ManageNoteTypesState())
46+
val state: StateFlow<ManageNoteTypesState> = _state.asStateFlow()
47+
private lateinit var initialNoteTypes: List<NoteTypeItemState>
48+
49+
init {
50+
refreshNoteTypes()
51+
}
52+
53+
fun refreshNoteTypes() {
54+
_state.update { oldState -> oldState.copy(isLoading = true) }
55+
viewModelScope.launch {
56+
withCol { safeGetNotetypeNameIdUseCount() }
57+
.onFailure {
58+
_state.update { oldState ->
59+
oldState.copy(isLoading = false, error = ReportableException(it))
60+
}
61+
}.onSuccess {
62+
initialNoteTypes = it
63+
_state.update { oldState ->
64+
oldState.copy(isLoading = false, noteTypes = it)
65+
}
66+
}
67+
}
68+
}
69+
70+
fun filter(query: String) {
71+
val matchedNoteTypes =
72+
initialNoteTypes.filter { entry ->
73+
entry.name.contains(query)
74+
}
75+
_state.update { oldState ->
76+
oldState.copy(isLoading = false, noteTypes = matchedNoteTypes, searchQuery = query)
77+
}
78+
}
79+
80+
fun rename(
81+
nid: NoteTypeId,
82+
name: String,
83+
) {
84+
_state.update { oldState -> oldState.copy(isLoading = true) }
85+
viewModelScope.launch {
86+
undoableOp<OpChanges> {
87+
safeRenameNoteType(nid, name)
88+
.onSuccess { changes ->
89+
_state.update { oldState ->
90+
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 }
99+
oldState.copy(isLoading = false, noteTypes = updatedNoteTypes)
100+
}
101+
return@undoableOp changes
102+
}.onFailure {
103+
// TODO: Change to CardTypeException: https://github.com/ankidroid/Anki-Android-Backend/issues/537
104+
// Card template 1 in note type 'character' has a problem.
105+
// Expected to find a field replacement on the front of the card template.
106+
_state.update { oldState ->
107+
oldState.copy(
108+
isLoading = false,
109+
error = ReportableException(it, it !is BackendException),
110+
)
111+
}
112+
OpChanges.getDefaultInstance()
113+
}
114+
OpChanges.getDefaultInstance()
115+
}
116+
}
117+
}
118+
119+
fun delete(nid: NoteTypeId) {
120+
_state.update { oldState -> oldState.copy(isLoading = true) }
121+
val noteTypesCount = _state.value.noteTypes.size
122+
viewModelScope.launch {
123+
undoableOp<OpChanges> {
124+
if (noteTypesCount <= 1) {
125+
_state.update { oldState ->
126+
oldState.copy(isLoading = false, message = UserMessage.DeletingLastModel)
127+
}
128+
OpChanges.getDefaultInstance()
129+
}
130+
safeRemoveNoteType(nid)
131+
.onSuccess { changes ->
132+
_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)
138+
}
139+
return@undoableOp changes
140+
}.onFailure {
141+
_state.update { oldState ->
142+
oldState.copy(isLoading = false, error = ReportableException(it))
143+
}
144+
return@undoableOp OpChanges.getDefaultInstance()
145+
}
146+
OpChanges.getDefaultInstance()
147+
}
148+
}
149+
}
150+
151+
fun onItemClick(entry: NoteTypeItemState) {
152+
_state.update { oldState ->
153+
oldState.copy(destination = FieldsEditor(entry.id, entry.name))
154+
}
155+
}
156+
157+
fun onCardEditorRequested(entry: NoteTypeItemState) {
158+
_state.update { oldState ->
159+
oldState.copy(destination = CardEditor(entry.id))
160+
}
161+
}
162+
163+
fun clearMessage() {
164+
_state.update { oldState -> oldState.copy(message = null) }
165+
}
166+
167+
fun clearError() {
168+
_state.update { oldState -> oldState.copy(error = null) }
169+
}
170+
171+
fun clearDestination() {
172+
_state.update { oldState -> oldState.copy(destination = null) }
173+
}
174+
}
175+
176+
private fun Collection.safeRenameNoteType(
177+
nid: NoteTypeId,
178+
newName: String,
179+
): Result<OpChanges> =
180+
try {
181+
val currentNoteType: Notetype = getNotetype(nid)
182+
val renamedNotetype = currentNoteType.copy { this.name = newName }
183+
Result.success(updateNotetype(renamedNotetype))
184+
} catch (exception: Exception) {
185+
Result.failure(exception)
186+
}
187+
188+
private fun Collection.safeRemoveNoteType(nid: NoteTypeId): Result<OpChanges> =
189+
try {
190+
Result.success(removeNotetype(nid))
191+
} catch (exception: Exception) {
192+
Result.failure(exception)
193+
}
194+
195+
private fun Collection.safeGetNotetypeNameIdUseCount(): Result<List<NoteTypeItemState>> =
196+
try {
197+
Result.success(getNotetypeNameIdUseCount().map(::asModel))
198+
} catch (exception: Exception) {
199+
Result.failure(exception)
200+
}

0 commit comments

Comments
 (0)