Skip to content

Commit 3a6e9e3

Browse files
committed
feat(fields): add user-facing 'description' of 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)
1 parent 1678f46 commit 3a6e9e3

File tree

8 files changed

+446
-33
lines changed

8 files changed

+446
-33
lines changed

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

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,9 @@ open class CardTemplateEditor :
536536
private val cardIndex
537537
get() = requireArguments().getInt(CARD_INDEX)
538538

539+
private val templateName
540+
get() = tempModel.notetype.templates[cardIndex].name
541+
539542
val insertFieldRequestKey
540543
get() = "request_field_insert_$cardIndex"
541544

@@ -767,17 +770,40 @@ open class CardTemplateEditor :
767770
"the kotlin migration made this method crash due to a recursive call when the dialog would return its data",
768771
)
769772
fun showInsertFieldDialog() {
770-
templateEditor.fieldNames?.let { fieldNames ->
773+
launchCatchingTask {
774+
val fieldNames = templateEditor.fieldNames ?: return@launchCatchingTask
775+
771776
val side =
772777
when (currentEditTab) {
773778
EditTab.FRONT -> SingleCardSide.FRONT
774779
EditTab.BACK -> SingleCardSide.BACK
775780
else -> SingleCardSide.FRONT
776781
}
782+
783+
val noteId = if (templateEditor.noteId > 0) templateEditor.noteId else null
784+
785+
// deletions change ordinals
786+
val ord =
787+
if (tempModel.templateChanges.any {
788+
it.type == CardTemplateNotetype.ChangeType.DELETE
789+
}
790+
) {
791+
null
792+
} else {
793+
templateEditor.ord
794+
}
795+
777796
val dialog =
778797
InsertFieldDialog.newInstance(
779798
fieldItems = fieldNames,
780-
metadata = InsertFieldMetadata(side = side),
799+
metadata =
800+
InsertFieldMetadata.query(
801+
side = side,
802+
noteId = noteId,
803+
ord = ord,
804+
cardTemplateName = templateName,
805+
noteTypeName = tempModel.notetype.name,
806+
),
781807
requestKey = insertFieldRequestKey,
782808
)
783809
templateEditor.showDialogFragment(dialog)

AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt

Lines changed: 82 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,21 @@
1616

1717
package com.ichi2.anki.dialogs
1818

19+
import android.content.Context
1920
import android.os.Bundle
21+
import android.text.Spanned
22+
import android.view.LayoutInflater
2023
import android.view.View
21-
import android.view.View.MeasureSpec
2224
import android.view.ViewGroup
2325
import android.widget.TextView
26+
import androidx.annotation.CheckResult
2427
import androidx.annotation.StringRes
2528
import androidx.appcompat.app.AlertDialog
2629
import androidx.core.os.bundleOf
30+
import androidx.core.text.HtmlCompat
31+
import androidx.core.text.parseAsHtml
2732
import androidx.fragment.app.DialogFragment
2833
import androidx.fragment.app.Fragment
29-
import androidx.fragment.app.FragmentManager
3034
import androidx.fragment.app.viewModels
3135
import androidx.recyclerview.widget.LinearLayoutManager
3236
import androidx.recyclerview.widget.RecyclerView
@@ -35,18 +39,23 @@ import androidx.viewpager2.widget.ViewPager2
3539
import com.google.android.material.tabs.TabLayout
3640
import com.google.android.material.tabs.TabLayoutMediator
3741
import com.ichi2.anki.CardTemplateEditor
42+
import com.ichi2.anki.Flag
3843
import com.ichi2.anki.R
3944
import com.ichi2.anki.databinding.DialogGenericRecyclerViewBinding
4045
import com.ichi2.anki.databinding.DialogInsertFieldBinding
46+
import com.ichi2.anki.databinding.DialogInsertSpecialFieldRecyclerItemBinding
4147
import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_FIELD_ITEMS
4248
import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_INSERT_FIELD_METADATA
4349
import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_REQUEST_KEY
4450
import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Tab
4551
import com.ichi2.anki.launchCatchingTask
52+
import com.ichi2.anki.model.SpecialField
53+
import com.ichi2.anki.model.SpecialFields
4654
import com.ichi2.utils.create
4755
import com.ichi2.utils.negativeButton
4856
import com.ichi2.utils.title
4957
import dev.androidbroadcast.vbpd.viewBinding
58+
import org.jetbrains.annotations.VisibleForTesting
5059

5160
/**
5261
* Dialog fragment used to show the fields that the user can insert in the card editor. This
@@ -99,8 +108,6 @@ class InsertFieldDialog : DialogFragment() {
99108
viewModel.currentTab = selectedTab
100109
}
101110
super.onPageSelected(position)
102-
103-
binding.viewPager.updateHeight(childFragmentManager)
104111
}
105112
},
106113
)
@@ -199,6 +206,11 @@ class InsertFieldDialog : DialogFragment() {
199206
override fun getItemCount(): Int = viewModel.fieldNames.size
200207
}
201208
}
209+
210+
override fun onResume() {
211+
super.onResume()
212+
this.requireView().requestLayout() // update the height of the ViewPager
213+
}
202214
}
203215

204216
class SelectSpecialFieldFragment : Fragment(R.layout.dialog_generic_recycler_view) {
@@ -214,52 +226,93 @@ class InsertFieldDialog : DialogFragment() {
214226
super.onViewCreated(view, savedInstanceState)
215227

216228
binding.root.adapter =
217-
object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
229+
object : RecyclerView.Adapter<InsertFieldViewHolder>() {
218230
override fun onCreateViewHolder(
219231
parent: ViewGroup,
220232
viewType: Int,
221-
): RecyclerView.ViewHolder {
222-
val root = layoutInflater.inflate(R.layout.material_dialog_list_item, parent, false)
223-
return object : RecyclerView.ViewHolder(root) {}
224-
}
233+
) = InsertFieldViewHolder(
234+
DialogInsertSpecialFieldRecyclerItemBinding.inflate(
235+
LayoutInflater.from(parent.context),
236+
parent,
237+
false,
238+
),
239+
)
225240

226241
override fun onBindViewHolder(
227-
holder: RecyclerView.ViewHolder,
242+
holder: InsertFieldViewHolder,
228243
position: Int,
229244
) {
230-
val textView = holder.itemView as TextView
231245
val field = viewModel.specialFields[position]
232-
textView.text = field.name
233-
textView.setOnClickListener { viewModel.selectSpecialField(field) }
246+
247+
holder.binding.title.text = "{{${field.name}}}"
248+
holder.binding.description.text = field.buildDescription(requireContext(), viewModel.metadata)
249+
holder.binding.root.setOnClickListener { viewModel.selectSpecialField(field) }
234250
}
235251

236252
override fun getItemCount(): Int = viewModel.specialFields.size
237253
}
238254
binding.root.layoutManager = LinearLayoutManager(context)
239255
}
256+
257+
override fun onResume() {
258+
super.onResume()
259+
this.requireView().requestLayout() // update the height of the ViewPager
260+
}
240261
}
262+
263+
private class InsertFieldViewHolder(
264+
val binding: DialogInsertSpecialFieldRecyclerItemBinding,
265+
) : RecyclerView.ViewHolder(binding.root)
241266
}
242267

243-
fun ViewPager2.updateHeight(fragmentManager: FragmentManager) {
244-
fun getCurrentFragment(fragmentManager: FragmentManager): Fragment? {
245-
val currentTag = "f$currentItem"
246-
return fragmentManager.findFragmentByTag(currentTag)
268+
@VisibleForTesting
269+
@CheckResult
270+
fun SpecialField.buildDescription(
271+
context: Context,
272+
metadata: InsertFieldMetadata,
273+
): Spanned {
274+
fun buildSuffix(value: String?): String {
275+
if (value == null) return ""
276+
return context.getString(R.string.special_field_example_suffix, value)
247277
}
278+
return when (this) {
279+
SpecialFields.FrontSide -> context.getString(R.string.special_field_front_side_help)
280+
SpecialFields.Deck ->
281+
context.getString(R.string.special_field_deck_help, buildSuffix(metadata.deck))
248282

249-
post {
250-
val fragment = getCurrentFragment(fragmentManager) ?: return@post
251-
val recyclerView = fragment.view as? RecyclerView ?: return@post
283+
SpecialFields.Subdeck ->
284+
context.getString(R.string.special_field_subdeck_help, buildSuffix(metadata.subdeck))
285+
SpecialFields.Flag -> {
286+
val code = metadata.flag ?: "N"
287+
context.getString(
288+
R.string.special_field_card_flag_help,
289+
if (code == "N") "flag$code" else "<b>flag$code</b>",
290+
"<b>$code</b>",
291+
Flag.entries.minOf { it.code },
292+
Flag.entries.maxOf { it.code },
293+
)
294+
}
295+
SpecialFields.Tags -> {
296+
val tags = if (metadata.tags.isNullOrBlank()) null else metadata.tags
297+
context.getString(R.string.special_field_tags_help, buildSuffix(tags))
298+
}
299+
SpecialFields.CardId ->
300+
context.getString(R.string.special_field_card_id_help, buildSuffix(metadata.cardId?.toString()))
252301

253-
// Measure RecyclerView height
254-
recyclerView.measure(
255-
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
256-
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
257-
)
302+
SpecialFields.CardTemplate ->
303+
context.getString(
304+
R.string.special_field_card_help,
305+
buildSuffix(metadata.cardTemplateName),
306+
)
258307

259-
// Update ViewPager height
260-
layoutParams.height = recyclerView.measuredHeight
261-
requestLayout()
262-
}
308+
SpecialFields.NoteType ->
309+
context.getString(
310+
R.string.special_field_type_help,
311+
buildSuffix(metadata.noteTypeName),
312+
)
313+
// this shouldn't happen
314+
else -> ""
315+
}.parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY)
263316
}
264317

265318
context(dialog: InsertFieldDialog)

AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ import android.os.Parcelable
2020
import androidx.annotation.CheckResult
2121
import androidx.lifecycle.SavedStateHandle
2222
import androidx.lifecycle.ViewModel
23+
import com.ichi2.anki.CollectionManager.withCol
2324
import com.ichi2.anki.cardviewer.SingleCardSide
25+
import com.ichi2.anki.common.utils.ellipsize
26+
import com.ichi2.anki.libanki.CardId
27+
import com.ichi2.anki.libanki.Decks
28+
import com.ichi2.anki.libanki.NoteId
2429
import com.ichi2.anki.model.FieldName
2530
import com.ichi2.anki.model.SpecialFields
2631
import com.ichi2.anki.utils.ext.require
@@ -43,7 +48,12 @@ class InsertFieldDialogViewModel(
4348
/** The field names of the note type */
4449
val fieldNames = savedStateHandle.require<ArrayList<String>>(KEY_FIELD_ITEMS).map(::FieldName)
4550

46-
private val metadata = savedStateHandle.require<InsertFieldMetadata>(KEY_INSERT_FIELD_METADATA)
51+
/**
52+
* State of the selected card when the screen was opened
53+
*
54+
* Used for providing [special fields][SpecialFields] with the output they'd produce.
55+
*/
56+
val metadata = savedStateHandle.require<InsertFieldMetadata>(KEY_INSERT_FIELD_METADATA)
4757

4858
val selectedFieldFlow = MutableStateFlow<SelectedField?>(null)
4959

@@ -120,4 +130,63 @@ class InsertFieldDialogViewModel(
120130
@Parcelize
121131
data class InsertFieldMetadata(
122132
val side: SingleCardSide,
123-
) : Parcelable
133+
val cardTemplateName: String,
134+
val noteTypeName: String,
135+
val tags: String?,
136+
val flag: Int?,
137+
val cardId: CardId?,
138+
val deck: String?,
139+
) : Parcelable {
140+
val subdeck: String?
141+
get() = deck?.let { Decks.basename(it) }
142+
143+
companion object {
144+
@CheckResult
145+
suspend fun query(
146+
side: SingleCardSide,
147+
cardTemplateName: String,
148+
noteTypeName: String,
149+
noteId: NoteId?,
150+
ord: Int?,
151+
): InsertFieldMetadata {
152+
val note =
153+
try {
154+
noteId?.let { nid -> withCol { getNote(nid) } }
155+
} catch (e: Exception) {
156+
Timber.w(e, "failed to get note")
157+
null
158+
}
159+
160+
// BUG: This is the saved tags of the note, not the currently edited tags
161+
val tags =
162+
note
163+
?.tags
164+
?.joinToString(separator = " ")
165+
// truncate, so we don't pass unbounded text into the arguments
166+
?.ellipsize(75)
167+
168+
val card =
169+
try {
170+
if (ord == null || note == null) {
171+
null
172+
} else {
173+
// ord can be invalid if the user has in-memory template additions
174+
withCol { note.cards(this).getOrNull(ord) }
175+
}
176+
} catch (e: Exception) {
177+
Timber.w(e, "failed to get card")
178+
null
179+
}
180+
181+
return InsertFieldMetadata(
182+
side = side,
183+
cardTemplateName = cardTemplateName,
184+
noteTypeName = noteTypeName,
185+
tags = tags,
186+
cardId = card?.id,
187+
flag = card?.userFlag(),
188+
deck = card?.currentDeckId()?.let { did -> withCol { decks.get(did)?.name } },
189+
)
190+
}
191+
}
192+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?xml version="1.0" encoding="utf-8"?><!--
2+
~ Copyright (c) 2026 David Allison <[email protected]>
3+
~
4+
~ This program is free software; you can redistribute it and/or modify it under
5+
~ the terms of the GNU General Public License as published by the Free Software
6+
~ Foundation; either version 3 of the License, or (at your option) any later
7+
~ version.
8+
~
9+
~ This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
~ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
~ PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
~
13+
~ You should have received a copy of the GNU General Public License along with
14+
~ this program. If not, see <http://www.gnu.org/licenses/>.
15+
-->
16+
17+
<LinearLayout
18+
xmlns:android="http://schemas.android.com/apk/res/android"
19+
xmlns:tools="http://schemas.android.com/tools"
20+
android:orientation="vertical"
21+
android:layout_width="match_parent"
22+
android:layout_height="wrap_content"
23+
android:paddingHorizontal="24dp"
24+
android:background="?attr/selectableItemBackground"
25+
android:gravity="center_vertical">
26+
27+
<TextView
28+
android:id="@+id/title"
29+
android:layout_width="match_parent"
30+
android:layout_height="wrap_content"
31+
android:layout_gravity="center_vertical"
32+
android:maxLines="1"
33+
android:paddingBottom="2dp"
34+
android:fontFamily="monospace"
35+
android:textAppearance="?attr/textAppearanceTitleMedium"
36+
tools:text="{{FrontSide}}"
37+
/>
38+
39+
40+
<TextView
41+
android:id="@+id/description"
42+
android:layout_width="match_parent"
43+
android:layout_height="wrap_content"
44+
android:layout_gravity="center_vertical"
45+
android:textAppearance="?attr/textAppearanceBodyMedium"
46+
android:maxLines="3"
47+
android:paddingBottom="12dp"
48+
tools:text="The content of the front template. Audio is not automatically played"
49+
/>
50+
51+
</LinearLayout>

AnkiDroid/src/main/res/values/03-dialogs.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,4 +279,13 @@ also changes the interval of the card"
279279
<!-- Special Fields -->
280280
<string name="basic_fields_tab_header" comment="Tab header for inserting non-special fields: {{Front}}">Basic</string>
281281
<string name="special_fields_tab_header" comment="Tab header for inserting special fields: {{Deck}}">Special</string>
282+
<string name="special_field_example_suffix"><![CDATA[: ‘<b>%1$s</b>’]]></string>
283+
<string name="special_field_front_side_help">The front template content. Audio is not automatically played</string>
284+
<string name="special_field_deck_help">The full deck of the card, including parent decks%s</string>
285+
<string name="special_field_subdeck_help">The current deck of the card, excluding parent decks%s</string>
286+
<string name="special_field_card_flag_help">Outputs ‘%1$s’, where %2$s is the flag code (%3$d\–%4$d\)</string>
287+
<string name="special_field_tags_help">The tags of the note%s</string>
288+
<string name="special_field_card_id_help">The ID of the card%s</string>
289+
<string name="special_field_card_help">The name of the card template%s</string>
290+
<string name="special_field_type_help">The name of the note type%s</string>
282291
</resources>

0 commit comments

Comments
 (0)