diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index 1b5d3422cc78..5f63eb4867d1 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 @@ -154,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 @@ -245,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( @@ -364,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)) { @@ -460,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 @@ -528,11 +536,23 @@ 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" 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 +659,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) @@ -749,8 +770,44 @@ 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 -> - val dialog = InsertFieldDialog.newInstance(fieldNames, insertFieldRequestKey) + 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.query( + side = side, + noteId = noteId, + ord = ord, + cardTemplateName = templateName, + noteTypeName = tempModel.notetype.name, + ), + requestKey = insertFieldRequestKey, + ) templateEditor.showDialogFragment(dialog) } } @@ -764,7 +821,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( @@ -789,19 +846,17 @@ 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") } } - @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( @@ -857,7 +912,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) { @@ -908,12 +963,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 } @@ -1110,9 +1164,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, @@ -1155,13 +1207,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, @@ -1190,8 +1241,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") @@ -1502,6 +1552,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 4f323784eefd..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,19 +16,46 @@ 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.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.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.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.customListAdapter 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 @@ -37,59 +64,76 @@ import com.ichi2.utils.title * @see [CardTemplateEditor.CardTemplateFragment] */ class InsertFieldDialog : DialogFragment() { - private lateinit var fieldList: List - private lateinit var requestKey: String + private lateinit var binding: DialogInsertFieldBinding + private val viewModel by viewModels() + 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) - fieldList = requireArguments().getStringArrayList(KEY_FIELD_ITEMS)!! - 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 - textView.text = fieldList[position] - textView.setOnClickListener { selectFieldAndClose(textView) } + 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) + } + + 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) } + }, + ) - override fun getItemCount(): Int = fieldList.size + // setup flows + launchCatchingTask { + viewModel.selectedFieldFlow.collect { field -> + if (field == null) return@collect + parentFragmentManager.setFragmentResult( + requestKey, + bundleOf(KEY_INSERTED_FIELD to field.renderToTemplateTag()), + ) + dismiss() } - return AlertDialog.Builder(requireContext()).create { - title(R.string.card_template_editor_select_field) - negativeButton(R.string.dialog_cancel) - customListAdapter(adapter) } - } - private fun selectFieldAndClose(textView: TextView) { - parentFragmentManager.setFragmentResult( - requestKey, - bundleOf(KEY_INSERTED_FIELD to textView.text.toString()), - ) - dismiss() + return dialog } 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] @@ -100,14 +144,184 @@ 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, ) } } + + 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 + } + } + + override fun onResume() { + super.onResume() + this.requireView().requestLayout() // update the height of the ViewPager + } + } + + 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, + ) = InsertFieldViewHolder( + DialogInsertSpecialFieldRecyclerItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ), + ) + + override fun onBindViewHolder( + holder: InsertFieldViewHolder, + position: Int, + ) { + val field = viewModel.specialFields[position] + + 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) +} + +@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)) + + 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())) + + SpecialFields.CardTemplate -> + context.getString( + R.string.special_field_card_help, + buildSuffix(metadata.cardTemplateName), + ) + + 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) +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 new file mode 100644 index 000000000000..dbcab6bc0996 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt @@ -0,0 +1,192 @@ +/* + * 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 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 +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +private typealias SpecialFieldModel = com.ichi2.anki.model.SpecialField + +/** + * ViewModel for [InsertFieldDialog] + * + * Handles availability of fields + */ +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) + + /** + * 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) + + /** + * 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 + */ + fun selectNamedField(fieldName: FieldName) { + Timber.i("selected named field") + if (!fieldNames.contains(fieldName)) return + selectedFieldFlow.value = SelectedField.NoteTypeField.from(fieldName) + } + + /** + * 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) + } + + 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) + } + } + + class SpecialField( + val model: SpecialFieldModel, + ) : SelectedField() { + override fun renderToTemplateTag(): String = "{{${model.name}}}" + } + + /** + * Renders the field for use in the Card Template + * + * Example: `{{type:Front}}` + */ + @CheckResult + 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" + const val KEY_REQUEST_KEY = "key_request_key" + } +} + +@Parcelize +data class InsertFieldMetadata( + val side: SingleCardSide, + 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/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/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/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/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 5818137a8a24..3b60ace714e4 100644 --- a/AnkiDroid/src/main/res/values/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values/03-dialogs.xml @@ -275,4 +275,17 @@ 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 + %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/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/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 new file mode 100644 index 000000000000..7ec8a7e77f1d --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModelTest.kt @@ -0,0 +1,142 @@ +/* + * 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.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 +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}}")) + } + + @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, + cardTemplateName = "Card Template", + noteTypeName = "Note Type", + tags = "tag1 tag2", + cardId = 1, + deck = "aa::bb", + flag = 0, + ) + } + withViewModel(savedStateHandle, block) + } + + fun withViewModel( + savedStateHandle: SavedStateHandle, + block: InsertFieldDialogViewModel.() -> Unit, + ) { + InsertFieldDialogViewModel(savedStateHandle).run(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) + "…" +}