diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt index 337531d5b35f..15ee946a55ae 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt @@ -40,8 +40,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import org.jetbrains.annotations.VisibleForTesting -import org.json.JSONObject import timber.log.Timber abstract class CardViewerViewModel( @@ -108,12 +106,18 @@ abstract class CardViewerViewModel( *************************************** Internal methods *************************************** ********************************************************************************************* */ - protected abstract suspend fun typeAnsFilter(text: String): String + protected abstract suspend fun typeAnsFilter( + text: String, + typedAnswer: String? = null, + ): String private suspend fun bodyClass(): String = bodyClassForCardOrd(currentCard.await().ord) /** From the [desktop code](https://github.com/ankitects/anki/blob/1ff55475b93ac43748d513794bcaabd5d7df6d9d/qt/aqt/reviewer.py#L358) */ - private suspend fun mungeQA(text: String): String = typeAnsFilter(prepareCardTextForDisplay(text)) + private suspend fun mungeQA( + text: String, + typedAnswer: String? = null, + ): String = typeAnsFilter(prepareCardTextForDisplay(text), typedAnswer) private suspend fun prepareCardTextForDisplay(text: String): String = Sound.addPlayButtons( @@ -134,13 +138,21 @@ abstract class CardViewerViewModel( eval.emit("_showQuestion(${Json.encodeToString(question)}, ${Json.encodeToString(answer)}, '${bodyClass()}');") } - protected open suspend fun showAnswerInternal() { + /** + * Parses the card answer and sends a [eval] request to load it into the `qa` HTML div + * + * * [Anki reference](https://github.com/ankitects/anki/blob/c985acb9fe36d3651eb83cf4cfe44d046ec7458f/qt/aqt/reviewer.py#L460) + * * [Typescript reference](https://github.com/ankitects/anki/blob/c985acb9fe36d3651eb83cf4cfe44d046ec7458f/ts/reviewer/index.ts#L193) + * + * @see [stdHtml] + */ + protected open suspend fun showAnswer(typedAnswer: String? = null) { Timber.v("showAnswer()") showingAnswer.emit(true) val card = currentCard.await() val answerData = withCol { card.answer(this) } - val answer = mungeQA(answerData) + val answer = mungeQA(answerData, typedAnswer) eval.emit("_showAnswer(${Json.encodeToString(answer)}, '${bodyClass()}');") } @@ -195,54 +207,4 @@ abstract class CardViewerViewModel( } else { throw IllegalArgumentException("Unhandled POST request: $uri") } - - companion object { - // ********************************** Type-in answer ************************************ - /** From the [desktop code](https://github.com/ankitects/anki/blob/1ff55475b93ac43748d513794bcaabd5d7df6d9d/qt/aqt/reviewer.py#L669] */ - @VisibleForTesting - val typeAnsRe = Regex("\\[\\[type:(.+?)]]") - - suspend fun getTypeAnswerField( - card: Card, - text: String, - ): JSONObject? { - val match = typeAnsRe.find(text) ?: return null - - val typeAnsFieldName = - match.groups[1]!!.value.let { - if (it.startsWith("cloze:")) { - it.split(":")[1] - } else { - it - } - } - - val fields = withCol { card.noteType(this).flds } - for (i in 0 until fields.length()) { - val field = fields.get(i) as JSONObject - if (field.getString("name") == typeAnsFieldName) { - return field - } - } - return null - } - - 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).takeIf { it.isNotBlank() } - } - } else { - expected - } - } - - fun getFontSize(field: JSONObject): String = field.getString("size") - } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt index 23a93ff49f87..294412f6458c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt @@ -41,8 +41,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update -import org.intellij.lang.annotations.Language -import org.jetbrains.annotations.VisibleForTesting import timber.log.Timber class PreviewerViewModel( @@ -109,7 +107,7 @@ class PreviewerViewModel( showQuestion() cardMediaPlayer.autoplayAllSoundsForSide(CardSide.QUESTION) } else if (backSideOnly.value && !showingAnswer.value) { - showAnswerInternal() + showAnswer() cardMediaPlayer.autoplayAllSoundsForSide(CardSide.ANSWER) } } @@ -149,7 +147,7 @@ class PreviewerViewModel( fun onNextButtonClick() { launchCatchingIO { if (!showingAnswer.value && !backSideOnly.value) { - showAnswerInternal() + showAnswer() cardMediaPlayer.autoplayAllSoundsForSide(CardSide.ANSWER) } else { currentIndex.update { it + 1 } @@ -209,7 +207,7 @@ class PreviewerViewModel( asyncIO { withCol { getCard(selectedCardIds[currentIndex.value]) } } - if (showAnswer) showAnswerInternal() else showQuestion() + if (showAnswer) showAnswer() else showQuestion() updateFlagIcon() updateMarkIcon() } @@ -236,11 +234,15 @@ class PreviewerViewModel( } /** From the [desktop code](https://github.com/ankitects/anki/blob/1ff55475b93ac43748d513794bcaabd5d7df6d9d/qt/aqt/reviewer.py#L671) */ - override suspend fun typeAnsFilter(text: String): String = + override suspend fun typeAnsFilter( + text: String, + typedAnswer: String?, + ): String = if (showingAnswer.value) { - typeAnsAnswerFilter(currentCard.await(), text) + val typeAnswer = TypeAnswer.getInstance(currentCard.await(), text) + typeAnswer?.answerFilter() ?: text } else { - typeAnsQuestionFilter(text) + TypeAnswer.removeTags(text) } companion object { @@ -254,30 +256,5 @@ class PreviewerViewModel( PreviewerViewModel(previewerIdsFile, currentIndex, cardMediaPlayer) } } - - /** removes `[[type:]]` blocks in questions */ - @VisibleForTesting - fun typeAnsQuestionFilter(text: String) = typeAnsRe.replace(text, "") - - /** Adapted from the [desktop code](https://github.com/ankitects/anki/blob/1ff55475b93ac43748d513794bcaabd5d7df6d9d/qt/aqt/reviewer.py#L720) */ - suspend fun typeAnsAnswerFilter( - card: Card, - text: String, - ): String { - val typeAnswerField = - getTypeAnswerField(card, text) - ?: return typeAnsRe.replace(text, "") - val expectedAnswer = - getExpectedTypeInAnswer(card, typeAnswerField) - ?: return typeAnsRe.replace(text, "") - val typeFont = typeAnswerField.getString("font") - val typeSize = getFontSize(typeAnswerField) - val answerComparison = withCol { compareAnswer(expectedAnswer, provided = "") } - - @Language("HTML") - val output = - """
$answerComparison
""" - return typeAnsRe.replace(text, output) - } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TemplatePreviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TemplatePreviewerViewModel.kt index d99b0b51998f..3ae5775ae62c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TemplatePreviewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TemplatePreviewerViewModel.kt @@ -28,7 +28,6 @@ import com.ichi2.anki.cardviewer.CardMediaPlayer import com.ichi2.anki.launchCatchingIO import com.ichi2.anki.pages.AnkiServer import com.ichi2.anki.reviewer.CardSide -import com.ichi2.anki.utils.ext.ifNullOrEmpty import com.ichi2.libanki.Card import com.ichi2.libanki.Note import com.ichi2.libanki.NotetypeJson @@ -37,7 +36,6 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.parcelize.Parcelize -import org.intellij.lang.annotations.Language import org.jetbrains.annotations.VisibleForTesting class TemplatePreviewerViewModel( @@ -117,7 +115,7 @@ class TemplatePreviewerViewModel( if (isAfterRecreation) { launchCatchingIO { // TODO: We should persist showingAnswer to SavedStateHandle - if (showingAnswer.value) showAnswerInternal() else showQuestion() + if (showingAnswer.value) showAnswer() else showQuestion() } return } @@ -147,7 +145,7 @@ class TemplatePreviewerViewModel( showQuestion() loadAndPlaySounds(CardSide.QUESTION) } else { - showAnswerInternal() + showAnswer() loadAndPlaySounds(CardSide.ANSWER) } } @@ -186,37 +184,21 @@ class TemplatePreviewerViewModel( } // https://github.com/ankitects/anki/blob/df70564079f53e587dc44f015c503fdf6a70924f/qt/aqt/clayout.py#L579 - override suspend fun typeAnsFilter(text: String): String { - val typeAnswerField = getTypeAnswerField(currentCard.await(), text) - val expectedAnswer = - typeAnswerField - ?.let { - getExpectedTypeInAnswer(currentCard.await(), typeAnswerField) - }.ifNullOrEmpty { "sample" } - - val repl = - if (showingAnswer.value) { - withCol { compareAnswer(expectedAnswer, "example") } - } else { - "
" - } - // 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 = """
$repl
""" - typeAnsRe.replaceFirst(text, replWithFontSize) - } else { - typeAnsRe.replaceFirst(text, repl) + override suspend fun typeAnsFilter( + text: String, + typedAnswer: String?, + ): String = + if (showingAnswer.value) { + val typeAnswer = TypeAnswer.getInstance(currentCard.await(), text) + if (typeAnswer?.expectedAnswer?.isEmpty() == true) { + typeAnswer.expectedAnswer = "sample" } - - val warning = "
${CollectionManager.TR.cardTemplatesTypeBoxesWarning()}
" - return typeAnsRe.replace(out, warning) - } + typeAnswer?.answerFilter(typedAnswer = "example") ?: text + } else { + val repl = "
" + val warning = "
${CollectionManager.TR.cardTemplatesTypeBoxesWarning()}
" + 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 @@ 8dp 8dp 8dp + + 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 "), ) }