From 14ed66806aa184bf4adb3a3dc36b12e7cd07ed11 Mon Sep 17 00:00:00 2001 From: nikitamarysolomanpvt <54615666+nikitamarysolomanpvt@users.noreply.github.com> Date: Wed, 22 Jan 2020 15:57:37 +0530 Subject: [PATCH] Fix part #376: Fraction input interaction view validation (#419) * nit * UI hi-fi for text,number,and fraction input views * UI hi-fi for text,number,and fraction input views * UI hi-fi for text,number,and fraction input views nit * nit * nit * test cases update * accent color * input type in fraction input type * input type in fraction input type * Merge branches 'develop' and 'hi-fi-input-interaction-views' of https://github.com/oppia/oppia-android into hi-fi-input-interaction-views # Conflicts: # app/src/main/res/layout/text_input_interaction_item.xml * text color in input type views * changed inputtype in edit text * margin updated in input views * nit * keyboardhelper to handle softinoutkeyboard * Edit text focus removed. On click of input type interaction item, it requires two clicks to display keyboard, which should actually be just a single click. thus preventing scroll due to edit text focus * as per review suggestion added binding.stateRecyclerView.smoothScrollToPosition(0) in processEphemeralStateResult * nit * Fix-406 * nit changes and keybord helper class renamed. * nit * kdoc for keyboardhelper.nit changes * kdoc for keyboardhelper * nit * nit * nit * nit * nit * validation in fraction input * nit * nit * nit * errorcode enum * errorcode enum * nit * nit * nit * nit * error text on Fraction input * error text on Fraction input * nit * nit * updated FractionParsingErrors Enum with string resources, added getPendingAnswerError in InteractionAnswerHandler,in error text of fraction set minimum height 32dp and text size 12sp ,color code updated,and othere nit changes * nit * nit * nit * Merge conflict issue fix Merge branches 'develop' and 'hi-fi-input-interaction-views-validation' of https://github.com/oppia/oppia-android into hi-fi-input-interaction-views-validation # Conflicts: # app/src/main/java/org/oppia/app/parser/StringToFractionParser.kt # app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt # app/src/main/java/org/oppia/app/player/state/itemviewmodel/FractionInteractionViewModel.kt # app/src/main/java/org/oppia/app/player/state/itemviewmodel/InteractionViewModelModule.kt # app/src/main/java/org/oppia/app/player/state/itemviewmodel/NumericInputViewModel.kt # app/src/main/java/org/oppia/app/player/state/itemviewmodel/TextInputViewModel.kt # app/src/main/res/values/strings.xml * not showing error on fiest - symbol in fraction input, new method for submit button click * not showing error on fiest - symbol in fraction input, new method for submit button click * nit * partial answer removed error message display by regex for partial values * nit import optimisation changes reverted * nit import optimisation changes reverted * nit import optimisation changes reverted * carsh fix in EditTextBindingAdapters * on submit button displays error for partial input and divided by 0 * instance check * moved FractionParsingError to StringToFractionParser, In InteractionAnswerHandler added isExplicitErrorCheckRequired, and updated existing with getPendingAnswerErrorOnSubmit and other changes required for the above. * color names updated casing * Introduced disparity between the patterns used to validate vs the ones we use to parse. Suggested by ben. * test cases for error messages. * nit * partial mixed fraction issue fix * nit * nit * nit * added few testcases * nit changes. * parseFunction introduced and the helper is used for both for parsing and validation * getter and setter for PendingAnswerError in FractionInteractionViewModel, removed valid string resource and returns string "valid" inside enum, setPendingAnswerError,hasPendingAnswerError flags in InteractionAnswerHandler, and other nit * removed digit filter in test activity nit * removed digit filter in test activity nit * added new method onAnswerRealTimeError in InteractionAnswerHandler * added new method onAnswerRealTimeError in InteractionAnswerHandler * state fragment is added as parameter to FractionInteractionViewModel * submit button active/inactive on realtime error implementation on StateFragmentPresenter * nit changes * nit changes, InputInteractionViewTestActivity updated with new realtime error implemenatations and submit button enable and disable. * submit button issue fix. * interactionVieewModelModule code fix for on continue button click for multiplechoice issue/crash. * nit * setting error on submit is moved to stateFragmentPresenter * nit * InteractionAnswerErrorReceiver * 3rd approach * fraction input validation final approach * nit * nit * nit * nit * New fraction submit time error "None of the numbers of the fraction should be larger than 7 digits." * test-case to check long number, nit changes ,kDoc * nit --- .../FractionInputInteractionView.kt | 4 +- .../NumericInputInteractionView.kt | 4 + .../interaction/TextInputInteractionView.kt | 4 +- .../databinding/EditTextBindingAdapters.kt | 11 +++ .../app/parser/StringToFractionParser.kt | 71 ++++++++++++++- .../oppia/app/player/state/StateFragment.kt | 9 +- .../player/state/StateFragmentPresenter.kt | 24 +++-- .../oppia/app/player/state/StateViewModel.kt | 6 +- .../InteractionAnswerErrorReceiver.kt | 15 ++++ .../InteractionAnswerHandler.kt | 17 +++- .../FractionInteractionViewModel.kt | 60 ++++++++++++- .../InteractionViewModelFactory.kt | 4 +- .../InteractionViewModelModule.kt | 10 +-- .../SelectionInteractionViewModel.kt | 9 +- .../InputInteractionViewTestActivity.kt | 42 +++++++-- .../state_button_inactive_background.xml | 6 ++ .../state_button_transparent_background.xml | 6 +- ... activity_input_interaction_view_test.xml} | 55 +++++++++--- .../res/layout/fraction_interaction_item.xml | 30 +++++-- .../layout/numeric_input_interaction_item.xml | 4 +- app/src/main/res/layout/state_button_item.xml | 1 + .../layout/text_input_interaction_item.xml | 4 +- app/src/main/res/values/colors.xml | 5 +- app/src/main/res/values/strings.xml | 4 + .../InputInteractionViewTestActivityTest.kt | 89 +++++++++++++++++++ 25 files changed, 430 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/org/oppia/app/databinding/EditTextBindingAdapters.kt create mode 100644 app/src/main/java/org/oppia/app/player/state/answerhandling/InteractionAnswerErrorReceiver.kt create mode 100644 app/src/main/res/drawable/state_button_inactive_background.xml rename app/src/main/res/layout/{activity_numeric_input_interaction_view_test.xml => activity_input_interaction_view_test.xml} (60%) diff --git a/app/src/main/java/org/oppia/app/customview/interaction/FractionInputInteractionView.kt b/app/src/main/java/org/oppia/app/customview/interaction/FractionInputInteractionView.kt index b1e6c2ca63b..45368654b00 100644 --- a/app/src/main/java/org/oppia/app/customview/interaction/FractionInputInteractionView.kt +++ b/app/src/main/java/org/oppia/app/customview/interaction/FractionInputInteractionView.kt @@ -26,12 +26,12 @@ class FractionInputInteractionView @JvmOverloads constructor( attrs: AttributeSet? = null, defStyle: Int = android.R.attr.editTextStyle ) : EditText(context, attrs, defStyle), View.OnFocusChangeListener { - private val hintText: String + private val hintText: CharSequence private val stateKeyboardButtonListener: StateKeyboardButtonListener init { onFocusChangeListener = this - hintText = (hint ?: "").toString() + hintText = (hint ?: "") stateKeyboardButtonListener = context as StateKeyboardButtonListener } diff --git a/app/src/main/java/org/oppia/app/customview/interaction/NumericInputInteractionView.kt b/app/src/main/java/org/oppia/app/customview/interaction/NumericInputInteractionView.kt index 6e0b256942b..b04c4f7b4be 100644 --- a/app/src/main/java/org/oppia/app/customview/interaction/NumericInputInteractionView.kt +++ b/app/src/main/java/org/oppia/app/customview/interaction/NumericInputInteractionView.kt @@ -27,16 +27,20 @@ class NumericInputInteractionView @JvmOverloads constructor( defStyle: Int = android.R.attr.editTextStyle ) : EditText(context, attrs, defStyle), View.OnFocusChangeListener { private val stateKeyboardButtonListener: StateKeyboardButtonListener + private val hintText: CharSequence init { onFocusChangeListener = this + hintText = (hint ?: "") stateKeyboardButtonListener = context as StateKeyboardButtonListener } override fun onFocusChange(v: View, hasFocus: Boolean) = if (hasFocus) { + hint = "" typeface = Typeface.DEFAULT showSoftKeyboard(v, context) } else { + hint = hintText if (text.isEmpty()) setTypeface(typeface, Typeface.ITALIC) hideSoftKeyboard(v, context) } diff --git a/app/src/main/java/org/oppia/app/customview/interaction/TextInputInteractionView.kt b/app/src/main/java/org/oppia/app/customview/interaction/TextInputInteractionView.kt index fb263d1c3dc..092529b3a7e 100644 --- a/app/src/main/java/org/oppia/app/customview/interaction/TextInputInteractionView.kt +++ b/app/src/main/java/org/oppia/app/customview/interaction/TextInputInteractionView.kt @@ -23,12 +23,12 @@ class TextInputInteractionView @JvmOverloads constructor( attrs: AttributeSet? = null, defStyle: Int = android.R.attr.editTextStyle ) : EditText(context, attrs, defStyle), View.OnFocusChangeListener { - private val hintText: String + private val hintText: CharSequence private val stateKeyboardButtonListener: StateKeyboardButtonListener init { onFocusChangeListener = this - hintText = (hint ?: "").toString() + hintText = (hint ?: "") stateKeyboardButtonListener = context as StateKeyboardButtonListener } diff --git a/app/src/main/java/org/oppia/app/databinding/EditTextBindingAdapters.kt b/app/src/main/java/org/oppia/app/databinding/EditTextBindingAdapters.kt new file mode 100644 index 00000000000..bf66211b71e --- /dev/null +++ b/app/src/main/java/org/oppia/app/databinding/EditTextBindingAdapters.kt @@ -0,0 +1,11 @@ +package org.oppia.app.databinding + +import android.text.TextWatcher +import android.widget.EditText +import androidx.databinding.BindingAdapter + +/** Binding adapter for setting a [TextWatcher] as a change listener for an [EditText]. */ +@BindingAdapter("app:textChangedListener") +fun bindTextWatcher(editText: EditText, textWatcher: TextWatcher) { + editText.addTextChangedListener(textWatcher) +} diff --git a/app/src/main/java/org/oppia/app/parser/StringToFractionParser.kt b/app/src/main/java/org/oppia/app/parser/StringToFractionParser.kt index dfa93f157a1..069400687c7 100644 --- a/app/src/main/java/org/oppia/app/parser/StringToFractionParser.kt +++ b/app/src/main/java/org/oppia/app/parser/StringToFractionParser.kt @@ -1,5 +1,9 @@ package org.oppia.app.parser +import android.content.Context +import androidx.annotation.StringRes +import org.oppia.app.R +import org.oppia.app.customview.interaction.FractionInputInteractionView import org.oppia.app.model.Fraction import org.oppia.domain.util.normalizeWhitespace @@ -8,14 +12,59 @@ class StringToFractionParser { private val wholeNumberOnlyRegex = """^-? ?(\d+)$""".toRegex() private val fractionOnlyRegex = """^-? ?(\d+) ?/ ?(\d+)$""".toRegex() private val mixedNumberRegex = """^-? ?(\d+) (\d+) ?/ ?(\d+)$""".toRegex() + private val invalidCharsRegex = """^[\d\s/-]+$""".toRegex() + private val invalidCharsLengthRegex = "\\d{8,}".toRegex() - fun getFractionFromString(text: String): Fraction { + /** + * Returns a [FractionParsingError] for the specified text input if it's an invalid fraction, or + * [FractionParsingError.VALID] if no issues are found. Note that a valid fraction returned by this method is guaranteed + * to be parsed correctly by [parseRegularFraction]. + * + * This method should only be used when a user tries submitting an answer. Real-time error detection should be done + * using [getRealTimeAnswerError], instead. + */ + fun getSubmitTimeError(text: String): FractionParsingError { + if (invalidCharsLengthRegex.find(text) != null) + return FractionParsingError.NUMBER_TOO_LONG + val fraction = parseFraction(text) + return when { + fraction == null -> FractionParsingError.INVALID_FORMAT + fraction.denominator == 0 -> FractionParsingError.DIVISION_BY_ZERO + else -> FractionParsingError.VALID + } + } + + /** + * Returns a [FractionParsingError] for obvious incorrect fraction formatting issues for the specified raw text, or + * [FractionParsingError.VALID] if not such issues are found. + * + * Note that this method returning a valid result does not guarantee the text is a valid fraction-- + * [getSubmitTimeError] should be used for that, instead. This method is meant to be used as a quick sanity check for + * general validity, not for definite correctness. + */ + fun getRealTimeAnswerError(text: String): FractionParsingError { + val normalized = text.normalizeWhitespace() + return when { + !normalized.matches(invalidCharsRegex) -> FractionParsingError.INVALID_CHARS + normalized.startsWith("/") -> FractionParsingError.INVALID_FORMAT + normalized.count { it == '/' } > 1 -> FractionParsingError.INVALID_FORMAT + normalized.lastIndexOf('-') > 0 -> FractionParsingError.INVALID_FORMAT + else -> FractionParsingError.VALID + } + } + + /** Returns a [Fraction] parse from the specified raw text string. */ + fun parseFraction(text: String): Fraction? { // Normalize whitespace to ensure that answer follows a simpler subset of possible patterns. val inputText: String = text.normalizeWhitespace() return parseMixedNumber(inputText) - ?: parseFraction(inputText) + ?: parseRegularFraction(inputText) ?: parseWholeNumber(inputText) - ?: throw IllegalArgumentException("Incorrectly formatted fraction: $text") + } + + /** Returns a [Fraction] parse from the specified raw text string. */ + fun parseFractionFromString(text: String): Fraction { + return parseFraction(text) ?: throw IllegalArgumentException("Incorrectly formatted fraction: $text") } private fun parseMixedNumber(inputText: String): Fraction? { @@ -29,7 +78,7 @@ class StringToFractionParser { .build() } - private fun parseFraction(inputText: String): Fraction? { + private fun parseRegularFraction(inputText: String): Fraction? { val fractionOnlyMatch = fractionOnlyRegex.matchEntire(inputText) ?: return null val (_, numeratorText, denominatorText) = fractionOnlyMatch.groupValues // Fraction-only numbers imply no whole number. @@ -53,4 +102,18 @@ class StringToFractionParser { } private fun isInputNegative(inputText: String): Boolean = inputText.startsWith("-") + + /** Enum to store the errors of [FractionInputInteractionView]. */ + enum class FractionParsingError(@StringRes private var error: Int?) { + VALID(error = null), + INVALID_CHARS(error = R.string.fraction_error_invalid_chars), + INVALID_FORMAT(error = R.string.fraction_error_invalid_format), + DIVISION_BY_ZERO(error = R.string.fraction_error_divide_by_zero), + NUMBER_TOO_LONG(error = R.string.fraction_error_larger_than_seven_digits); + + /** Returns the string corresponding to this error's string resources, or null if there is none. */ + fun getErrorMessageFromStringRes(context: Context): String? { + return error?.let(context::getString) + } + } } diff --git a/app/src/main/java/org/oppia/app/player/state/StateFragment.kt b/app/src/main/java/org/oppia/app/player/state/StateFragment.kt index 210a8cd9bed..8475e743436 100755 --- a/app/src/main/java/org/oppia/app/player/state/StateFragment.kt +++ b/app/src/main/java/org/oppia/app/player/state/StateFragment.kt @@ -7,11 +7,14 @@ import android.view.View import android.view.ViewGroup import org.oppia.app.fragment.InjectableFragment import org.oppia.app.model.UserAnswer +import org.oppia.app.player.state.answerhandling.InteractionAnswerErrorReceiver +import org.oppia.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.app.player.state.answerhandling.InteractionAnswerReceiver import javax.inject.Inject /** Fragment that represents the current state of an exploration. */ -class StateFragment : InjectableFragment(), InteractionAnswerReceiver { +class StateFragment : InjectableFragment(), InteractionAnswerReceiver, InteractionAnswerHandler, + InteractionAnswerErrorReceiver { companion object { /** * Creates a new instance of a StateFragment. @@ -46,6 +49,10 @@ class StateFragment : InjectableFragment(), InteractionAnswerReceiver { fun handleKeyboardAction() = stateFragmentPresenter.handleKeyboardAction() + override fun onPendingAnswerError(pendingAnswerError: String?) { + stateFragmentPresenter.updateSubmitButton(pendingAnswerError) + } + fun setAudioBarVisibility(visibility: Boolean) = stateFragmentPresenter.setAudioBarVisibility(visibility) fun scrollToTop() = stateFragmentPresenter.scrollToTop() diff --git a/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt b/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt index 20aa877f59f..5af7807c6b0 100755 --- a/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt @@ -38,10 +38,11 @@ import org.oppia.app.model.EphemeralState import org.oppia.app.model.Interaction import org.oppia.app.model.State import org.oppia.app.model.SubtitledHtml -import org.oppia.app.player.audio.AudioButtonListener import org.oppia.app.model.UserAnswer +import org.oppia.app.player.audio.AudioButtonListener import org.oppia.app.player.audio.AudioFragment import org.oppia.app.player.audio.AudioUiManager +import org.oppia.app.player.state.answerhandling.InteractionAnswerErrorReceiver import org.oppia.app.player.state.answerhandling.InteractionAnswerReceiver import org.oppia.app.player.state.itemviewmodel.ContentViewModel import org.oppia.app.player.state.itemviewmodel.ContinueInteractionViewModel @@ -100,7 +101,7 @@ class StateFragmentPresenter @Inject constructor( /** * A list of view models corresponding to past view models that are hidden by default. These are intentionally not * retained upon configuration changes since the user can just re-expand the list. Note that the first element of this - * list (when initialized), will always be the previous answers header to help locate the items in the recycler view + * list (when initialized), will always be the previous answer's header to help locate the items in the recycler view * (when present). */ private val previousAnswerViewModels: MutableList = mutableListOf() @@ -109,6 +110,7 @@ class StateFragmentPresenter @Inject constructor( * configuration changes since the user can just re-expand the list. */ private var hasPreviousResponsesExpanded: Boolean = false + private lateinit var stateNavigationButtonViewModel: StateNavigationButtonViewModel fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View? { explorationId = fragment.arguments!!.getString(STATE_FRAGMENT_EXPLORATION_ID_ARGUMENT_KEY)!! @@ -399,7 +401,7 @@ class StateFragmentPresenter @Inject constructor( Handler().postDelayed({ binding.congratulationTextview.clearAnimation() binding.congratulationTextview.visibility = View.INVISIBLE - },2000) + }, 2000) } /** Helper for subscribeToAnswerOutcome. */ @@ -440,7 +442,8 @@ class StateFragmentPresenter @Inject constructor( fun handleKeyboardAction() { hideKeyboard() - handleSubmitAnswer(viewModel.getPendingAnswer()) + if (stateNavigationButtonViewModel.isInteractionButtonActive.get()!!) + handleSubmitAnswer(viewModel.getPendingAnswer()) } override fun onContinueButtonClicked() { @@ -473,7 +476,7 @@ class StateFragmentPresenter @Inject constructor( ) { val interactionViewModelFactory = interactionViewModelFactoryMap.getValue(interaction.id) pendingItemList += interactionViewModelFactory( - explorationId, interaction, fragment as InteractionAnswerReceiver + explorationId, interaction, fragment as InteractionAnswerReceiver, fragment as InteractionAnswerErrorReceiver ) } @@ -559,7 +562,7 @@ class StateFragmentPresenter @Inject constructor( hasGeneralContinueButton: Boolean, stateIsTerminal: Boolean ) { - val stateNavigationButtonViewModel = + stateNavigationButtonViewModel = StateNavigationButtonViewModel(context, this as StateNavigationButtonListener) stateNavigationButtonViewModel.updatePreviousButton(isEnabled = hasPreviousState) @@ -611,4 +614,13 @@ class StateFragmentPresenter @Inject constructor( } private fun isAudioShowing(): Boolean = viewModel.isAudioBarVisible.get()!! + + /** Updates submit button UI as active if pendingAnswerError null else inactive. */ + fun updateSubmitButton(pendingAnswerError: String?) { + if (pendingAnswerError != null) { + stateNavigationButtonViewModel.isInteractionButtonActive.set(false) + } else { + stateNavigationButtonViewModel.isInteractionButtonActive.set(true) + } + } } diff --git a/app/src/main/java/org/oppia/app/player/state/StateViewModel.kt b/app/src/main/java/org/oppia/app/player/state/StateViewModel.kt index 64de089ee7a..c4b56a8a5aa 100644 --- a/app/src/main/java/org/oppia/app/player/state/StateViewModel.kt +++ b/app/src/main/java/org/oppia/app/player/state/StateViewModel.kt @@ -5,6 +5,7 @@ import androidx.databinding.ObservableList import androidx.lifecycle.ViewModel import org.oppia.app.fragment.FragmentScope import org.oppia.app.model.UserAnswer +import org.oppia.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.app.player.state.itemviewmodel.StateItemViewModel import org.oppia.app.viewmodel.ObservableArrayList @@ -34,7 +35,10 @@ class StateViewModel @Inject constructor() : ObservableViewModel() { // TODO(#164): Add a hasPendingAnswer() that binds to the enabled state of the Submit button. fun getPendingAnswer(): UserAnswer { - return getPendingAnswerHandler(itemList)?.getPendingAnswer() ?: UserAnswer.getDefaultInstance() + return if (getPendingAnswerHandler(itemList)?.checkPendingAnswerError(AnswerErrorCategory.SUBMIT_TIME) != null) { + UserAnswer.getDefaultInstance() + } else + getPendingAnswerHandler(itemList)?.getPendingAnswer() ?: UserAnswer.getDefaultInstance() } private fun getPendingAnswerHandler(itemList: List): InteractionAnswerHandler? { diff --git a/app/src/main/java/org/oppia/app/player/state/answerhandling/InteractionAnswerErrorReceiver.kt b/app/src/main/java/org/oppia/app/player/state/answerhandling/InteractionAnswerErrorReceiver.kt new file mode 100644 index 00000000000..b7fa87be9b8 --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/answerhandling/InteractionAnswerErrorReceiver.kt @@ -0,0 +1,15 @@ +package org.oppia.app.player.state.answerhandling + +/** + * A handler for interaction answer's error receiving to update submit button. + * Handlers can either require an additional user action before the submit button UI can be updated. + */ +interface InteractionAnswerErrorReceiver { + + /** + * Called when an error was detected upon answer submission. Implementations are recommended to prevent further answer + * submission until the pending answer itself changes. The interaction is responsible for displaying the error provided + * here, not the implementation. + */ + fun onPendingAnswerError(pendingAnswerError: String?) {} +} diff --git a/app/src/main/java/org/oppia/app/player/state/answerhandling/InteractionAnswerHandler.kt b/app/src/main/java/org/oppia/app/player/state/answerhandling/InteractionAnswerHandler.kt index 04272dbe190..484ded82e1b 100644 --- a/app/src/main/java/org/oppia/app/player/state/answerhandling/InteractionAnswerHandler.kt +++ b/app/src/main/java/org/oppia/app/player/state/answerhandling/InteractionAnswerHandler.kt @@ -14,8 +14,15 @@ interface InteractionAnswerHandler { */ fun isExplicitAnswerSubmissionRequired(): Boolean = true + /** Return the current answer's error messages if not valid else return null. */ + fun checkPendingAnswerError(category: AnswerErrorCategory): String? { + return null + } + /** Return the current answer that is ready for handling. */ - fun getPendingAnswer(): UserAnswer + fun getPendingAnswer(): UserAnswer? { + return null + } } /** @@ -25,3 +32,11 @@ interface InteractionAnswerHandler { interface InteractionAnswerReceiver { fun onAnswerReadyForSubmission(answer: UserAnswer) } + +/** Categories of errors that can be inferred from a pending answer. */ +enum class AnswerErrorCategory { + /** Corresponds to errors that may be found while the user is trying to input an answer. */ + REAL_TIME, + /** Corresponds to errors that may be found only when a user tries to submit an answer. */ + SUBMIT_TIME +} diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FractionInteractionViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FractionInteractionViewModel.kt index bcc78f07786..febc7f0ee7d 100644 --- a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FractionInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FractionInteractionViewModel.kt @@ -1,32 +1,88 @@ package org.oppia.app.player.state.itemviewmodel import android.content.Context +import android.text.Editable +import android.text.TextWatcher +import androidx.databinding.Bindable +import androidx.databinding.Observable +import androidx.databinding.ObservableField import org.oppia.app.R import org.oppia.app.model.Interaction import org.oppia.app.model.InteractionObject import org.oppia.app.model.UserAnswer import org.oppia.app.parser.StringToFractionParser +import org.oppia.app.player.state.answerhandling.AnswerErrorCategory +import org.oppia.app.player.state.answerhandling.InteractionAnswerErrorReceiver import org.oppia.app.player.state.answerhandling.InteractionAnswerHandler /** [ViewModel] for the fraction input interaction. */ class FractionInteractionViewModel( - interaction: Interaction, private val context: Context + interaction: Interaction, + private val context: Context, + private val interactionAnswerErrorReceiver: InteractionAnswerErrorReceiver ) : StateItemViewModel(ViewType.FRACTION_INPUT_INTERACTION), InteractionAnswerHandler { + private var pendingAnswerError: String? = null var answerText: CharSequence = "" + var errorMessage = ObservableField("") + val hintText: CharSequence = deriveHintText(interaction) + private val stringToFractionParser: StringToFractionParser = StringToFractionParser() + + init { + val callback: Observable.OnPropertyChangedCallback = object : Observable.OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable, propertyId: Int) { + interactionAnswerErrorReceiver.onPendingAnswerError(pendingAnswerError) + } + } + errorMessage.addOnPropertyChangedCallback(callback) + } override fun getPendingAnswer(): UserAnswer { val userAnswerBuilder = UserAnswer.newBuilder() if (answerText.isNotEmpty()) { val answerTextString = answerText.toString() userAnswerBuilder.answer = InteractionObject.newBuilder() - .setFraction(StringToFractionParser().getFractionFromString(answerTextString)) + .setFraction(stringToFractionParser.parseFractionFromString(answerTextString)) .build() userAnswerBuilder.plainAnswer = answerTextString } return userAnswerBuilder.build() } + /** It checks the pending error for the current fraction input, and correspondingly updates the error string based on the specified error category. */ + override fun checkPendingAnswerError(category: AnswerErrorCategory): String? { + if (answerText.isNotEmpty()) { + when (category) { + AnswerErrorCategory.REAL_TIME -> pendingAnswerError = + stringToFractionParser.getRealTimeAnswerError(answerText.toString()).getErrorMessageFromStringRes( + context + ) + AnswerErrorCategory.SUBMIT_TIME -> pendingAnswerError = + stringToFractionParser.getSubmitTimeError(answerText.toString()).getErrorMessageFromStringRes( + context + ) + } + errorMessage.set(pendingAnswerError) + } + return pendingAnswerError + } + + @Bindable + fun getAnswerTextWatcher(): TextWatcher { + return object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + } + + override fun onTextChanged(answer: CharSequence, start: Int, before: Int, count: Int) { + answerText = answer.toString().trim() + checkPendingAnswerError(AnswerErrorCategory.REAL_TIME) + } + + override fun afterTextChanged(s: Editable) { + } + } + } + private fun deriveHintText(interaction: Interaction): CharSequence { val customPlaceholder = interaction.customizationArgsMap["customPlaceholder"]?.normalizedString ?: "" val allowNonzeroIntegerPart = interaction.customizationArgsMap["allowNonzeroIntegerPart"]?.boolValue ?: true diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/InteractionViewModelFactory.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/InteractionViewModelFactory.kt index 9aad4791032..14a9c8f4f0d 100644 --- a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/InteractionViewModelFactory.kt +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/InteractionViewModelFactory.kt @@ -1,7 +1,7 @@ package org.oppia.app.player.state.itemviewmodel import org.oppia.app.model.Interaction -import org.oppia.app.model.InteractionObject +import org.oppia.app.player.state.answerhandling.InteractionAnswerErrorReceiver import org.oppia.app.player.state.answerhandling.InteractionAnswerReceiver /** @@ -9,5 +9,5 @@ import org.oppia.app.player.state.answerhandling.InteractionAnswerReceiver * pushes answers, the [Interaction] object corresponding to the interaction view, and the exploration ID. */ typealias InteractionViewModelFactory = ( - explorationId: String, interaction: Interaction, interactionAnswerReceiver: InteractionAnswerReceiver + explorationId: String, interaction: Interaction, interactionAnswerReceiver: InteractionAnswerReceiver, interactionAnswerHandler: InteractionAnswerErrorReceiver ) -> StateItemViewModel diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/InteractionViewModelModule.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/InteractionViewModelModule.kt index 45a261641fd..49881b93a86 100644 --- a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/InteractionViewModelModule.kt +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/InteractionViewModelModule.kt @@ -18,7 +18,7 @@ class InteractionViewModelModule { @IntoMap @StringKey("Continue") fun provideContinueInteractionViewModelFactory(): InteractionViewModelFactory { - return { _, _, interactionAnswerReceiver -> + return { _, _, interactionAnswerReceiver, _ -> ContinueInteractionViewModel(interactionAnswerReceiver) } } @@ -26,7 +26,7 @@ class InteractionViewModelModule { @Provides @IntoMap @StringKey("MultipleChoiceInput") - fun provideMultipleChoiceInputViewModelFactory(): InteractionViewModelFactory { + fun provideMultipleChoiceInputViewModelFactory(): InteractionViewModelFactory{ return ::SelectionInteractionViewModel } @@ -41,20 +41,20 @@ class InteractionViewModelModule { @IntoMap @StringKey("FractionInput") fun provideFractionInputViewModelFactory(context: Context): InteractionViewModelFactory { - return { _, interaction, _ -> FractionInteractionViewModel(interaction, context) } + return { _, interaction, _, interactionAnswerHandler -> FractionInteractionViewModel(interaction, context, interactionAnswerHandler) } } @Provides @IntoMap @StringKey("NumericInput") fun provideNumericInputViewModelFactory(): InteractionViewModelFactory { - return { _, _, _ -> NumericInputViewModel() } + return { _, _, _, _ -> NumericInputViewModel() } } @Provides @IntoMap @StringKey("TextInput") fun provideTextInputViewModelFactory(): InteractionViewModelFactory { - return { _, interaction, _ -> TextInputViewModel(interaction) } + return { _, interaction, _, _ -> TextInputViewModel(interaction) } } } diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt index d7e68285bfa..15d5748a547 100644 --- a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt @@ -6,15 +6,20 @@ import org.oppia.app.model.InteractionObject import org.oppia.app.model.StringList import org.oppia.app.model.UserAnswer import org.oppia.app.player.state.SelectionItemInputType +import org.oppia.app.player.state.answerhandling.InteractionAnswerErrorReceiver import org.oppia.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.app.player.state.answerhandling.InteractionAnswerReceiver import org.oppia.app.viewmodel.ObservableArrayList /** ViewModel for multiple or item-selection input choice list. */ class SelectionInteractionViewModel( - val explorationId: String, interaction: Interaction, private val interactionAnswerReceiver: InteractionAnswerReceiver -): StateItemViewModel(ViewType.SELECTION_INTERACTION), InteractionAnswerHandler { + val explorationId: String, + interaction: Interaction, + private val interactionAnswerReceiver: InteractionAnswerReceiver, + interactionAnswerErrorReceiver: InteractionAnswerErrorReceiver +) : StateItemViewModel(ViewType.SELECTION_INTERACTION), InteractionAnswerHandler { private val interactionId: String = interaction.id + private val choiceStrings: List by lazy { interaction.customizationArgsMap["choices"]?.setOfHtmlString?.htmlList ?: listOf() } diff --git a/app/src/main/java/org/oppia/app/testing/InputInteractionViewTestActivity.kt b/app/src/main/java/org/oppia/app/testing/InputInteractionViewTestActivity.kt index 3310db63d7c..ac367daba86 100644 --- a/app/src/main/java/org/oppia/app/testing/InputInteractionViewTestActivity.kt +++ b/app/src/main/java/org/oppia/app/testing/InputInteractionViewTestActivity.kt @@ -1,39 +1,63 @@ package org.oppia.app.testing import android.os.Bundle +import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.databinding.DataBindingUtil import org.oppia.app.R import org.oppia.app.customview.interaction.FractionInputInteractionView import org.oppia.app.customview.interaction.NumericInputInteractionView import org.oppia.app.customview.interaction.TextInputInteractionView -import org.oppia.app.databinding.ActivityNumericInputInteractionViewTestBinding +import org.oppia.app.databinding.ActivityInputInteractionViewTestBinding import org.oppia.app.model.Interaction +import org.oppia.app.player.state.answerhandling.AnswerErrorCategory +import org.oppia.app.player.state.answerhandling.InteractionAnswerErrorReceiver import org.oppia.app.player.state.itemviewmodel.FractionInteractionViewModel import org.oppia.app.player.state.itemviewmodel.NumericInputViewModel import org.oppia.app.player.state.itemviewmodel.TextInputViewModel +import org.oppia.app.player.state.listener.StateKeyboardButtonListener /** * This is a dummy activity to test input interaction views. - * It contains [NumericInputInteractionView], [TextInputInteractionView], [FractionInputInteractionView] and [NumberWithUnitsInputInteractionView]. + * It contains [NumericInputInteractionView], [TextInputInteractionView],and [FractionInputInteractionView]. */ -class InputInteractionViewTestActivity : AppCompatActivity() { +class InputInteractionViewTestActivity : AppCompatActivity(), StateKeyboardButtonListener, + InteractionAnswerErrorReceiver { + override fun onEditorAction(actionCode: Int) { + } + + private lateinit var binding: ActivityInputInteractionViewTestBinding val numericInputViewModel = NumericInputViewModel() val textInputViewModel = TextInputViewModel( interaction = Interaction.getDefaultInstance() ) - val fractionInteractionViewModel = FractionInteractionViewModel( - interaction = Interaction.getDefaultInstance(), - context = this@InputInteractionViewTestActivity.applicationContext - ) + lateinit var fractionInteractionViewModel: FractionInteractionViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val binding = DataBindingUtil.setContentView( - this, R.layout.activity_numeric_input_interaction_view_test + binding = DataBindingUtil.setContentView( + this, R.layout.activity_input_interaction_view_test + ) + fractionInteractionViewModel = FractionInteractionViewModel( + interaction = Interaction.getDefaultInstance(), + context = this, + interactionAnswerErrorReceiver = this ) binding.numericInputViewModel = numericInputViewModel binding.textInputViewModel = textInputViewModel binding.fractionInteractionViewModel = fractionInteractionViewModel } + + fun getPendingAnswerErrorOnSubmitClick(v: View) { + fractionInteractionViewModel.checkPendingAnswerError(AnswerErrorCategory.SUBMIT_TIME) + } + + override fun onPendingAnswerError( + pendingAnswerError: String? + ) { + if (pendingAnswerError != null) + binding.submitButton.isEnabled = false + else + binding.submitButton.isEnabled = true + } } diff --git a/app/src/main/res/drawable/state_button_inactive_background.xml b/app/src/main/res/drawable/state_button_inactive_background.xml new file mode 100644 index 00000000000..9089afd6a24 --- /dev/null +++ b/app/src/main/res/drawable/state_button_inactive_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/state_button_transparent_background.xml b/app/src/main/res/drawable/state_button_transparent_background.xml index 96aa2dbe473..5ea1eb8baaa 100644 --- a/app/src/main/res/drawable/state_button_transparent_background.xml +++ b/app/src/main/res/drawable/state_button_transparent_background.xml @@ -1,7 +1,5 @@ - - + diff --git a/app/src/main/res/layout/activity_numeric_input_interaction_view_test.xml b/app/src/main/res/layout/activity_input_interaction_view_test.xml similarity index 60% rename from app/src/main/res/layout/activity_numeric_input_interaction_view_test.xml rename to app/src/main/res/layout/activity_input_interaction_view_test.xml index 6e11a8fa8e4..74f0cd43b03 100644 --- a/app/src/main/res/layout/activity_numeric_input_interaction_view_test.xml +++ b/app/src/main/res/layout/activity_input_interaction_view_test.xml @@ -1,15 +1,20 @@ + + + + @@ -18,9 +23,9 @@ @@ -49,11 +54,11 @@ android:focusable="true" android:hint="Write here." android:inputType="text" - android:text="@={textInputViewModel.answerText}" android:longClickable="false" android:maxLength="200" android:padding="8dp" - android:singleLine="true" /> + android:singleLine="true" + android:text="@={textInputViewModel.answerText}" /> + android:minHeight="48dp" + android:paddingStart="16dp" + android:paddingEnd="16dp" + android:paddingBottom="8dp" + android:singleLine="true" + android:text="@={fractionInteractionViewModel.answerText}" + android:textColor="@color/oppiaPrimaryText" + android:textColorHint="@color/editTextHint" + android:textSize="14sp" + android:textStyle="italic" + app:textChangedListener="@{fractionInteractionViewModel.answerTextWatcher}" /> + + + +