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