diff --git a/AnkiDroid/src/main/AndroidManifest.xml b/AnkiDroid/src/main/AndroidManifest.xml
index 6185173afd9d..b7a50f9d32f1 100644
--- a/AnkiDroid/src/main/AndroidManifest.xml
+++ b/AnkiDroid/src/main/AndroidManifest.xml
@@ -366,7 +366,7 @@
android:exported="false"
/>
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt
deleted file mode 100644
index 926ea7b70802..000000000000
--- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt
+++ /dev/null
@@ -1,600 +0,0 @@
-/*
- * Copyright (c) 2015 Ryan Annis
- * Copyright (c) 2015 Timothy Rae
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
-package com.ichi2.anki
-
-import android.content.Context
-import android.os.Bundle
-import android.text.InputType
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.AdapterView
-import android.widget.ArrayAdapter
-import android.widget.EditText
-import androidx.annotation.VisibleForTesting
-import androidx.appcompat.app.AlertDialog
-import androidx.core.os.BundleCompat
-import androidx.fragment.app.FragmentManager
-import com.google.android.material.snackbar.Snackbar
-import com.ichi2.anki.CollectionManager.TR
-import com.ichi2.anki.CollectionManager.withCol
-import com.ichi2.anki.common.annotations.NeedsTest
-import com.ichi2.anki.databinding.ItemNotetypeFieldBinding
-import com.ichi2.anki.databinding.NoteTypeFieldEditorBinding
-import com.ichi2.anki.dialogs.ConfirmationDialog
-import com.ichi2.anki.dialogs.LocaleSelectionDialog
-import com.ichi2.anki.dialogs.LocaleSelectionDialog.Companion.KEY_SELECTED_LOCALE
-import com.ichi2.anki.dialogs.LocaleSelectionDialog.Companion.REQUEST_HINT_LOCALE_SELECTION
-import com.ichi2.anki.dialogs.NoteTypeFieldEditorContextMenu.Companion.newInstance
-import com.ichi2.anki.dialogs.NoteTypeFieldEditorContextMenu.NoteTypeFieldEditorContextMenuAction
-import com.ichi2.anki.libanki.Collection
-import com.ichi2.anki.libanki.Fields
-import com.ichi2.anki.libanki.NotetypeJson
-import com.ichi2.anki.libanki.exception.ConfirmModSchemaException
-import com.ichi2.anki.servicelayer.LanguageHintService.setLanguageHintForField
-import com.ichi2.anki.snackbar.showSnackbar
-import com.ichi2.anki.utils.ext.dismissAllDialogFragments
-import com.ichi2.anki.utils.ext.setCompoundDrawablesRelativeWithIntrinsicBoundsKt
-import com.ichi2.anki.utils.ext.setFragmentResultListener
-import com.ichi2.anki.utils.ext.showDialogFragment
-import com.ichi2.ui.FixedEditText
-import com.ichi2.utils.customView
-import com.ichi2.utils.getInputField
-import com.ichi2.utils.input
-import com.ichi2.utils.moveCursorToEnd
-import com.ichi2.utils.negativeButton
-import com.ichi2.utils.positiveButton
-import com.ichi2.utils.show
-import com.ichi2.utils.title
-import dev.androidbroadcast.vbpd.viewBinding
-import org.json.JSONArray
-import org.json.JSONException
-import timber.log.Timber
-import java.util.Locale
-
-@NeedsTest("perform one action, then another")
-class NoteTypeFieldEditor : AnkiActivity(R.layout.note_type_field_editor) {
- private val binding by viewBinding(NoteTypeFieldEditorBinding::bind)
-
- // Position of the current field selected
- private var currentPos = 0
- private var fieldNameInput: EditText? = null
-
- // Backing field for [notetype]. Not with _ because it's only allowed for public field.
- private var notetypeBackup: NotetypeJson? = null
- private var notetype: NotetypeJson
- get() = notetypeBackup!!
- set(value) {
- notetypeBackup = value
- }
-
- private lateinit var fieldsLabels: List
-
- // WARN: this should be lateinit, but this can't yet be done on an inline class
- private var noteFields: Fields = Fields(JSONArray())
-
- // ----------------------------------------------------------------------------
- // ANDROID METHODS
- // ----------------------------------------------------------------------------
- override fun onCreate(savedInstanceState: Bundle?) {
- if (showedActivityFailedScreen(savedInstanceState)) {
- return
- }
- super.onCreate(savedInstanceState)
- setContentView(R.layout.note_type_field_editor)
- enableToolbar()
- binding.notetypeName.text = intent.getStringExtra(EXTRA_NOTETYPE_NAME)
- startLoadingCollection()
- setFragmentResultListener(REQUEST_HINT_LOCALE_SELECTION) { _, bundle ->
- val selectedLocale =
- BundleCompat.getSerializable(
- bundle,
- KEY_SELECTED_LOCALE,
- Locale::class.java,
- )
- if (selectedLocale != null) {
- addFieldLocaleHint(selectedLocale)
- }
- dismissAllDialogFragments()
- }
- }
-
- // ----------------------------------------------------------------------------
- // ANKI METHODS
- // ----------------------------------------------------------------------------
- override fun onCollectionLoaded(col: Collection) {
- super.onCollectionLoaded(col)
- initialize()
- }
-
- // ----------------------------------------------------------------------------
- // UI SETUP
- // ----------------------------------------------------------------------------
-
- /**
- * Initialize the data holding properties and the UI from the model. This method expects that it
- * isn't followed by other type of work that access the data properties as it has the capability
- * to finish the activity.
- */
- private fun initialize() {
- val noteTypeID = intent.getLongExtra(EXTRA_NOTETYPE_ID, 0)
- val collectionModel = getColUnsafe.notetypes.get(noteTypeID)
- if (collectionModel == null) {
- showThemedToast(this, R.string.field_editor_model_not_available, true)
- finish()
- return
- }
- notetype = collectionModel
- noteFields = notetype.fields
- fieldsLabels = notetype.fieldsNames
- binding.fields.adapter = NoteFieldAdapter(this, fieldNamesWithKind())
- binding.fields.onItemClickListener =
- AdapterView.OnItemClickListener { _, _, position: Int, _ ->
- showDialogFragment(newInstance(fieldsLabels[position]))
- currentPos = position
- }
- binding.btnAdd.setOnClickListener { addFieldDialog() }
- }
- // ----------------------------------------------------------------------------
- // CONTEXT MENU DIALOGUES
- // ----------------------------------------------------------------------------
-
- /**
- * Clean the input field or explain why it's rejected
- * @param fieldNameInput Editor to get the input
- * @return The value to use, or null in case of failure
- */
- private fun uniqueName(fieldNameInput: EditText): String? {
- var input =
- fieldNameInput.text
- .toString()
- .replace("[\\n\\r{}:\"]".toRegex(), "")
- // The number of #, ^, /, space, tab, starting the input
- var offset = 0
- while (offset < input.length) {
- if (!listOf('#', '^', '/', ' ', '\t').contains(input[offset])) {
- break
- }
- offset++
- }
- input = input.substring(offset).trim()
- if (input.isEmpty()) {
- showThemedToast(this, resources.getString(R.string.toast_empty_name), true)
- return null
- }
- if (fieldsLabels.any { input == it }) {
- showThemedToast(this, resources.getString(R.string.toast_duplicate_field), true)
- return null
- }
- return input
- }
-
- /*
- * Creates a dialog to create a field
- */
- private fun addFieldDialog() {
- fieldNameInput =
- FixedEditText(this).apply {
- focusWithKeyboard()
- }
- fieldNameInput?.let { fieldNameInput ->
- fieldNameInput.isSingleLine = true
- AlertDialog.Builder(this).show {
- customView(view = fieldNameInput, paddingStart = 64, paddingEnd = 64, paddingTop = 32)
- title(R.string.model_field_editor_add)
- positiveButton(R.string.menu_add) {
- // Name is valid, now field is added
- val fieldName = uniqueName(fieldNameInput)
- try {
- addField(fieldName, true)
- } catch (e: ConfirmModSchemaException) {
- e.log()
-
- // Create dialogue to for schema change
- val c = ConfirmationDialog()
- c.setArgs(resources.getString(R.string.full_sync_confirmation))
- val confirm =
- Runnable {
- try {
- addField(fieldName, false)
- } catch (e1: ConfirmModSchemaException) {
- e1.log()
- // This should never be thrown
- }
- }
- c.setConfirm(confirm)
- this@NoteTypeFieldEditor.showDialogFragment(c)
- }
- getColUnsafe.notetypes.update(notetype)
- initialize()
- }
- negativeButton(R.string.dialog_cancel)
- }
- }
- }
-
- /**
- * Adds a field with the given name
- */
- @Throws(ConfirmModSchemaException::class)
- private fun addField(
- fieldName: String?,
- modSchemaCheck: Boolean,
- ) {
- fieldName ?: return
- // Name is valid, now field is added
- if (modSchemaCheck) {
- getColUnsafe.modSchema(check = true)
- } else {
- getColUnsafe.modSchema(check = false)
- }
- launchCatchingTask {
- Timber.d("doInBackgroundAddField")
- withProgress {
- withCol {
- notetypes.addFieldModChanged(notetype, notetypes.newField(fieldName))
- }
- }
- initialize()
- }
- }
-
- /*
- * Creates a dialog to delete the currently selected field
- */
- private fun deleteFieldDialog() {
- val confirm =
- Runnable {
- getColUnsafe.modSchema(check = false)
- deleteField()
-
- // This ensures that the context menu closes after the field has been deleted
- supportFragmentManager.popBackStackImmediate(
- null,
- FragmentManager.POP_BACK_STACK_INCLUSIVE,
- )
- }
-
- if (fieldsLabels.size < 2) {
- showThemedToast(this, resources.getString(R.string.toast_last_field), true)
- } else {
- try {
- getColUnsafe.modSchema(check = true)
- val fieldName = noteFields[currentPos].name
- ConfirmationDialog().let {
- it.setArgs(
- title = fieldName,
- message = resources.getString(R.string.field_delete_warning),
- )
- it.setConfirm(confirm)
- showDialogFragment(it)
- }
- } catch (e: ConfirmModSchemaException) {
- e.log()
- ConfirmationDialog().let {
- it.setConfirm(confirm)
- it.setArgs(resources.getString(R.string.full_sync_confirmation))
- showDialogFragment(it)
- }
- }
- }
- }
-
- private fun deleteField() {
- launchCatchingTask {
- Timber.d("doInBackGroundDeleteField")
- withProgress(message = getString(R.string.model_field_editor_changing)) {
- val result =
- withCol {
- try {
- notetypes.remFieldLegacy(notetype, noteFields[currentPos])
- true
- } catch (e: ConfirmModSchemaException) {
- // Should never be reached
- e.log()
- false
- }
- }
- if (!result) {
- closeActivity()
- }
- initialize()
- }
- }
- }
-
- /*
- * Creates a dialog to rename the currently selected field
- * Processing time is constant
- */
- private fun renameFieldDialog() {
- fieldNameInput = FixedEditText(this).apply { focusWithKeyboard() }
- fieldNameInput?.let { fieldNameInput ->
- fieldNameInput.isSingleLine = true
- fieldNameInput.setText(fieldsLabels[currentPos])
- fieldNameInput.moveCursorToEnd()
- AlertDialog.Builder(this).show {
- customView(view = fieldNameInput, paddingStart = 64, paddingEnd = 64, paddingTop = 32)
- title(R.string.model_field_editor_rename)
- positiveButton(R.string.rename) {
- if (uniqueName(fieldNameInput) == null) {
- return@positiveButton
- }
- // Field is valid, now rename
- try {
- renameField()
- } catch (e: ConfirmModSchemaException) {
- e.log()
-
- // Handler mod schema confirmation
- val c = ConfirmationDialog()
- c.setArgs(resources.getString(R.string.full_sync_confirmation))
- val confirm =
- Runnable {
- getColUnsafe.modSchema(check = false)
- try {
- renameField()
- } catch (e1: ConfirmModSchemaException) {
- e1.log()
- // This should never be thrown
- }
- }
- c.setConfirm(confirm)
- this@NoteTypeFieldEditor.showDialogFragment(c)
- }
- }
- negativeButton(R.string.dialog_cancel)
- }
- }
- }
-
- /**
- * Displays a dialog to allow the user to reposition a field within a list.
- */
- private fun repositionFieldDialog() {
- /**
- * Shows an input dialog for selecting a new position.
- *
- * @param numberOfTemplates The total number of available positions.
- * @param result A lambda function that receives the validated new position as an integer.
- */
- fun showDialog(
- numberOfTemplates: Int,
- result: (Int) -> Unit,
- ) {
- AlertDialog
- .Builder(this)
- .show {
- positiveButton(R.string.dialog_ok) {
- val input = (it as AlertDialog).getInputField()
- result(input.text.toString().toInt())
- }
- negativeButton(R.string.dialog_cancel)
- setMessage(TR.fieldsNewPosition1(numberOfTemplates))
- setView(R.layout.dialog_generic_text_input)
- }.input(
- prefill = (currentPos + 1).toString(),
- inputType = InputType.TYPE_CLASS_NUMBER,
- displayKeyboard = true,
- waitForPositiveButton = false,
- ) { dialog, text: CharSequence ->
- val number = text.toString().toIntOrNull()
- dialog.positiveButton.isEnabled = number != null && number in 1..numberOfTemplates
- }
- }
-
- // handle repositioning
- showDialog(fieldsLabels.size) { newPosition ->
- if (newPosition == currentPos + 1) return@showDialog
-
- Timber.i("Repositioning field from %d to %d", currentPos, newPosition)
- try {
- getColUnsafe.modSchema(check = true)
- repositionField(newPosition - 1)
- } catch (e: ConfirmModSchemaException) {
- e.log()
-
- // Handle mod schema confirmation
- val c = ConfirmationDialog()
- c.setArgs(resources.getString(R.string.full_sync_confirmation))
- val confirm =
- Runnable {
- try {
- getColUnsafe.modSchema(check = false)
- repositionField(newPosition - 1)
- } catch (e1: JSONException) {
- throw RuntimeException(e1)
- }
- }
- c.setConfirm(confirm)
- this@NoteTypeFieldEditor.showDialogFragment(c)
- }
- }
- }
-
- private fun repositionField(index: Int) {
- launchCatchingTask {
- withProgress(message = getString(R.string.model_field_editor_changing)) {
- val result =
- withCol {
- Timber.d("doInBackgroundRepositionField")
- try {
- notetypes.moveFieldLegacy(notetype, noteFields[currentPos], index)
- true
- } catch (e: ConfirmModSchemaException) {
- e.log()
- // Should never be reached
- false
- }
- }
- if (!result) {
- closeActivity()
- }
- initialize()
- }
- }
- }
-
- /*
- * Renames the current field
- */
- @Throws(ConfirmModSchemaException::class)
- private fun renameField() {
- val fieldLabel =
- fieldNameInput!!
- .text
- .toString()
- .replace("[\\n\\r]".toRegex(), "")
- val field = noteFields[currentPos]
- getColUnsafe.notetypes.renameFieldLegacy(notetype, field, fieldLabel)
- initialize()
- }
-
- /*
- * Changes the sort field (that displays in card browser) to the current field
- */
- private fun sortByField() {
- try {
- getColUnsafe.modSchema(check = true)
- launchCatchingTask { changeSortField(notetype, currentPos) }
- } catch (e: ConfirmModSchemaException) {
- e.log()
- // Handler mMod schema confirmation
- val c = ConfirmationDialog()
- c.setArgs(resources.getString(R.string.full_sync_confirmation))
- val confirm =
- Runnable {
- getColUnsafe.modSchema(check = false)
- launchCatchingTask { changeSortField(notetype, currentPos) }
- }
- c.setConfirm(confirm)
- this@NoteTypeFieldEditor.showDialogFragment(c)
- }
- }
-
- private suspend fun changeSortField(
- notetype: NotetypeJson,
- idx: Int,
- ) {
- withProgress(resources.getString(R.string.model_field_editor_changing)) {
- withCol {
- Timber.d("doInBackgroundChangeSortField")
- notetypes.setSortIndex(notetype, idx)
- notetypes.save(notetype)
- }
- }
- initialize()
- }
-
- private fun closeActivity() {
- finish()
- }
-
- fun handleAction(contextMenuAction: NoteTypeFieldEditorContextMenuAction) {
- when (contextMenuAction) {
- NoteTypeFieldEditorContextMenuAction.Sort -> sortByField()
- NoteTypeFieldEditorContextMenuAction.Reposition -> repositionFieldDialog()
- NoteTypeFieldEditorContextMenuAction.Delete -> deleteFieldDialog()
- NoteTypeFieldEditorContextMenuAction.Rename -> renameFieldDialog()
- NoteTypeFieldEditorContextMenuAction.AddLanguageHint -> localeHintDialog()
- }
- }
-
- private fun localeHintDialog() {
- Timber.i("displaying locale hint dialog")
- // We don't currently show the current value, but we may want to in the future
- showDialogFragment(LocaleSelectionDialog())
- }
-
- /*
- * Sets the Locale Hint of the field to the provided value.
- * This allows some keyboard (GBoard) to change language
- */
- private fun addFieldLocaleHint(selectedLocale: Locale) {
- setLanguageHintForField(getColUnsafe.notetypes, notetype, currentPos, selectedLocale)
- val format = getString(R.string.model_field_editor_language_hint_dialog_success_result, selectedLocale.displayName)
- showSnackbar(format, Snackbar.LENGTH_SHORT)
- initialize()
- }
-
- @VisibleForTesting(otherwise = VisibleForTesting.NONE)
- @Throws(ConfirmModSchemaException::class)
- fun addField(fieldNameInput: EditText) {
- val fieldName = uniqueName(fieldNameInput)
- addField(fieldName, true)
- }
-
- @VisibleForTesting(otherwise = VisibleForTesting.NONE)
- @Throws(ConfirmModSchemaException::class)
- fun renameField(fieldNameInput: EditText?) {
- this.fieldNameInput = fieldNameInput
- renameField()
- }
-
- /*
- * Returns a list of field names with their kind
- * So far the only kind is SORT, which defines the field upon which notes could be sorted
- */
- private fun fieldNamesWithKind(): List> =
- fieldsLabels.mapIndexed { index, fieldName ->
- Pair(
- fieldName,
- if (index == notetype.sortf) NodetypeKind.SORT else NodetypeKind.UNDEFINED,
- )
- }
-
- companion object {
- const val EXTRA_NOTETYPE_NAME = "extra_notetype_name"
- const val EXTRA_NOTETYPE_ID = "extra_notetype_id"
- }
-}
-
-enum class NodetypeKind {
- SORT,
- UNDEFINED,
-}
-
-internal class NoteFieldAdapter(
- private val context: Context,
- labels: List>,
-) : ArrayAdapter>(context, 0, labels) {
- override fun getView(
- position: Int,
- convertView: View?,
- parent: ViewGroup,
- ): View {
- val binding =
- if (convertView != null) {
- ItemNotetypeFieldBinding.bind(convertView)
- } else {
- ItemNotetypeFieldBinding.inflate(LayoutInflater.from(context), parent, false)
- }
-
- getItem(position)?.let {
- val (name, kind) = it
- binding.fieldName.text = name
- binding.fieldName.setCompoundDrawablesRelativeWithIntrinsicBoundsKt(
- end =
- when (kind) {
- NodetypeKind.SORT -> R.drawable.ic_sort
- NodetypeKind.UNDEFINED -> 0
- },
- )
- }
- return binding.root
- }
-}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenu.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenu.kt
index 5e3db068d938..cdb02b1d5194 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenu.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenu.kt
@@ -9,9 +9,9 @@ import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf
-import com.ichi2.anki.NoteTypeFieldEditor
import com.ichi2.anki.R
import com.ichi2.anki.analytics.AnalyticsDialogFragment
+import com.ichi2.anki.notetype.fieldeditor.NoteTypeFieldEditor
import com.ichi2.utils.create
import timber.log.Timber
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesState.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesState.kt
index d054b461a26c..35f7f460ba75 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesState.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesState.kt
@@ -23,8 +23,8 @@ import anki.notetypes.Notetype
import anki.notetypes.NotetypeNameIdUseCount
import com.google.android.material.snackbar.Snackbar
import com.ichi2.anki.CardTemplateEditor
-import com.ichi2.anki.NoteTypeFieldEditor
import com.ichi2.anki.libanki.NoteTypeId
+import com.ichi2.anki.notetype.fieldeditor.NoteTypeFieldEditor
import com.ichi2.anki.utils.Destination
/** Encapsulates the entire state for [ManageNotetypes] */
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/AddNewNoteTypeField.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/AddNewNoteTypeField.kt
new file mode 100644
index 000000000000..17a56e4dc125
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/AddNewNoteTypeField.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2026 S-H-Y-A
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package com.ichi2.anki.notetype.fieldeditor
+
+import androidx.appcompat.app.AlertDialog
+import com.ichi2.anki.R
+import com.ichi2.anki.launchCatchingTask
+import com.ichi2.anki.sync.userAcceptsSchemaChange
+import com.ichi2.utils.input
+import com.ichi2.utils.negativeButton
+import com.ichi2.utils.positiveButton
+import com.ichi2.utils.show
+import com.ichi2.utils.title
+
+class AddNewNoteTypeField(
+ private val activity: NoteTypeFieldEditor,
+) {
+ fun showAddNewNoteTypeFieldDialog(confirm: (String) -> Unit) {
+ activity.apply {
+ launchCatchingTask {
+ val confirmation = userAcceptsSchemaChange()
+ if (!confirmation) {
+ return@launchCatchingTask
+ }
+ AlertDialog
+ .Builder(activity)
+ .show {
+ setView(R.layout.dialog_generic_text_input)
+ title(R.string.model_field_editor_add)
+ positiveButton(R.string.menu_add)
+ negativeButton(R.string.dialog_cancel)
+ }.input(
+ displayKeyboard = true,
+ ) { _, text ->
+ confirm(text.toString())
+ }
+ }
+ }
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/NoteTypeFieldEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/NoteTypeFieldEditor.kt
new file mode 100644
index 000000000000..0c687581e380
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/NoteTypeFieldEditor.kt
@@ -0,0 +1,363 @@
+/*
+ * Copyright (c) 2015 Ryan Annis
+ * Copyright (c) 2015 Timothy Rae
+ * Copyright (c) 2026 S-H-Y-A
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package com.ichi2.anki.notetype.fieldeditor
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.AdapterView
+import android.widget.ArrayAdapter
+import androidx.activity.viewModels
+import androidx.annotation.VisibleForTesting
+import androidx.core.os.BundleCompat
+import androidx.fragment.app.FragmentManager
+import com.google.android.material.snackbar.Snackbar
+import com.ichi2.anki.AnkiActivity
+import com.ichi2.anki.R
+import com.ichi2.anki.common.annotations.NeedsTest
+import com.ichi2.anki.databinding.ItemNotetypeFieldBinding
+import com.ichi2.anki.databinding.NoteTypeFieldEditorBinding
+import com.ichi2.anki.dialogs.ConfirmationDialog
+import com.ichi2.anki.dialogs.LocaleSelectionDialog
+import com.ichi2.anki.dialogs.LocaleSelectionDialog.Companion.KEY_SELECTED_LOCALE
+import com.ichi2.anki.dialogs.LocaleSelectionDialog.Companion.REQUEST_HINT_LOCALE_SELECTION
+import com.ichi2.anki.dialogs.NoteTypeFieldEditorContextMenu.Companion.newInstance
+import com.ichi2.anki.dialogs.NoteTypeFieldEditorContextMenu.NoteTypeFieldEditorContextMenuAction
+import com.ichi2.anki.launchCatchingTask
+import com.ichi2.anki.libanki.Collection
+import com.ichi2.anki.libanki.exception.ConfirmModSchemaException
+import com.ichi2.anki.showThemedToast
+import com.ichi2.anki.snackbar.showSnackbar
+import com.ichi2.anki.sync.userAcceptsSchemaChange
+import com.ichi2.anki.utils.ext.dismissAllDialogFragments
+import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope
+import com.ichi2.anki.utils.ext.setCompoundDrawablesRelativeWithIntrinsicBoundsKt
+import com.ichi2.anki.utils.ext.setFragmentResultListener
+import com.ichi2.anki.utils.ext.showDialogFragment
+import com.ichi2.anki.withProgress
+import dev.androidbroadcast.vbpd.viewBinding
+import timber.log.Timber
+import java.util.Locale
+
+@NeedsTest("perform one action, then another")
+class NoteTypeFieldEditor : AnkiActivity(R.layout.note_type_field_editor) {
+ private val binding by viewBinding(NoteTypeFieldEditorBinding::bind)
+ val viewModel by viewModels()
+
+ // ----------------------------------------------------------------------------
+ // ANDROID METHODS
+ // ----------------------------------------------------------------------------
+ override fun onCreate(savedInstanceState: Bundle?) {
+ if (showedActivityFailedScreen(savedInstanceState)) {
+ return
+ }
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.note_type_field_editor)
+ enableToolbar()
+ binding.notetypeName.text = intent.getStringExtra(EXTRA_NOTETYPE_NAME)
+ startLoadingCollection()
+ setFragmentResultListener(REQUEST_HINT_LOCALE_SELECTION) { _, bundle ->
+ val selectedLocale =
+ BundleCompat.getSerializable(
+ bundle,
+ KEY_SELECTED_LOCALE,
+ Locale::class.java,
+ )
+ if (selectedLocale != null) {
+ addFieldLocaleHint(selectedLocale)
+ }
+ dismissAllDialogFragments()
+ }
+ }
+
+ // ----------------------------------------------------------------------------
+ // ANKI METHODS
+ // ----------------------------------------------------------------------------
+ override fun onCollectionLoaded(col: Collection) {
+ super.onCollectionLoaded(col)
+ initialize()
+ }
+
+ // ----------------------------------------------------------------------------
+ // UI SETUP
+ // ----------------------------------------------------------------------------
+
+ /**
+ * Initialize the data holding properties and the UI from the model. This method expects that it
+ * isn't followed by other type of work that access the data properties as it has the capability
+ * to finish the activity.
+ */
+ private fun initialize() {
+ val noteTypeID = intent.getLongExtra(EXTRA_NOTETYPE_ID, 0)
+ val collectionModel = getColUnsafe.notetypes.get(noteTypeID)
+ if (collectionModel == null) {
+ showThemedToast(this, R.string.field_editor_model_not_available, true)
+ finish()
+ return
+ }
+ viewModel.state.launchCollectionInLifecycleScope {
+ binding.fields.adapter =
+ NoteFieldAdapter(this@NoteTypeFieldEditor, fieldNamesWithKind())
+ }
+ binding.fields.onItemClickListener =
+ AdapterView.OnItemClickListener { _, _, position: Int, _ ->
+ val label = viewModel.state.value.fieldsLabels[position]
+ showDialogFragment(newInstance(label))
+ viewModel.updateCurrentPosition(position)
+ }
+ binding.btnAdd.setOnClickListener { addFieldDialog() }
+ }
+ // ----------------------------------------------------------------------------
+ // CONTEXT MENU DIALOGUES
+ // ----------------------------------------------------------------------------
+
+ /**
+ * Clean the input field or explain why it's rejected
+ * @param name the input
+ * @return The value to use, or null in case of failure
+ */
+ private fun uniqueName(name: String): String? {
+ var input =
+ name
+ .replace("[\\n\\r{}:\"]".toRegex(), "")
+ // The number of #, ^, /, space, tab, starting the input
+ var offset = 0
+ while (offset < input.length) {
+ if (!listOf('#', '^', '/', ' ', '\t').contains(input[offset])) {
+ break
+ }
+ offset++
+ }
+ input = input.substring(offset).trim()
+ if (input.isEmpty()) {
+ showThemedToast(this, resources.getString(R.string.toast_empty_name), true)
+ return null
+ }
+ val fieldsLabels = viewModel.state.value.fieldsLabels
+ if (fieldsLabels.any { input == it }) {
+ showThemedToast(this, resources.getString(R.string.toast_duplicate_field), true)
+ return null
+ }
+ return input
+ }
+
+ /*
+ * Creates a dialog to create a field
+ */
+ private fun addFieldDialog() {
+ val addFieldDialog = AddNewNoteTypeField(this)
+ addFieldDialog.showAddNewNoteTypeFieldDialog { name ->
+ launchCatchingTask {
+ val validName = uniqueName(name) ?: return@launchCatchingTask
+ val isConfirmed = userAcceptsSchemaChange()
+ if (!isConfirmed) return@launchCatchingTask
+ viewModel.add(validName)
+ }
+ }
+ }
+
+ /*
+ * Creates a dialog to delete the currently selected field
+ */
+ private fun deleteFieldDialog() {
+ if (viewModel.state.value.fieldsLabels.size < 2) {
+ showThemedToast(
+ this,
+ resources.getString(R.string.toast_last_field),
+ true,
+ )
+ return
+ }
+
+ val position = viewModel.state.value.currentPos
+ val fieldName = viewModel.state.value.fieldsLabels[position]
+ ConfirmationDialog().let {
+ it.setArgs(
+ title = fieldName,
+ message = resources.getString(R.string.field_delete_warning),
+ )
+ it.setConfirm {
+ launchCatchingTask {
+ val isConfirmed = userAcceptsSchemaChange()
+ if (!isConfirmed) return@launchCatchingTask
+
+ viewModel.delete(position)
+
+ // This ensures that the context menu closes after the field has been deleted
+ supportFragmentManager.popBackStackImmediate(
+ null,
+ FragmentManager.POP_BACK_STACK_INCLUSIVE,
+ )
+ }
+ }
+ showDialogFragment(it)
+ }
+ }
+
+ /*
+ * Creates a dialog to rename the currently selected field
+ * Processing time is constant
+ */
+ private fun renameFieldDialog() {
+ val position = viewModel.state.value.currentPos
+ val name = viewModel.state.value.fieldsLabels[position]
+ val renameFieldDialog = RenameNoteTypeField(this@NoteTypeFieldEditor, name)
+ renameFieldDialog.showRenameNoteTypeFieldDialog { name ->
+ launchCatchingTask {
+ val validName = uniqueName(name) ?: return@launchCatchingTask
+ val isConfirmed = userAcceptsSchemaChange()
+ if (!isConfirmed) return@launchCatchingTask
+ viewModel.rename(position, validName)
+ }
+ }
+ }
+
+ /**
+ * Displays a dialog to allow the user to reposition a field within a list.
+ */
+ private fun repositionFieldDialog() {
+ val repositionFieldDialog = RepositionTypeField(this@NoteTypeFieldEditor, viewModel.state.value.fieldsLabels.size)
+ repositionFieldDialog.showRepositionNoteTypeFieldDialog { position ->
+ launchCatchingTask {
+ val isConfirmed = userAcceptsSchemaChange()
+ if (!isConfirmed) return@launchCatchingTask
+ Timber.i("Repositioning field from %d to %d", viewModel.state.value.currentPos, position)
+ val position = viewModel.state.value.currentPos
+ viewModel.reposition(position, position)
+ }
+ }
+ }
+
+ /*
+ * Changes the sort field (that displays in card browser) to the current field
+ */
+ private fun sortByField() {
+ launchCatchingTask {
+ val isConfirmed = userAcceptsSchemaChange()
+ if (!isConfirmed) return@launchCatchingTask
+ val position = viewModel.state.value.currentPos
+ viewModel.changeSort(position)
+ }
+ }
+
+ fun handleAction(contextMenuAction: NoteTypeFieldEditorContextMenuAction) {
+ when (contextMenuAction) {
+ NoteTypeFieldEditorContextMenuAction.Sort -> sortByField()
+ NoteTypeFieldEditorContextMenuAction.Reposition -> repositionFieldDialog()
+ NoteTypeFieldEditorContextMenuAction.Delete -> deleteFieldDialog()
+ NoteTypeFieldEditorContextMenuAction.Rename -> renameFieldDialog()
+ NoteTypeFieldEditorContextMenuAction.AddLanguageHint -> localeHintDialog()
+ }
+ }
+
+ private fun localeHintDialog() {
+ Timber.i("displaying locale hint dialog")
+ // We don't currently show the current value, but we may want to in the future
+ showDialogFragment(LocaleSelectionDialog())
+ }
+
+ /*
+ * Sets the Locale Hint of the field to the provided value.
+ * This allows some keyboard (GBoard) to change language
+ */
+ private fun addFieldLocaleHint(selectedLocale: Locale) {
+ launchCatchingTask {
+ withProgress(message = getString(R.string.model_field_editor_changing)) {
+ val position = viewModel.state.value.currentPos
+ viewModel.languageHint(position, selectedLocale)
+ }
+ }
+ val format = getString(R.string.model_field_editor_language_hint_dialog_success_result, selectedLocale.displayName)
+ showSnackbar(format, Snackbar.LENGTH_SHORT)
+ initialize()
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+ @Throws(ConfirmModSchemaException::class)
+ fun addField(name: String) {
+ val fieldName = uniqueName(name) ?: return
+ launchCatchingTask {
+ viewModel.add(fieldName)
+ }
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+ @Throws(ConfirmModSchemaException::class)
+ fun renameField(name: String) {
+ val fieldLabel = uniqueName(name) ?: return
+ val position = viewModel.state.value.currentPos
+ launchCatchingTask {
+ viewModel.rename(position, fieldLabel)
+ }
+ }
+
+ /*
+ * Returns a list of field names with their kind
+ * So far the only kind is SORT, which defines the field upon which notes could be sorted
+ */
+ private fun fieldNamesWithKind(): List> =
+ viewModel.state.value.fieldsLabels.mapIndexed { index, fieldName ->
+ Pair(
+ fieldName,
+ if (index == viewModel.state.value.sortf) NotetypeKind.SORT else NotetypeKind.UNDEFINED,
+ )
+ }
+
+ companion object {
+ const val EXTRA_NOTETYPE_NAME = "extra_notetype_name"
+ const val EXTRA_NOTETYPE_ID = "extra_notetype_id"
+ }
+}
+
+enum class NotetypeKind {
+ SORT,
+ UNDEFINED,
+}
+
+internal class NoteFieldAdapter(
+ private val context: Context,
+ labels: List>,
+) : ArrayAdapter>(context, 0, labels) {
+ override fun getView(
+ position: Int,
+ convertView: View?,
+ parent: ViewGroup,
+ ): View {
+ val binding =
+ if (convertView != null) {
+ ItemNotetypeFieldBinding.bind(convertView)
+ } else {
+ ItemNotetypeFieldBinding.inflate(LayoutInflater.from(context), parent, false)
+ }
+
+ getItem(position)?.let {
+ val (name, kind) = it
+ binding.fieldName.text = name
+ binding.fieldName.setCompoundDrawablesRelativeWithIntrinsicBoundsKt(
+ end =
+ when (kind) {
+ NotetypeKind.SORT -> R.drawable.ic_sort
+ NotetypeKind.UNDEFINED -> 0
+ },
+ )
+ }
+ return binding.root
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/NoteTypeFieldEditorState.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/NoteTypeFieldEditorState.kt
new file mode 100644
index 000000000000..2507443ce4d6
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/NoteTypeFieldEditorState.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2026 S-H-Y-A
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package com.ichi2.anki.notetype.fieldeditor
+
+data class NoteTypeFieldEditorState(
+ val currentPos: Int = 0,
+ val sortf: Int = 0,
+ val fieldsLabels: List,
+)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/NoteTypeFieldEditorViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/NoteTypeFieldEditorViewModel.kt
new file mode 100644
index 000000000000..f16f4b606c01
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/NoteTypeFieldEditorViewModel.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright (c) 2026 S-H-Y-A
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package com.ichi2.anki.notetype.fieldeditor
+
+import androidx.annotation.VisibleForTesting
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ichi2.anki.CollectionManager.withCol
+import com.ichi2.anki.notetype.fieldeditor.NoteTypeFieldEditor.Companion.EXTRA_NOTETYPE_ID
+import com.ichi2.anki.servicelayer.LanguageHint
+import com.ichi2.anki.servicelayer.LanguageHintService.setLanguageHintForField
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+class NoteTypeFieldEditorViewModel(
+ savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+ val noteTypeId = savedStateHandle.get(EXTRA_NOTETYPE_ID) ?: 0
+ val state: StateFlow
+ field = MutableStateFlow(NoteTypeFieldEditorState(fieldsLabels = emptyList()))
+
+ init {
+ initialize()
+ }
+
+ fun initialize() {
+ viewModelScope.launch {
+ refreshNoteTypes()
+ }
+ }
+
+ fun updateCurrentPosition(position: Int) {
+ state.update { oldState -> oldState.copy(currentPos = position) }
+ }
+
+ suspend fun add(name: String) {
+ Timber.d("doInBackgroundAddField")
+ runCatching {
+ withCol {
+ val notetype = notetypes.get(noteTypeId)!!
+ notetypes.addField(notetype, notetypes.newField(name))
+ notetypes.save(notetype)
+ }
+ }
+ initialize()
+ }
+
+ suspend fun rename(
+ position: Int,
+ name: String,
+ ) {
+ Timber.d("doInBackgroundRenameField")
+ runCatching {
+ withCol {
+ val notetype = notetypes.get(noteTypeId)!!
+ val field = notetype.getField(position)
+ notetypes.renameField(notetype, field, name)
+ notetypes.save(notetype)
+ }
+ }
+ initialize()
+ }
+
+ suspend fun delete(position: Int) {
+ Timber.d("doInBackGroundDeleteField")
+ runCatching {
+ withCol {
+ val notetype = notetypes.get(noteTypeId)!!
+ val field = notetype.getField(position)
+ notetypes.removeField(notetype, field)
+ notetypes.save(notetype)
+ }
+ }
+ initialize()
+ }
+
+ suspend fun changeSort(position: Int) {
+ Timber.d("doInBackgroundChangeSortField")
+ runCatching {
+ withCol {
+ val notetype = notetypes.get(noteTypeId)!!
+ notetypes.setSortIndex(notetype, position)
+ notetypes.save(notetype)
+ }
+ }
+ initialize()
+ }
+
+ suspend fun reposition(
+ oldPosition: Int,
+ newPosition: Int,
+ ) {
+ Timber.d("doInBackgroundRepositionField")
+ runCatching {
+ withCol {
+ val notetype = notetypes.get(noteTypeId)!!
+ val field = notetype.getField(oldPosition)
+ notetypes.repositionField(notetype, field, newPosition)
+ }
+ }
+ initialize()
+ }
+
+ suspend fun languageHint(
+ position: Int,
+ locale: LanguageHint,
+ ) {
+ runCatching {
+ withCol {
+ val notetype = notetypes.get(noteTypeId)!!
+ setLanguageHintForField(
+ notetypes,
+ notetype,
+ position,
+ locale,
+ )
+ }
+ }
+ initialize()
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ suspend fun refreshNoteTypes() {
+ val notetype = withCol { notetypes.get(noteTypeId)!! }
+ state.value =
+ state.value.copy(
+ currentPos = 0,
+ sortf = notetype.sortf,
+ fieldsLabels = notetype.fieldsNames,
+ )
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/RenameNoteTypeField.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/RenameNoteTypeField.kt
new file mode 100644
index 000000000000..fd2098a0ef31
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/RenameNoteTypeField.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2026 S-H-Y-A
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package com.ichi2.anki.notetype.fieldeditor
+
+import androidx.appcompat.app.AlertDialog
+import com.ichi2.anki.R
+import com.ichi2.anki.launchCatchingTask
+import com.ichi2.anki.sync.userAcceptsSchemaChange
+import com.ichi2.ui.FixedEditText
+import com.ichi2.utils.customView
+import com.ichi2.utils.moveCursorToEnd
+import com.ichi2.utils.negativeButton
+import com.ichi2.utils.positiveButton
+import com.ichi2.utils.show
+import com.ichi2.utils.title
+
+class RenameNoteTypeField(
+ private val activity: NoteTypeFieldEditor,
+ val fieldName: String,
+) {
+ fun showRenameNoteTypeFieldDialog(confirm: (name: String) -> Unit) {
+ val fieldNameInput =
+ FixedEditText(activity).apply {
+ focusWithKeyboard()
+ isSingleLine = true
+ setText(fieldName)
+ moveCursorToEnd()
+ }
+
+ activity.apply {
+ launchCatchingTask {
+ val confirmation = userAcceptsSchemaChange()
+ if (!confirmation) {
+ return@launchCatchingTask
+ }
+ AlertDialog
+ .Builder(activity)
+ .show {
+ customView(fieldNameInput, paddingStart = 64, paddingEnd = 64, paddingTop = 32)
+ title(R.string.model_field_editor_rename)
+ positiveButton(R.string.rename) {
+ confirm(fieldNameInput.text.toString())
+ }
+ negativeButton(R.string.dialog_cancel)
+ }
+ }
+ }
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/RepositionTypeField.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/RepositionTypeField.kt
new file mode 100644
index 000000000000..3308f544d071
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/fieldeditor/RepositionTypeField.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2026 S-H-Y-A
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package com.ichi2.anki.notetype.fieldeditor
+
+import android.text.InputType
+import androidx.appcompat.app.AlertDialog
+import com.ichi2.anki.CollectionManager.TR
+import com.ichi2.anki.R
+import com.ichi2.anki.launchCatchingTask
+import com.ichi2.anki.sync.userAcceptsSchemaChange
+import com.ichi2.utils.getInputField
+import com.ichi2.utils.input
+import com.ichi2.utils.negativeButton
+import com.ichi2.utils.positiveButton
+import com.ichi2.utils.show
+
+class RepositionTypeField(
+ private val activity: NoteTypeFieldEditor,
+ private val numberOfTemplates: Int,
+) {
+ fun showRepositionNoteTypeFieldDialog(confirm: (position: Int) -> Unit) {
+ activity.apply {
+ launchCatchingTask {
+ val confirmation = userAcceptsSchemaChange()
+ if (!confirmation) {
+ return@launchCatchingTask
+ }
+ AlertDialog
+ .Builder(activity)
+ .show {
+ positiveButton(R.string.dialog_ok) {
+ val input = (it as AlertDialog).getInputField()
+ confirm(input.text.toString().toInt() - 1)
+ }
+ negativeButton(R.string.dialog_cancel)
+ setMessage(TR.fieldsNewPosition1(numberOfTemplates))
+ setView(R.layout.dialog_generic_text_input)
+ }.input(
+ prefill = (numberOfTemplates + 1).toString(),
+ inputType = InputType.TYPE_CLASS_NUMBER,
+ displayKeyboard = true,
+ waitForPositiveButton = false,
+ ) { dialog, text: CharSequence ->
+ val number = text.toString().toIntOrNull()
+ dialog.positiveButton.isEnabled = number != null && number in 1..(numberOfTemplates + 1)
+ }
+ }
+ }
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/NoteTypeFieldEditorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/notetype/fieldeditor/NoteTypeFieldEditorTest.kt
similarity index 94%
rename from AnkiDroid/src/test/java/com/ichi2/anki/NoteTypeFieldEditorTest.kt
rename to AnkiDroid/src/test/java/com/ichi2/anki/notetype/fieldeditor/NoteTypeFieldEditorTest.kt
index 195da32791a5..8cdf44eec148 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/NoteTypeFieldEditorTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/notetype/fieldeditor/NoteTypeFieldEditorTest.kt
@@ -14,13 +14,15 @@
* this program. If not, see .
*/
-package com.ichi2.anki
+package com.ichi2.anki.notetype.fieldeditor
import android.content.DialogInterface
import android.content.Intent
import android.view.ContextThemeWrapper
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
+import com.ichi2.anki.R
+import com.ichi2.anki.RobolectricTest
import com.ichi2.anki.libanki.exception.ConfirmModSchemaException
import com.ichi2.utils.positiveButton
import com.ichi2.utils.show
@@ -105,6 +107,7 @@ class NoteTypeFieldEditorTest(
positiveButton(text = "") {
try {
val noteTypeName = "Basic"
+ val fieldName = fieldNameInput.text.toString()
// start ModelFieldEditor activity
val intent = Intent()
@@ -117,11 +120,8 @@ class NoteTypeFieldEditorTest(
intent,
)
when (fieldOperationType) {
- FieldOperationType.ADD_FIELD -> noteTypeFieldEditor.addField(fieldNameInput)
- FieldOperationType.RENAME_FIELD ->
- noteTypeFieldEditor.renameField(
- fieldNameInput,
- )
+ FieldOperationType.ADD_FIELD -> noteTypeFieldEditor.addField(fieldName)
+ FieldOperationType.RENAME_FIELD -> noteTypeFieldEditor.renameField(fieldName)
}
} catch (exception: ConfirmModSchemaException) {
throw RuntimeException(exception)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/notetype/fieldeditor/NoteTypeFieldEditorViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/notetype/fieldeditor/NoteTypeFieldEditorViewModelTest.kt
new file mode 100644
index 000000000000..c29c22362633
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/notetype/fieldeditor/NoteTypeFieldEditorViewModelTest.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2026 S-H-Y-A
+ *
+ * This program is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3 of the License, or (at your option) any later
+ * version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ * PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+package com.ichi2.anki.notetype.fieldeditor
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.RobolectricTest
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class NoteTypeFieldEditorViewModelTest : RobolectricTest() {
+ private fun getViewModel(): NoteTypeFieldEditorViewModel {
+ val noteTypeName = addStandardNoteType(TEST_NAME, arrayOf("front", "back"), "", "")
+ val noteTypeId = col.notetypes.idForName(noteTypeName)!!
+ val initialState =
+ buildMap {
+ this[NoteTypeFieldEditor.EXTRA_NOTETYPE_ID] = noteTypeId
+ }
+ val savedStateHandle = SavedStateHandle(initialState)
+ return NoteTypeFieldEditorViewModel(savedStateHandle)
+ }
+
+ @Test
+ fun testAddField() =
+ runTest {
+ val viewModel = getViewModel()
+ viewModel.refreshNoteTypes()
+ viewModel.add(NEW_FIELD_NAME)
+ assert(
+ viewModel.state.value.fieldsLabels
+ .last() == NEW_FIELD_NAME,
+ )
+ }
+
+ @Test
+ fun testAddBlankNameField() =
+ runTest {
+ val viewModel = getViewModel()
+ viewModel.refreshNoteTypes()
+ val originalLabels = viewModel.state.value.fieldsLabels
+ viewModel.add(EMPTY_FIELD_NAME)
+ assert(viewModel.state.value.fieldsLabels == originalLabels)
+ }
+
+ @Test
+ fun testRenameField() =
+ runTest {
+ val viewModel = getViewModel()
+ viewModel.refreshNoteTypes()
+ val position = 0
+ viewModel.rename(position, NEW_FIELD_NAME)
+ assert(viewModel.state.value.fieldsLabels[position] == NEW_FIELD_NAME)
+ }
+
+ @Test
+ fun testDeleteField() =
+ runTest {
+ val viewModel = getViewModel()
+ viewModel.refreshNoteTypes()
+ val originalLabels = viewModel.state.value.fieldsLabels
+ val position = 0
+ viewModel.delete(position)
+ val expectedLabels =
+ originalLabels
+ .toMutableList()
+ .apply {
+ removeAt(position)
+ }.toList()
+ assert(viewModel.state.value.fieldsLabels == expectedLabels)
+ }
+
+ @Test
+ fun testDeleteLastField() =
+ runTest {
+ val viewModel = getViewModel()
+ viewModel.refreshNoteTypes()
+ val originalLabels = viewModel.state.value.fieldsLabels
+ val originalSize = originalLabels.size
+ val position = 0
+ repeat(originalSize) {
+ viewModel.delete(position)
+ }
+ val expectedLabels = listOf(originalLabels.last())
+ assert(viewModel.state.value.fieldsLabels == expectedLabels)
+ }
+
+ @Test
+ fun testChangeSort() =
+ runTest {
+ val viewModel = getViewModel()
+ viewModel.refreshNoteTypes()
+ val position = 1
+ viewModel.changeSort(position)
+ assert(viewModel.state.value.sortf == position)
+ }
+
+ @Test
+ fun testReposition() =
+ runTest {
+ val viewModel = getViewModel()
+ viewModel.refreshNoteTypes()
+ val originalLabels = viewModel.state.value.fieldsLabels
+ val oldPosition = 0
+ val newPosition = 1
+ viewModel.reposition(oldPosition, newPosition)
+ val expectedLabels =
+ originalLabels
+ .toMutableList()
+ .apply {
+ val label = removeAt(oldPosition)
+ add(newPosition, label)
+ }.toList()
+ assert(viewModel.state.value.fieldsLabels == expectedLabels)
+ }
+
+ companion object {
+ private const val TEST_NAME = "BasicTestNoteType"
+ private const val NEW_FIELD_NAME = "Extra Field"
+ private const val EMPTY_FIELD_NAME = " "
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt
index 84ca49b47a32..e8479aa4c9b3 100644
--- a/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt
@@ -32,7 +32,6 @@ import com.ichi2.anki.IntentHandler.Companion.getReviewDeckIntent
import com.ichi2.anki.IntentHandler2
import com.ichi2.anki.IntroductionActivity
import com.ichi2.anki.NoteEditorActivity
-import com.ichi2.anki.NoteTypeFieldEditor
import com.ichi2.anki.Reviewer
import com.ichi2.anki.SharedDecksActivity
import com.ichi2.anki.SingleFragmentActivity
@@ -41,6 +40,7 @@ import com.ichi2.anki.account.AccountActivity
import com.ichi2.anki.instantnoteeditor.InstantNoteEditorActivity
import com.ichi2.anki.multimedia.MultimediaActivity
import com.ichi2.anki.notetype.ManageNotetypes
+import com.ichi2.anki.notetype.fieldeditor.NoteTypeFieldEditor
import com.ichi2.anki.preferences.PreferencesActivity
import com.ichi2.anki.previewer.CardViewerActivity
import com.ichi2.anki.ui.windows.managespace.ManageSpaceActivity