"
- }
- // Anki doesn't set the font size of the type answer field in the template previewer,
- // but it does in the reviewer. To get a more accurate preview of what people are going
- // to study, the font size is being set here.
- val out =
- if (typeAnswerField != null) {
- val fontSize = getFontSize(typeAnswerField)
-
- @Language("HTML")
- val replWithFontSize = """
"
+ StringBuilder(text).replaceFirst(typeAnsRe, repl).replace(typeAnsRe, warning)
+ }
companion object {
fun factory(
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TypeAnswer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TypeAnswer.kt
new file mode 100644
index 000000000000..5964b491c964
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TypeAnswer.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright (c) 2024 Brayan Oliveira
+ *
+ * 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.previewer
+
+import android.os.LocaleList
+import com.ichi2.anki.CollectionManager.withCol
+import com.ichi2.anki.servicelayer.LanguageHintService
+import com.ichi2.annotations.NeedsTest
+import com.ichi2.libanki.Card
+import com.ichi2.utils.jsonObjectIterable
+import org.intellij.lang.annotations.Language
+import org.jetbrains.annotations.VisibleForTesting
+import org.json.JSONObject
+
+/**
+ * Handles `type in the answer card` properties
+ *
+ * @see [combining]
+ * @see [imeHintLocales]
+ * */
+@NeedsTest("combining and non combining answers are properly parsed")
+@NeedsTest("cloze and non cloze 'type in the answer' cards are properly parsed")
+class TypeAnswer private constructor(
+ private val text: String,
+ /** whether combining characters should be compared. Defined by the presence of the
+ * `nc:` specifier in the type answer tag */
+ private val combining: Boolean,
+ private val field: JSONObject,
+ var expectedAnswer: String,
+) {
+ /** a field property specific to AnkiDroid that allows to automatically select
+ * a language for the keyboard. @see [LanguageHintService] */
+ val imeHintLocales: LocaleList? by lazy {
+ LanguageHintService.getImeHintLocales(this.field)
+ }
+
+ suspend fun answerFilter(typedAnswer: String = ""): String {
+ val typeFont = field.getString("font")
+ val typeSize = field.getString("size")
+ val answerComparison = withCol { compareAnswer(expectedAnswer, provided = typedAnswer, combining = combining) }
+
+ @Language("HTML")
+ val repl = """
$answerComparison
"""
+ return typeAnsRe.replace(text, repl)
+ }
+
+ companion object {
+ /** removes `[[type:]]` tags from the given [text] */
+ fun removeTags(text: String): String = typeAnsRe.replace(text, "")
+
+ /**
+ * @return a [TypeAnswer] instance if [text] contains a `[[type:Field]]` tag
+ * with a valid field name, or null if not.
+ *
+ * ([Source](https://github.com/ankitects/anki/blob/8af63f81eb235b8d21df4e8eeaa6e02f46b3fbf6/qt/aqt/reviewer.py#L702))
+ */
+ suspend fun getInstance(
+ card: Card,
+ text: String,
+ ): TypeAnswer? {
+ val match = typeAnsRe.find(text) ?: return null
+ val fld = match.groups[1]?.value ?: return null
+
+ var combining = true
+ val typeAnsFieldName =
+ if (fld.startsWith("cloze:")) {
+ fld.split(":")[1]
+ } else if (fld.startsWith("nc:")) {
+ combining = false
+ fld.split(":")[1]
+ } else {
+ fld
+ }
+ val fields = withCol { card.noteType(this).flds }
+ val typeAnswerField =
+ fields.jsonObjectIterable().firstOrNull {
+ it.getString("name") == typeAnsFieldName
+ } ?: return null
+ val expectedAnswer = getExpectedTypeInAnswer(card, typeAnswerField)
+
+ return TypeAnswer(
+ text = text,
+ combining = combining,
+ field = typeAnswerField,
+ expectedAnswer = expectedAnswer,
+ )
+ }
+
+ private suspend fun getExpectedTypeInAnswer(
+ card: Card,
+ field: JSONObject,
+ ): String {
+ val fieldName = field.getString("name")
+ val expected = withCol { card.note(this@withCol).getItem(fieldName) }
+ return if (fieldName.startsWith("cloze:")) {
+ val clozeIdx = card.ord + 1
+ withCol {
+ extractClozeForTyping(expected, clozeIdx)
+ }
+ } else {
+ expected
+ }
+ }
+ }
+}
+
+@VisibleForTesting
+val typeAnsRe = Regex("\\[\\[type:(.+?)]]")
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/LanguageHintService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/LanguageHintService.kt
index c1bb9b398710..c78d17279175 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/LanguageHintService.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/LanguageHintService.kt
@@ -56,6 +56,11 @@ object LanguageHintService {
Timber.i("Set field locale to %s", selectedLocale)
}
+ fun getImeHintLocales(field: JSONObject?): LocaleList? {
+ if (field == null) return null
+ return getLanguageHintForField(field)?.let { LocaleList(it) }
+ }
+
fun EditText.applyLanguageHint(languageHint: LanguageHint?) {
this.imeHintLocales = if (languageHint != null) LocaleList(languageHint) else null
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt
index f6c1cdaedbb5..b4c62bd7972b 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt
@@ -23,13 +23,16 @@ import android.text.style.UnderlineSpan
import android.view.Menu
import android.view.MenuItem
import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputMethodManager
import android.webkit.WebView
import android.widget.FrameLayout
+import android.widget.LinearLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.appcompat.view.menu.SubMenuBuilder
import androidx.appcompat.widget.ActionMenuView
-import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.content.getSystemService
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
@@ -40,6 +43,8 @@ import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.button.MaterialButton
+import com.google.android.material.card.MaterialCardView
+import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textview.MaterialTextView
import com.ichi2.anki.AbstractFlashcardViewer.Companion.RESULT_NO_MORE_CARDS
import com.ichi2.anki.CollectionManager
@@ -92,6 +97,7 @@ import com.ichi2.anki.utils.ext.collectLatestIn
import com.ichi2.anki.utils.ext.menu
import com.ichi2.anki.utils.ext.removeSubMenu
import com.ichi2.anki.utils.ext.sharedPrefs
+import com.ichi2.anki.utils.ext.window
import com.ichi2.libanki.sched.Counts
import kotlinx.coroutines.launch
@@ -107,7 +113,13 @@ class ReviewerFragment :
get() = requireView().findViewById(R.id.webview)
override val baseSnackbarBuilder: SnackbarBuilder = {
- anchorView = this@ReviewerFragment.view?.findViewById(R.id.buttons_area)
+ val typeAnswerContainer = this@ReviewerFragment.view?.findViewById(R.id.type_answer_container)
+ anchorView =
+ if (typeAnswerContainer?.isVisible == true) {
+ typeAnswerContainer
+ } else {
+ this@ReviewerFragment.view?.findViewById(R.id.buttons_area)
+ }
}
override fun onStop() {
@@ -128,6 +140,7 @@ class ReviewerFragment :
}
setupImmersiveMode(view)
+ setupTypeAnswer(view)
setupAnswerButtons(view)
setupCounts(view)
setupMenu(view)
@@ -195,6 +208,47 @@ class ReviewerFragment :
return true
}
+ private fun setupTypeAnswer(view: View) {
+ // TODO keep text after configuration changes
+ val typeAnswerContainer = view.findViewById(R.id.type_answer_container)
+ val typeAnswerEditText =
+ view.findViewById(R.id.type_answer_edit_text).apply {
+ setOnEditorActionListener { editTextView, actionId, _ ->
+ if (actionId == EditorInfo.IME_ACTION_DONE) {
+ viewModel.onShowAnswer(editTextView.text.toString())
+ return@setOnEditorActionListener true
+ }
+ false
+ }
+ setOnFocusChangeListener { editTextView, hasFocus ->
+ val insetsController = WindowInsetsControllerCompat(window, editTextView)
+ if (hasFocus) {
+ insetsController.show(WindowInsetsCompat.Type.ime())
+ } else {
+ insetsController.hide(WindowInsetsCompat.Type.ime())
+ }
+ }
+ }
+ val autoFocusTypeAnswer = sharedPrefs().getBoolean(getString(R.string.type_in_answer_focus_key), true)
+ viewModel.typeAnswerFlow.collectIn(lifecycleScope) { typeInAnswer ->
+ typeAnswerEditText.text = null
+ if (typeInAnswer == null) {
+ typeAnswerContainer.isVisible = false
+ return@collectIn
+ }
+ typeAnswerContainer.isVisible = true
+ typeAnswerEditText.apply {
+ if (imeHintLocales != typeInAnswer.imeHintLocales) {
+ imeHintLocales = typeInAnswer.imeHintLocales
+ context?.getSystemService()?.restartInput(this)
+ }
+ if (autoFocusTypeAnswer) {
+ requestFocus()
+ }
+ }
+ }
+ }
+
private fun setupAnswerButtons(view: View) {
val hideAnswerButtons = sharedPrefs().getBoolean(getString(R.string.hide_answer_buttons_key), false)
if (hideAnswerButtons) {
@@ -238,11 +292,13 @@ class ReviewerFragment :
val showAnswerButton =
view.findViewById(R.id.show_answer).apply {
+ val editText = view.findViewById(R.id.type_answer_edit_text)
setOnClickListener {
- viewModel.showAnswer()
+ val typedAnswer = editText?.text?.toString()
+ viewModel.onShowAnswer(typedAnswer = typedAnswer)
}
}
- val answerButtonsLayout = view.findViewById(R.id.answer_buttons)
+ val answerButtonsLayout = view.findViewById(R.id.answer_buttons)
// TODO add some kind of feedback/animation after tapping show answer or the answer buttons
viewModel.showingAnswer.collectLatestIn(lifecycleScope) { shouldShowAnswer ->
@@ -387,11 +443,12 @@ class ReviewerFragment :
val ignoreDisplayCutout = sharedPrefs().getBoolean(getString(R.string.ignore_display_cutout_key), false)
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
+ val defaultTypes = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime()
val typeMask =
if (ignoreDisplayCutout) {
- WindowInsetsCompat.Type.systemBars()
+ defaultTypes
} else {
- WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
+ defaultTypes or WindowInsetsCompat.Type.displayCutout()
}
val bars = insets.getInsets(typeMask)
v.updatePadding(
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt
index f63e7d377b76..6cdc87859e7d 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt
@@ -37,6 +37,7 @@ import com.ichi2.anki.pages.CardInfoDestination
import com.ichi2.anki.pages.DeckOptionsDestination
import com.ichi2.anki.preferences.getShowIntervalOnButtons
import com.ichi2.anki.previewer.CardViewerViewModel
+import com.ichi2.anki.previewer.TypeAnswer
import com.ichi2.anki.reviewer.CardSide
import com.ichi2.anki.servicelayer.MARKED_TAG
import com.ichi2.anki.servicelayer.NoteService
@@ -78,6 +79,7 @@ class ReviewerViewModel(
val undoLabelFlow = MutableStateFlow(null)
val redoLabelFlow = MutableStateFlow(null)
val countsFlow = MutableStateFlow(Counts() to Counts.Queue.NEW)
+ val typeAnswerFlow = MutableStateFlow(null)
override val server = AnkiServer(this).also { it.start() }
private val stateMutationKey = TimeManager.time.intTimeMS().toString()
@@ -132,7 +134,7 @@ class ReviewerViewModel(
if (isAfterRecreation) {
launchCatchingIO {
// TODO handle "Don't keep activities"
- if (showingAnswer.value) showAnswerInternal() else showQuestion()
+ if (showingAnswer.value) showAnswer() else showQuestion()
}
} else {
launchCatchingIO {
@@ -141,13 +143,19 @@ class ReviewerViewModel(
}
}
- fun showAnswer() {
+ /**
+ * Sends an [eval] request to load the card answer, and updates components
+ * with behavior specific to the `Answer` card side.
+ *
+ * @see showAnswer
+ */
+ fun onShowAnswer(typedAnswer: String? = null) {
launchCatchingIO {
while (!statesMutated) {
delay(50)
}
updateNextTimes()
- showAnswerInternal()
+ showAnswer(typedAnswer)
loadAndPlaySounds(CardSide.ANSWER)
if (!autoAdvance.shouldWaitForAudio()) {
autoAdvance.onShowAnswer()
@@ -417,8 +425,19 @@ class ReviewerViewModel(
countsFlow.emit(state.counts to state.countsIndex)
}
- // TODO
- override suspend fun typeAnsFilter(text: String): String = text
+ override suspend fun typeAnsFilter(
+ text: String,
+ typedAnswer: String?,
+ ): String {
+ val typeAnswer = TypeAnswer.getInstance(currentCard.await(), text)
+ return if (showingAnswer.value) {
+ typeAnswerFlow.emit(null)
+ typeAnswer?.answerFilter(typedAnswer ?: "") ?: text
+ } else {
+ typeAnswerFlow.emit(typeAnswer)
+ TypeAnswer.removeTags(text)
+ }
+ }
private suspend fun updateUndoAndRedoLabels() {
undoLabelFlow.emit(withCol { undoLabel() })
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvance.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvance.kt
index d87dddb527fc..b50383e01414 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvance.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/autoadvance/AutoAdvance.kt
@@ -76,7 +76,7 @@ class AutoAdvance(
viewModel.launchCatchingIO {
delay(durationToShowQuestionFor())
when (questionAction()) {
- QuestionAction.SHOW_ANSWER -> viewModel.showAnswer()
+ QuestionAction.SHOW_ANSWER -> viewModel.onShowAnswer()
QuestionAction.SHOW_REMINDER -> showReminder(TR.studyingQuestionTimeElapsed())
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Fragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Fragment.kt
index 130eb1455f2a..6e573be54018 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Fragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Fragment.kt
@@ -17,8 +17,10 @@ package com.ichi2.anki.utils.ext
import android.content.SharedPreferences
import android.content.pm.PackageManager
+import android.view.Window
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.anki.utils.showDialogFragmentImpl
@@ -33,3 +35,7 @@ val Fragment.packageManager: PackageManager
* @see showDialogFragmentImpl
*/
fun Fragment.showDialogFragment(newFragment: DialogFragment) = requireActivity().showDialogFragment(newFragment)
+
+/** @see FragmentActivity.getWindow */
+val Fragment.window: Window
+ get() = requireActivity().window
diff --git a/AnkiDroid/src/main/res/layout/fragment_audio_recording.xml b/AnkiDroid/src/main/res/layout/fragment_audio_recording.xml
index 6c2f75d60d80..1f6e2bedabf3 100644
--- a/AnkiDroid/src/main/res/layout/fragment_audio_recording.xml
+++ b/AnkiDroid/src/main/res/layout/fragment_audio_recording.xml
@@ -34,7 +34,7 @@
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/action_done"
- style="@style/CardView.PreviewerStyle" >
+ style="@style/CardView.ViewerStyle" >
+ style="@style/CardView.ViewerStyle" >
+ style="@style/CardView.ViewerStyle" >
-
+ android:layout_marginBottom="4dp"
+ android:orientation="vertical">
+ >
@@ -71,29 +71,56 @@
android:id="@+id/webview_container"
android:layout_width="match_parent"
android:layout_height="0dp"
- android:layout_marginHorizontal="8dp"
+ android:layout_marginHorizontal="@dimen/reviewer_side_margin"
android:layout_marginBottom="4dp"
- app:layout_constraintTop_toBottomOf="@id/appbar"
- app:layout_constraintBottom_toTopOf="@id/buttons_area"
- style="@style/CardView.PreviewerStyle">
+ style="@style/CardView.ViewerStyle"
+ android:layout_weight="1">
-
+ android:layout_height="match_parent"/>
+
+
+
+
+
+
+
+
+
+
-
-
+
-
+
\ No newline at end of file
diff --git a/AnkiDroid/src/main/res/layout/template_previewer.xml b/AnkiDroid/src/main/res/layout/template_previewer.xml
index 1ecc0b7f87b1..a65d1cda55cc 100644
--- a/AnkiDroid/src/main/res/layout/template_previewer.xml
+++ b/AnkiDroid/src/main/res/layout/template_previewer.xml
@@ -1,18 +1,18 @@
-
+ android:layout_height="match_parent"
+ android:orientation="vertical">
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/AnkiDroid/src/main/res/layout/template_previewer_container.xml b/AnkiDroid/src/main/res/layout/template_previewer_container.xml
index d05c79c51059..43bf12c6a5f6 100644
--- a/AnkiDroid/src/main/res/layout/template_previewer_container.xml
+++ b/AnkiDroid/src/main/res/layout/template_previewer_container.xml
@@ -9,21 +9,20 @@
android:background="?attr/alternativeBackgroundColor"
tools:context=".previewer.TemplatePreviewerPage">
-
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+ android:layout_height="wrap_content">
@@ -45,8 +44,7 @@
android:id="@+id/fragment_container"
android:layout_width="match_parent"
android:layout_height="0dp"
- app:layout_constraintTop_toBottomOf="@id/appbar"
- app:layout_constraintBottom_toBottomOf="parent" />
-
+ android:layout_weight="1"/>
+
\ No newline at end of file
diff --git a/AnkiDroid/src/main/res/values/dimens.xml b/AnkiDroid/src/main/res/values/dimens.xml
index f2972034785d..246a717470e1 100644
--- a/AnkiDroid/src/main/res/values/dimens.xml
+++ b/AnkiDroid/src/main/res/values/dimens.xml
@@ -33,4 +33,7 @@
8dp8dp8dp
+
+ 8dp
+ 3dp
\ No newline at end of file
diff --git a/AnkiDroid/src/main/res/values/styles.xml b/AnkiDroid/src/main/res/values/styles.xml
index 3338fbe2b5ef..9e99ce37ecc6 100644
--- a/AnkiDroid/src/main/res/values/styles.xml
+++ b/AnkiDroid/src/main/res/values/styles.xml
@@ -231,7 +231,7 @@
?attr/progressDialogButtonTextColor
-
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt
index 5877f4219fc6..ae5ee54732e9 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt
@@ -23,7 +23,7 @@ class PreviewerViewModelTest {
@Test
fun `type answer fields are removed in questions`() {
assertThat(
- PreviewerViewModel.typeAnsQuestionFilter("creu [[type:leu]]"),
+ TypeAnswer.removeTags("creu [[type:leu]]"),
equalTo("creu "),
)
}