From 091c1d53b441770d1d72777394bc11daa098179e Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 1 Jan 2026 23:00:17 +0700 Subject: [PATCH 1/5] refactor: add InsertFieldDialogViewModel I will be extending this class to handle field filters and special fields * rendering the output is now the responsibility of `renderToTemplateTag()` rather than done in the CardTemplateEditor --- .../java/com/ichi2/anki/CardTemplateEditor.kt | 6 +- .../ichi2/anki/dialogs/InsertFieldDialog.kt | 44 +++++++---- .../dialogs/InsertFieldDialogViewModel.kt | 76 +++++++++++++++++++ .../java/com/ichi2/anki/model/FieldName.kt | 29 +++++++ .../com/ichi2/anki/CardTemplateEditorTest.kt | 2 +- .../dialogs/InsertFieldDialogViewModelTest.kt | 71 +++++++++++++++++ 6 files changed, 209 insertions(+), 19 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/model/FieldName.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index 1b5d3422cc78..eff8e0e58d5b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -795,13 +795,11 @@ open class CardTemplateEditor : } } - @Suppress("unused") - private fun insertField(fieldName: String) { + private fun insertField(fieldToInsert: String) { val start = max(binding.editText.selectionStart, 0) val end = max(binding.editText.selectionEnd, 0) // add string to editText - val updatedString = "{{$fieldName}}" - binding.editText.text!!.replace(min(start, end), max(start, end), updatedString, 0, updatedString.length) + binding.editText.text!!.replace(min(start, end), max(start, end), fieldToInsert, 0, fieldToInsert.length) } fun setCurrentEditorView( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt index 4f323784eefd..05850466df38 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt @@ -17,14 +17,19 @@ package com.ichi2.anki.dialogs import android.os.Bundle +import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels import androidx.recyclerview.widget.RecyclerView import com.ichi2.anki.CardTemplateEditor import com.ichi2.anki.R +import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_FIELD_ITEMS +import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_REQUEST_KEY +import com.ichi2.anki.launchCatchingTask import com.ichi2.utils.create import com.ichi2.utils.customListAdapter import com.ichi2.utils.negativeButton @@ -37,7 +42,7 @@ import com.ichi2.utils.title * @see [CardTemplateEditor.CardTemplateFragment] */ class InsertFieldDialog : DialogFragment() { - private lateinit var fieldList: List + private val viewModel by viewModels() private lateinit var requestKey: String /** @@ -45,7 +50,6 @@ class InsertFieldDialog : DialogFragment() { */ override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { super.onCreate(savedInstanceState) - fieldList = requireArguments().getStringArrayList(KEY_FIELD_ITEMS)!! requestKey = requireArguments().getString(KEY_REQUEST_KEY)!! val adapter: RecyclerView.Adapter<*> = object : RecyclerView.Adapter() { @@ -62,11 +66,12 @@ class InsertFieldDialog : DialogFragment() { position: Int, ) { val textView = holder.itemView as TextView - textView.text = fieldList[position] - textView.setOnClickListener { selectFieldAndClose(textView) } + val field = viewModel.fieldNames[position] + textView.text = field.name + textView.setOnClickListener { viewModel.selectNamedField(field) } } - override fun getItemCount(): Int = fieldList.size + override fun getItemCount(): Int = viewModel.fieldNames.size } return AlertDialog.Builder(requireContext()).create { title(R.string.card_template_editor_select_field) @@ -75,21 +80,32 @@ class InsertFieldDialog : DialogFragment() { } } - private fun selectFieldAndClose(textView: TextView) { - parentFragmentManager.setFragmentResult( - requestKey, - bundleOf(KEY_INSERTED_FIELD to textView.text.toString()), - ) - dismiss() + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + // setup flows + launchCatchingTask { + viewModel.selectedFieldFlow.collect { field -> + if (field == null) return@collect + parentFragmentManager.setFragmentResult( + requestKey, + bundleOf(KEY_INSERTED_FIELD to field.renderToTemplateTag()), + ) + dismiss() + } + } } companion object { /** - * This fragment requires that a list of fields names to be passed in. + * A key in the extras of the Fragment Result + * + * Represents the template tag for the selected field: `{{Front}}` */ const val KEY_INSERTED_FIELD = "key_inserted_field" - private const val KEY_FIELD_ITEMS = "key_field_items" - private const val KEY_REQUEST_KEY = "key_request_key" /** * Creates a new instance of [InsertFieldDialog] diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt new file mode 100644 index 000000000000..53941b3e9047 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 David Allison + * + * 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.dialogs + +import androidx.annotation.CheckResult +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.ichi2.anki.model.FieldName +import com.ichi2.anki.utils.ext.require +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * ViewModel for [InsertFieldDialog] + * + * Handles availability of fields + */ +class InsertFieldDialogViewModel( + savedStateHandle: SavedStateHandle, +) : ViewModel() { + /** The field names of the note type */ + val fieldNames = savedStateHandle.require>(KEY_FIELD_ITEMS).map(::FieldName) + + val selectedFieldFlow = MutableStateFlow(null) + + /** + * Select a named field defined on the note type + */ + fun selectNamedField(fieldName: FieldName) { + if (!fieldNames.contains(fieldName)) return + selectedFieldFlow.value = SelectedField.NoteTypeField.from(fieldName) + } + + sealed class SelectedField { + /** + * A field defined on the note type + * + * e.g `Front` + */ + class NoteTypeField( + val name: FieldName, + ) : SelectedField() { + override fun renderToTemplateTag(): String = "{{$name}}" + + companion object { + fun from(fieldName: FieldName) = NoteTypeField(fieldName) + } + } + + /** + * Renders the field for use in the Card Template + * + * Example: `{{type:Front}}` + */ + @CheckResult + abstract fun renderToTemplateTag(): String + } + + companion object { + const val KEY_FIELD_ITEMS = "key_field_items" + const val KEY_REQUEST_KEY = "key_request_key" + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/model/FieldName.kt b/AnkiDroid/src/main/java/com/ichi2/anki/model/FieldName.kt new file mode 100644 index 000000000000..9c6aa3c6caed --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/model/FieldName.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 David Allison + * + * 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.model + +/** + * The name of a Note Type's field + * + * example: `Front` + */ +@JvmInline +value class FieldName( + val name: String, +) { + override fun toString() = name +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt index bf9f4a8cf590..dfccb33d8e5c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt @@ -819,7 +819,7 @@ class CardTemplateEditorTest : RobolectricTest() { advanceRobolectricLooper() val resultBundle = Bundle() - resultBundle.putString(InsertFieldDialog.KEY_INSERTED_FIELD, fieldToInsert) + resultBundle.putString(InsertFieldDialog.KEY_INSERTED_FIELD, expectedFieldText) testEditor.supportFragmentManager.setFragmentResult(firstFragmentAgain.insertFieldRequestKey, resultBundle) advanceRobolectricLooper() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt new file mode 100644 index 000000000000..8b058808f1eb --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 David Allison + * + * 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.dialogs + +import androidx.lifecycle.SavedStateHandle +import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.SelectedField.NoteTypeField +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.nullValue +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test +import org.junit.jupiter.api.assertInstanceOf +import kotlin.test.assertNotNull + +/** + * Test for [InsertFieldDialogViewModel] + */ +class InsertFieldDialogViewModelTest { + @Test + fun `expected fields are exposed`() = + withViewModel { + assertThat( + "Note type fields are copied", + fieldNames.map { it.name }, + equalTo(listOf("Front", "Back")), + ) + } + + @Test + fun `field selection emits data`() = + withViewModel { + assertThat(selectedFieldFlow.value, nullValue()) + + selectNamedField(fieldNames[0]) + + val selectedField = assertNotNull(selectedFieldFlow.value) + val field = assertInstanceOf(selectedField) + assertThat(field.renderToTemplateTag(), equalTo("{{Front}}")) + } + + fun withViewModel( + fieldList: List = listOf("Front", "Back"), + block: InsertFieldDialogViewModel.() -> Unit, + ) { + val savedStateHandle = + SavedStateHandle().apply { + this[InsertFieldDialogViewModel.KEY_FIELD_ITEMS] = ArrayList(fieldList) + } + withViewModel(savedStateHandle, block) + } + + fun withViewModel( + savedStateHandle: SavedStateHandle, + block: InsertFieldDialogViewModel.() -> Unit, + ) { + InsertFieldDialogViewModel(savedStateHandle).run(block) + } +} From 35b1180c5a81ebbf26b159c3d44ed3d2392ac254 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 6 Jan 2026 12:22:37 +0700 Subject: [PATCH 2/5] feat(insert-field): backend for 'Special Fields' This introduces Anki's 'Special Fields' in the ViewModel and adds a 'side' argument, so `{{FrontSide}}` can optionally be displayed This does not implement the user interface See: https://docs.ankiweb.net/templates/fields.html#special-fields --- .../java/com/ichi2/anki/CardTemplateEditor.kt | 37 +++++- .../ichi2/anki/dialogs/InsertFieldDialog.kt | 3 + .../dialogs/InsertFieldDialogViewModel.kt | 35 ++++++ .../java/com/ichi2/anki/model/SpecialField.kt | 115 ++++++++++++++++++ .../dialogs/InsertFieldDialogViewModelTest.kt | 65 ++++++++++ 5 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/model/SpecialField.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index eff8e0e58d5b..a4527c64a970 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -62,6 +62,7 @@ import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.android.input.ShortcutGroup import com.ichi2.anki.android.input.shortcut +import com.ichi2.anki.cardviewer.SingleCardSide import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.databinding.CardTemplateEditorBinding @@ -73,6 +74,7 @@ import com.ichi2.anki.dialogs.DeckSelectionDialog import com.ichi2.anki.dialogs.DeckSelectionDialog.DeckSelectionListener import com.ichi2.anki.dialogs.DiscardChangesDialog import com.ichi2.anki.dialogs.InsertFieldDialog +import com.ichi2.anki.dialogs.InsertFieldMetadata import com.ichi2.anki.libanki.CardTemplates import com.ichi2.anki.libanki.Collection import com.ichi2.anki.libanki.Note @@ -533,6 +535,15 @@ open class CardTemplateEditor : var currentEditorViewId = 0 + private val currentEditTab: EditTab? + get() = + when (currentEditorViewId) { + R.id.front_edit -> EditTab.FRONT + R.id.back_edit -> EditTab.BACK + R.id.styling_edit -> EditTab.STYLING + else -> null + } + private lateinit var templateEditor: CardTemplateEditor lateinit var tempModel: CardTemplateNotetype @@ -639,9 +650,10 @@ open class CardTemplateEditor : override fun afterTextChanged(arg0: Editable) { refreshFragmentRunnable?.let { refreshFragmentHandler.removeCallbacks(it) } - when (currentEditorViewId) { - R.id.styling_edit -> tempModel.css = binding.editText.text.toString() - R.id.back_edit -> template.afmt = binding.editText.text.toString() + when (currentEditTab) { + EditTab.STYLING -> tempModel.css = binding.editText.text.toString() + EditTab.BACK -> template.afmt = binding.editText.text.toString() + EditTab.FRONT -> template.qfmt = binding.editText.text.toString() else -> template.qfmt = binding.editText.text.toString() } templateEditor.tempNoteType!!.updateTemplate(cardIndex, template) @@ -750,7 +762,18 @@ open class CardTemplateEditor : ) fun showInsertFieldDialog() { templateEditor.fieldNames?.let { fieldNames -> - val dialog = InsertFieldDialog.newInstance(fieldNames, insertFieldRequestKey) + val side = + when (currentEditTab) { + EditTab.FRONT -> SingleCardSide.FRONT + EditTab.BACK -> SingleCardSide.BACK + else -> SingleCardSide.FRONT + } + val dialog = + InsertFieldDialog.newInstance( + fieldItems = fieldNames, + metadata = InsertFieldMetadata(side = side), + requestKey = insertFieldRequestKey, + ) templateEditor.showDialogFragment(dialog) } } @@ -1500,6 +1523,12 @@ open class CardTemplateEditor : } } + enum class EditTab { + FRONT, + BACK, + STYLING, + } + companion object { private const val TAB_TO_CURSOR_POSITION_KEY = "tabToCursorPosition" private const val EDITOR_VIEW_ID_KEY = "editorViewId" diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt index 05850466df38..3225aa998bda 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt @@ -28,6 +28,7 @@ import androidx.recyclerview.widget.RecyclerView import com.ichi2.anki.CardTemplateEditor import com.ichi2.anki.R import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_FIELD_ITEMS +import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_INSERT_FIELD_METADATA import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_REQUEST_KEY import com.ichi2.anki.launchCatchingTask import com.ichi2.utils.create @@ -116,12 +117,14 @@ class InsertFieldDialog : DialogFragment() { */ fun newInstance( fieldItems: List, + metadata: InsertFieldMetadata, requestKey: String, ): InsertFieldDialog = InsertFieldDialog().apply { arguments = bundleOf( KEY_FIELD_ITEMS to ArrayList(fieldItems), + KEY_INSERT_FIELD_METADATA to metadata, KEY_REQUEST_KEY to requestKey, ) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt index 53941b3e9047..b98b31fe7779 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt @@ -16,12 +16,18 @@ package com.ichi2.anki.dialogs +import android.os.Parcelable import androidx.annotation.CheckResult import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import com.ichi2.anki.cardviewer.SingleCardSide import com.ichi2.anki.model.FieldName +import com.ichi2.anki.model.SpecialFields import com.ichi2.anki.utils.ext.require import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.parcelize.Parcelize + +private typealias SpecialFieldModel = com.ichi2.anki.model.SpecialField /** * ViewModel for [InsertFieldDialog] @@ -34,8 +40,17 @@ class InsertFieldDialogViewModel( /** The field names of the note type */ val fieldNames = savedStateHandle.require>(KEY_FIELD_ITEMS).map(::FieldName) + private val metadata = savedStateHandle.require(KEY_INSERT_FIELD_METADATA) + val selectedFieldFlow = MutableStateFlow(null) + /** + * An ordered list of special fields which may be used + * + * @see com.ichi2.anki.model.SpecialField + */ + val specialFields = SpecialFields.all(side = metadata.side) + /** * Select a named field defined on the note type */ @@ -44,6 +59,14 @@ class InsertFieldDialogViewModel( selectedFieldFlow.value = SelectedField.NoteTypeField.from(fieldName) } + /** + * Select a usable special field + */ + fun selectSpecialField(field: SpecialFieldModel) { + if (!specialFields.contains(field)) return + selectedFieldFlow.value = SelectedField.SpecialField(model = field) + } + sealed class SelectedField { /** * A field defined on the note type @@ -60,6 +83,12 @@ class InsertFieldDialogViewModel( } } + class SpecialField( + val model: SpecialFieldModel, + ) : SelectedField() { + override fun renderToTemplateTag(): String = "{{${model.name}}}" + } + /** * Renders the field for use in the Card Template * @@ -71,6 +100,12 @@ class InsertFieldDialogViewModel( companion object { const val KEY_FIELD_ITEMS = "key_field_items" + const val KEY_INSERT_FIELD_METADATA = "key_field_options" const val KEY_REQUEST_KEY = "key_request_key" } } + +@Parcelize +data class InsertFieldMetadata( + val side: SingleCardSide, +) : Parcelable diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/model/SpecialField.kt b/AnkiDroid/src/main/java/com/ichi2/anki/model/SpecialField.kt new file mode 100644 index 000000000000..4b5ba89e608a --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/model/SpecialField.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026 David Allison + * + * 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.model + +import androidx.annotation.VisibleForTesting +import com.ichi2.anki.cardviewer.SingleCardSide +import com.ichi2.anki.cardviewer.SingleCardSide.FRONT + +/** + * Special fields allow a card template to use properties of the current card/note + * + * Example: `{{Subdeck}}` displays the deck name + * + * - [Anki Manual: Special fields](https://docs.ankiweb.net/templates/fields.html#special-fields) + * - [Source (permalink)](https://github.com/ankitects/anki/blob/8f2144534bff6efedb22b7f052fba13ffe28cbc2/rslib/src/notetype/mod.rs#L70-L82) + */ +@JvmInline +value class SpecialField( + val name: String, +) + +/** @see SpecialField */ +object SpecialFields { + /** + * The content of the front template. Only valid on the back template + * + * `FrontSide` does not automatically play any audio that was on the front side of the card + */ + val FrontSide = SpecialField("FrontSide") + + /** + * The name of the card template (`Card 1`) + */ + val CardTemplate = SpecialField("Card") + + /** + * The card's flag, including its integer code. + * + * `flagN` where N : + * * 0 - unset + * * 1 - RED etc... + * + * @see com.ichi2.anki.Flag.code + */ + val Flag = SpecialField("CardFlag") + + /** + * The full tree of the card's deck + * + * `A::B:C` + */ + val Deck = SpecialField("Deck") + + /** + * The card's subdeck + * + * `C`, if the card is in deck: `A::B:C` + */ + val Subdeck = SpecialField("Subdeck") + + /** + * The note's tags + * + * space-delimited: `tag1 tag2` + */ + val Tags = SpecialField("Tags") + + /** + * The name of the note type + * + * example: `Basic` + */ + val NoteType = SpecialField("Type") + + /** @see com.ichi2.anki.libanki.CardId */ + val CardId = SpecialField("CardID") + + @VisibleForTesting + internal val ALL = + listOf( + FrontSide, + Deck, + Subdeck, + Tags, + Flag, + NoteType, + CardTemplate, + CardId, + ) + + /** + * Returns all available special fields in an order suitable for displaying to a user + */ + fun all(side: SingleCardSide) = + ALL.filter { field -> + when { + field == FrontSide && side == FRONT -> false + else -> true + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt index 8b058808f1eb..4bcb2576828e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt @@ -17,7 +17,12 @@ package com.ichi2.anki.dialogs import androidx.lifecycle.SavedStateHandle +import com.ichi2.anki.cardviewer.SingleCardSide +import com.ichi2.anki.cardviewer.SingleCardSide.BACK +import com.ichi2.anki.cardviewer.SingleCardSide.FRONT +import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.SelectedField import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.SelectedField.NoteTypeField +import com.ichi2.anki.model.SpecialFields import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.nullValue import org.hamcrest.MatcherAssert.assertThat @@ -51,13 +56,73 @@ class InsertFieldDialogViewModelTest { assertThat(field.renderToTemplateTag(), equalTo("{{Front}}")) } + @Test + fun `special field ordering (Front)`() = + withViewModel(side = FRONT) { + assertThat( + this.specialFields, + equalTo( + with(SpecialFields) { + listOf( + Deck, + Subdeck, + Tags, + Flag, + NoteType, + CardTemplate, + CardId, + ) + }, + ), + ) + } + + @Test + fun `special field ordering (Back)`() = + withViewModel(side = BACK) { + assertThat( + this.specialFields, + equalTo( + with(SpecialFields) { + listOf( + FrontSide, + Deck, + Subdeck, + Tags, + Flag, + NoteType, + CardTemplate, + CardId, + ) + }, + ), + ) + } + + @Test + fun `special field selection emits data`() = + withViewModel { + assertThat(selectedFieldFlow.value, nullValue()) + + selectSpecialField(SpecialFields.Deck) + + val selectedField = assertNotNull(selectedFieldFlow.value) + val field = assertInstanceOf(selectedField) + assertThat(field.renderToTemplateTag(), equalTo("{{Deck}}")) + } + fun withViewModel( fieldList: List = listOf("Front", "Back"), + side: SingleCardSide = FRONT, block: InsertFieldDialogViewModel.() -> Unit, ) { val savedStateHandle = SavedStateHandle().apply { this[InsertFieldDialogViewModel.KEY_FIELD_ITEMS] = ArrayList(fieldList) + this[InsertFieldDialogViewModel.KEY_INSERT_FIELD_METADATA] = + InsertFieldMetadata( + side = side, + ) } withViewModel(savedStateHandle, block) } From 4c9c3f46d7413ff60dd4497893c3a2cfe48a260c Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:32:31 +0700 Subject: [PATCH 3/5] feat(insert-field): insert special fields Improves discoverability of this feature vs needing to go through the manual Assisted-by: GPT-5.2: ViewPager2.updateHeight --- .../ichi2/anki/dialogs/InsertFieldDialog.kt | 210 +++++++++++++++--- .../dialogs/InsertFieldDialogViewModel.kt | 12 + .../main/res/layout/dialog_insert_field.xml | 55 +++++ AnkiDroid/src/main/res/values/03-dialogs.xml | 4 + 4 files changed, 247 insertions(+), 34 deletions(-) create mode 100644 AnkiDroid/src/main/res/layout/dialog_insert_field.xml diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt index 3225aa998bda..68407d5e94d4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt @@ -18,23 +18,35 @@ package com.ichi2.anki.dialogs import android.os.Bundle import android.view.View +import android.view.View.MeasureSpec import android.view.ViewGroup import android.widget.TextView +import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator import com.ichi2.anki.CardTemplateEditor import com.ichi2.anki.R +import com.ichi2.anki.databinding.DialogGenericRecyclerViewBinding +import com.ichi2.anki.databinding.DialogInsertFieldBinding import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_FIELD_ITEMS import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_INSERT_FIELD_METADATA import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_REQUEST_KEY +import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Tab import com.ichi2.anki.launchCatchingTask import com.ichi2.utils.create -import com.ichi2.utils.customListAdapter import com.ichi2.utils.negativeButton import com.ichi2.utils.title +import dev.androidbroadcast.vbpd.viewBinding /** * Dialog fragment used to show the fields that the user can insert in the card editor. This @@ -43,49 +55,55 @@ import com.ichi2.utils.title * @see [CardTemplateEditor.CardTemplateFragment] */ class InsertFieldDialog : DialogFragment() { + private lateinit var binding: DialogInsertFieldBinding private val viewModel by viewModels() - private lateinit var requestKey: String + private val requestKey + get() = + requireNotNull(requireArguments().getString(KEY_REQUEST_KEY)) { + KEY_REQUEST_KEY + } /** * A dialog for inserting field in card template editor */ override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { super.onCreate(savedInstanceState) - requestKey = requireArguments().getString(KEY_REQUEST_KEY)!! - val adapter: RecyclerView.Adapter<*> = - object : RecyclerView.Adapter() { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): RecyclerView.ViewHolder { - val root = layoutInflater.inflate(R.layout.material_dialog_list_item, parent, false) - return object : RecyclerView.ViewHolder(root) {} - } - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - ) { - val textView = holder.itemView as TextView - val field = viewModel.fieldNames[position] - textView.text = field.name - textView.setOnClickListener { viewModel.selectNamedField(field) } - } - - override fun getItemCount(): Int = viewModel.fieldNames.size + binding = DialogInsertFieldBinding.inflate(layoutInflater) + val dialog = + AlertDialog.Builder(requireContext()).create { + title(R.string.card_template_editor_select_field) + negativeButton(R.string.dialog_cancel) + setView(binding.root) } - return AlertDialog.Builder(requireContext()).create { - title(R.string.card_template_editor_select_field) - negativeButton(R.string.dialog_cancel) - customListAdapter(adapter) - } - } - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) + binding.viewPager.adapter = InsertFieldDialogAdapter(this) + TabLayoutMediator( + binding.tabLayout, + binding.viewPager, + ) { tab: TabLayout.Tab, position: Int -> + val entry = + Tab.entries + .first { it.position == position } + + tab.text = entry.title + }.attach() + binding.tabLayout.selectTab(binding.tabLayout.getTabAt(0)) + + binding.viewPager.registerOnPageChangeCallback( + object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + Tab.entries + .first { it.position == position } + .let { selectedTab -> + viewModel.currentTab = selectedTab + } + super.onPageSelected(position) + + binding.viewPager.updateHeight(childFragmentManager) + } + }, + ) // setup flows launchCatchingTask { @@ -98,6 +116,8 @@ class InsertFieldDialog : DialogFragment() { dismiss() } } + + return dialog } companion object { @@ -129,4 +149,126 @@ class InsertFieldDialog : DialogFragment() { ) } } + + class InsertFieldDialogAdapter( + fragment: Fragment, + ) : FragmentStateAdapter(fragment) { + override fun createFragment(position: Int): Fragment = + when (position) { + 0 -> SelectBasicFieldFragment() + 1 -> SelectSpecialFieldFragment() + else -> throw IllegalStateException("invalid position: $position") + } + + override fun getItemCount() = 2 + } + + class SelectBasicFieldFragment : Fragment(R.layout.dialog_generic_recycler_view) { + val viewModel by viewModels( + ownerProducer = { requireParentFragment() as InsertFieldDialog }, + ) + val binding by viewBinding(DialogGenericRecyclerViewBinding::bind) + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + binding.root.layoutManager = LinearLayoutManager(context) + binding.root.adapter = + object : RecyclerView.Adapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): RecyclerView.ViewHolder { + val root = layoutInflater.inflate(R.layout.material_dialog_list_item, parent, false) + return object : RecyclerView.ViewHolder(root) {} + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + ) { + val textView = holder.itemView as TextView + val field = viewModel.fieldNames[position] + textView.text = field.name + textView.setOnClickListener { viewModel.selectNamedField(field) } + } + + override fun getItemCount(): Int = viewModel.fieldNames.size + } + } + } + + class SelectSpecialFieldFragment : Fragment(R.layout.dialog_generic_recycler_view) { + val viewModel by viewModels( + ownerProducer = { requireParentFragment() as InsertFieldDialog }, + ) + val binding by viewBinding(DialogGenericRecyclerViewBinding::bind) + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + binding.root.adapter = + object : RecyclerView.Adapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): RecyclerView.ViewHolder { + val root = layoutInflater.inflate(R.layout.material_dialog_list_item, parent, false) + return object : RecyclerView.ViewHolder(root) {} + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + ) { + val textView = holder.itemView as TextView + val field = viewModel.specialFields[position] + textView.text = field.name + textView.setOnClickListener { viewModel.selectSpecialField(field) } + } + + override fun getItemCount(): Int = viewModel.specialFields.size + } + binding.root.layoutManager = LinearLayoutManager(context) + } + } } + +fun ViewPager2.updateHeight(fragmentManager: FragmentManager) { + fun getCurrentFragment(fragmentManager: FragmentManager): Fragment? { + val currentTag = "f$currentItem" + return fragmentManager.findFragmentByTag(currentTag) + } + + post { + val fragment = getCurrentFragment(fragmentManager) ?: return@post + val recyclerView = fragment.view as? RecyclerView ?: return@post + + // Measure RecyclerView height + recyclerView.measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), + ) + + // Update ViewPager height + layoutParams.height = recyclerView.measuredHeight + requestLayout() + } +} + +context(dialog: InsertFieldDialog) +private val Tab.title: String + @StringRes + get() = + dialog.requireContext().getString( + when (this) { + Tab.BASIC -> R.string.basic_fields_tab_header + Tab.SPECIAL -> R.string.special_fields_tab_header + }, + ) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt index b98b31fe7779..53907d17055e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt @@ -26,6 +26,7 @@ import com.ichi2.anki.model.SpecialFields import com.ichi2.anki.utils.ext.require import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.parcelize.Parcelize +import timber.log.Timber private typealias SpecialFieldModel = com.ichi2.anki.model.SpecialField @@ -37,6 +38,8 @@ private typealias SpecialFieldModel = com.ichi2.anki.model.SpecialField class InsertFieldDialogViewModel( savedStateHandle: SavedStateHandle, ) : ViewModel() { + var currentTab: Tab = Tab.BASIC + /** The field names of the note type */ val fieldNames = savedStateHandle.require>(KEY_FIELD_ITEMS).map(::FieldName) @@ -55,6 +58,7 @@ class InsertFieldDialogViewModel( * Select a named field defined on the note type */ fun selectNamedField(fieldName: FieldName) { + Timber.i("selected named field") if (!fieldNames.contains(fieldName)) return selectedFieldFlow.value = SelectedField.NoteTypeField.from(fieldName) } @@ -63,6 +67,7 @@ class InsertFieldDialogViewModel( * Select a usable special field */ fun selectSpecialField(field: SpecialFieldModel) { + Timber.i("selected special field: %s", field.name) if (!specialFields.contains(field)) return selectedFieldFlow.value = SelectedField.SpecialField(model = field) } @@ -98,6 +103,13 @@ class InsertFieldDialogViewModel( abstract fun renderToTemplateTag(): String } + enum class Tab( + val position: Int, + ) { + BASIC(0), + SPECIAL(1), + } + companion object { const val KEY_FIELD_ITEMS = "key_field_items" const val KEY_INSERT_FIELD_METADATA = "key_field_options" diff --git a/AnkiDroid/src/main/res/layout/dialog_insert_field.xml b/AnkiDroid/src/main/res/layout/dialog_insert_field.xml new file mode 100644 index 000000000000..e1119b359a32 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/dialog_insert_field.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/values/03-dialogs.xml b/AnkiDroid/src/main/res/values/03-dialogs.xml index 5818137a8a24..cf1b31f0bcc0 100644 --- a/AnkiDroid/src/main/res/values/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values/03-dialogs.xml @@ -275,4 +275,8 @@ also changes the interval of the card" Ensure you are connected to the internet Copy the file to your device and try again with the local file Open the file using your device’s file browser app + + + Basic + Special From 6f1d3e674425ca9bf00fdfd9178fa43d8591a7c0 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:30:08 +0700 Subject: [PATCH 4/5] refactor(template-editor): add 'ord' helper --- .../java/com/ichi2/anki/CardTemplateEditor.kt | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index a4527c64a970..2e1914ed1d85 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -156,6 +156,14 @@ open class CardTemplateEditor : private var tabToViewId: HashMap = HashMap() private var startingOrdId = 0 + /** + * The ordinal of the current template being edited + * + * Valid for use in [tempNoteType] + */ + private val ord: Int + get() = mainBinding.cardTemplateEditorPager.currentItem + /** * If true, the view is split in two. The template editor appears on the leading side and the previewer on the trailing side. * This occurs when the screen size is large @@ -247,7 +255,6 @@ open class CardTemplateEditor : launchCatchingTask { val notetype = tempNoteType!!.notetype val notetypeFile = NotetypeFile(this@CardTemplateEditor, notetype) - val ord = mainBinding.cardTemplateEditorPager.currentItem val note = withCol { currentFragment?.getNote(this) ?: Note.fromNotetypeId(this@withCol, notetype.id) } val args = TemplatePreviewerArguments( @@ -366,8 +373,7 @@ open class CardTemplateEditor : return } - val ordinal = mainBinding.cardTemplateEditorPager.currentItem - val template = tempNoteType!!.getTemplate(ordinal) + val template = tempNoteType!!.getTemplate(ord) val templateName = template.name if (deck != null && getColUnsafe.decks.isFiltered(deck.deckId)) { @@ -462,7 +468,7 @@ open class CardTemplateEditor : val currentFragment: CardTemplateFragment? get() = try { - supportFragmentManager.findFragmentByTag("f" + mainBinding.cardTemplateEditorPager.currentItem) as CardTemplateFragment? + supportFragmentManager.findFragmentByTag("f" + ord) as CardTemplateFragment? } catch (e: Exception) { Timber.w("Failed to get current fragment") null @@ -787,7 +793,7 @@ open class CardTemplateEditor : Timber.w("attempted to rename a dynamic note type") return } - val ordinal = templateEditor.mainBinding.cardTemplateEditorPager.currentItem + val ordinal = templateEditor.ord val template = templateEditor.tempNoteType!!.getTemplate(ordinal) RenameCardTemplateDialog.showInstance( @@ -812,7 +818,7 @@ open class CardTemplateEditor : templateEditor.mainBinding.cardTemplateEditorPager.adapter!! .itemCount, ) { newPosition -> - val currentPosition = templateEditor.mainBinding.cardTemplateEditorPager.currentItem + val currentPosition = templateEditor.ord Timber.w("moving card template %d to %d", currentPosition, newPosition) TODO("CardTemplateNotetype is a complex class and requires significant testing") } @@ -878,7 +884,7 @@ open class CardTemplateEditor : fun deleteCardTemplate() { templateEditor.lifecycleScope.launch { val tempModel = templateEditor.tempNoteType - val ordinal = templateEditor.mainBinding.cardTemplateEditorPager.currentItem + val ordinal = templateEditor.ord val template = tempModel!!.getTemplate(ordinal) // Don't do anything if only one template if (tempModel.templateCount < 2) { @@ -929,12 +935,11 @@ open class CardTemplateEditor : return } // Show confirmation dialog - val ordinal = templateEditor.mainBinding.cardTemplateEditorPager.currentItem // isOrdinalPendingAdd method will check if there are any new card types added or not, // if TempModel has new card type then numAffectedCards will be 0 by default. val numAffectedCards = - if (!CardTemplateNotetype.isOrdinalPendingAdd(templateEditor.tempNoteType!!, ordinal)) { - templateEditor.getColUnsafe.notetypes.tmplUseCount(templateEditor.tempNoteType!!.notetype, ordinal) + if (!CardTemplateNotetype.isOrdinalPendingAdd(templateEditor.tempNoteType!!, templateEditor.ord)) { + templateEditor.getColUnsafe.notetypes.tmplUseCount(templateEditor.tempNoteType!!.notetype, templateEditor.ord) } else { 0 } @@ -1131,9 +1136,7 @@ open class CardTemplateEditor : try { val tempModel = templateEditor.tempNoteType val template: BackendCardTemplate = - tempModel!!.getTemplate( - templateEditor.mainBinding.cardTemplateEditorPager.currentItem, - ) + tempModel!!.getTemplate(templateEditor.ord) CardTemplate( front = template.qfmt, back = template.afmt, @@ -1176,13 +1179,12 @@ open class CardTemplateEditor : launchCatchingTask { val notetype = templateEditor.tempNoteType!!.notetype val notetypeFile = NotetypeFile(requireContext(), notetype) - val ord = templateEditor.mainBinding.cardTemplateEditorPager.currentItem val note = withCol { getNote(this) ?: Note.fromNotetypeId(this@withCol, notetype.id) } val args = TemplatePreviewerArguments( notetypeFile = notetypeFile, id = note.id, - ord = ord, + ord = templateEditor.ord, fields = note.fields, tags = note.tags, fillEmpty = true, @@ -1211,8 +1213,7 @@ open class CardTemplateEditor : private fun getCurrentTemplateName(tempModel: CardTemplateNotetype): String = try { - val ordinal = templateEditor.mainBinding.cardTemplateEditorPager.currentItem - val template = tempModel.getTemplate(ordinal) + val template = tempModel.getTemplate(templateEditor.ord) template.name } catch (e: Exception) { Timber.w(e, "Failed to get name for template") From 29902aa3f4665c7601327826e86198d10e7e8bb1 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:13:25 +0700 Subject: [PATCH 5/5] feat(insert-field): add description for special fields This provides an explanation of all Special Fields to the user in the 'Insert field' dialog This also produces contextual help for all fields based on the context of the selected card/note (if any) --- .../java/com/ichi2/anki/CardTemplateEditor.kt | 32 ++- .../ichi2/anki/dialogs/InsertFieldDialog.kt | 111 ++++++++--- .../dialogs/InsertFieldDialogViewModel.kt | 73 ++++++- ...log_insert_special_field_recycler_item.xml | 51 +++++ AnkiDroid/src/main/res/values/03-dialogs.xml | 9 + .../anki/dialogs/InsertFieldDialogTest.kt | 187 ++++++++++++++++++ .../dialogs/InsertFieldDialogViewModelTest.kt | 6 + .../ichi2/anki/common/utils/StringUtils.kt | 12 ++ 8 files changed, 448 insertions(+), 33 deletions(-) create mode 100644 AnkiDroid/src/main/res/layout/dialog_insert_special_field_recycler_item.xml create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index 2e1914ed1d85..5f63eb4867d1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -536,6 +536,9 @@ open class CardTemplateEditor : private val cardIndex get() = requireArguments().getInt(CARD_INDEX) + private val templateName + get() = tempModel.notetype.templates[cardIndex].name + val insertFieldRequestKey get() = "request_field_insert_$cardIndex" @@ -767,17 +770,42 @@ open class CardTemplateEditor : "the kotlin migration made this method crash due to a recursive call when the dialog would return its data", ) fun showInsertFieldDialog() { - templateEditor.fieldNames?.let { fieldNames -> + launchCatchingTask { + val fieldNames = templateEditor.fieldNames ?: return@launchCatchingTask + val side = when (currentEditTab) { EditTab.FRONT -> SingleCardSide.FRONT EditTab.BACK -> SingleCardSide.BACK else -> SingleCardSide.FRONT } + + val noteId = if (templateEditor.noteId > 0) templateEditor.noteId else null + + // use the ord of the selected template, not the ord of the currently edited card + + val ord = + // deletions change ordinals, don't try to preview metadata if this occurs. + if (tempModel.templateChanges.any { + it.type == CardTemplateNotetype.ChangeType.DELETE + } + ) { + null + } else { + templateEditor.ord + } + val dialog = InsertFieldDialog.newInstance( fieldItems = fieldNames, - metadata = InsertFieldMetadata(side = side), + metadata = + InsertFieldMetadata.query( + side = side, + noteId = noteId, + ord = ord, + cardTemplateName = templateName, + noteTypeName = tempModel.notetype.name, + ), requestKey = insertFieldRequestKey, ) templateEditor.showDialogFragment(dialog) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt index 68407d5e94d4..64402e2e8157 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt @@ -16,17 +16,21 @@ package com.ichi2.anki.dialogs +import android.content.Context import android.os.Bundle +import android.text.Spanned +import android.view.LayoutInflater import android.view.View -import android.view.View.MeasureSpec import android.view.ViewGroup import android.widget.TextView +import androidx.annotation.CheckResult import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf +import androidx.core.text.HtmlCompat +import androidx.core.text.parseAsHtml import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -35,18 +39,23 @@ import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import com.ichi2.anki.CardTemplateEditor +import com.ichi2.anki.Flag import com.ichi2.anki.R import com.ichi2.anki.databinding.DialogGenericRecyclerViewBinding import com.ichi2.anki.databinding.DialogInsertFieldBinding +import com.ichi2.anki.databinding.DialogInsertSpecialFieldRecyclerItemBinding import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_FIELD_ITEMS import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_INSERT_FIELD_METADATA import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_REQUEST_KEY import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Tab import com.ichi2.anki.launchCatchingTask +import com.ichi2.anki.model.SpecialField +import com.ichi2.anki.model.SpecialFields import com.ichi2.utils.create import com.ichi2.utils.negativeButton import com.ichi2.utils.title import dev.androidbroadcast.vbpd.viewBinding +import org.jetbrains.annotations.VisibleForTesting /** * Dialog fragment used to show the fields that the user can insert in the card editor. This @@ -99,8 +108,6 @@ class InsertFieldDialog : DialogFragment() { viewModel.currentTab = selectedTab } super.onPageSelected(position) - - binding.viewPager.updateHeight(childFragmentManager) } }, ) @@ -199,6 +206,11 @@ class InsertFieldDialog : DialogFragment() { override fun getItemCount(): Int = viewModel.fieldNames.size } } + + override fun onResume() { + super.onResume() + this.requireView().requestLayout() // update the height of the ViewPager + } } class SelectSpecialFieldFragment : Fragment(R.layout.dialog_generic_recycler_view) { @@ -214,52 +226,93 @@ class InsertFieldDialog : DialogFragment() { super.onViewCreated(view, savedInstanceState) binding.root.adapter = - object : RecyclerView.Adapter() { + object : RecyclerView.Adapter() { override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, - ): RecyclerView.ViewHolder { - val root = layoutInflater.inflate(R.layout.material_dialog_list_item, parent, false) - return object : RecyclerView.ViewHolder(root) {} - } + ) = InsertFieldViewHolder( + DialogInsertSpecialFieldRecyclerItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ), + ) override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, + holder: InsertFieldViewHolder, position: Int, ) { - val textView = holder.itemView as TextView val field = viewModel.specialFields[position] - textView.text = field.name - textView.setOnClickListener { viewModel.selectSpecialField(field) } + + holder.binding.title.text = "{{${field.name}}}" + holder.binding.description.text = field.buildDescription(requireContext(), viewModel.metadata) + holder.binding.root.setOnClickListener { viewModel.selectSpecialField(field) } } override fun getItemCount(): Int = viewModel.specialFields.size } binding.root.layoutManager = LinearLayoutManager(context) } + + override fun onResume() { + super.onResume() + this.requireView().requestLayout() // update the height of the ViewPager + } } + + private class InsertFieldViewHolder( + val binding: DialogInsertSpecialFieldRecyclerItemBinding, + ) : RecyclerView.ViewHolder(binding.root) } -fun ViewPager2.updateHeight(fragmentManager: FragmentManager) { - fun getCurrentFragment(fragmentManager: FragmentManager): Fragment? { - val currentTag = "f$currentItem" - return fragmentManager.findFragmentByTag(currentTag) +@VisibleForTesting +@CheckResult +fun SpecialField.buildDescription( + context: Context, + metadata: InsertFieldMetadata, +): Spanned { + fun buildSuffix(value: String?): String { + if (value == null) return "" + return context.getString(R.string.special_field_example_suffix, value) } + return when (this) { + SpecialFields.FrontSide -> context.getString(R.string.special_field_front_side_help) + SpecialFields.Deck -> + context.getString(R.string.special_field_deck_help, buildSuffix(metadata.deck)) - post { - val fragment = getCurrentFragment(fragmentManager) ?: return@post - val recyclerView = fragment.view as? RecyclerView ?: return@post + SpecialFields.Subdeck -> + context.getString(R.string.special_field_subdeck_help, buildSuffix(metadata.subdeck)) + SpecialFields.Flag -> { + val code = metadata.flag ?: "N" + context.getString( + R.string.special_field_card_flag_help, + if (code == "N") "flag$code" else "flag$code", + "$code", + Flag.entries.minOf { it.code }, + Flag.entries.maxOf { it.code }, + ) + } + SpecialFields.Tags -> { + val tags = if (metadata.tags.isNullOrBlank()) null else metadata.tags + context.getString(R.string.special_field_tags_help, buildSuffix(tags)) + } + SpecialFields.CardId -> + context.getString(R.string.special_field_card_id_help, buildSuffix(metadata.cardId?.toString())) - // Measure RecyclerView height - recyclerView.measure( - MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), - ) + SpecialFields.CardTemplate -> + context.getString( + R.string.special_field_card_help, + buildSuffix(metadata.cardTemplateName), + ) - // Update ViewPager height - layoutParams.height = recyclerView.measuredHeight - requestLayout() - } + SpecialFields.NoteType -> + context.getString( + R.string.special_field_type_help, + buildSuffix(metadata.noteTypeName), + ) + // this shouldn't happen + else -> "" + }.parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY) } context(dialog: InsertFieldDialog) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt index 53907d17055e..dbcab6bc0996 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt @@ -20,7 +20,12 @@ import android.os.Parcelable import androidx.annotation.CheckResult import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.cardviewer.SingleCardSide +import com.ichi2.anki.common.utils.ellipsize +import com.ichi2.anki.libanki.CardId +import com.ichi2.anki.libanki.Decks +import com.ichi2.anki.libanki.NoteId import com.ichi2.anki.model.FieldName import com.ichi2.anki.model.SpecialFields import com.ichi2.anki.utils.ext.require @@ -43,7 +48,12 @@ class InsertFieldDialogViewModel( /** The field names of the note type */ val fieldNames = savedStateHandle.require>(KEY_FIELD_ITEMS).map(::FieldName) - private val metadata = savedStateHandle.require(KEY_INSERT_FIELD_METADATA) + /** + * State of the selected card when the screen was opened + * + * Used for providing [special fields][SpecialFields] with the output they'd produce. + */ + val metadata = savedStateHandle.require(KEY_INSERT_FIELD_METADATA) val selectedFieldFlow = MutableStateFlow(null) @@ -120,4 +130,63 @@ class InsertFieldDialogViewModel( @Parcelize data class InsertFieldMetadata( val side: SingleCardSide, -) : Parcelable + val cardTemplateName: String, + val noteTypeName: String, + val tags: String?, + val flag: Int?, + val cardId: CardId?, + val deck: String?, +) : Parcelable { + val subdeck: String? + get() = deck?.let { Decks.basename(it) } + + companion object { + @CheckResult + suspend fun query( + side: SingleCardSide, + cardTemplateName: String, + noteTypeName: String, + noteId: NoteId?, + ord: Int?, + ): InsertFieldMetadata { + val note = + try { + noteId?.let { nid -> withCol { getNote(nid) } } + } catch (e: Exception) { + Timber.w(e, "failed to get note") + null + } + + // BUG: This is the saved tags of the note, not the currently edited tags + val tags = + note + ?.tags + ?.joinToString(separator = " ") + // truncate, so we don't pass unbounded text into the arguments + ?.ellipsize(75) + + val card = + try { + if (ord == null || note == null) { + null + } else { + // ord can be invalid if the user has in-memory template additions + withCol { note.cards(this).getOrNull(ord) } + } + } catch (e: Exception) { + Timber.w(e, "failed to get card") + null + } + + return InsertFieldMetadata( + side = side, + cardTemplateName = cardTemplateName, + noteTypeName = noteTypeName, + tags = tags, + cardId = card?.id, + flag = card?.userFlag(), + deck = card?.currentDeckId()?.let { did -> withCol { decks.get(did)?.name } }, + ) + } + } +} diff --git a/AnkiDroid/src/main/res/layout/dialog_insert_special_field_recycler_item.xml b/AnkiDroid/src/main/res/layout/dialog_insert_special_field_recycler_item.xml new file mode 100644 index 000000000000..6789a0ccfc9f --- /dev/null +++ b/AnkiDroid/src/main/res/layout/dialog_insert_special_field_recycler_item.xml @@ -0,0 +1,51 @@ + + + + + + + + + + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/values/03-dialogs.xml b/AnkiDroid/src/main/res/values/03-dialogs.xml index cf1b31f0bcc0..3b60ace714e4 100644 --- a/AnkiDroid/src/main/res/values/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values/03-dialogs.xml @@ -279,4 +279,13 @@ also changes the interval of the card" Basic Special + %1$s’]]> + The front template content. Audio is not automatically played + The full deck of the card, including parent decks%s + The current deck of the card, excluding parent decks%s + Outputs ‘%1$s’, where %2$s is the flag code (%3$d\–%4$d\) + The tags of the note%s + The ID of the card%s + The name of the card template%s + The name of the note type%s diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogTest.kt new file mode 100644 index 000000000000..e1f4def377af --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogTest.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2026 David Allison + * + * 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.dialogs + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.cardviewer.SingleCardSide +import com.ichi2.anki.model.SpecialField +import com.ichi2.anki.model.SpecialFields +import com.ichi2.testutils.EmptyApplication +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.not +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.emptyString +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** Tests for [InsertFieldDialog] */ +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class) +class InsertFieldDialogTest : RobolectricTest() { + val metadata = + InsertFieldMetadata( + side = SingleCardSide.FRONT, + cardTemplateName = "A", + noteTypeName = "B", + tags = "tag1 tag2", + cardId = 1, + deck = "aa::bb", + flag = 0, + ) + + @Test + fun `all special fields have a descriptions`() { + val allSpecialFields = SpecialFields.ALL + + for (field in allSpecialFields) { + assertThat(field.buildDescription(), not(emptyString())) + } + } + + @Test + fun `{{Type}} description uses note type name`() { + val metadata = metadata.copy(noteTypeName = "A") + + assertThat( + SpecialFields.NoteType.buildDescription(metadata = metadata), + equalTo("The name of the note type: ‘A’"), + ) + } + + @Test + fun `{{Card}} description uses card template name`() { + val metadata = metadata.copy(cardTemplateName = "B") + + assertThat( + SpecialFields.CardTemplate.buildDescription(metadata = metadata), + equalTo( + "The name of the card template: ‘B’", + ), + ) + } + + @Test + fun `{{CardFlag}} description with missing flag`() { + assertThat( + SpecialFields.Flag.buildDescription( + metadata.copy(flag = null), + ), + equalTo("Outputs ‘flagN’, where N is the flag code (0–7)"), + ) + } + + @Test + fun `{{CardFlag}} description with flag`() { + assertThat( + SpecialFields.Flag.buildDescription( + metadata.copy(flag = 3), + ), + equalTo("Outputs ‘flag3’, where 3 is the flag code (0–7)"), + ) + } + + @Test + fun `{{Tags}} description uses tags if set`() { + val metadata = metadata.copy(tags = "one two") + assertThat( + SpecialFields.Tags.buildDescription(metadata), + equalTo("The tags of the note: ‘one two’"), + ) + } + + @Test + fun `{{Tags}} description if tags is blank`() { + val metadata = metadata.copy(tags = " ") + assertThat( + SpecialFields.Tags.buildDescription(metadata), + equalTo("The tags of the note"), + ) + } + + @Test + fun `{{Tags}} description if tags is null`() { + val metadata = metadata.copy(tags = null) + assertThat( + SpecialFields.Tags.buildDescription(metadata), + equalTo( + """ + The tags of the note + """.trimIndent(), + ), + ) + } + + @Test + fun `{{CardID}} description if ID null`() { + val metadata = metadata.copy(cardId = null) + assertThat( + SpecialFields.CardId.buildDescription(metadata), + equalTo("The ID of the card"), + ) + } + + @Test + fun `{{CardID}} description if ID is set`() { + val metadata = metadata.copy(cardId = 1767778189) + assertThat( + SpecialFields.CardId.buildDescription(metadata), + equalTo("The ID of the card: ‘1767778189’"), + ) + } + + @Test + fun `{{Deck}} description if not set`() { + val metadata = metadata.copy(deck = null) + assertThat( + SpecialFields.Deck.buildDescription(metadata), + equalTo("The full deck of the card, including parent decks"), + ) + } + + @Test + fun `{{Deck}} description if set`() { + val metadata = metadata.copy(deck = "aa::bb") + assertThat( + SpecialFields.Deck.buildDescription(metadata), + equalTo("The full deck of the card, including parent decks: ‘aa::bb’"), + ) + } + + @Test + fun `{{Subdeck}} description if not set`() { + val metadata = metadata.copy(deck = null) + assertThat( + SpecialFields.Subdeck.buildDescription(metadata), + equalTo("The current deck of the card, excluding parent decks"), + ) + } + + @Test + fun `{{Subdeck}} description if set`() { + val metadata = metadata.copy(deck = "aa::bb") + assertThat( + SpecialFields.Subdeck.buildDescription(metadata), + equalTo("The current deck of the card, excluding parent decks: ‘bb’"), + ) + } +} + +context(testContext: InsertFieldDialogTest) +fun SpecialField.buildDescription(metadata: InsertFieldMetadata = testContext.metadata) = + buildDescription(testContext.targetContext, metadata).toString() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt index 4bcb2576828e..7ec8a7e77f1d 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt @@ -122,6 +122,12 @@ class InsertFieldDialogViewModelTest { this[InsertFieldDialogViewModel.KEY_INSERT_FIELD_METADATA] = InsertFieldMetadata( side = side, + cardTemplateName = "Card Template", + noteTypeName = "Note Type", + tags = "tag1 tag2", + cardId = 1, + deck = "aa::bb", + flag = 0, ) } withViewModel(savedStateHandle, block) diff --git a/common/src/main/java/com/ichi2/anki/common/utils/StringUtils.kt b/common/src/main/java/com/ichi2/anki/common/utils/StringUtils.kt index df7e4c800bc7..8cdea8964bdd 100644 --- a/common/src/main/java/com/ichi2/anki/common/utils/StringUtils.kt +++ b/common/src/main/java/com/ichi2/anki/common/utils/StringUtils.kt @@ -94,3 +94,15 @@ fun String.htmlEncode(): String { } return sb.toString() } + +/** + * Truncates the string to the given maximum length and appends an ellipsis (`…`) + * if the text exceeds that length. + * + * Prefer [android.text.TextUtils.ellipsize] when you have a reference to a TextView + */ +fun String.ellipsize(maxLength: Int): String { + require(maxLength > 1) { "invalid length: $maxLength" } + if (this.length <= maxLength) return this + return this.take(maxLength - 1) + "…" +}