diff --git a/app/BUILD.bazel b/app/BUILD.bazel index 9cc32896072..69f7a4238c0 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -283,6 +283,7 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/player/state/itemviewmodel/LessonProgressViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/MathExpressionInteractionsViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/NextButtonViewModel.kt", + "src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumberWithUnitsInputViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/PreviousButtonViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/PreviousResponsesHeaderViewModel.kt", @@ -383,6 +384,7 @@ VIEWS = [ "src/main/java/org/oppia/android/app/customview/VerticalDashedLineView.kt", "src/main/java/org/oppia/android/app/customview/interaction/FractionInputInteractionView.kt", "src/main/java/org/oppia/android/app/customview/interaction/MathExpressionInteractionsView.kt", + "src/main/java/org/oppia/android/app/customview/interaction/NumberWithUnitsInputInteractionView.kt", "src/main/java/org/oppia/android/app/customview/interaction/NumericInputInteractionView.kt", "src/main/java/org/oppia/android/app/customview/interaction/RatioInputInteractionView.kt", "src/main/java/org/oppia/android/app/customview/interaction/TextInputInteractionView.kt", diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7ed5ab01eb4..1f48f60bef5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -220,6 +220,7 @@ + diff --git a/app/src/main/java/org/oppia/android/app/activity/ActivityComponentImpl.kt b/app/src/main/java/org/oppia/android/app/activity/ActivityComponentImpl.kt index d26c4ca132d..600df9c4288 100644 --- a/app/src/main/java/org/oppia/android/app/activity/ActivityComponentImpl.kt +++ b/app/src/main/java/org/oppia/android/app/activity/ActivityComponentImpl.kt @@ -84,6 +84,7 @@ import org.oppia.android.app.testing.InputInteractionViewTestActivity import org.oppia.android.app.testing.MarginBindingAdaptersTestActivity import org.oppia.android.app.testing.MathExpressionInteractionsViewTestActivity import org.oppia.android.app.testing.NavigationDrawerTestActivity +import org.oppia.android.app.testing.NumberWithUnitsInputInteractionViewTestActivity import org.oppia.android.app.testing.PoliciesFragmentTestActivity import org.oppia.android.app.testing.ProfileChooserFragmentTestActivity import org.oppia.android.app.testing.ProfileEditFragmentTestActivity @@ -165,6 +166,11 @@ interface ActivityComponentImpl : fun inject(imageRegionSelectionTestActivity: ImageRegionSelectionTestActivity) fun inject(imageViewBindingAdaptersTestActivity: ImageViewBindingAdaptersTestActivity) fun inject(inputInteractionViewTestActivity: InputInteractionViewTestActivity) + fun inject( + numberWithUnitsInputInteractionViewTestActivity: + NumberWithUnitsInputInteractionViewTestActivity + ) + fun inject(textInputInteractionViewTestActivity: TextInputInteractionViewTestActivity) fun inject(mathExpressionInteractionsViewTestActivity: MathExpressionInteractionsViewTestActivity) fun inject(ratioInputInteractionViewTestActivity: RatioInputInteractionViewTestActivity) diff --git a/app/src/main/java/org/oppia/android/app/customview/interaction/NumberWithUnitsInputInteractionView.kt b/app/src/main/java/org/oppia/android/app/customview/interaction/NumberWithUnitsInputInteractionView.kt new file mode 100644 index 00000000000..356f057ef81 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/customview/interaction/NumberWithUnitsInputInteractionView.kt @@ -0,0 +1,72 @@ +package org.oppia.android.app.customview.interaction + +import android.content.Context +import android.graphics.Typeface +import android.util.AttributeSet +import android.view.KeyEvent +import android.view.View +import android.view.inputmethod.EditorInfo +import androidx.appcompat.widget.AppCompatEditText +import org.oppia.android.app.player.state.listener.StateKeyboardButtonListener +import org.oppia.android.app.utility.KeyboardHelper.Companion.hideSoftKeyboard +import org.oppia.android.app.utility.KeyboardHelper.Companion.showSoftKeyboard + +// TODO(#249): These are the attributes which should be defined in XML, that are required for this interaction view to work correctly +// hint="Write here." +// inputType="text" +// background="@drawable/edit_text_background" +// maxLength="200". + +/** The custom AppCompatEditText class for number with units input interaction view. */ +class NumberWithUnitsInputInteractionView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = android.R.attr.editTextStyle +) : AppCompatEditText(context, attrs, defStyle), View.OnFocusChangeListener { + private var hintText: CharSequence = "" + private val stateKeyboardButtonListener: StateKeyboardButtonListener + + init { + onFocusChangeListener = this + // Assume multi-line for the purpose of properly showing long hints. + isSingleLine = hint != null + stateKeyboardButtonListener = context as StateKeyboardButtonListener + } + + // TODO(#4574): Add tests to verify that the placeholder correctly shows/doesn’t show when expected + override fun onFocusChange(v: View, hasFocus: Boolean) = if (hasFocus) { + hintText = hint + hideHint() + showSoftKeyboard(v, context) + } else { + restoreHint() + hideSoftKeyboard(v, context) + } + + override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { + clearFocus() + restoreHint() + } + return super.onKeyPreIme(keyCode, event) + } + + override fun onEditorAction(actionCode: Int) { + if (actionCode == EditorInfo.IME_ACTION_DONE) { + stateKeyboardButtonListener.onEditorAction(EditorInfo.IME_ACTION_DONE) + } + super.onEditorAction(actionCode) + } + + private fun hideHint() { + hint = "" + typeface = Typeface.DEFAULT + isSingleLine = true + } + + private fun restoreHint() { + hint = hintText + if (text?.isEmpty() == true) setTypeface(typeface, Typeface.ITALIC) + isSingleLine = false + } +} diff --git a/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt b/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt index 1e7656491e2..29f897fd0d0 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt @@ -31,6 +31,7 @@ import org.oppia.android.app.databinding.databinding.LessonProgressIndicatorItem import org.oppia.android.app.databinding.databinding.MathExpressionInteractionsItemBinding import org.oppia.android.app.databinding.databinding.MultipleChoiceSubmittedAnswerItemsBinding import org.oppia.android.app.databinding.databinding.NextButtonItemBinding +import org.oppia.android.app.databinding.databinding.NumberWithUnitsInputInteractionItemBinding import org.oppia.android.app.databinding.databinding.NumericInputInteractionItemBinding import org.oppia.android.app.databinding.databinding.PreviousButtonItemBinding import org.oppia.android.app.databinding.databinding.PreviousResponsesHeaderItemBinding @@ -74,6 +75,7 @@ import org.oppia.android.app.player.state.itemviewmodel.ImageRegionSelectionInte import org.oppia.android.app.player.state.itemviewmodel.LessonProgressViewModel import org.oppia.android.app.player.state.itemviewmodel.MathExpressionInteractionsViewModel import org.oppia.android.app.player.state.itemviewmodel.NextButtonViewModel +import org.oppia.android.app.player.state.itemviewmodel.NumberWithUnitsInputViewModel import org.oppia.android.app.player.state.itemviewmodel.NumericInputViewModel import org.oppia.android.app.player.state.itemviewmodel.PreviousButtonViewModel import org.oppia.android.app.player.state.itemviewmodel.PreviousResponsesHeaderViewModel @@ -1455,6 +1457,11 @@ class StatePlayerRecyclerViewAssembler private constructor( inflateDataBinding = TextInputInteractionItemBinding::inflate, setViewModel = TextInputInteractionItemBinding::setViewModel, transformViewModel = { it as TextInputViewModel } + ).registerViewDataBinder( + viewType = StateItemViewModel.ViewType.NUMBER_WITH_UNITS_INPUT_INTERACTION, + inflateDataBinding = NumberWithUnitsInputInteractionItemBinding::inflate, + setViewModel = NumberWithUnitsInputInteractionItemBinding::setViewModel, + transformViewModel = { it as NumberWithUnitsInputViewModel } ).registerViewDataBinder( viewType = StateItemViewModel.ViewType.RATIO_EXPRESSION_INPUT_INTERACTION, inflateDataBinding = RatioInputInteractionItemBinding::inflate, diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelModule.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelModule.kt index 51ea00e2e6e..aefb73264a0 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelModule.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelModule.kt @@ -77,6 +77,13 @@ interface InteractionViewModelModule { factoryImpl: RatioExpressionInputInteractionViewModel.FactoryImpl ): StateItemViewModel.InteractionItemFactory + @Binds + @IntoMap + @StringKey("NumberWithUnits") + fun provideNumberWithUnitsInputViewModelFactory( + factoryImpl: NumberWithUnitsInputViewModel.FactoryImpl + ): StateItemViewModel.InteractionItemFactory + // Note that Dagger doesn't support mixing binds & provides methods. See // https://stackoverflow.com/a/54592300 for the origin of this approach. @Module diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumberWithUnitsInputViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumberWithUnitsInputViewModel.kt new file mode 100644 index 00000000000..8521454dba2 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumberWithUnitsInputViewModel.kt @@ -0,0 +1,185 @@ +package org.oppia.android.app.player.state.itemviewmodel + +import android.text.Editable +import android.text.TextWatcher +import androidx.annotation.StringRes +import androidx.databinding.Observable +import androidx.databinding.ObservableField +import org.oppia.android.app.model.AnswerAndResponse +import org.oppia.android.app.model.AnswerErrorCategory +import org.oppia.android.app.model.Interaction +import org.oppia.android.app.model.InteractionObject +import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.UserAnswerState +import org.oppia.android.app.model.WrittenTranslationContext +import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver +import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler +import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.app.view.models.R +import org.oppia.android.domain.translation.TranslationController +import javax.inject.Inject + +/** [StateItemViewModel] for the number with units input interaction. */ +class NumberWithUnitsInputViewModel private constructor( + interaction: Interaction, + val hasConversationView: Boolean, + private val interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length + val isSplitView: Boolean, + private val writtenTranslationContext: WrittenTranslationContext, + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController, + userAnswerState: UserAnswerState +) : StateItemViewModel(ViewType.NUMBER_WITH_UNITS_INPUT_INTERACTION), InteractionAnswerHandler { + var answerText: CharSequence = userAnswerState.textInputAnswer + private var answerErrorCategory: AnswerErrorCategory = AnswerErrorCategory.NO_ERROR + val hintText: CharSequence = deriveHintText(interaction) + private var pendingAnswerError: String? = null + + var isAnswerAvailable = ObservableField(false) + val errorMessage = ObservableField("") + + init { + val callback: Observable.OnPropertyChangedCallback = + object : Observable.OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable, propertyId: Int) { + interactionAnswerErrorOrAvailabilityCheckReceiver.onPendingAnswerErrorOrAvailabilityCheck( + pendingAnswerError = pendingAnswerError, + inputAnswerAvailable = true // Allow submit on empty answer. + ) + } + } + isAnswerAvailable.addOnPropertyChangedCallback(callback) + errorMessage.addOnPropertyChangedCallback(callback) + + // Initializing with default values so that submit button is enabled by default. + interactionAnswerErrorOrAvailabilityCheckReceiver.onPendingAnswerErrorOrAvailabilityCheck( + pendingAnswerError = null, + inputAnswerAvailable = true + ) + checkPendingAnswerError(userAnswerState.answerErrorCategory) + } + + override fun checkPendingAnswerError(category: AnswerErrorCategory): String? { + answerErrorCategory = category + return when (category) { + AnswerErrorCategory.REAL_TIME -> null + AnswerErrorCategory.SUBMIT_TIME -> { + TextParsingUiError.createForText( + answerText.toString() + ).createForText(resourceHandler) + } + else -> null + }.also { + pendingAnswerError = it + errorMessage.set(it) + } + } + + 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() + val isAnswerTextAvailable = answerText.isNotEmpty() + if (isAnswerTextAvailable != isAnswerAvailable.get()) { + isAnswerAvailable.set(isAnswerTextAvailable) + } + checkPendingAnswerError(AnswerErrorCategory.REAL_TIME) + } + + override fun afterTextChanged(s: Editable) { + } + } + } + + override fun getPendingAnswer(): UserAnswer = UserAnswer.newBuilder().apply { + if (answerText.isNotEmpty()) { + val answerTextString = answerText.toString() + answer = InteractionObject.newBuilder().apply { + normalizedString = answerTextString + }.build() + plainAnswer = answerTextString + writtenTranslationContext = this@NumberWithUnitsInputViewModel.writtenTranslationContext + } + }.build() + + override fun getUserAnswerState(): UserAnswerState { + return UserAnswerState.newBuilder().apply { + this.textInputAnswer = answerText.toString() + this.answerErrorCategory = answerErrorCategory + }.build() + } + + private fun deriveHintText(interaction: Interaction): CharSequence { + // The subtitled unicode can apparently exist in the structure in two different formats. + val placeholderUnicodeOption1 = + interaction.customizationArgsMap["placeholder"]?.subtitledUnicode + val placeholderUnicodeOption2 = + interaction.customizationArgsMap["placeholder"]?.customSchemaValue?.subtitledUnicode + val placeholder1 = + placeholderUnicodeOption1?.let { unicode -> + translationController.extractString(unicode, writtenTranslationContext) + } ?: "" + val placeholder2 = + placeholderUnicodeOption2?.let { unicode -> + translationController.extractString(unicode, writtenTranslationContext) + } ?: "" // The default placeholder for text input is empty. + return when { + placeholder1.isNotEmpty() -> placeholder1 + placeholder2.isNotEmpty() -> placeholder2 + else -> resourceHandler.getStringInLocale(R.string.number_with_units_input_hint_text) + } + } + + /** Implementation of [StateItemViewModel.InteractionItemFactory] for this view model. */ + class FactoryImpl @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController + ) : InteractionItemFactory { + override fun create( + entityId: String, + hasConversationView: Boolean, + interaction: Interaction, + interactionAnswerReceiver: InteractionAnswerReceiver, + answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, + hasPreviousButton: Boolean, + isSplitView: Boolean, + writtenTranslationContext: WrittenTranslationContext, + timeToStartNoticeAnimationMs: Long?, + userAnswerState: UserAnswerState, + wrongAnswerList: List + ): StateItemViewModel { + return NumberWithUnitsInputViewModel( + interaction, + hasConversationView, + answerErrorReceiver, + isSplitView, + writtenTranslationContext, + resourceHandler, + translationController, + userAnswerState + ) + } + } + + private enum class TextParsingUiError(@StringRes private var error: Int?) { + /** Corresponds to non empty input. */ + VALID(error = null), + + /** Corresponds to empty input. */ + EMPTY_INPUT(error = R.string.text_error_empty_input); + + /** Returns the string corresponding to this error's string resources, or null if there is none. */ + fun createForText(resourceHandler: AppLanguageResourceHandler): String? = + error?.let(resourceHandler::getStringInLocale) + + companion object { + /** Returns the [TextParsingUiError] corresponding to the input. */ + fun createForText(text: String): TextParsingUiError = + if (text.isEmpty()) EMPTY_INPUT else VALID + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/StateItemViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/StateItemViewModel.kt index c7f26e82a6e..f3001d3703d 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/StateItemViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/StateItemViewModel.kt @@ -40,7 +40,8 @@ abstract class StateItemViewModel(val viewType: ViewType) : ObservableViewModel( FLASHBACK_BUTTON, RETURN_TO_QUESTION_BUTTON, FLASHBACK_SOLUTION, - LESSON_PROGRESS_INDICATOR + LESSON_PROGRESS_INDICATOR, + NUMBER_WITH_UNITS_INPUT_INTERACTION } /** Factory for creating new [StateItemViewModel]s for interactions. */ diff --git a/app/src/main/java/org/oppia/android/app/testing/NumberWithUnitsInputInteractionViewTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/NumberWithUnitsInputInteractionViewTestActivity.kt new file mode 100644 index 00000000000..3fe2de0660d --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/testing/NumberWithUnitsInputInteractionViewTestActivity.kt @@ -0,0 +1,109 @@ +package org.oppia.android.app.testing + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.databinding.DataBindingUtil +import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity +import org.oppia.android.app.customview.interaction.NumberWithUnitsInputInteractionView +import org.oppia.android.app.databinding.databinding.ActivityNumberWithUnitsInputInteractionViewTestBinding +import org.oppia.android.app.model.AnswerErrorCategory +import org.oppia.android.app.model.InputInteractionViewTestActivityParams +import org.oppia.android.app.model.Interaction +import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.WrittenTranslationContext +import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver +import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver +import org.oppia.android.app.player.state.itemviewmodel.NumberWithUnitsInputViewModel +import org.oppia.android.app.player.state.itemviewmodel.StateItemViewModel +import org.oppia.android.app.player.state.itemviewmodel.StateItemViewModel.InteractionItemFactory +import org.oppia.android.app.player.state.listener.StateKeyboardButtonListener +import org.oppia.android.app.ui.R +import org.oppia.android.util.extensions.getProtoExtra +import org.oppia.android.util.extensions.putProtoExtra +import javax.inject.Inject + +/** + * This is a dummy activity to test [NumberWithUnitsInputInteractionView]. + */ +class NumberWithUnitsInputInteractionViewTestActivity : + InjectableAutoLocalizedAppCompatActivity(), + StateKeyboardButtonListener, + InteractionAnswerErrorOrAvailabilityCheckReceiver, + InteractionAnswerReceiver { + private lateinit var binding: ActivityNumberWithUnitsInputInteractionViewTestBinding + + @Inject + lateinit var numberWithUnitsInputViewModelFactory: NumberWithUnitsInputViewModel.FactoryImpl + + /** Gives access to the [NumberWithUnitsInputViewModel]. */ + val numberWithUnitsInputViewModel by lazy { + numberWithUnitsInputViewModelFactory.create() + } + + /** Gives access to the translation context. */ + lateinit var writtenTranslationContext: WrittenTranslationContext + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + (activityComponent as ActivityComponentImpl).inject(this) + binding = DataBindingUtil + .setContentView( + this, R.layout.activity_number_with_units_input_interaction_view_test + ) + + val params = + intent.getProtoExtra( + TEST_ACTIVITY_PARAMS_ARGUMENT_KEY, + InputInteractionViewTestActivityParams.getDefaultInstance() + ) + writtenTranslationContext = params.writtenTranslationContext + + binding.numberWithUnitsInputViewModel = numberWithUnitsInputViewModel + binding.getPendingAnswerErrorOnSubmitClick = Runnable { + numberWithUnitsInputViewModel.checkPendingAnswerError(AnswerErrorCategory.SUBMIT_TIME) + } + } + + override fun onPendingAnswerErrorOrAvailabilityCheck( + pendingAnswerError: String?, + inputAnswerAvailable: Boolean + ) { + } + + override fun onAnswerReadyForSubmission(answer: UserAnswer) { } + + override fun onEditorAction(actionCode: Int) { } + + private inline fun InteractionItemFactory.create( + interaction: Interaction = Interaction.getDefaultInstance() + ): T { + return create( + entityId = "fake_entity_id", + hasConversationView = false, + interaction = interaction, + interactionAnswerReceiver = this@NumberWithUnitsInputInteractionViewTestActivity, + answerErrorReceiver = this@NumberWithUnitsInputInteractionViewTestActivity, + hasPreviousButton = false, + isSplitView = false, + writtenTranslationContext, + timeToStartNoticeAnimationMs = null + ) as T + } + + companion object { + private const val TEST_ACTIVITY_PARAMS_ARGUMENT_KEY = + "NumberWithUnitsInputInteractionViewTestActivity.params" + + /** Creates an intent to open [NumberWithUnitsInputInteractionViewTestActivity]. */ + fun createIntent( + context: Context, + extras: InputInteractionViewTestActivityParams + ): Intent { + return Intent(context, NumberWithUnitsInputInteractionViewTestActivity::class.java).also { + it.putProtoExtra(TEST_ACTIVITY_PARAMS_ARGUMENT_KEY, extras) + } + } + } +} diff --git a/app/src/main/res/layout/activity_number_with_units_input_interaction_view_test.xml b/app/src/main/res/layout/activity_number_with_units_input_interaction_view_test.xml new file mode 100644 index 00000000000..4f7bb4ada9f --- /dev/null +++ b/app/src/main/res/layout/activity_number_with_units_input_interaction_view_test.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + +