diff --git a/app/BUILD.bazel b/app/BUILD.bazel index f67cca04269..1fff43dafc4 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -100,6 +100,7 @@ LISTENERS = [ "src/main/java/org/oppia/android/app/devoptions/RouteToMarkChaptersCompletedListener.kt", "src/main/java/org/oppia/android/app/devoptions/RouteToMarkStoriesCompletedListener.kt", "src/main/java/org/oppia/android/app/devoptions/RouteToMarkTopicsCompletedListener.kt", + "src/main/java/org/oppia/android/app/devoptions/RouteToMathExpressionParserTestListener.kt", "src/main/java/org/oppia/android/app/devoptions/RouteToViewEventLogsListener.kt", "src/main/java/org/oppia/android/app/drawer/RouteToProfileProgressListener.kt", "src/main/java/org/oppia/android/app/help/LoadFaqListFragmentListener.kt", @@ -176,6 +177,7 @@ DATABINDING_LAYOUTS = ["src/main/res/layout*/**"] VIEW_MODELS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/administratorcontrols/appversion/AppVersionViewModel.kt", "src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeViewModel.kt", + "src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserViewModel.kt", "src/main/java/org/oppia/android/app/drawer/NavigationDrawerHeaderViewModel.kt", "src/main/java/org/oppia/android/app/help/HelpItemViewModel.kt", "src/main/java/org/oppia/android/app/help/HelpListViewModel.kt", @@ -202,6 +204,7 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragDropInteractionContentViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt", + "src/main/java/org/oppia/android/app/player/state/itemviewmodel/MathExpressionInteractionsViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/PreviousResponsesHeaderViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt", @@ -241,6 +244,7 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/devoptions/devoptionsitemviewmodel/DeveloperOptionsItemViewModel.kt", "src/main/java/org/oppia/android/app/devoptions/devoptionsitemviewmodel/DeveloperOptionsModifyLessonProgressViewModel.kt", "src/main/java/org/oppia/android/app/devoptions/devoptionsitemviewmodel/DeveloperOptionsOverrideAppBehaviorsViewModel.kt", + "src/main/java/org/oppia/android/app/devoptions/devoptionsitemviewmodel/DeveloperOptionsTestParsersViewModel.kt", "src/main/java/org/oppia/android/app/devoptions/devoptionsitemviewmodel/DeveloperOptionsViewLogsViewModel.kt", "src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsViewModel.kt", "src/main/java/org/oppia/android/app/devoptions/forcenetworktype/NetworkTypeItemViewModel.kt", @@ -388,6 +392,7 @@ VIEWS_WITH_RESOURCE_IMPORTS = [ # keep sorted VIEWS = [ "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/NumericInputInteractionView.kt", "src/main/java/org/oppia/android/app/customview/interaction/TextInputInteractionView.kt", "src/main/java/org/oppia/android/app/customview/interaction/RatioInputInteractionView.kt", @@ -544,6 +549,7 @@ android_library( resource_files = glob(DATABINDING_LAYOUTS), visibility = [ "//app/src/main/java/org/oppia/android/app/shim:__pkg__", + "//app/src/main/java/org/oppia/android/app/testing/activity:__pkg__", ], deps = [ ":annotations", @@ -654,6 +660,7 @@ kt_android_library( "//app/src/main/java/org/oppia/android/app/viewmodel:observable_view_model", "//app/src/main/java/org/oppia/android/app/viewmodel:view_model_provider", "//app/src/main/java/org/oppia/android/app/utility/datetime:date_time_util", + "//app/src/main/java/org/oppia/android/app/utility/math:math_expression_accessibility_util", "//domain", "//domain/src/main/java/org/oppia/android/domain/audio:audio_player_controller", "//domain/src/main/java/org/oppia/android/domain/onboarding:state_controller", @@ -669,6 +676,7 @@ kt_android_library( # TODO(#59): Remove 'debug_util_module' once we completely migrate to Bazel from Gradle as # we can then directly exclude debug files from the build and thus won't be requiring this module. "//utility/src/main/java/org/oppia/android/util/networking:debug_util_module", + "//utility/src/main/java/org/oppia/android/util/parser/html:html_parser", ], ) diff --git a/app/build.gradle b/app/build.gradle index 49adb870cce..e07e6139feb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -208,6 +208,7 @@ dependencies { 'androidx.test.ext:junit:1.1.1', 'com.github.bumptech.glide:mocks:4.11.0', 'com.google.truth:truth:1.1.3', + 'com.google.truth.extensions:truth-liteproto-extension:1.1.3', 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2', 'org.mockito:mockito-android:2.7.22', 'org.robolectric:annotations:4.5', diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7f2f0f6ef14..ef7fd32c0c2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -264,6 +264,10 @@ + { + viewModel.itemIndex.set(3) + ViewType.VIEW_TYPE_TEST_PARSERS + } else -> throw IllegalArgumentException("Encountered unexpected view model: $viewModel") } } @@ -93,12 +99,19 @@ class DeveloperOptionsFragmentPresenter @Inject constructor( setViewModel = DeveloperOptionsOverrideAppBehaviorsViewBinding::setViewModel, transformViewModel = { it as DeveloperOptionsOverrideAppBehaviorsViewModel } ) + .registerViewDataBinder( + viewType = ViewType.VIEW_TYPE_TEST_PARSERS, + inflateDataBinding = DeveloperOptionsTestParsersViewBinding::inflate, + setViewModel = DeveloperOptionsTestParsersViewBinding::setViewModel, + transformViewModel = { it as DeveloperOptionsTestParsersViewModel } + ) .build() } private enum class ViewType { VIEW_TYPE_MODIFY_LESSON_PROGRESS, VIEW_TYPE_VIEW_LOGS, - VIEW_TYPE_OVERRIDE_APP_BEHAVIORS + VIEW_TYPE_OVERRIDE_APP_BEHAVIORS, + VIEW_TYPE_TEST_PARSERS } } diff --git a/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsViewModel.kt b/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsViewModel.kt index a1724ce1485..a6ee237ec55 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import org.oppia.android.app.devoptions.devoptionsitemviewmodel.DeveloperOptionsItemViewModel import org.oppia.android.app.devoptions.devoptionsitemviewmodel.DeveloperOptionsModifyLessonProgressViewModel import org.oppia.android.app.devoptions.devoptionsitemviewmodel.DeveloperOptionsOverrideAppBehaviorsViewModel +import org.oppia.android.app.devoptions.devoptionsitemviewmodel.DeveloperOptionsTestParsersViewModel import org.oppia.android.app.devoptions.devoptionsitemviewmodel.DeveloperOptionsViewLogsViewModel import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.domain.devoptions.ShowAllHintsAndSolutionController @@ -28,6 +29,8 @@ class DeveloperOptionsViewModel @Inject constructor( activity as RouteToMarkTopicsCompletedListener private val routeToViewEventLogsListener = activity as RouteToViewEventLogsListener private val routeToForceNetworkTypeListener = activity as RouteToForceNetworkTypeListener + private val routeToMathExpressionParserTestListener = + activity as RouteToMathExpressionParserTestListener /** * List of [DeveloperOptionsItemViewModel] used to populate recyclerview of @@ -49,7 +52,8 @@ class DeveloperOptionsViewModel @Inject constructor( forceCrashButtonClickListener, routeToForceNetworkTypeListener, showAllHintsAndSolutionController - ) + ), + DeveloperOptionsTestParsersViewModel(routeToMathExpressionParserTestListener) ) } } diff --git a/app/src/main/java/org/oppia/android/app/devoptions/RouteToMathExpressionParserTestListener.kt b/app/src/main/java/org/oppia/android/app/devoptions/RouteToMathExpressionParserTestListener.kt new file mode 100644 index 00000000000..daf68a254d9 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/devoptions/RouteToMathExpressionParserTestListener.kt @@ -0,0 +1,7 @@ +package org.oppia.android.app.devoptions + +/** Listener for when the user wants to test math expressions/equations. */ +interface RouteToMathExpressionParserTestListener { + /** Called when the user indicates that they want to test math expressions/equations. */ + fun routeToMathExpressionParserTest() +} diff --git a/app/src/main/java/org/oppia/android/app/devoptions/devoptionsitemviewmodel/DeveloperOptionsTestParsersViewModel.kt b/app/src/main/java/org/oppia/android/app/devoptions/devoptionsitemviewmodel/DeveloperOptionsTestParsersViewModel.kt new file mode 100644 index 00000000000..00e7edf8f56 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/devoptions/devoptionsitemviewmodel/DeveloperOptionsTestParsersViewModel.kt @@ -0,0 +1,16 @@ +package org.oppia.android.app.devoptions.devoptionsitemviewmodel + +import org.oppia.android.app.devoptions.RouteToMathExpressionParserTestListener + +/** + * [DeveloperOptionsItemViewModel] to provide features to test and debug math expressions and + * equations. + */ +class DeveloperOptionsTestParsersViewModel( + private val routeToMathExpressionParserTestListener: RouteToMathExpressionParserTestListener +) : DeveloperOptionsItemViewModel() { + /** Routes the user to an activity for testing math expressions & equations. */ + fun onMathExpressionsClicked() { + routeToMathExpressionParserTestListener.routeToMathExpressionParserTest() + } +} diff --git a/app/src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserActivity.kt new file mode 100644 index 00000000000..25349699375 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserActivity.kt @@ -0,0 +1,33 @@ +package org.oppia.android.app.devoptions.mathexpressionparser + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import org.oppia.android.R +import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.activity.InjectableAppCompatActivity +import org.oppia.android.app.translation.AppLanguageResourceHandler +import javax.inject.Inject + +/** Activity to allow the user to test math expressions/equations. */ +class MathExpressionParserActivity : InjectableAppCompatActivity() { + @Inject + lateinit var mathExpressionParserActivityPresenter: MathExpressionParserActivityPresenter + + @Inject + lateinit var resourceHandler: AppLanguageResourceHandler + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + (activityComponent as ActivityComponentImpl).inject(this) + mathExpressionParserActivityPresenter.handleOnCreate() + title = resourceHandler.getStringInLocale(R.string.math_expression_parser_activity_title) + } + + companion object { + /** Returns [Intent] for [MathExpressionParserActivity]. */ + fun createIntent(context: Context): Intent { + return Intent(context, MathExpressionParserActivity::class.java) + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserActivityPresenter.kt new file mode 100644 index 00000000000..0d9bcfc1d80 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserActivityPresenter.kt @@ -0,0 +1,33 @@ +package org.oppia.android.app.devoptions.mathexpressionparser + +import androidx.appcompat.app.AppCompatActivity +import org.oppia.android.R +import org.oppia.android.app.activity.ActivityScope +import javax.inject.Inject + +/** The presenter for [MathExpressionParserActivity]. */ +@ActivityScope +class MathExpressionParserActivityPresenter @Inject constructor( + private val activity: AppCompatActivity +) { + + /** Called when [MathExpressionParserActivity] is created. Handles UI for the activity. */ + fun handleOnCreate() { + activity.supportActionBar?.setDisplayHomeAsUpEnabled(true) + activity.supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_arrow_back_white_24dp) + activity.setContentView(R.layout.math_expression_parser_activity) + + if (getMathExpressionParserFragment() == null) { + val forceNetworkTypeFragment = MathExpressionParserFragment.createNewInstance() + activity.supportFragmentManager.beginTransaction().add( + R.id.math_expression_parser_container, + forceNetworkTypeFragment + ).commitNow() + } + } + + private fun getMathExpressionParserFragment(): MathExpressionParserFragment? { + return activity.supportFragmentManager + .findFragmentById(R.id.force_network_type_container) as? MathExpressionParserFragment + } +} diff --git a/app/src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserFragment.kt b/app/src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserFragment.kt new file mode 100644 index 00000000000..4675e0eb376 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserFragment.kt @@ -0,0 +1,34 @@ +package org.oppia.android.app.devoptions.mathexpressionparser + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableFragment +import javax.inject.Inject + +/** Fragment to provide user testing support for math expressions/equations. */ +class MathExpressionParserFragment : InjectableFragment() { + @Inject + lateinit var mathExpressionParserFragmentPresenter: MathExpressionParserFragmentPresenter + + companion object { + /** Returns a new instance of [MathExpressionParserFragment]. */ + fun createNewInstance(): MathExpressionParserFragment = MathExpressionParserFragment() + } + + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return mathExpressionParserFragmentPresenter.handleCreateView(inflater, container) + } +} diff --git a/app/src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserFragmentPresenter.kt new file mode 100644 index 00000000000..26439dbd0fc --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserFragmentPresenter.kt @@ -0,0 +1,39 @@ +package org.oppia.android.app.devoptions.mathexpressionparser + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import org.oppia.android.databinding.MathExpressionParserFragmentBinding +import javax.inject.Inject + +/** The presenter for [MathExpressionParserFragment]. */ +class MathExpressionParserFragmentPresenter @Inject constructor( + private val activity: AppCompatActivity, + private val fragment: Fragment, + private val viewModel: MathExpressionParserViewModel +) { + /** Called when [MathExpressionParserFragment] is created. Handles UI for the fragment. */ + fun handleCreateView( + inflater: LayoutInflater, + container: ViewGroup? + ): View { + val binding = MathExpressionParserFragmentBinding.inflate( + inflater, + container, + /* attachToRoot= */ false + ) + + binding.mathExpressionParserToolbar.setNavigationOnClickListener { + (activity as MathExpressionParserActivity).finish() + } + + binding.apply { + lifecycleOwner = fragment + viewModel = this@MathExpressionParserFragmentPresenter.viewModel + } + viewModel.initialize(binding.mathExpressionParseResultTextView) + return binding.root + } +} diff --git a/app/src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserViewModel.kt b/app/src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserViewModel.kt new file mode 100644 index 00000000000..741b17ca7aa --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserViewModel.kt @@ -0,0 +1,228 @@ +package org.oppia.android.app.devoptions.mathexpressionparser + +import android.widget.TextView +import androidx.databinding.ObservableField +import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentScope +import org.oppia.android.app.model.MathEquation +import org.oppia.android.app.model.MathExpression +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.app.utility.math.MathExpressionAccessibilityUtil +import org.oppia.android.app.viewmodel.ObservableViewModel +import org.oppia.android.util.locale.OppiaLocale +import org.oppia.android.util.math.MathExpressionParser +import org.oppia.android.util.math.MathExpressionParser.Companion.MathParsingResult +import org.oppia.android.util.math.toComparableOperation +import org.oppia.android.util.math.toPolynomial +import org.oppia.android.util.math.toRawLatex +import org.oppia.android.util.parser.html.HtmlParser +import javax.inject.Inject + +/** + * View model that provides different debugging scenarios for math expressions, equations, and + * numeric expressions. + */ +@FragmentScope +class MathExpressionParserViewModel @Inject constructor( + private val appLanguageResourceHandler: AppLanguageResourceHandler, + private val machineLocale: OppiaLocale.MachineLocale, + private val mathExpressionAccessibilityUtil: MathExpressionAccessibilityUtil, + private val htmlParserFactory: HtmlParser.Factory +) : ObservableViewModel() { + private val htmlParser by lazy { + // TODO(#4206): Replace this with the variant that doesn't require GCS properties. + htmlParserFactory.create( + gcsResourceName = "", + entityType = "", + entityId = "", + imageCenterAlign = false + ) + } + private lateinit var parseResultTextView: TextView + + /** + * Specifies the math expression currently being entered by the user. This is expected to be + * directly bound to the UI. + */ + var mathExpression = ObservableField() + + /** + * Specifies the comma-separated list of variables allowed for algebraic expressions/equations, as + * specified by the user. This is expected to be directly bound to the UI. + */ + var allowedVariables = ObservableField("x,y") + private var parseType = ParseType.NUMERIC_EXPRESSION + private var resultType = ResultType.MATH_EXPRESSION + private var useDivAsFractions = false + + /** Initializes the view model to use [parseResultTextView] for displaying the parse result. */ + fun initialize(parseResultTextView: TextView) { + this.parseResultTextView = parseResultTextView + updateParseResult() + } + + /** Callback for the UI to recompute the parse result. */ + fun onParseButtonClicked() { + updateParseResult() + } + + /** Callback for the UI to update the current [ParseType] used. */ + fun onParseTypeSelected(parseType: ParseType) { + this.parseType = parseType + } + + /** Callback for the UI to update the current [ResultType] used. */ + fun onResultTypeSelected(resultType: ResultType) { + this.resultType = resultType + } + + /** + * Callback for the UI to update whether divisions should be treated as fractions for relevant + * [ResultType]s. + */ + fun onChangedUseDivAsFractions(useDivAsFractions: Boolean) { + this.useDivAsFractions = useDivAsFractions + } + + private fun updateParseResult() { + val newText = computeParseResult() + // Only parse HTML if there is HTML to preserve formatting. + parseResultTextView.text = if ("oppia-noninteractive-math" in newText) { + htmlParser.parseOppiaHtml(newText.replace("\n", "
"), parseResultTextView) + } else newText + } + + private fun computeParseResult(): String { + val expression = mathExpression.get() + val allowedVariables = allowedVariables.get() + ?.split(",") + ?.map { variable -> + machineLocale.run { + variable.toMachineLowerCase().trim() + } + } ?: listOf() + if (expression == null) { + return appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_parse_result_label, "Uninitialized" + ) + } + val parseResult = when (parseType) { + ParseType.NUMERIC_EXPRESSION -> { + MathExpressionParser.parseNumericExpression(expression) + .transformExpression(resultType, useDivAsFractions, mathExpressionAccessibilityUtil) + } + ParseType.ALGEBRAIC_EXPRESSION -> { + MathExpressionParser.parseAlgebraicExpression(expression, allowedVariables) + .transformExpression(resultType, useDivAsFractions, mathExpressionAccessibilityUtil) + } + ParseType.ALGEBRAIC_EQUATION -> { + MathExpressionParser.parseAlgebraicEquation(expression, allowedVariables) + .transformEquation(resultType, useDivAsFractions, mathExpressionAccessibilityUtil) + } + } + val parseResultStr = when (parseResult) { + is MathParsingResult.Failure -> parseResult.error.toString() + is MathParsingResult.Success -> parseResult.result + } + return appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_parse_result_label, "\n$parseResultStr" + ) + } + + /** Defines how text expressions should be parsed. */ + enum class ParseType { + /** Indicates that the user-inputted text should be parsed as a numeric expression. */ + NUMERIC_EXPRESSION, + + /** Indicates that the user-inputted text should be parsed as an algebraic expression. */ + ALGEBRAIC_EXPRESSION, + + /** Indicates that the user-inputted text should be parsed as an algebraic/math equation. */ + ALGEBRAIC_EQUATION + } + + /** Defines how the parsed expression/equation should be processed and displayed. */ + enum class ResultType { + /** Indicates that the raw parsed expression/equation proto should be displayed. */ + MATH_EXPRESSION, + + /** + * Indicates that the comparable operation representation proto of the expression/equation + * should be displayed. + */ + COMPARABLE_OPERATION, + + /** + * Indicates that the polynomial representation proto of the expression/equation should be + * displayed. + */ + POLYNOMIAL, + + /** Indicates that the expression should be converted to LaTeX and rendered as an image. */ + LATEX, + + /** + * Indicates that the expression should be converted to a human-readable accessibility string + * and displayed. + */ + HUMAN_READABLE_STRING + } + + private companion object { + private fun MathParsingResult.map(transform: (I) -> O): MathParsingResult { + return when (this) { + is MathParsingResult.Failure -> MathParsingResult.Failure(error) + is MathParsingResult.Success -> MathParsingResult.Success(transform(result)) + } + } + + private fun MathParsingResult.transformExpression( + resultType: ResultType, + useDivAsFractions: Boolean, + mathExpressionAccessibilityUtil: MathExpressionAccessibilityUtil + ): MathParsingResult { + return when (resultType) { + ResultType.MATH_EXPRESSION -> this + ResultType.COMPARABLE_OPERATION -> map { it.toComparableOperation() } + ResultType.POLYNOMIAL -> map { it.toPolynomial() } + ResultType.LATEX -> map { it.toRawLatex(useDivAsFractions).wrapAsLatexHtml() } + ResultType.HUMAN_READABLE_STRING -> map { + mathExpressionAccessibilityUtil.convertToHumanReadableString( + it, OppiaLanguage.ENGLISH, useDivAsFractions + ) + } + }.map { it.toString() } + } + + private fun MathParsingResult.transformEquation( + resultType: ResultType, + useDivAsFractions: Boolean, + mathExpressionAccessibilityUtil: MathExpressionAccessibilityUtil + ): MathParsingResult { + return when (resultType) { + ResultType.MATH_EXPRESSION -> this + ResultType.COMPARABLE_OPERATION -> map { + "Left side: ${it.leftSide.toComparableOperation()}" + + "\n\nRight side: ${it.rightSide.toComparableOperation()}" + } + ResultType.POLYNOMIAL -> map { + "Left side: ${it.leftSide.toPolynomial()}\n\nRight side: ${it.rightSide.toPolynomial()}" + } + ResultType.LATEX -> map { it.toRawLatex(useDivAsFractions).wrapAsLatexHtml() } + ResultType.HUMAN_READABLE_STRING -> map { + mathExpressionAccessibilityUtil.convertToHumanReadableString( + it, OppiaLanguage.ENGLISH, useDivAsFractions + ) + } + }.map { it.toString() } + } + + private fun String.wrapAsLatexHtml(): String { + val mathContentValue = + "{&quot;raw_latex&quot;:&quot;${this.replace("\\", "\\\\")}&quot;}" + return "" + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt index 25053173e47..228c180be65 100644 --- a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt +++ b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt @@ -13,6 +13,7 @@ import org.oppia.android.app.devoptions.forcenetworktype.ForceNetworkTypeFragmen import org.oppia.android.app.devoptions.markchapterscompleted.MarkChaptersCompletedFragment import org.oppia.android.app.devoptions.markstoriescompleted.MarkStoriesCompletedFragment import org.oppia.android.app.devoptions.marktopicscompleted.MarkTopicsCompletedFragment +import org.oppia.android.app.devoptions.mathexpressionparser.MathExpressionParserFragment import org.oppia.android.app.devoptions.vieweventlogs.ViewEventLogsFragment import org.oppia.android.app.drawer.ExitProfileDialogFragment import org.oppia.android.app.drawer.NavigationDrawerFragment @@ -59,6 +60,7 @@ import org.oppia.android.app.settings.profile.ProfileResetPinFragment import org.oppia.android.app.shim.IntentFactoryShimModule import org.oppia.android.app.shim.ViewBindingShimModule import org.oppia.android.app.story.StoryFragment +import org.oppia.android.app.testing.ExplorationTestActivityPresenter import org.oppia.android.app.testing.ImageRegionSelectionTestFragment import org.oppia.android.app.topic.TopicFragment import org.oppia.android.app.topic.conceptcard.ConceptCardFragment @@ -110,6 +112,7 @@ interface FragmentComponentImpl : FragmentComponent, ViewComponentBuilderInjecto fun inject(exitProfileDialogFragment: ExitProfileDialogFragment) fun inject(explorationFragment: ExplorationFragment) fun inject(explorationManagerFragment: ExplorationManagerFragment) + fun inject(explorationTestActivityTestFragment: ExplorationTestActivityPresenter.TestFragment) fun inject(faqListFragment: FAQListFragment) fun inject(forceNetworkTypeFragment: ForceNetworkTypeFragment) fun inject(helpFragment: HelpFragment) @@ -125,6 +128,7 @@ interface FragmentComponentImpl : FragmentComponent, ViewComponentBuilderInjecto fun inject(markChapterCompletedFragment: MarkChaptersCompletedFragment) fun inject(markStoriesCompletedFragment: MarkStoriesCompletedFragment) fun inject(markTopicsCompletedFragment: MarkTopicsCompletedFragment) + fun inject(mathExpressionParserFragment: MathExpressionParserFragment) fun inject(myDownloadsFragment: MyDownloadsFragment) fun inject(navigationDrawerFragment: NavigationDrawerFragment) fun inject(onboardingFragment: OnboardingFragment) 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 9d32680b3e0..99954487d9b 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 @@ -41,7 +41,7 @@ import org.oppia.android.app.player.state.itemviewmodel.DragAndDropSortInteracti import org.oppia.android.app.player.state.itemviewmodel.FeedbackViewModel import org.oppia.android.app.player.state.itemviewmodel.FractionInteractionViewModel import org.oppia.android.app.player.state.itemviewmodel.ImageRegionSelectionInteractionViewModel -import org.oppia.android.app.player.state.itemviewmodel.InteractionViewModelFactory +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.NumericInputViewModel import org.oppia.android.app.player.state.itemviewmodel.PreviousButtonViewModel @@ -51,6 +51,7 @@ import org.oppia.android.app.player.state.itemviewmodel.ReplayButtonViewModel import org.oppia.android.app.player.state.itemviewmodel.ReturnToTopicButtonViewModel import org.oppia.android.app.player.state.itemviewmodel.SelectionInteractionViewModel 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.itemviewmodel.SubmitButtonViewModel import org.oppia.android.app.player.state.itemviewmodel.SubmittedAnswerViewModel import org.oppia.android.app.player.state.itemviewmodel.TextInputViewModel @@ -74,6 +75,7 @@ import org.oppia.android.databinding.DragDropInteractionItemBinding import org.oppia.android.databinding.FeedbackItemBinding import org.oppia.android.databinding.FractionInteractionItemBinding import org.oppia.android.databinding.ImageRegionSelectionInteractionItemBinding +import org.oppia.android.databinding.MathExpressionInteractionsItemBinding import org.oppia.android.databinding.NextButtonItemBinding import org.oppia.android.databinding.NumericInputInteractionItemBinding import org.oppia.android.databinding.PreviousButtonItemBinding @@ -138,8 +140,7 @@ class StatePlayerRecyclerViewAssembler private constructor( private val currentStateName: ObservableField?, private val isAudioPlaybackEnabled: ObservableField?, private val audioUiManagerRetriever: AudioUiManagerRetriever?, - private val interactionViewModelFactoryMap: Map< - String, @JvmSuppressWildcards InteractionViewModelFactory>, + private val interactionViewModelFactoryMap: Map, backgroundCoroutineDispatcher: CoroutineDispatcher, private val hasConversationView: Boolean, private val resourceHandler: AppLanguageResourceHandler, @@ -159,8 +160,6 @@ class StatePlayerRecyclerViewAssembler private constructor( */ private var hasPreviousResponsesExpanded: Boolean = false - val isCorrectAnswer = ObservableField(false) - private val lifecycleSafeTimerFactory = LifecycleSafeTimerFactory(backgroundCoroutineDispatcher) /** The most recent content ID read by the audio system. */ @@ -246,7 +245,6 @@ class StatePlayerRecyclerViewAssembler private constructor( // Ensure the answer is marked in situations where that's guaranteed (e.g. completed state) // so that the UI always has the correct answer indication, even after configuration changes. - isCorrectAnswer.set(true) addPreviousAnswers( conversationPendingItemList, extraInteractionPendingItemList, @@ -310,7 +308,7 @@ class StatePlayerRecyclerViewAssembler private constructor( writtenTranslationContext: WrittenTranslationContext ) { val interactionViewModelFactory = interactionViewModelFactoryMap.getValue(interaction.id) - pendingItemList += interactionViewModelFactory( + pendingItemList += interactionViewModelFactory.create( gcsEntityId, hasConversationView, interaction, @@ -885,7 +883,7 @@ class StatePlayerRecyclerViewAssembler private constructor( private val fragment: Fragment, private val profileId: ProfileId, private val context: Context, - private val interactionViewModelFactoryMap: Map, + private val interactionViewModelFactoryMap: Map, private val backgroundCoroutineDispatcher: CoroutineDispatcher, private val resourceHandler: AppLanguageResourceHandler, private val translationController: TranslationController @@ -1029,6 +1027,21 @@ class StatePlayerRecyclerViewAssembler private constructor( inflateDataBinding = RatioInputInteractionItemBinding::inflate, setViewModel = RatioInputInteractionItemBinding::setViewModel, transformViewModel = { it as RatioExpressionInputInteractionViewModel } + ).registerViewDataBinder( + viewType = StateItemViewModel.ViewType.NUMERIC_EXPRESSION_INPUT_INTERACTION, + inflateDataBinding = MathExpressionInteractionsItemBinding::inflate, + setViewModel = MathExpressionInteractionsItemBinding::setViewModel, + transformViewModel = { it as MathExpressionInteractionsViewModel } + ).registerViewDataBinder( + viewType = StateItemViewModel.ViewType.ALGEBRAIC_EXPRESSION_INPUT_INTERACTION, + inflateDataBinding = MathExpressionInteractionsItemBinding::inflate, + setViewModel = MathExpressionInteractionsItemBinding::setViewModel, + transformViewModel = { it as MathExpressionInteractionsViewModel } + ).registerViewDataBinder( + viewType = StateItemViewModel.ViewType.MATH_EQUATION_INPUT_INTERACTION, + inflateDataBinding = MathExpressionInteractionsItemBinding::inflate, + setViewModel = MathExpressionInteractionsItemBinding::setViewModel, + transformViewModel = { it as MathExpressionInteractionsViewModel } ).registerViewDataBinder( viewType = StateItemViewModel.ViewType.SUBMIT_ANSWER_BUTTON, inflateDataBinding = SubmitButtonItemBinding::inflate, @@ -1372,7 +1385,7 @@ class StatePlayerRecyclerViewAssembler private constructor( private val fragment: Fragment, private val context: Context, private val interactionViewModelFactoryMap: Map< - String, @JvmSuppressWildcards InteractionViewModelFactory>, + String, @JvmSuppressWildcards InteractionItemFactory>, @BackgroundDispatcher private val backgroundCoroutineDispatcher: CoroutineDispatcher, private val resourceHandler: AppLanguageResourceHandler, private val translationController: TranslationController diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt index dbabbbd32ee..e8c2a70786a 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt @@ -1,11 +1,15 @@ package org.oppia.android.app.player.state.itemviewmodel +import androidx.fragment.app.Fragment +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.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.player.state.listener.PreviousNavigationButtonListener +import javax.inject.Inject // For context: // https://github.com/oppia/oppia/blob/37285a/extensions/interactions/Continue/directives/oppia-interactive-continue.directive.ts @@ -17,7 +21,7 @@ private const val DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER = "Please continue." * from [ContinueNavigationButtonViewModel] in that the latter is for an already completed state, whereas this * represents an actual interaction. */ -class ContinueInteractionViewModel( +class ContinueInteractionViewModel private constructor( private val interactionAnswerReceiver: InteractionAnswerReceiver, val hasConversationView: Boolean, val hasPreviousButton: Boolean, @@ -41,4 +45,29 @@ class ContinueInteractionViewModel( fun handleButtonClicked() { interactionAnswerReceiver.onAnswerReadyForSubmission(getPendingAnswer()) } + + /** Implementation of [StateItemViewModel.InteractionItemFactory] for this view model. */ + class FactoryImpl @Inject constructor( + private val fragment: Fragment + ) : InteractionItemFactory { + override fun create( + entityId: String, + hasConversationView: Boolean, + interaction: Interaction, + interactionAnswerReceiver: InteractionAnswerReceiver, + answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, + hasPreviousButton: Boolean, + isSplitView: Boolean, + writtenTranslationContext: WrittenTranslationContext + ): StateItemViewModel { + return ContinueInteractionViewModel( + interactionAnswerReceiver, + hasConversationView, + hasPreviousButton, + fragment as PreviousNavigationButtonListener, + isSplitView, + writtenTranslationContext + ) + } + } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt index 6eb8dee40bc..1509d9a295b 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt @@ -15,14 +15,16 @@ 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.InteractionAnswerHandler +import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver import org.oppia.android.app.recyclerview.BindableAdapter import org.oppia.android.app.recyclerview.OnDragEndedListener import org.oppia.android.app.recyclerview.OnItemDragListener import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.domain.translation.TranslationController +import javax.inject.Inject /** [StateItemViewModel] for drag drop & sort choice list. */ -class DragAndDropSortInteractionViewModel( +class DragAndDropSortInteractionViewModel private constructor( val entityId: String, val hasConversationView: Boolean, interaction: Interaction, @@ -188,6 +190,34 @@ class DragAndDropSortInteractionViewModel( (adapter as BindableAdapter<*>).setDataUnchecked(_choiceItems) } + /** 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 + ): StateItemViewModel { + return DragAndDropSortInteractionViewModel( + entityId, + hasConversationView, + interaction, + answerErrorReceiver, + isSplitView, + writtenTranslationContext, + resourceHandler, + translationController + ) + } + } + companion object { private fun computeChoiceItems( contentIdHtmlMap: Map, diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt index 8ce11c53e71..54ee1566a38 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt @@ -13,12 +13,14 @@ import org.oppia.android.app.parser.FractionParsingUiError import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory 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.domain.translation.TranslationController import org.oppia.android.util.math.FractionParser +import javax.inject.Inject /** [StateItemViewModel] for the fraction input interaction. */ -class FractionInteractionViewModel( +class FractionInteractionViewModel private constructor( interaction: Interaction, val hasConversationView: Boolean, val isSplitView: Boolean, @@ -125,4 +127,31 @@ class FractionInteractionViewModel( else -> resourceHandler.getStringInLocale(R.string.fractions_default_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 + ): StateItemViewModel { + return FractionInteractionViewModel( + interaction, + hasConversationView, + isSplitView, + answerErrorReceiver, + writtenTranslationContext, + resourceHandler, + translationController + ) + } + } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt index 7079d3b10b6..47b6a5e4569 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt @@ -11,14 +11,16 @@ 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.InteractionAnswerHandler +import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.utility.DefaultRegionClickedEvent import org.oppia.android.app.utility.NamedRegionClickedEvent import org.oppia.android.app.utility.OnClickableAreaClickedListener import org.oppia.android.app.utility.RegionClickedEvent +import javax.inject.Inject /** [StateItemViewModel] for image region selection. */ -class ImageRegionSelectionInteractionViewModel( +class ImageRegionSelectionInteractionViewModel private constructor( val entityId: String, val hasConversationView: Boolean, interaction: Interaction, @@ -88,4 +90,30 @@ class ImageRegionSelectionInteractionViewModel( .addClickedRegions(region?.label ?: "") .build() } + + /** Implementation of [StateItemViewModel.InteractionItemFactory] for this view model. */ + class FactoryImpl @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler + ) : InteractionItemFactory { + override fun create( + entityId: String, + hasConversationView: Boolean, + interaction: Interaction, + interactionAnswerReceiver: InteractionAnswerReceiver, + answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, + hasPreviousButton: Boolean, + isSplitView: Boolean, + writtenTranslationContext: WrittenTranslationContext + ): StateItemViewModel { + return ImageRegionSelectionInteractionViewModel( + entityId, + hasConversationView, + interaction, + answerErrorReceiver, + isSplitView, + writtenTranslationContext, + resourceHandler + ) + } + } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelFactory.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelFactory.kt deleted file mode 100644 index 2ad060e78e3..00000000000 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelFactory.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.oppia.android.app.player.state.itemviewmodel - -import org.oppia.android.app.model.Interaction -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 - -/** - * Returns a new [StateItemViewModel] corresponding to this interaction with the GCS entity ID, the [Interaction] - * object corresponding to the interaction view, a receiver for answers if this interaction pushes answers, and whether - * there's a previous button enabled (only relevant for navigation-based interactions). - */ -typealias InteractionViewModelFactory = ( - entityId: String, - hasConversationView: Boolean, - interaction: Interaction, - interactionAnswerReceiver: InteractionAnswerReceiver, - interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length - hasPreviousButton: Boolean, - isSplitView: Boolean, - writtenTranslationContext: WrittenTranslationContext -) -> StateItemViewModel 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 5ad96318870..51ea00e2e6e 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 @@ -1,191 +1,114 @@ package org.oppia.android.app.player.state.itemviewmodel -import androidx.fragment.app.Fragment +import dagger.Binds import dagger.Module import dagger.Provides import dagger.multibindings.IntoMap import dagger.multibindings.StringKey -import org.oppia.android.app.player.state.listener.PreviousNavigationButtonListener -import org.oppia.android.app.translation.AppLanguageResourceHandler -import org.oppia.android.domain.translation.TranslationController /** * Module to provide interaction view model-specific dependencies for interactions that should be * explicitly displayed to the user. */ @Module -class InteractionViewModelModule { - companion object { - val splitScreenInteractionIdsPool = listOf("DragAndDropSortInput", "ImageClickInput") - } - +interface InteractionViewModelModule { // TODO(#300): Use a common source for these interaction IDs to de-duplicate them from // other places in the codebase where they are referenced. - @Provides + @Binds @IntoMap @StringKey("Continue") - fun provideContinueInteractionViewModelFactory(fragment: Fragment): InteractionViewModelFactory { - return { _, hasConversationView, _, interactionAnswerReceiver, _, hasPreviousButton, - isSplitView, writtenTranslationContext -> - ContinueInteractionViewModel( - interactionAnswerReceiver, - hasConversationView, - hasPreviousButton, - fragment as PreviousNavigationButtonListener, - isSplitView, - writtenTranslationContext - ) - } - } + fun provideContinueInteractionViewModelFactory( + factoryImpl: ContinueInteractionViewModel.FactoryImpl + ): StateItemViewModel.InteractionItemFactory - @Provides + @Binds @IntoMap @StringKey("MultipleChoiceInput") fun provideMultipleChoiceInputViewModelFactory( - translationController: TranslationController - ): InteractionViewModelFactory { - return { entityId, hasConversationView, interaction, _, - interactionAnswerErrorReceiver, _, isSplitView, writtenTranslationContext -> - SelectionInteractionViewModel( - entityId, - hasConversationView, - interaction, - interactionAnswerErrorReceiver, - isSplitView, - writtenTranslationContext, - translationController - ) - } - } + factoryImpl: SelectionInteractionViewModel.FactoryImpl + ): StateItemViewModel.InteractionItemFactory - @Provides + @Binds @IntoMap @StringKey("ItemSelectionInput") fun provideItemSelectionInputViewModelFactory( - translationController: TranslationController - ): InteractionViewModelFactory { - return { entityId, hasConversationView, interaction, _, - interactionAnswerErrorReceiver, _, isSplitView, writtenTranslationContext -> - SelectionInteractionViewModel( - entityId, - hasConversationView, - interaction, - interactionAnswerErrorReceiver, - isSplitView, - writtenTranslationContext, - translationController - ) - } - } + factoryImpl: SelectionInteractionViewModel.FactoryImpl + ): StateItemViewModel.InteractionItemFactory - @Provides + @Binds @IntoMap @StringKey("FractionInput") fun provideFractionInputViewModelFactory( - resourceHandler: AppLanguageResourceHandler, - translationController: TranslationController - ): InteractionViewModelFactory { - return { _, hasConversationView, interaction, _, interactionAnswerErrorReceiver, _, - isSplitView, writtenTranslationContext -> - FractionInteractionViewModel( - interaction, - hasConversationView, - isSplitView, - interactionAnswerErrorReceiver, - writtenTranslationContext, - resourceHandler, - translationController - ) - } - } + factoryImpl: FractionInteractionViewModel.FactoryImpl + ): StateItemViewModel.InteractionItemFactory - @Provides + @Binds @IntoMap @StringKey("NumericInput") fun provideNumericInputViewModelFactory( - resourceHandler: AppLanguageResourceHandler - ): InteractionViewModelFactory { - return { _, hasConversationView, _, _, interactionAnswerErrorReceiver, _, isSplitView, - writtenTranslationContext -> - NumericInputViewModel( - hasConversationView, - interactionAnswerErrorReceiver, - isSplitView, - writtenTranslationContext, - resourceHandler - ) - } - } + factoryImpl: NumericInputViewModel.FactoryImpl + ): StateItemViewModel.InteractionItemFactory - @Provides + @Binds @IntoMap @StringKey("TextInput") fun provideTextInputViewModelFactory( - translationController: TranslationController - ): InteractionViewModelFactory { - return { _, hasConversationView, interaction, _, interactionAnswerErrorReceiver, _, - isSplitView, writtenTranslationContext -> - TextInputViewModel( - interaction, hasConversationView, interactionAnswerErrorReceiver, isSplitView, - writtenTranslationContext, translationController - ) - } - } + factoryImpl: TextInputViewModel.FactoryImpl + ): StateItemViewModel.InteractionItemFactory - @Provides + @Binds @IntoMap @StringKey("DragAndDropSortInput") fun provideDragAndDropSortInputViewModelFactory( - resourceHandler: AppLanguageResourceHandler, - translationController: TranslationController - ): InteractionViewModelFactory { - return { entityId, hasConversationView, interaction, _, interactionAnswerErrorReceiver, _, - isSplitView, writtenTranslationContext -> - DragAndDropSortInteractionViewModel( - entityId, hasConversationView, interaction, interactionAnswerErrorReceiver, isSplitView, - writtenTranslationContext, resourceHandler, translationController - ) - } - } + factoryImpl: DragAndDropSortInteractionViewModel.FactoryImpl + ): StateItemViewModel.InteractionItemFactory - @Provides + @Binds @IntoMap @StringKey("ImageClickInput") fun provideImageClickInputViewModelFactory( - resourceHandler: AppLanguageResourceHandler - ): InteractionViewModelFactory { - return { entityId, hasConversationView, interaction, _, answerErrorReceiver, _, isSplitView, - writtenTranslationContext -> - ImageRegionSelectionInteractionViewModel( - entityId, - hasConversationView, - interaction, - answerErrorReceiver, - isSplitView, - writtenTranslationContext, - resourceHandler - ) - } - } + factoryImpl: ImageRegionSelectionInteractionViewModel.FactoryImpl + ): StateItemViewModel.InteractionItemFactory - @Provides + @Binds @IntoMap @StringKey("RatioExpressionInput") fun provideRatioExpressionInputViewModelFactory( - resourceHandler: AppLanguageResourceHandler, - translationController: TranslationController - ): InteractionViewModelFactory { - return { _, hasConversationView, interaction, _, answerErrorReceiver, _, isSplitView, - writtenTranslationContext -> - RatioExpressionInputInteractionViewModel( - interaction, - hasConversationView, - isSplitView, - answerErrorReceiver, - writtenTranslationContext, - resourceHandler, - translationController - ) + factoryImpl: RatioExpressionInputInteractionViewModel.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 + companion object { + @Provides + @IntoMap + @StringKey("NumericExpressionInput") + @JvmStatic + fun provideNumericExpressionInputViewModelFactory( + factoryFactoryImpl: MathExpressionInteractionsViewModel.FactoryImpl.FactoryFactoryImpl + ): StateItemViewModel.InteractionItemFactory { + return factoryFactoryImpl.createFactoryForNumericExpression() + } + + @Provides + @IntoMap + @StringKey("AlgebraicExpressionInput") + @JvmStatic + fun provideAlgebraicExpressionInputViewModelFactory( + factoryFactoryImpl: MathExpressionInteractionsViewModel.FactoryImpl.FactoryFactoryImpl + ): StateItemViewModel.InteractionItemFactory { + return factoryFactoryImpl.createFactoryForAlgebraicExpression() + } + + @Provides + @IntoMap + @StringKey("MathEquationInput") + @JvmStatic + fun provideMathEquationInputViewModelFactory( + factoryFactoryImpl: MathExpressionInteractionsViewModel.FactoryImpl.FactoryFactoryImpl + ): StateItemViewModel.InteractionItemFactory { + return factoryFactoryImpl.createFactoryForMathEquation() } } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/MathExpressionInteractionsViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/MathExpressionInteractionsViewModel.kt new file mode 100644 index 00000000000..77687c70a5d --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/MathExpressionInteractionsViewModel.kt @@ -0,0 +1,648 @@ +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.R +import org.oppia.android.app.model.Interaction +import org.oppia.android.app.model.InteractionObject +import org.oppia.android.app.model.MathEquation +import org.oppia.android.app.model.MathExpression +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.WrittenTranslationContext +import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory +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.player.state.itemviewmodel.MathExpressionInteractionsViewModel.FactoryImpl.FactoryFactoryImpl +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.app.utility.math.MathExpressionAccessibilityUtil +import org.oppia.android.domain.translation.TranslationController +import org.oppia.android.util.math.MathExpressionParser +import org.oppia.android.util.math.MathExpressionParser.Companion.MathParsingResult +import org.oppia.android.util.math.MathParsingError.DisabledVariablesInUseError +import org.oppia.android.util.math.MathParsingError.EquationHasTooManyEqualsError +import org.oppia.android.util.math.MathParsingError.EquationIsMissingEqualsError +import org.oppia.android.util.math.MathParsingError.EquationMissingLhsOrRhsError +import org.oppia.android.util.math.MathParsingError.ExponentIsVariableExpressionError +import org.oppia.android.util.math.MathParsingError.ExponentTooLargeError +import org.oppia.android.util.math.MathParsingError.FunctionNameIncompleteError +import org.oppia.android.util.math.MathParsingError.GenericError +import org.oppia.android.util.math.MathParsingError.HangingSquareRootError +import org.oppia.android.util.math.MathParsingError.InvalidFunctionInUseError +import org.oppia.android.util.math.MathParsingError.MultipleRedundantParenthesesError +import org.oppia.android.util.math.MathParsingError.NestedExponentsError +import org.oppia.android.util.math.MathParsingError.NoVariableOrNumberAfterBinaryOperatorError +import org.oppia.android.util.math.MathParsingError.NoVariableOrNumberBeforeBinaryOperatorError +import org.oppia.android.util.math.MathParsingError.NumberAfterVariableError +import org.oppia.android.util.math.MathParsingError.RedundantParenthesesForIndividualTermsError +import org.oppia.android.util.math.MathParsingError.SingleRedundantParenthesesError +import org.oppia.android.util.math.MathParsingError.SpacesBetweenNumbersError +import org.oppia.android.util.math.MathParsingError.SubsequentBinaryOperatorsError +import org.oppia.android.util.math.MathParsingError.SubsequentUnaryOperatorsError +import org.oppia.android.util.math.MathParsingError.TermDividedByZeroError +import org.oppia.android.util.math.MathParsingError.UnbalancedParenthesesError +import org.oppia.android.util.math.MathParsingError.UnnecessarySymbolsError +import org.oppia.android.util.math.MathParsingError.VariableInNumericExpressionError +import org.oppia.android.util.math.toPlainText +import org.oppia.android.util.math.toRawLatex +import javax.inject.Inject +import org.oppia.android.app.model.MathBinaryOperation.Operator as UnaryOperator + +/** + * [StateItemViewModel] for input for numeric expressions, algebraic expressions, and math + * (algebraic) equations. + */ +class MathExpressionInteractionsViewModel private constructor( + interaction: Interaction, + val hasConversationView: Boolean, + private val errorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, + private val writtenTranslationContext: WrittenTranslationContext, + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController, + private val mathExpressionAccessibilityUtil: MathExpressionAccessibilityUtil, + private val interactionType: InteractionType +) : StateItemViewModel(interactionType.viewType), InteractionAnswerHandler { + private var pendingAnswerError: String? = null + + /** + * Defines the current answer text being entered by the learner. This is expected to be directly + * bound to the corresponding edit text. + */ + var answerText: CharSequence = "" + + /** + * Defines whether an answer is currently available to parse. This is expected to be directly + * bound to the UI. + */ + var isAnswerAvailable = ObservableField(false) + + /** + * Specifies the current error caused by the current answer (if any; this is empty if there is no + * error). This is expected to be directly bound to the UI. + */ + var errorMessage = ObservableField("") + + /** Specifies the text to show in the answer box when no text is entered. */ + val hintText: CharSequence = deriveHintText(interaction) + + private val allowedVariables = retrieveAllowedVariables(interaction) + private val useFractionsForDivision = + interaction.customizationArgsMap["useFractionForDivision"]?.boolValue ?: false + + init { + val callback: Observable.OnPropertyChangedCallback = + object : Observable.OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable, propertyId: Int) { + errorOrAvailabilityCheckReceiver.onPendingAnswerErrorOrAvailabilityCheck( + pendingAnswerError, + answerText.isNotEmpty() + ) + } + } + errorMessage.addOnPropertyChangedCallback(callback) + isAnswerAvailable.addOnPropertyChangedCallback(callback) + } + + override fun getPendingAnswer(): UserAnswer = UserAnswer.newBuilder().apply { + if (answerText.isNotEmpty()) { + val answerTextString = answerText.toString() + answer = InteractionObject.newBuilder().apply { + mathExpression = answerTextString + }.build() + + // Since the LaTeX is embedded without a JSON object, backslashes need to be double escaped. + val answerAsLatex = + interactionType.computeLatex( + answerTextString, useFractionsForDivision, allowedVariables + )?.replace("\\", "\\\\") + if (answerAsLatex != null) { + val mathContentValue = "{&quot;raw_latex&quot;:&quot;$answerAsLatex&quot;}" + htmlAnswer = + "" + } else plainAnswer = answerTextString + + contentDescription = + interactionType.computeHumanReadableString( + answerTextString, + useFractionsForDivision, + allowedVariables, + mathExpressionAccessibilityUtil, + this@MathExpressionInteractionsViewModel.writtenTranslationContext.language + ) ?: answerTextString + + this.writtenTranslationContext = + this@MathExpressionInteractionsViewModel.writtenTranslationContext + } + }.build() + + override fun checkPendingAnswerError(category: AnswerErrorCategory): String? { + if (answerText.isNotEmpty()) { + pendingAnswerError = when (category) { + // There's no support for real-time errors. + AnswerErrorCategory.REAL_TIME -> null + AnswerErrorCategory.SUBMIT_TIME -> { + interactionType.computeSubmitTimeError( + answerText.toString(), allowedVariables, resourceHandler + ) + } + } + errorMessage.set(pendingAnswerError) + } + return pendingAnswerError + } + + /** + * Returns the [TextWatcher] which helps track the current pending answer and whether there is one + * presently being entered. + */ + 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) { + } + } + } + + private fun deriveHintText(interaction: Interaction): CharSequence { + // The subtitled unicode can apparently exist in the structure in two different formats. + if (interactionType.hasPlaceholder) { + val placeholderUnicodeOption1 = + interaction.customizationArgsMap["placeholder"]?.subtitledUnicode + val placeholderUnicodeOption2 = + interaction.customizationArgsMap["placeholder"]?.customSchemaValue?.subtitledUnicode + val customPlaceholder1 = + placeholderUnicodeOption1?.let { unicode -> + translationController.extractString(unicode, writtenTranslationContext) + } ?: "" + val customPlaceholder2 = + placeholderUnicodeOption2?.let { unicode -> + translationController.extractString(unicode, writtenTranslationContext) + } ?: "" + return when { + customPlaceholder1.isNotEmpty() -> customPlaceholder1 + customPlaceholder2.isNotEmpty() -> customPlaceholder2 + else -> resourceHandler.getStringInLocale(interactionType.defaultHintTextStringId) + } + } else return resourceHandler.getStringInLocale(interactionType.defaultHintTextStringId) + } + + private fun retrieveAllowedVariables(interaction: Interaction): List { + return if (interactionType.hasCustomVariables) { + interaction.customizationArgsMap["customOskLetters"] + ?.schemaObjectList + ?.schemaObjectList + ?.map { it.normalizedString } + ?: listOf() + } else listOf() + } + + /** + * Implementation of [StateItemViewModel.InteractionItemFactory] for this view model. Note that + * instances of this class must be created by injecting [FactoryFactoryImpl]. + */ + class FactoryImpl private constructor( + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController, + private val mathExpressionAccessibilityUtil: MathExpressionAccessibilityUtil, + private val interactionType: InteractionType + ) : InteractionItemFactory { + override fun create( + entityId: String, + hasConversationView: Boolean, + interaction: Interaction, + interactionAnswerReceiver: InteractionAnswerReceiver, + answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, + hasPreviousButton: Boolean, + isSplitView: Boolean, + writtenTranslationContext: WrittenTranslationContext + ): StateItemViewModel { + return MathExpressionInteractionsViewModel( + interaction, + hasConversationView, + answerErrorReceiver, + writtenTranslationContext, + resourceHandler, + translationController, + mathExpressionAccessibilityUtil, + interactionType + ) + } + + /** A factory for [FactoryImpl]s based on for which interaction the factory is needed. */ + class FactoryFactoryImpl @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController, + private val mathExpressionAccessibilityUtil: MathExpressionAccessibilityUtil + ) { + /** Returns a new instance of [FactoryImpl] for NumericExpressionInput. */ + fun createFactoryForNumericExpression(): InteractionItemFactory { + return FactoryImpl( + resourceHandler, + translationController, + mathExpressionAccessibilityUtil, + InteractionType.NUMERIC_EXPRESSION + ) + } + + /** Returns a new instance of [FactoryImpl] for AlgebraicExpressionInput. */ + fun createFactoryForAlgebraicExpression(): InteractionItemFactory { + return FactoryImpl( + resourceHandler, + translationController, + mathExpressionAccessibilityUtil, + InteractionType.ALGEBRAIC_EXPRESSION + ) + } + + /** Returns a new instance of [FactoryImpl] for MathEquationInput. */ + fun createFactoryForMathEquation(): InteractionItemFactory { + return FactoryImpl( + resourceHandler, + translationController, + mathExpressionAccessibilityUtil, + InteractionType.MATH_EQUATION + ) + } + } + } + + private companion object { + private enum class InteractionType( + val viewType: ViewType, + @StringRes val defaultHintTextStringId: Int, + val hasPlaceholder: Boolean, + val hasCustomVariables: Boolean + ) { + /** Defines the view model behaviors corresponding to numeric expressions. */ + NUMERIC_EXPRESSION( + ViewType.NUMERIC_EXPRESSION_INPUT_INTERACTION, + defaultHintTextStringId = R.string.numeric_expression_default_hint_text, + hasPlaceholder = true, + hasCustomVariables = false + ) { + override fun computeLatex( + answerText: String, + useFractionsForDivision: Boolean, + allowedVariables: List + ): String? { + return parseAnswer(answerText, allowedVariables) + .getResult() + ?.toRawLatex(useFractionsForDivision) + } + + override fun computeHumanReadableString( + answerText: String, + useFractionsForDivision: Boolean, + allowedVariables: List, + mathExpressionAccessibilityUtil: MathExpressionAccessibilityUtil, + language: OppiaLanguage + ): String? { + return parseAnswer(answerText, allowedVariables).getResult()?.let { exp -> + mathExpressionAccessibilityUtil.convertToHumanReadableString( + exp, language, useFractionsForDivision + ) + } + } + + override fun parseAnswer( + answerText: String, + allowedVariables: List + ): MathParsingResult { + return MathExpressionParser.parseNumericExpression(answerText) + } + }, + + /** Defines the view model behaviors corresponding to algebraic expressions. */ + ALGEBRAIC_EXPRESSION( + ViewType.ALGEBRAIC_EXPRESSION_INPUT_INTERACTION, + defaultHintTextStringId = R.string.algebraic_expression_default_hint_text, + hasPlaceholder = false, + hasCustomVariables = true + ) { + override fun computeLatex( + answerText: String, + useFractionsForDivision: Boolean, + allowedVariables: List + ): String? { + return parseAnswer(answerText, allowedVariables) + .getResult() + ?.toRawLatex(useFractionsForDivision) + } + + override fun computeHumanReadableString( + answerText: String, + useFractionsForDivision: Boolean, + allowedVariables: List, + mathExpressionAccessibilityUtil: MathExpressionAccessibilityUtil, + language: OppiaLanguage + ): String? { + return parseAnswer(answerText, allowedVariables).getResult()?.let { exp -> + mathExpressionAccessibilityUtil.convertToHumanReadableString( + exp, language, useFractionsForDivision + ) + } + } + + override fun parseAnswer( + answerText: String, + allowedVariables: List + ): MathParsingResult = + MathExpressionParser.parseAlgebraicExpression(answerText, allowedVariables) + }, + + /** Defines the view model behaviors corresponding to math equations. */ + MATH_EQUATION( + ViewType.MATH_EQUATION_INPUT_INTERACTION, + defaultHintTextStringId = R.string.math_equation_default_hint_text, + hasPlaceholder = false, + hasCustomVariables = true + ) { + override fun computeLatex( + answerText: String, + useFractionsForDivision: Boolean, + allowedVariables: List + ): String? { + return parseAnswer(answerText, allowedVariables) + .getResult() + ?.toRawLatex(useFractionsForDivision) + } + + override fun computeHumanReadableString( + answerText: String, + useFractionsForDivision: Boolean, + allowedVariables: List, + mathExpressionAccessibilityUtil: MathExpressionAccessibilityUtil, + language: OppiaLanguage + ): String? { + return parseAnswer(answerText, allowedVariables).getResult()?.let { exp -> + mathExpressionAccessibilityUtil.convertToHumanReadableString( + exp, language, useFractionsForDivision + ) + } + } + + override fun parseAnswer( + answerText: String, + allowedVariables: List + ): MathParsingResult = + MathExpressionParser.parseAlgebraicEquation(answerText, allowedVariables) + }; + + /** + * Computes and returns the human-readable error corresponding to the specified answer and + * context, or null if there the answer has no errors. + */ + fun computeSubmitTimeError( + answerText: String, + allowedVariables: List, + appLanguageResourceHandler: AppLanguageResourceHandler + ): String? { + return when (val parseResult = parseAnswer(answerText, allowedVariables)) { + is MathParsingResult.Failure -> when (val error = parseResult.error) { + is DisabledVariablesInUseError -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_invalid_variable, + error.variables.joinToString(separator = ", ") + ) + } + EquationIsMissingEqualsError -> { + appLanguageResourceHandler.getStringInLocale( + R.string.math_expression_error_missing_equals + ) + } + EquationHasTooManyEqualsError -> { + appLanguageResourceHandler.getStringInLocale( + R.string.math_expression_error_more_than_one_equals + ) + } + EquationMissingLhsOrRhsError -> { + appLanguageResourceHandler.getStringInLocale( + R.string.math_expression_error_hanging_equals + ) + } + ExponentIsVariableExpressionError -> { + appLanguageResourceHandler.getStringInLocale( + R.string.math_expression_error_exponent_has_variable + ) + } + ExponentTooLargeError -> { + appLanguageResourceHandler.getStringInLocale( + R.string.math_expression_error_exponent_too_large + ) + } + FunctionNameIncompleteError -> { + appLanguageResourceHandler.getStringInLocale( + R.string.math_expression_error_incomplete_function_name + ) + } + GenericError -> { + appLanguageResourceHandler.getStringInLocale( + R.string.math_expression_error_generic + ) + } + HangingSquareRootError -> { + appLanguageResourceHandler.getStringInLocale( + R.string.math_expression_error_hanging_square_root + ) + } + is InvalidFunctionInUseError -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_unsupported_function, error.functionName + ) + } + is MultipleRedundantParenthesesError -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_multiple_redundant_parentheses, error.rawExpression + ) + } + NestedExponentsError -> { + appLanguageResourceHandler.getStringInLocale( + R.string.math_expression_error_nested_exponent + ) + } + is NoVariableOrNumberAfterBinaryOperatorError -> when (error.operator) { + UnaryOperator.ADD -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_missing_rhs_for_addition_operator, + error.operatorSymbol + ) + } + UnaryOperator.SUBTRACT -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_missing_rhs_for_subtraction_operator, + error.operatorSymbol + ) + } + UnaryOperator.MULTIPLY -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_missing_rhs_for_multiplication_operator, + error.operatorSymbol + ) + } + UnaryOperator.DIVIDE -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_missing_rhs_for_division_operator, + error.operatorSymbol + ) + } + UnaryOperator.EXPONENTIATE -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_missing_rhs_for_exponentiation_operator, + error.operatorSymbol + ) + } + UnaryOperator.OPERATOR_UNSPECIFIED, UnaryOperator.UNRECOGNIZED -> { + appLanguageResourceHandler.getStringInLocale( + R.string.math_expression_error_generic + ) + } + } + is NoVariableOrNumberBeforeBinaryOperatorError -> when (error.operator) { + UnaryOperator.ADD -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_missing_lhs_for_addition_operator, + error.operatorSymbol + ) + } + // Subtraction can't happen since these cases are treated as negation. + UnaryOperator.SUBTRACT -> error("This case should never happen.") + UnaryOperator.MULTIPLY -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_missing_lhs_for_multiplication_operator, + error.operatorSymbol + ) + } + UnaryOperator.DIVIDE -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_missing_lhs_for_division_operator, + error.operatorSymbol + ) + } + UnaryOperator.EXPONENTIATE -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_missing_lhs_for_exponentiation_operator, + error.operatorSymbol + ) + } + UnaryOperator.OPERATOR_UNSPECIFIED, UnaryOperator.UNRECOGNIZED -> { + appLanguageResourceHandler.getStringInLocale( + R.string.math_expression_error_generic + ) + } + } + is NumberAfterVariableError -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_number_after_var_term, + error.variable, + error.number.toPlainText() + ) + } + is RedundantParenthesesForIndividualTermsError -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_redundant_parentheses_individual_term, + error.rawExpression + ) + } + is SingleRedundantParenthesesError -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_single_redundant_parentheses, error.rawExpression + ) + } + SpacesBetweenNumbersError -> { + appLanguageResourceHandler.getStringInLocale( + R.string.math_expression_error_spaces_in_numerical_input + ) + } + is SubsequentBinaryOperatorsError -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_consecutive_binary_operators, + error.operator1, + error.operator2 + ) + } + is SubsequentUnaryOperatorsError -> { + appLanguageResourceHandler.getStringInLocale( + R.string.math_expression_error_consecutive_unary_operators + ) + } + TermDividedByZeroError -> { + appLanguageResourceHandler.getStringInLocale( + R.string.math_expression_error_term_divided_by_zero + ) + } + UnbalancedParenthesesError -> { + appLanguageResourceHandler.getStringInLocale( + R.string.math_expression_error_unbalanced_parentheses + ) + } + is UnnecessarySymbolsError -> { + appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.math_expression_error_unnecessary_symbols, error.invalidSymbol + ) + } + VariableInNumericExpressionError -> { + appLanguageResourceHandler.getStringInLocale( + R.string.math_expression_error_variable_in_numeric_expression + ) + } + } + is MathParsingResult.Success -> null // No errors. + } + } + + /** + * Returns the LaTeX representation of the specified answer with potential customization for + * treating divisions as fractions per [useFractionsForDivision]. + */ + abstract fun computeLatex( + answerText: String, + useFractionsForDivision: Boolean, + allowedVariables: List + ): String? + + /** + * Returns the human-readable accessibility string corresponding to the specified answer with + * potential customization for treating divisions as fractions per [useFractionsForDivision]. + */ + abstract fun computeHumanReadableString( + answerText: String, + useFractionsForDivision: Boolean, + allowedVariables: List, + mathExpressionAccessibilityUtil: MathExpressionAccessibilityUtil, + language: OppiaLanguage + ): String? + + /** Attempts to parse the provided raw answer and return the [MathParsingResult]. */ + protected abstract fun parseAnswer( + answerText: String, + allowedVariables: List + ): MathParsingResult<*> + + protected companion object { + /** + * Returns the successful result from this [MathParsingResult] or null if it's a failure. + */ + fun MathParsingResult.getResult(): T? = when (this) { + is MathParsingResult.Success -> result + is MathParsingResult.Failure -> null + } + } + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt index 4c34453e937..70deff47224 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt @@ -4,6 +4,7 @@ import android.text.Editable import android.text.TextWatcher import androidx.databinding.Observable import androidx.databinding.ObservableField +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.WrittenTranslationContext @@ -11,10 +12,12 @@ import org.oppia.android.app.parser.StringToNumberParser import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory 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 javax.inject.Inject /** [StateItemViewModel] for the numeric input interaction. */ -class NumericInputViewModel( +class NumericInputViewModel private constructor( val hasConversationView: Boolean, private val interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length val isSplitView: Boolean, @@ -86,4 +89,28 @@ class NumericInputViewModel( this.writtenTranslationContext = this@NumericInputViewModel.writtenTranslationContext } }.build() + + /** Implementation of [StateItemViewModel.InteractionItemFactory] for this view model. */ + class FactoryImpl @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler + ) : InteractionItemFactory { + override fun create( + entityId: String, + hasConversationView: Boolean, + interaction: Interaction, + interactionAnswerReceiver: InteractionAnswerReceiver, + answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, + hasPreviousButton: Boolean, + isSplitView: Boolean, + writtenTranslationContext: WrittenTranslationContext + ): StateItemViewModel { + return NumericInputViewModel( + hasConversationView, + answerErrorReceiver, + isSplitView, + writtenTranslationContext, + resourceHandler + ) + } + } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt index 6f916b0f4d0..749151c4c40 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt @@ -13,13 +13,15 @@ import org.oppia.android.app.parser.StringToRatioParser import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory 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.utility.toAccessibleAnswerString import org.oppia.android.domain.translation.TranslationController import org.oppia.android.util.math.toAnswerString +import javax.inject.Inject /** [StateItemViewModel] for the ratio expression input interaction. */ -class RatioExpressionInputInteractionViewModel( +class RatioExpressionInputInteractionViewModel private constructor( interaction: Interaction, val hasConversationView: Boolean, val isSplitView: Boolean, @@ -124,4 +126,31 @@ class RatioExpressionInputInteractionViewModel( else -> resourceHandler.getStringInLocale(R.string.ratio_default_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 + ): StateItemViewModel { + return RatioExpressionInputInteractionViewModel( + interaction, + hasConversationView, + isSplitView, + answerErrorReceiver, + writtenTranslationContext, + resourceHandler, + translationController + ) + } + } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt index f86578a3c7d..21976f2a971 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt @@ -12,8 +12,10 @@ 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.InteractionAnswerHandler +import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver import org.oppia.android.app.viewmodel.ObservableArrayList import org.oppia.android.domain.translation.TranslationController +import javax.inject.Inject /** Corresponds to the type of input that should be used for an item selection interaction view. */ enum class SelectionItemInputType { @@ -22,7 +24,7 @@ enum class SelectionItemInputType { } /** [StateItemViewModel] for multiple or item-selection input choice list. */ -class SelectionInteractionViewModel( +class SelectionInteractionViewModel private constructor( val entityId: String, val hasConversationView: Boolean, interaction: Interaction, @@ -157,6 +159,32 @@ class SelectionInteractionViewModel( } } + /** Implementation of [StateItemViewModel.InteractionItemFactory] for this view model. */ + class FactoryImpl @Inject constructor( + private val translationController: TranslationController + ) : InteractionItemFactory { + override fun create( + entityId: String, + hasConversationView: Boolean, + interaction: Interaction, + interactionAnswerReceiver: InteractionAnswerReceiver, + answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, + hasPreviousButton: Boolean, + isSplitView: Boolean, + writtenTranslationContext: WrittenTranslationContext + ): StateItemViewModel { + return SelectionInteractionViewModel( + entityId, + hasConversationView, + interaction, + answerErrorReceiver, + isSplitView, + writtenTranslationContext, + translationController + ) + } + } + companion object { private fun computeChoiceItems( choiceSubtitledHtmls: List, diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SplitScreenInteractionIds.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SplitScreenInteractionIds.kt new file mode 100644 index 00000000000..244d58fa46d --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SplitScreenInteractionIds.kt @@ -0,0 +1,8 @@ +package org.oppia.android.app.player.state.itemviewmodel + +import javax.inject.Qualifier + +/** + * Corresponds to an injectable set of string interaction IDs that support split-screen variants. + */ +@Qualifier annotation class SplitScreenInteractionIds diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SplitScreenInteractionModule.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SplitScreenInteractionModule.kt new file mode 100644 index 00000000000..b9ab2f588da --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SplitScreenInteractionModule.kt @@ -0,0 +1,19 @@ +package org.oppia.android.app.player.state.itemviewmodel + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet + +/** Module to define which interactions support split-screen versions. */ +@Module +class SplitScreenInteractionModule { + @Provides + @IntoSet + @SplitScreenInteractionIds + fun provideDragAndDropSortInputSplitScreenSupportIndication(): String = "DragAndDropSortInput" + + @Provides + @IntoSet + @SplitScreenInteractionIds + fun provideImageClickInputSplitScreenSupportIndication(): String = "ImageClickInput" +} 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 cce34492ed6..bce47761e47 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 @@ -1,5 +1,9 @@ package org.oppia.android.app.player.state.itemviewmodel +import org.oppia.android.app.model.Interaction +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.viewmodel.ObservableViewModel /** @@ -27,5 +31,28 @@ abstract class StateItemViewModel(val viewType: ViewType) : ObservableViewModel( DRAG_DROP_SORT_INTERACTION, IMAGE_REGION_SELECTION_INTERACTION, RATIO_EXPRESSION_INPUT_INTERACTION, + NUMERIC_EXPRESSION_INPUT_INTERACTION, + ALGEBRAIC_EXPRESSION_INPUT_INTERACTION, + MATH_EQUATION_INPUT_INTERACTION + } + + /** Factory for creating new [StateItemViewModel]s for interactions. */ + interface InteractionItemFactory { + /** + * Returns a new [StateItemViewModel] corresponding to this interaction with the GCS entity ID, + * the [Interaction] object corresponding to the interaction view, a receiver for answers if this + * interaction pushes answers, and whether there's a previous button enabled (only relevant for + * navigation-based interactions). + */ + fun create( + entityId: String, + hasConversationView: Boolean, + interaction: Interaction, + interactionAnswerReceiver: InteractionAnswerReceiver, + answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, + hasPreviousButton: Boolean, + isSplitView: Boolean, + writtenTranslationContext: WrittenTranslationContext + ): StateItemViewModel } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt index 821faa8676d..4afebaccc72 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt @@ -10,10 +10,12 @@ 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.InteractionAnswerHandler +import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver import org.oppia.android.domain.translation.TranslationController +import javax.inject.Inject /** [StateItemViewModel] for the text input interaction. */ -class TextInputViewModel( +class TextInputViewModel private constructor( interaction: Interaction, val hasConversationView: Boolean, private val interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length @@ -84,4 +86,29 @@ class TextInputViewModel( } ?: "" // The default placeholder for text input is empty. return if (placeholder1.isNotEmpty()) placeholder1 else placeholder2 } + + /** Implementation of [StateItemViewModel.InteractionItemFactory] for this view model. */ + class FactoryImpl @Inject constructor( + private val translationController: TranslationController + ) : InteractionItemFactory { + override fun create( + entityId: String, + hasConversationView: Boolean, + interaction: Interaction, + interactionAnswerReceiver: InteractionAnswerReceiver, + answerErrorReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, + hasPreviousButton: Boolean, + isSplitView: Boolean, + writtenTranslationContext: WrittenTranslationContext + ): StateItemViewModel { + return TextInputViewModel( + interaction, + hasConversationView, + answerErrorReceiver, + isSplitView, + writtenTranslationContext, + translationController + ) + } + } } diff --git a/app/src/main/java/org/oppia/android/app/testing/ExplorationTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/ExplorationTestActivity.kt index a1793b20654..0eab42a60a2 100644 --- a/app/src/main/java/org/oppia/android/app/testing/ExplorationTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/ExplorationTestActivity.kt @@ -6,17 +6,25 @@ import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.home.RouteToExplorationListener import org.oppia.android.app.player.exploration.ExplorationActivity import org.oppia.android.app.topic.TopicFragment +import org.oppia.android.app.utility.SplitScreenManager import javax.inject.Inject /** The activity for testing [TopicFragment]. */ class ExplorationTestActivity : InjectableAppCompatActivity(), RouteToExplorationListener { @Inject - lateinit var explorationTestActivityPresenter: ExplorationTestActivityPresenter + lateinit var presenter: ExplorationTestActivityPresenter + + /** + * Exposes the [SplitScreenManager] corresponding to the fragment under test for tests to interact + * with. + */ + val splitScreenManager: SplitScreenManager + get() = getTestFragment().splitScreenManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) - explorationTestActivityPresenter.handleOnCreate() + presenter.handleOnCreate() } override fun routeToExploration( @@ -39,4 +47,9 @@ class ExplorationTestActivity : InjectableAppCompatActivity(), RouteToExploratio ) ) } + + private fun getTestFragment() = checkNotNull(presenter.getTestFragment()) { + "Expected TestFragment to be present in inflated test activity. Did you try to retrieve the" + + " screen manager too early in the test?" + } } diff --git a/app/src/main/java/org/oppia/android/app/testing/ExplorationTestActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/testing/ExplorationTestActivityPresenter.kt index 5fda9abfdb1..2b916b46d2c 100644 --- a/app/src/main/java/org/oppia/android/app/testing/ExplorationTestActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/testing/ExplorationTestActivityPresenter.kt @@ -1,12 +1,16 @@ package org.oppia.android.app.testing +import android.content.Context import android.widget.Button import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.Observer import org.oppia.android.R import org.oppia.android.app.activity.ActivityScope +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.app.home.RouteToExplorationListener import org.oppia.android.app.model.ExplorationCheckpoint +import org.oppia.android.app.utility.SplitScreenManager import org.oppia.android.domain.exploration.ExplorationDataController import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.topic.TEST_EXPLORATION_ID_2 @@ -21,6 +25,7 @@ private const val TOPIC_ID = TEST_TOPIC_ID_0 private const val STORY_ID = TEST_STORY_ID_0 private const val EXPLORATION_ID = TEST_EXPLORATION_ID_2 private const val TAG_EXPLORATION_TEST_ACTIVITY = "ExplorationTestActivity" +private const val TEST_FRAGMENT_TAG = "ExplorationTestActivity.TestFragment" /** The presenter for [ExplorationTestActivityPresenter]. */ @ActivityScope @@ -34,6 +39,9 @@ class ExplorationTestActivityPresenter @Inject constructor( fun handleOnCreate() { activity.setContentView(R.layout.exploration_test_activity) + activity.supportFragmentManager.beginTransaction().apply { + add(R.id.exploration_test_fragment_placeholder, TestFragment(), TEST_FRAGMENT_TAG) + }.commitNow() activity.findViewById