diff --git a/app/build.gradle b/app/build.gradle index e4798b22142..69702f1ccb1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -62,6 +62,7 @@ dependencies { 'androidx.appcompat:appcompat:1.0.2', 'androidx.constraintlayout:constraintlayout:1.1.3', 'androidx.core:core-ktx:1.0.2', + "androidx.fragment:fragment:$fragment_version", 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0-alpha03', 'androidx.multidex:multidex:2.0.1', 'androidx.recyclerview:recyclerview:1.0.0', @@ -72,6 +73,7 @@ dependencies { "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version", 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1', 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1', + 'org.mockito:mockito-core:2.7.22', ) testImplementation( 'androidx.test:core:1.2.0', @@ -82,6 +84,7 @@ dependencies { 'com.google.truth:truth:0.43', 'org.robolectric:robolectric:4.3', 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2', + 'org.mockito:mockito-core:2.7.22', ) androidTestImplementation( 'androidx.test:core:1.2.0', @@ -91,7 +94,8 @@ dependencies { 'androidx.test.ext:junit:1.1.1', 'androidx.test:runner:1.2.0', 'com.google.truth:truth:0.43', - 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2' + 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2', + 'org.mockito:mockito-android:2.7.22', ) androidTestUtil( 'androidx.test:orchestrator:1.2.0', diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3be33405c45..ef846c9da5a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,9 @@ - + + fun inject(addProfileFragment: AddProfileFragment) fun inject(adminAuthFragment: AdminAuthFragment) fun inject(audioFragment: AudioFragment) diff --git a/app/src/main/java/org/oppia/app/fragment/FragmentModule.kt b/app/src/main/java/org/oppia/app/fragment/FragmentModule.kt new file mode 100644 index 00000000000..3592fc0d8f7 --- /dev/null +++ b/app/src/main/java/org/oppia/app/fragment/FragmentModule.kt @@ -0,0 +1,7 @@ +package org.oppia.app.fragment + +import dagger.Module +import org.oppia.app.view.ViewComponent + +/** Root fragment module. */ +@Module(subcomponents = [ViewComponent::class]) class FragmentModule diff --git a/app/src/main/java/org/oppia/app/fragment/InjectableDialogFragment.kt b/app/src/main/java/org/oppia/app/fragment/InjectableDialogFragment.kt index 2f0f96f3dde..d51e5b26116 100644 --- a/app/src/main/java/org/oppia/app/fragment/InjectableDialogFragment.kt +++ b/app/src/main/java/org/oppia/app/fragment/InjectableDialogFragment.kt @@ -16,7 +16,7 @@ abstract class InjectableDialogFragment: DialogFragment() { */ lateinit var fragmentComponent: FragmentComponent - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent = (requireActivity() as InjectableAppCompatActivity).createFragmentComponent(this) } diff --git a/app/src/main/java/org/oppia/app/fragment/InjectableFragment.kt b/app/src/main/java/org/oppia/app/fragment/InjectableFragment.kt index 25c3628f07a..8504c3e9a46 100644 --- a/app/src/main/java/org/oppia/app/fragment/InjectableFragment.kt +++ b/app/src/main/java/org/oppia/app/fragment/InjectableFragment.kt @@ -1,8 +1,10 @@ package org.oppia.app.fragment import android.content.Context +import android.view.View import androidx.fragment.app.Fragment import org.oppia.app.activity.InjectableAppCompatActivity +import org.oppia.app.view.ViewComponent /** * A fragment that facilitates field injection to children. This fragment can only be used with @@ -16,8 +18,12 @@ abstract class InjectableFragment: Fragment() { */ lateinit var fragmentComponent: FragmentComponent - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent = (requireActivity() as InjectableAppCompatActivity).createFragmentComponent(this) } + + fun createViewComponent(view: View): ViewComponent { + return fragmentComponent.getViewComponentBuilderProvider().get().setView(view).build() + } } diff --git a/app/src/main/java/org/oppia/app/home/HomeFragment.kt b/app/src/main/java/org/oppia/app/home/HomeFragment.kt index d22e1524374..bcde5a35255 100644 --- a/app/src/main/java/org/oppia/app/home/HomeFragment.kt +++ b/app/src/main/java/org/oppia/app/home/HomeFragment.kt @@ -14,7 +14,7 @@ import javax.inject.Inject class HomeFragment : InjectableFragment(), TopicSummaryClickListener { @Inject lateinit var homeFragmentPresenter: HomeFragmentPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/home/HomeFragmentPresenter.kt b/app/src/main/java/org/oppia/app/home/HomeFragmentPresenter.kt index ada9daff3e2..2d52065f9f9 100644 --- a/app/src/main/java/org/oppia/app/home/HomeFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/home/HomeFragmentPresenter.kt @@ -20,14 +20,14 @@ import org.oppia.app.model.TopicSummary import org.oppia.app.model.UserAppHistory import org.oppia.domain.UserAppHistoryController import org.oppia.domain.exploration.ExplorationDataController -import org.oppia.domain.exploration.TEST_EXPLORATION_ID_5 +import org.oppia.domain.exploration.TEST_EXPLORATION_ID_30 import org.oppia.domain.topic.TEST_TOPIC_ID_0 import org.oppia.domain.topic.TopicListController import org.oppia.util.data.AsyncResult import org.oppia.util.logging.Logger import javax.inject.Inject -private const val EXPLORATION_ID = TEST_EXPLORATION_ID_5 +private const val EXPLORATION_ID = TEST_EXPLORATION_ID_30 private const val TAG_HOME_FRAGMENT = "HomeFragment" /** The presenter for [HomeFragment]. */ @@ -82,6 +82,7 @@ class HomeFragmentPresenter @Inject constructor( } fun playExplorationButton(v: View) { + explorationDataController.stopPlayingExploration() explorationDataController.startPlayingExploration( EXPLORATION_ID ).observe(fragment, Observer> { result -> diff --git a/app/src/main/java/org/oppia/app/home/continueplaying/ContinuePlayingFragment.kt b/app/src/main/java/org/oppia/app/home/continueplaying/ContinuePlayingFragment.kt index ab4043b69a2..3afc7a5129d 100644 --- a/app/src/main/java/org/oppia/app/home/continueplaying/ContinuePlayingFragment.kt +++ b/app/src/main/java/org/oppia/app/home/continueplaying/ContinuePlayingFragment.kt @@ -13,7 +13,7 @@ import javax.inject.Inject class ContinuePlayingFragment : InjectableFragment(), OngoingStoryClickListener { @Inject lateinit var continuePlayingFragmentPresenter: ContinuePlayingFragmentPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/home/topiclist/PromotedStoryViewModel.kt b/app/src/main/java/org/oppia/app/home/topiclist/PromotedStoryViewModel.kt index 0469191daa5..9a571d0266f 100755 --- a/app/src/main/java/org/oppia/app/home/topiclist/PromotedStoryViewModel.kt +++ b/app/src/main/java/org/oppia/app/home/topiclist/PromotedStoryViewModel.kt @@ -5,10 +5,10 @@ import androidx.appcompat.app.AppCompatActivity import androidx.databinding.ObservableField import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel -import org.oppia.app.home.continueplaying.ContinuePlayingActivity import org.oppia.app.home.HomeItemViewModel import org.oppia.app.home.RouteToContinuePlayingListener import org.oppia.app.home.RouteToTopicPlayStoryListener +import org.oppia.app.home.continueplaying.ContinuePlayingActivity import org.oppia.app.model.PromotedStory import org.oppia.app.topic.TopicActivity diff --git a/app/src/main/java/org/oppia/app/home/topiclist/TopicSummaryViewModel.kt b/app/src/main/java/org/oppia/app/home/topiclist/TopicSummaryViewModel.kt index a0be5207e55..722ec79a86b 100755 --- a/app/src/main/java/org/oppia/app/home/topiclist/TopicSummaryViewModel.kt +++ b/app/src/main/java/org/oppia/app/home/topiclist/TopicSummaryViewModel.kt @@ -3,7 +3,6 @@ package org.oppia.app.home.topiclist import android.graphics.Color import android.view.View import androidx.annotation.ColorInt -import androidx.lifecycle.ViewModel import org.oppia.app.home.HomeItemViewModel import org.oppia.app.model.TopicSummary diff --git a/app/src/main/java/org/oppia/app/parser/StringToFractionParser.kt b/app/src/main/java/org/oppia/app/parser/StringToFractionParser.kt index ad60458c009..33d2facb17d 100644 --- a/app/src/main/java/org/oppia/app/parser/StringToFractionParser.kt +++ b/app/src/main/java/org/oppia/app/parser/StringToFractionParser.kt @@ -1,34 +1,56 @@ package org.oppia.app.parser import org.oppia.app.model.Fraction +import org.oppia.domain.util.normalizeWhitespace /** This class contains method that helps to parse string to fraction. */ class StringToFractionParser { + private val wholeNumberOnlyRegex = """^-? ?(\d+)$""".toRegex() + private val fractionOnlyRegex = """^-? ?(\d+) ?/ ?(\d)+$""".toRegex() + private val mixedNumberRegex = """^-? ?(\d)+ ?(\d+) ?/ ?(\d)+$""".toRegex() + fun getFractionFromString(text: String): Fraction { - var inputText: String = text - var isNegative = false - var numerator = "0" - var denominator = "0" - var wholeNumber = "0" - val fractionObjectBuilder = Fraction.newBuilder() - if (inputText.startsWith("-")) - isNegative = true - inputText = inputText.replace("-", "").trim() - wholeNumber = if (inputText.contains("/") && inputText.contains(" ")) { - inputText.substringBefore(" ") - } else if (inputText.contains("/")) { - wholeNumber - } else { - inputText - } - inputText = - if (inputText.contains(" ")) inputText.substringAfter(" ").replace(" ", "") else inputText.replace(" ", "") - if (inputText.contains("/")) { - numerator = inputText.substringBefore("/") - denominator = inputText.substringAfter("/") - } - fractionObjectBuilder.setIsNegative(isNegative).setNumerator(numerator.toInt()) - .setDenominator(denominator.toInt()).wholeNumber = wholeNumber.toInt() - return fractionObjectBuilder.build() + // Normalize whitespace to ensure that answer follows a simpler subset of possible patterns. + val inputText: String = text.normalizeWhitespace() + return parseMixedNumber(inputText) + ?: parseFraction(inputText) + ?: parseWholeNumber(inputText) + ?: throw IllegalArgumentException("Incorrectly formatted fraction: $text") + } + + private fun parseMixedNumber(inputText: String): Fraction? { + val mixedNumberMatch = mixedNumberRegex.matchEntire(inputText) ?: return null + val (_, wholeNumberText, numeratorText, denominatorText) = mixedNumberMatch.groupValues + return Fraction.newBuilder() + .setIsNegative(isInputNegative(inputText)) + .setWholeNumber(wholeNumberText.toInt()) + .setNumerator(numeratorText.toInt()) + .setDenominator(denominatorText.toInt()) + .build() + } + + private fun parseFraction(inputText: String): Fraction? { + val fractionOnlyMatch = fractionOnlyRegex.matchEntire(inputText) ?: return null + val (_, numeratorText, denominatorText) = fractionOnlyMatch.groupValues + // Fraction-only numbers imply no whole number. + return Fraction.newBuilder() + .setIsNegative(isInputNegative(inputText)) + .setNumerator(numeratorText.toInt()) + .setDenominator(denominatorText.toInt()) + .build() + } + + private fun parseWholeNumber(inputText: String): Fraction? { + val wholeNumberMatch = wholeNumberOnlyRegex.matchEntire(inputText) ?: return null + val (_, wholeNumberText) = wholeNumberMatch.groupValues + // Whole number fractions imply '0/1' fractional parts. + return Fraction.newBuilder() + .setIsNegative(isInputNegative(inputText)) + .setWholeNumber(wholeNumberText.toInt()) + .setNumerator(0) + .setDenominator(1) + .build() } + + private fun isInputNegative(inputText: String): Boolean = inputText.startsWith("-") } diff --git a/app/src/main/java/org/oppia/app/player/audio/AudioFragment.kt b/app/src/main/java/org/oppia/app/player/audio/AudioFragment.kt index 50ebfab35e8..57328e23272 100755 --- a/app/src/main/java/org/oppia/app/player/audio/AudioFragment.kt +++ b/app/src/main/java/org/oppia/app/player/audio/AudioFragment.kt @@ -34,7 +34,7 @@ class AudioFragment : InjectableFragment(), LanguageInterface { @Inject lateinit var audioFragmentPresenter: AudioFragmentPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/player/audio/AudioFragmentPresenter.kt b/app/src/main/java/org/oppia/app/player/audio/AudioFragmentPresenter.kt index eb99183e4c6..aa9aded8043 100755 --- a/app/src/main/java/org/oppia/app/player/audio/AudioFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/player/audio/AudioFragmentPresenter.kt @@ -19,7 +19,6 @@ import org.oppia.domain.exploration.ExplorationDataController import org.oppia.util.data.AsyncResult import org.oppia.util.logging.Logger import javax.inject.Inject -import kotlin.collections.ArrayList private const val TAG_LANGUAGE_DIALOG = "LANGUAGE_DIALOG" private const val KEY_SELECTED_LANGUAGE = "SELECTED_LANGUAGE" diff --git a/app/src/main/java/org/oppia/app/player/audio/CellularDataDialogFragment.kt b/app/src/main/java/org/oppia/app/player/audio/CellularDataDialogFragment.kt index 417465ef018..187bd4ccbfe 100755 --- a/app/src/main/java/org/oppia/app/player/audio/CellularDataDialogFragment.kt +++ b/app/src/main/java/org/oppia/app/player/audio/CellularDataDialogFragment.kt @@ -3,9 +3,9 @@ package org.oppia.app.player.audio import android.app.Dialog import android.content.Context import android.os.Bundle +import android.widget.CheckBox import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment -import android.widget.CheckBox import org.oppia.app.R import org.oppia.app.player.state.StateFragment diff --git a/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivityPresenter.kt b/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivityPresenter.kt index 94faaf4371c..9d4c2743892 100755 --- a/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivityPresenter.kt +++ b/app/src/main/java/org/oppia/app/player/exploration/ExplorationActivityPresenter.kt @@ -1,6 +1,7 @@ package org.oppia.app.player.exploration import android.os.Bundle +import android.view.View import androidx.appcompat.app.AppCompatActivity import org.oppia.app.R import org.oppia.app.activity.ActivityScope @@ -11,6 +12,9 @@ import javax.inject.Inject class ExplorationActivityPresenter @Inject constructor(private val activity: AppCompatActivity) { fun handleOnCreate(explorationId: String) { activity.setContentView(R.layout.exploration_activity) + + activity.setSupportActionBar(activity.findViewById(R.id.exploration_toolbar)) + if (getExplorationFragment() == null) { val explorationFragment = ExplorationFragment() val args = Bundle() @@ -21,9 +25,15 @@ class ExplorationActivityPresenter @Inject constructor(private val activity: App explorationFragment ).commitNow() } + + activity.findViewById(R.id.enable_audio_playback_button).setOnClickListener { + getExplorationFragment()?.handlePlayAudio() + } } private fun getExplorationFragment(): ExplorationFragment? { - return activity.supportFragmentManager.findFragmentById(R.id.exploration_fragment_placeholder) as ExplorationFragment? + return activity.supportFragmentManager.findFragmentById( + R.id.exploration_fragment_placeholder + ) as ExplorationFragment? } } diff --git a/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragment.kt b/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragment.kt index 337112eee6c..998a5bd52ba 100755 --- a/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragment.kt +++ b/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragment.kt @@ -12,7 +12,7 @@ import javax.inject.Inject class ExplorationFragment : InjectableFragment() { @Inject lateinit var explorationFragmentPresenter: ExplorationFragmentPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } @@ -20,4 +20,6 @@ class ExplorationFragment : InjectableFragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return explorationFragmentPresenter.handleCreateView(inflater, container) } + + fun handlePlayAudio() = explorationFragmentPresenter.handlePlayAudio() } diff --git a/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragmentPresenter.kt b/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragmentPresenter.kt index 4b7dd45ed76..e3bea8ae910 100755 --- a/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragmentPresenter.kt @@ -1,6 +1,5 @@ package org.oppia.app.player.exploration -import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -31,6 +30,10 @@ class ExplorationFragmentPresenter @Inject constructor( return binding } + fun handlePlayAudio() { + getStateFragment()?.handlePlayAudio() + } + private fun getStateFragment(): StateFragment? { return fragment.childFragmentManager.findFragmentById(R.id.state_fragment_placeholder) as StateFragment? } diff --git a/app/src/main/java/org/oppia/app/player/state/InteractionAdapter.kt b/app/src/main/java/org/oppia/app/player/state/InteractionAdapter.kt deleted file mode 100755 index ac2c677d447..00000000000 --- a/app/src/main/java/org/oppia/app/player/state/InteractionAdapter.kt +++ /dev/null @@ -1,200 +0,0 @@ -package org.oppia.app.player.state - -import android.text.Spannable -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.databinding.DataBindingUtil -import androidx.databinding.ViewDataBinding -import androidx.databinding.library.baseAdapters.BR -import androidx.recyclerview.widget.RecyclerView -import kotlinx.android.synthetic.main.item_selection_interaction_items.view.item_selection_checkbox -import kotlinx.android.synthetic.main.item_selection_interaction_items.view.item_selection_contents_text_view -import kotlinx.android.synthetic.main.multiple_choice_interaction_items.view.multiple_choice_content_text_view -import kotlinx.android.synthetic.main.multiple_choice_interaction_items.view.multiple_choice_radio_button -import org.oppia.app.R -import org.oppia.app.databinding.ItemSelectionInteractionItemsBinding -import org.oppia.app.databinding.MultipleChoiceInteractionItemsBinding -import org.oppia.app.model.InteractionObject -import org.oppia.app.model.StringList -import org.oppia.app.player.state.itemviewmodel.SelectionInteractionContentViewModel -import org.oppia.app.player.state.itemviewmodel.SelectionInteractionCustomizationArgsViewModel -import org.oppia.app.player.state.listener.ItemClickListener -import org.oppia.util.logging.Logger -import org.oppia.util.parser.HtmlParser - -private const val VIEW_TYPE_RADIO_BUTTONS = 1 -private const val VIEW_TYPE_CHECKBOXES = 2 -private const val INTERACTION_ADAPTER_TAG = "Interaction Adapter" - -/** - * Adapter to bind the interactions to the [RecyclerView]. It handles MultipleChoiceInput - * and ItemSelectionInput interaction views. - */ -class InteractionAdapter( - private val logger: Logger, - private val htmlParserFactory: HtmlParser.Factory, - private val entityType: String, - private val explorationId: String, - private val itemList: MutableList, - private val selectionInteractionCustomizationArgsViewModel: SelectionInteractionCustomizationArgsViewModel, - private val itemClickListener: ItemClickListener, - private val selectedInputItemIndexes: ArrayList, - private val selectInputItemsListener: SelectInputItemsListener -) : RecyclerView.Adapter() { - - private var itemSelectedPosition = -1 - private var selectedAnswerIndex = -1 - private var selectedHtmlStringList = mutableListOf() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - VIEW_TYPE_RADIO_BUTTONS -> { - val inflater = LayoutInflater.from(parent.context) - val binding = - DataBindingUtil.inflate( - inflater, - R.layout.multiple_choice_interaction_items, - parent, - /* attachToParent= */ false - ) - MultipleChoiceViewHolder(binding) - } - VIEW_TYPE_CHECKBOXES -> { - val inflater = LayoutInflater.from(parent.context) - val binding = - DataBindingUtil.inflate( - inflater, - R.layout.item_selection_interaction_items, - parent, - /* attachToParent= */ false - ) - ItemSelectionViewHolder(binding) - } - else -> throw IllegalArgumentException("Invalid view type: $viewType") - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder.itemViewType) { - VIEW_TYPE_RADIO_BUTTONS -> (holder as MultipleChoiceViewHolder).bind( - itemList[position].htmlContent, - position, - itemSelectedPosition - ) - VIEW_TYPE_CHECKBOXES -> (holder as ItemSelectionViewHolder).bind( - itemList[position], - position - ) - } - } - - // Determines the appropriate ViewType according to the interaction type. - override fun getItemViewType(position: Int): Int { - return if (selectionInteractionCustomizationArgsViewModel.interactionId == "ItemSelectionInput") { - if (selectionInteractionCustomizationArgsViewModel.maxAllowableSelectionCount > 1) { - VIEW_TYPE_CHECKBOXES - } else { - VIEW_TYPE_RADIO_BUTTONS - } - } else { - VIEW_TYPE_RADIO_BUTTONS - } - } - - override fun getItemCount(): Int { - return itemList.size - } - - inner class ItemSelectionViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) { - internal fun bind(selectionInteractionContentViewModel: SelectionInteractionContentViewModel, position: Int) { - - var isOptionSelected = false - - if (selectedInputItemIndexes.contains(position) || selectionInteractionContentViewModel.isAnswerSelected) { - isOptionSelected = true - } - - binding.setVariable(BR.htmlContent, selectionInteractionContentViewModel.htmlContent) - binding.executePendingBindings() - val htmlResult: Spannable = htmlParserFactory.create(entityType, explorationId).parseOppiaHtml( - selectionInteractionContentViewModel.htmlContent, - binding.root.item_selection_contents_text_view - ) - binding.root.item_selection_contents_text_view.text = htmlResult - binding.root.item_selection_checkbox.isChecked = isOptionSelected - binding.root.setOnClickListener { - if (binding.root.item_selection_checkbox.isChecked) { - if (!selectedInputItemIndexes.contains(position)) { - selectedInputItemIndexes.add(position) - } else { - selectedInputItemIndexes.remove(position) - } - selectInputItemsListener.onInputItemSelection(selectedInputItemIndexes) - itemList[adapterPosition].isAnswerSelected = false - selectedHtmlStringList.remove(binding.root.item_selection_contents_text_view.text.toString()) - } else { - if (selectedHtmlStringList.size != selectionInteractionCustomizationArgsViewModel.maxAllowableSelectionCount) { - itemList[adapterPosition].isAnswerSelected = true - selectedHtmlStringList.add(binding.root.item_selection_contents_text_view.text.toString()) - if (selectedInputItemIndexes.contains(position)) { - selectedInputItemIndexes.remove(position) - } else { - selectedInputItemIndexes.add(position) - } - } else { - logger.d( - INTERACTION_ADAPTER_TAG, - "You cannot select more than ${selectionInteractionCustomizationArgsViewModel.maxAllowableSelectionCount} options" - ) - } - } - notifyDataSetChanged() - val interactionObjectBuilder = InteractionObject.newBuilder() - if (selectedHtmlStringList.size >= 0) { - interactionObjectBuilder.setOfHtmlString = StringList.newBuilder().addAllHtml(selectedHtmlStringList).build() - } else { - if (selectedAnswerIndex >= 0) { - interactionObjectBuilder.nonNegativeInt = selectedAnswerIndex - } - } - selectInputItemsListener.onInputItemSelection(selectedInputItemIndexes) - itemClickListener.onItemClick(interactionObjectBuilder.build()) - } - } - } - - inner class MultipleChoiceViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) { - internal fun bind(rawString: String, position: Int, selectedPosition: Int) { - - var isOptionSelected = false - - if (selectedInputItemIndexes.contains(position) || selectedPosition == position) { - isOptionSelected = true - } - - binding.setVariable(BR.htmlContent, rawString) - binding.executePendingBindings() - val htmlResult: Spannable = htmlParserFactory.create(entityType, explorationId).parseOppiaHtml( - rawString, - binding.root.multiple_choice_content_text_view - ) - binding.root.multiple_choice_content_text_view.text = htmlResult - binding.root.multiple_choice_radio_button.isChecked = isOptionSelected - binding.root.setOnClickListener { - - selectedInputItemIndexes.clear() - selectedInputItemIndexes.add(position) - selectInputItemsListener.onInputItemSelection(selectedInputItemIndexes) - - itemSelectedPosition = adapterPosition - selectedAnswerIndex = adapterPosition - notifyDataSetChanged() - val interactionObjectBuilder = InteractionObject.newBuilder() - if (selectedAnswerIndex >= 0) { - interactionObjectBuilder.nonNegativeInt = selectedAnswerIndex - } - itemClickListener.onItemClick(interactionObjectBuilder.build()) - } - } - } -} diff --git a/app/src/main/java/org/oppia/app/player/state/SelectInputItemsListener.kt b/app/src/main/java/org/oppia/app/player/state/SelectInputItemsListener.kt deleted file mode 100755 index 12d95e8f84d..00000000000 --- a/app/src/main/java/org/oppia/app/player/state/SelectInputItemsListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.oppia.app.player.state - -/** - * Interface to keep track of selected options in MultipleChoiceInput and ItemSelectionInput. - */ -interface SelectInputItemsListener { - fun onInputItemSelection(indexList: ArrayList) -} diff --git a/app/src/main/java/org/oppia/app/player/state/SelectionInteractionView.kt b/app/src/main/java/org/oppia/app/player/state/SelectionInteractionView.kt new file mode 100644 index 00000000000..cf4d2d9eafd --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/SelectionInteractionView.kt @@ -0,0 +1,112 @@ +package org.oppia.app.player.state + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.databinding.BindingAdapter +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.RecyclerView +import org.oppia.app.databinding.ItemSelectionInteractionItemsBinding +import org.oppia.app.databinding.MultipleChoiceInteractionItemsBinding +import org.oppia.app.fragment.InjectableFragment +import org.oppia.app.player.state.itemviewmodel.SelectionInteractionContentViewModel +import org.oppia.app.recyclerview.BindableAdapter +import org.oppia.util.parser.ExplorationHtmlParserEntityType +import org.oppia.util.parser.HtmlParser +import javax.inject.Inject + +/** Corresponds to the type of input that should be used for an item selection interaction view. */ +enum class SelectionItemInputType { + CHECKBOXES, + RADIO_BUTTONS +} + +/** + * A custom [RecyclerView] for displaying a variable list of items that may be selected by a user as part of the item + * selection or multiple choice interactions. + */ +class SelectionInteractionView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : RecyclerView(context, attrs, defStyleAttr) { + // Default to checkboxes to ensure that something can render even if it may not be correct. + private var selectionItemInputType: SelectionItemInputType = SelectionItemInputType.CHECKBOXES + + @Inject lateinit var htmlParserFactory: HtmlParser.Factory + @Inject @field:ExplorationHtmlParserEntityType lateinit var entityType: String + private lateinit var explorationId: String + + init { + adapter = createAdapter() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + FragmentManager.findFragment(this).createViewComponent(this).inject(this) + } + + fun setItemInputType(selectionItemInputType: SelectionItemInputType) { + // TODO(#299): Find a cleaner way to initialize the item input type. Using data-binding results in a race condition + // with setting the adapter data, so this needs to be done in an order-agnostic way. There should be a way to do + // this more efficiently and cleanly than always relying on notifying of potential changes in the adapter when the + // type is set (plus the type ought to be permanent). + this.selectionItemInputType = selectionItemInputType + adapter!!.notifyDataSetChanged() + } + + // TODO(#264): Clean up HTML parser such that it can be handled completely through a binding adapter, allowing + // TextViews that require custom Oppia HTML parsing to be fully automatically bound through data-binding. + fun setExplorationId(explorationId: String) { + this.explorationId = explorationId + } + + private fun createAdapter(): BindableAdapter { + return BindableAdapter.Builder + .newBuilder() + .registerViewTypeComputer { selectionItemInputType.ordinal } + .registerViewBinder( + viewType = SelectionItemInputType.CHECKBOXES.ordinal, + inflateView = { parent -> + ItemSelectionInteractionItemsBinding.inflate( + LayoutInflater.from(parent.context), parent, /* attachToParent= */ false + ).root + }, + bindView = { view, viewModel -> + val binding = DataBindingUtil.findBinding(view)!! + binding.htmlContent = htmlParserFactory.create(entityType, explorationId).parseOppiaHtml( + viewModel.htmlContent, binding.itemSelectionContentsTextView + ) + binding.viewModel = viewModel + } + ) + .registerViewBinder( + viewType = SelectionItemInputType.RADIO_BUTTONS.ordinal, + inflateView = { parent -> + MultipleChoiceInteractionItemsBinding.inflate( + LayoutInflater.from(parent.context), parent, /* attachToParent= */ false + ).root + }, + bindView = { view, viewModel -> + val binding = DataBindingUtil.findBinding(view)!! + binding.htmlContent = htmlParserFactory.create(entityType, explorationId).parseOppiaHtml( + viewModel.htmlContent, binding.multipleChoiceContentTextView + ) + binding.viewModel = viewModel + } + ) + .build() + } +} + +/** Sets the [SelectionItemInputType] for a specific [SelectionInteractionView] via data-binding. */ +@BindingAdapter("itemInputType") +fun setItemInputType( + selectionInteractionView: SelectionInteractionView, selectionItemInputType: SelectionItemInputType +) = selectionInteractionView.setItemInputType(selectionItemInputType) + + +/** Sets the exploration ID for a specific [SelectionInteractionView] via data-binding. */ +@BindingAdapter("explorationId") +fun setExplorationId( + selectionInteractionView: SelectionInteractionView, explorationId: String +) = selectionInteractionView.setExplorationId(explorationId) diff --git a/app/src/main/java/org/oppia/app/player/state/StateAdapter.kt b/app/src/main/java/org/oppia/app/player/state/StateAdapter.kt deleted file mode 100755 index a01a1572062..00000000000 --- a/app/src/main/java/org/oppia/app/player/state/StateAdapter.kt +++ /dev/null @@ -1,189 +0,0 @@ -package org.oppia.app.player.state - -import android.text.Spannable -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.databinding.DataBindingUtil -import androidx.databinding.ViewDataBinding -import androidx.recyclerview.widget.RecyclerView -import androidx.databinding.library.baseAdapters.BR -import kotlinx.android.synthetic.main.content_item.view.content_text_view -import kotlinx.android.synthetic.main.selection_interaction_item.view.selection_interaction_frameLayout -import kotlinx.android.synthetic.main.state_button_item.view.* -import org.oppia.app.R -import org.oppia.app.databinding.ContentItemBinding -import org.oppia.app.databinding.SelectionInteractionItemBinding -import org.oppia.app.player.state.itemviewmodel.StateButtonViewModel -import org.oppia.app.player.state.listener.ButtonInteractionListener -import org.oppia.app.databinding.StateButtonItemBinding -import org.oppia.app.model.InteractionObject -import org.oppia.app.player.state.itemviewmodel.ContentViewModel -import org.oppia.app.player.state.itemviewmodel.SelectionInteractionCustomizationArgsViewModel -import org.oppia.app.player.state.itemviewmodel.SelectionInteractionContentViewModel -import org.oppia.app.player.state.listener.ItemClickListener -import org.oppia.util.logging.Logger -import org.oppia.util.parser.HtmlParser - -@Suppress("unused") -private const val VIEW_TYPE_CONTENT = 1 -@Suppress("unused") -private const val VIEW_TYPE_INTERACTION_READ_ONLY = 2 -@Suppress("unused") -private const val VIEW_TYPE_NUMERIC_INPUT_INTERACTION = 3 -@Suppress("unused") -private const val VIEW_TYPE_TEXT_INPUT_INTERACTION = 4 -private const val VIEW_TYPE_STATE_BUTTON = 5 -const val VIEW_TYPE_SELECTION_INTERACTION = 6 - -/** Adapter to inflate different items/views inside [RecyclerView]. The itemList consists of various ViewModels. */ -class StateAdapter( - private val logger: Logger, - private val itemList: MutableList, - private val buttonInteractionListener: ButtonInteractionListener, - private val htmlParserFactory: HtmlParser.Factory, - private val entityType: String, - private val explorationId: String, - private val selectedInputItemIndexes: ArrayList, - private val selectInputItemsListener: SelectInputItemsListener -) : - RecyclerView.Adapter() { - - lateinit var stateButtonViewModel: StateButtonViewModel - private var interactionObjectBuilder: InteractionObject = InteractionObject.newBuilder().build() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - // TODO(#249): Generalize this binding to make adding future interactions easier. - VIEW_TYPE_STATE_BUTTON -> { - val inflater = LayoutInflater.from(parent.context) - val binding = - DataBindingUtil.inflate( - inflater, - R.layout.state_button_item, - parent, - /* attachToParent= */false - ) - StateButtonViewHolder(binding, buttonInteractionListener) - } - VIEW_TYPE_CONTENT -> { - val inflater = LayoutInflater.from(parent.context) - val binding = - DataBindingUtil.inflate( - inflater, - R.layout.content_item, - parent, - /* attachToParent= */ false - ) - ContentViewHolder(binding) - } - VIEW_TYPE_SELECTION_INTERACTION -> { - val inflater = LayoutInflater.from(parent.context) - val binding = - DataBindingUtil.inflate( - inflater, - R.layout.selection_interaction_item, - parent, - /* attachToParent= */ false - ) - SelectionInteractionViewHolder(binding) - } - else -> throw IllegalArgumentException("Invalid view type: $viewType") - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder.itemViewType) { - VIEW_TYPE_STATE_BUTTON -> { - (holder as StateButtonViewHolder).bind(itemList[position] as StateButtonViewModel) - } - VIEW_TYPE_CONTENT -> { - (holder as ContentViewHolder).bind((itemList[position] as ContentViewModel).htmlContent) - } - VIEW_TYPE_SELECTION_INTERACTION -> { - (holder as SelectionInteractionViewHolder).bind(itemList[position] as SelectionInteractionCustomizationArgsViewModel) - } - } - } - - override fun getItemViewType(position: Int): Int { - return when (itemList[position]) { - is ContentViewModel -> VIEW_TYPE_CONTENT - is SelectionInteractionCustomizationArgsViewModel -> VIEW_TYPE_SELECTION_INTERACTION - is StateButtonViewModel -> { - stateButtonViewModel = itemList[position] as StateButtonViewModel - VIEW_TYPE_STATE_BUTTON - } - else -> throw IllegalArgumentException("Invalid type of data $position: ${itemList[position]}") - } - } - - override fun getItemCount(): Int { - return itemList.size - } - - inner class ContentViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) { - internal fun bind(rawString: String) { - binding.setVariable(BR.htmlContent, rawString) - binding.executePendingBindings() - val htmlResult: Spannable = htmlParserFactory.create(entityType, explorationId).parseOppiaHtml( - rawString, - binding.root.content_text_view - ) - binding.root.content_text_view.text = htmlResult - } - } - - private class StateButtonViewHolder( - val binding: ViewDataBinding, - private val buttonInteractionListener: ButtonInteractionListener - ) : RecyclerView.ViewHolder(binding.root) { - internal fun bind(stateButtonViewModel: StateButtonViewModel) { - binding.setVariable(BR.buttonViewModel, stateButtonViewModel) - binding.root.interaction_button.setOnClickListener { - buttonInteractionListener.onInteractionButtonClicked() - } - binding.root.next_state_image_view.setOnClickListener { - buttonInteractionListener.onNextButtonClicked() - } - binding.root.previous_state_image_view.setOnClickListener { - buttonInteractionListener.onPreviousButtonClicked() - } - binding.executePendingBindings() - } - } - - inner class SelectionInteractionViewHolder( - private val binding: ViewDataBinding - ) : RecyclerView.ViewHolder(binding.root), ItemClickListener { - - override fun onItemClick(interactionObject: InteractionObject) { - interactionObjectBuilder = interactionObject - } - - internal fun bind(customizationArgs: SelectionInteractionCustomizationArgsViewModel) { - val items: Array? - val choiceInteractionContentList: MutableList = ArrayList() - val gaeCustomArgsInString = customizationArgs.choiceItems.toString().replace("[", "").replace("]", "") - items = gaeCustomArgsInString.split(",").toTypedArray() - for (values in items) { - val selectionContentViewModel = SelectionInteractionContentViewModel() - selectionContentViewModel.htmlContent = values - selectionContentViewModel.isAnswerSelected = false - choiceInteractionContentList.add(selectionContentViewModel) - } - val interactionAdapter = - InteractionAdapter( - logger, - htmlParserFactory, - entityType, - explorationId, - choiceInteractionContentList, - customizationArgs, - this as ItemClickListener, - selectedInputItemIndexes, - selectInputItemsListener - ) - binding.root.selection_interaction_frameLayout.setAdapter(interactionAdapter) - } - } -} diff --git a/app/src/main/java/org/oppia/app/player/state/StateFragment.kt b/app/src/main/java/org/oppia/app/player/state/StateFragment.kt index 6e96bd837d1..5455b216797 100755 --- a/app/src/main/java/org/oppia/app/player/state/StateFragment.kt +++ b/app/src/main/java/org/oppia/app/player/state/StateFragment.kt @@ -6,13 +6,13 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import org.oppia.app.fragment.InjectableFragment +import org.oppia.app.model.InteractionObject import org.oppia.app.player.audio.CellularDataInterface +import org.oppia.app.player.state.answerhandling.InteractionAnswerReceiver import javax.inject.Inject -internal const val KEY_SELECTED_INPUT_INDEXES = "SELECTED_INPUT_INDEXES" - /** Fragment that represents the current state of an exploration. */ -class StateFragment : InjectableFragment(), CellularDataInterface, SelectInputItemsListener { +class StateFragment : InjectableFragment(), CellularDataInterface, InteractionAnswerReceiver { companion object { /** * Creates a new instance of a StateFragment. @@ -28,37 +28,29 @@ class StateFragment : InjectableFragment(), CellularDataInterface, SelectInputIt } } - private var selectedInputItemIndexes = ArrayList() - @Inject lateinit var stateFragmentPresenter: StateFragmentPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - if (savedInstanceState != null) { - selectedInputItemIndexes = savedInstanceState.getIntegerArrayList(KEY_SELECTED_INPUT_INDEXES) - } - return stateFragmentPresenter.handleCreateView(inflater, container, selectedInputItemIndexes, this as SelectInputItemsListener) + return stateFragmentPresenter.handleCreateView(inflater, container) } - override fun enableAudioWhileOnCellular(saveUserChoice: Boolean) = + override fun enableAudioWhileOnCellular(saveUserChoice: Boolean) { stateFragmentPresenter.handleEnableAudio(saveUserChoice) + } - override fun disableAudioWhileOnCellular(saveUserChoice: Boolean) = + override fun disableAudioWhileOnCellular(saveUserChoice: Boolean) { stateFragmentPresenter.handleDisableAudio(saveUserChoice) - - fun dummyButtonClicked() = stateFragmentPresenter.handleAudioClick() - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putIntegerArrayList(KEY_SELECTED_INPUT_INDEXES, selectedInputItemIndexes) } - override fun onInputItemSelection(indexList: ArrayList) { - selectedInputItemIndexes = indexList + override fun onAnswerReadyForSubmission(answer: InteractionObject) { + stateFragmentPresenter.handleAnswerReadyForSubmission(answer) } + + fun handlePlayAudio() = stateFragmentPresenter.handleAudioClick() } diff --git a/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt b/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt index 51067aee65c..9e2fb0101b7 100755 --- a/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/player/state/StateFragmentPresenter.kt @@ -6,25 +6,46 @@ import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.Transformations +import androidx.recyclerview.widget.RecyclerView import org.oppia.app.R +import org.oppia.app.databinding.ContentItemBinding +import org.oppia.app.databinding.ContinueInteractionItemBinding +import org.oppia.app.databinding.FeedbackItemBinding +import org.oppia.app.databinding.FractionInteractionItemBinding +import org.oppia.app.databinding.NumericInputInteractionItemBinding +import org.oppia.app.databinding.SelectionInteractionItemBinding +import org.oppia.app.databinding.StateButtonItemBinding import org.oppia.app.databinding.StateFragmentBinding +import org.oppia.app.databinding.TextInputInteractionItemBinding import org.oppia.app.fragment.FragmentScope +import org.oppia.app.model.AnswerAndResponse import org.oppia.app.model.AnswerOutcome import org.oppia.app.model.CellularDataPreference import org.oppia.app.model.EphemeralState +import org.oppia.app.model.Interaction import org.oppia.app.model.InteractionObject import org.oppia.app.model.SubtitledHtml import org.oppia.app.player.audio.AudioFragment import org.oppia.app.player.audio.CellularDataDialogFragment -import org.oppia.app.player.exploration.ExplorationActivity +import org.oppia.app.player.state.answerhandling.InteractionAnswerReceiver import org.oppia.app.player.state.itemviewmodel.ContentViewModel -import org.oppia.app.player.state.itemviewmodel.SelectionInteractionCustomizationArgsViewModel -import org.oppia.app.player.state.itemviewmodel.StateButtonViewModel -import org.oppia.app.player.state.listener.ButtonInteractionListener +import org.oppia.app.player.state.itemviewmodel.ContinueInteractionViewModel +import org.oppia.app.player.state.itemviewmodel.FeedbackViewModel +import org.oppia.app.player.state.itemviewmodel.FractionInteractionViewModel +import org.oppia.app.player.state.itemviewmodel.InteractionViewModelFactory +import org.oppia.app.player.state.itemviewmodel.NumericInputViewModel +import org.oppia.app.player.state.itemviewmodel.SelectionInteractionViewModel +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel +import org.oppia.app.player.state.itemviewmodel.StateNavigationButtonViewModel +import org.oppia.app.player.state.itemviewmodel.StateNavigationButtonViewModel.ContinuationNavigationButtonType +import org.oppia.app.player.state.itemviewmodel.TextInputViewModel +import org.oppia.app.player.state.listener.StateNavigationButtonListener +import org.oppia.app.recyclerview.BindableAdapter import org.oppia.app.viewmodel.ViewModelProvider import org.oppia.domain.audio.CellularDialogController import org.oppia.domain.exploration.ExplorationDataController @@ -38,22 +59,6 @@ import javax.inject.Inject const val STATE_FRAGMENT_EXPLORATION_ID_ARGUMENT_KEY = "STATE_FRAGMENT_EXPLORATION_ID_ARGUMENT_KEY" private const val TAG_CELLULAR_DATA_DIALOG = "CELLULAR_DATA_DIALOG" private const val TAG_AUDIO_FRAGMENT = "AUDIO_FRAGMENT" -private const val TAG_STATE_FRAGMENT = "STATE_FRAGMENT" - -private const val CONTINUE = "Continue" -private const val END_EXPLORATION = "EndExploration" -@Suppress("unused") -private const val LEARN_AGAIN = "LearnAgain" -private const val MULTIPLE_CHOICE_INPUT = "MultipleChoiceInput" -private const val ITEM_SELECT_INPUT = "ItemSelectionInput" -private const val TEXT_INPUT = "TextInput" -private const val FRACTION_INPUT = "FractionInput" -private const val NUMERIC_INPUT = "NumericInput" -private const val NUMERIC_WITH_UNITS = "NumberWithUnits" - -// For context: -// https://github.com/oppia/oppia/blob/37285a/extensions/interactions/Continue/directives/oppia-interactive-continue.directive.ts -private const val DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER = "Please continue." /** The presenter for [StateFragment]. */ @FragmentScope @@ -62,42 +67,26 @@ class StateFragmentPresenter @Inject constructor( private val activity: AppCompatActivity, private val fragment: Fragment, private val cellularDialogController: CellularDialogController, - private val stateButtonViewModelProvider: ViewModelProvider, private val viewModelProvider: ViewModelProvider, private val explorationDataController: ExplorationDataController, private val explorationProgressController: ExplorationProgressController, private val logger: Logger, - private val htmlParserFactory: HtmlParser.Factory -) : ButtonInteractionListener { + private val htmlParserFactory: HtmlParser.Factory, + private val context: Context, + private val interactionViewModelFactoryMap: Map +) : StateNavigationButtonListener { private var showCellularDataDialog = true private var useCellularData = false private lateinit var explorationId: String + private lateinit var currentStateName: String + private lateinit var recyclerViewAdapter: RecyclerView.Adapter<*> + private lateinit var viewModel: StateViewModel + private val ephemeralStateLiveData: LiveData> by lazy { + explorationProgressController.getCurrentState() + } - // TODO(#257): Remove this once domain layer is capable to provide this information. - private val oldStateNameList: ArrayList = ArrayList() - - private lateinit var currentEphemeralState: EphemeralState - private var currentAnswerOutcome: AnswerOutcome? = null - - private val itemList: MutableList = ArrayList() - - // TODO(#257): Remove this once domain layer is capable to provide this information. - private var hasGeneralContinueButton: Boolean = false - - private lateinit var stateAdapter: StateAdapter - - private lateinit var binding: StateFragmentBinding - - private var selectedInputItemIndexes = ArrayList() - - private lateinit var selectInputItemsListener: SelectInputItemsListener - - fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?, selectedInputItemIndexes: ArrayList, selectInputItemsListener: SelectInputItemsListener): View? { - - this.selectedInputItemIndexes = selectedInputItemIndexes - this.selectInputItemsListener = selectInputItemsListener - + fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View? { cellularDialogController.getCellularDataPreference() .observe(fragment, Observer> { if (it.isSuccess()) { @@ -107,14 +96,16 @@ class StateFragmentPresenter @Inject constructor( } }) explorationId = fragment.arguments!!.getString(STATE_FRAGMENT_EXPLORATION_ID_ARGUMENT_KEY)!! - stateAdapter = StateAdapter(logger, itemList, this as ButtonInteractionListener, htmlParserFactory, entityType, explorationId, selectedInputItemIndexes, selectInputItemsListener) - binding = StateFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) + val binding = StateFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) + val stateRecyclerViewAdapter = createRecyclerViewAdapter() binding.stateRecyclerView.apply { - adapter = stateAdapter + adapter = stateRecyclerViewAdapter } + recyclerViewAdapter = stateRecyclerViewAdapter + viewModel = getStateViewModel() binding.let { - it.stateFragment = fragment as StateFragment - it.viewModel = getStateViewModel() + it.lifecycleOwner = fragment + it.viewModel = this.viewModel } subscribeToCurrentState() @@ -122,6 +113,85 @@ class StateFragmentPresenter @Inject constructor( return binding.root } + private fun createRecyclerViewAdapter(): BindableAdapter { + return BindableAdapter.Builder + .newBuilder() + .registerViewTypeComputer { viewModel -> + when (viewModel) { + is StateNavigationButtonViewModel -> ViewType.VIEW_TYPE_STATE_NAVIGATION_BUTTON.ordinal + is ContentViewModel -> ViewType.VIEW_TYPE_CONTENT.ordinal + is FeedbackViewModel -> ViewType.VIEW_TYPE_FEEDBACK.ordinal + is ContinueInteractionViewModel -> ViewType.VIEW_TYPE_CONTINUE_INTERACTION.ordinal + is SelectionInteractionViewModel -> ViewType.VIEW_TYPE_SELECTION_INTERACTION.ordinal + is FractionInteractionViewModel -> ViewType.VIEW_TYPE_FRACTION_INPUT_INTERACTION.ordinal + is NumericInputViewModel -> ViewType.VIEW_TYPE_NUMERIC_INPUT_INTERACTION.ordinal + is TextInputViewModel -> ViewType.VIEW_TYPE_TEXT_INPUT_INTERACTION.ordinal + else -> throw IllegalArgumentException("Encountered unexpected view model: $viewModel") + } + } + .registerViewDataBinder( + viewType = ViewType.VIEW_TYPE_STATE_NAVIGATION_BUTTON.ordinal, + inflateDataBinding = StateButtonItemBinding::inflate, + setViewModel = StateButtonItemBinding::setButtonViewModel, + transformViewModel = { it as StateNavigationButtonViewModel } + ) + .registerViewBinder( + viewType = ViewType.VIEW_TYPE_CONTENT.ordinal, + inflateView = { parent -> + ContentItemBinding.inflate(LayoutInflater.from(parent.context), parent, /* attachToParent= */ false).root + }, + bindView = { view, viewModel -> + val binding = DataBindingUtil.findBinding(view)!! + binding.htmlContent = htmlParserFactory.create(entityType, explorationId).parseOppiaHtml( + (viewModel as ContentViewModel).htmlContent.toString(), binding.contentTextView + ) + } + ) + .registerViewBinder( + viewType = ViewType.VIEW_TYPE_FEEDBACK.ordinal, + inflateView = { parent -> + FeedbackItemBinding.inflate(LayoutInflater.from(parent.context), parent, /* attachToParent= */ false).root + }, + bindView = { view, viewModel -> + val binding = DataBindingUtil.findBinding(view)!! + binding.htmlContent = htmlParserFactory.create(entityType, explorationId).parseOppiaHtml( + (viewModel as FeedbackViewModel).htmlContent.toString(), binding.feedbackTextView + ) + } + ) + .registerViewDataBinder( + viewType = ViewType.VIEW_TYPE_CONTINUE_INTERACTION.ordinal, + inflateDataBinding = ContinueInteractionItemBinding::inflate, + setViewModel = ContinueInteractionItemBinding::setViewModel, + transformViewModel = { it as ContinueInteractionViewModel } + ) + .registerViewDataBinder( + viewType = ViewType.VIEW_TYPE_SELECTION_INTERACTION.ordinal, + inflateDataBinding = SelectionInteractionItemBinding::inflate, + setViewModel = SelectionInteractionItemBinding::setViewModel, + transformViewModel = { it as SelectionInteractionViewModel } + ) + .registerViewDataBinder( + viewType = ViewType.VIEW_TYPE_FRACTION_INPUT_INTERACTION.ordinal, + inflateDataBinding = FractionInteractionItemBinding::inflate, + setViewModel = FractionInteractionItemBinding::setViewModel, + transformViewModel = { it as FractionInteractionViewModel } + ) + .registerViewDataBinder( + viewType = ViewType.VIEW_TYPE_NUMERIC_INPUT_INTERACTION.ordinal, + inflateDataBinding = NumericInputInteractionItemBinding::inflate, + setViewModel = NumericInputInteractionItemBinding::setViewModel, + transformViewModel = { it as NumericInputViewModel } + ) + .registerViewDataBinder( + viewType = ViewType.VIEW_TYPE_TEXT_INPUT_INTERACTION.ordinal, + inflateDataBinding = TextInputInteractionItemBinding::inflate, + setViewModel = TextInputInteractionItemBinding::setViewModel, + transformViewModel = { it as TextInputViewModel } + ) + .build() + } + fun handleAudioClick() { if (showCellularDataDialog) { showHideAudioFragment(false) @@ -148,6 +218,11 @@ class StateFragmentPresenter @Inject constructor( } } + fun handleAnswerReadyForSubmission(answer: InteractionObject) { + // An interaction has indicated that an answer is ready for submission. + handleSubmitAnswer(answer) + } + private fun showCellularDataDialogFragment() { val previousFragment = fragment.childFragmentManager.findFragmentByTag(TAG_CELLULAR_DATA_DIALOG) if (previousFragment != null) { @@ -168,7 +243,7 @@ class StateFragmentPresenter @Inject constructor( private fun showHideAudioFragment(isVisible: Boolean) { if (isVisible) { if (getAudioFragment() == null) { - val audioFragment = AudioFragment.newInstance(explorationId, "END") + val audioFragment = AudioFragment.newInstance(explorationId, currentStateName) fragment.childFragmentManager.beginTransaction().add( R.id.audio_fragment_placeholder, audioFragment, TAG_AUDIO_FRAGMENT @@ -182,54 +257,55 @@ class StateFragmentPresenter @Inject constructor( } private fun subscribeToCurrentState() { - ephemeralStateLiveData.observe(fragment, Observer { result -> - itemList.clear() - currentEphemeralState = result - checkAndAddContentItem() - addInteractionForPendingState() - updateDummyStateName() - - val interactionId = result.state.interaction.id - val hasPreviousState = result.hasPreviousState - var canContinueToNextState = false - hasGeneralContinueButton = false - - if (result.stateTypeCase != EphemeralState.StateTypeCase.TERMINAL_STATE) { - if (result.stateTypeCase == EphemeralState.StateTypeCase.COMPLETED_STATE - && !oldStateNameList.contains(result.state.name) - ) { - hasGeneralContinueButton = true - canContinueToNextState = false - } else if (result.completedState.answerList.size > 0 - && oldStateNameList.contains(result.state.name) - ) { - canContinueToNextState = true - hasGeneralContinueButton = false - } - } - - updateNavigationButtonVisibility( - interactionId, - hasPreviousState, - canContinueToNextState, - hasGeneralContinueButton - ) + ephemeralStateLiveData.observe(fragment, Observer> { result -> + processEphemeralStateResult(result) }) } - private val ephemeralStateLiveData: LiveData by lazy { - getEphemeralState() - } + private fun processEphemeralStateResult(result: AsyncResult) { + if (result.isFailure()) { + logger.e("StateFragment", "Failed to retrieve ephemeral state", result.getErrorOrNull()!!) + return + } else if (result.isPending()) { + // Display nothing until a valid result is available. + return + } - private fun getEphemeralState(): LiveData { - return Transformations.map(explorationProgressController.getCurrentState(), ::processCurrentState) - } + val ephemeralState = result.getOrThrow() + currentStateName = ephemeralState.state.name + val pendingItemList = mutableListOf() + addContentItem(pendingItemList, ephemeralState) + val interaction = ephemeralState.state.interaction + if (ephemeralState.stateTypeCase == EphemeralState.StateTypeCase.PENDING_STATE) { + addPreviousAnswers(pendingItemList, interaction, ephemeralState.pendingState.wrongAnswerList) + addInteractionForPendingState(pendingItemList, interaction) + } else if (ephemeralState.stateTypeCase == EphemeralState.StateTypeCase.COMPLETED_STATE) { + addPreviousAnswers(pendingItemList, interaction, ephemeralState.completedState.answerList) + } - private fun processCurrentState(ephemeralStateResult: AsyncResult): EphemeralState { - if (ephemeralStateResult.isFailure()) { - logger.e("StateFragment", "Failed to retrieve ephemeral state", ephemeralStateResult.getErrorOrNull()!!) + val hasPreviousState = ephemeralState.hasPreviousState + var canContinueToNextState = false + var hasGeneralContinueButton = false + + if (ephemeralState.stateTypeCase != EphemeralState.StateTypeCase.TERMINAL_STATE) { + if (ephemeralState.stateTypeCase == EphemeralState.StateTypeCase.COMPLETED_STATE + && !ephemeralState.hasNextState) { + hasGeneralContinueButton = true + } else if (ephemeralState.completedState.answerList.size > 0 && ephemeralState.hasNextState) { + canContinueToNextState = true + } } - return ephemeralStateResult.getOrDefault(EphemeralState.getDefaultInstance()) + + updateNavigationButtonVisibility( + pendingItemList, + hasPreviousState, + canContinueToNextState, + hasGeneralContinueButton, + ephemeralState.stateTypeCase == EphemeralState.StateTypeCase.TERMINAL_STATE + ) + + viewModel.itemList.clear() + viewModel.itemList += pendingItemList } /** @@ -239,17 +315,9 @@ class StateFragmentPresenter @Inject constructor( */ private fun subscribeToAnswerOutcome(answerOutcomeResultLiveData: LiveData>) { val answerOutcomeLiveData = getAnswerOutcome(answerOutcomeResultLiveData) - answerOutcomeLiveData.observe(fragment, Observer { - currentAnswerOutcome = it - - // 'CONTINUE' button has two different types of functionality in different scenarios. - // If the interaction-id is 'Continue', then learner can click the 'CONTINUE' button which will submit an answer - // and move to next state. In other cases, learner submits an answer and if the answer is correct than the `SUBMIT` - // button changes to 'CONTINUE' and in that case click on 'CONTINUE' button does not submit any answer and - // directly moves to next state. - // Here, after submitting an answer it checks whether the interaction-id was 'Continue', if it is continue then move - // to next state. - if (currentEphemeralState.state.interaction.id == CONTINUE) { + answerOutcomeLiveData.observe(fragment, Observer { result -> + // If the answer was submitted on behalf of the Continue interaction, automatically continue to the next state. + if (result.state.interaction.id == "Continue") { moveToNextState() } }) @@ -268,165 +336,116 @@ class StateFragmentPresenter @Inject constructor( return ephemeralStateResult.getOrDefault(AnswerOutcome.getDefaultInstance()) } - private fun endExploration() { + override fun onReturnToTopicButtonClicked() { + hideKeyboard() explorationDataController.stopPlayingExploration() - (activity as ExplorationActivity).finish() + activity.finish() } - override fun onInteractionButtonClicked() { + override fun onSubmitButtonClicked() { hideKeyboard() - // TODO(#163): Remove these dummy answers and fetch answers from different interaction views. - // NB: This sample data will work only with TEST_EXPLORATION_ID_5 - // 0 -> What Language - // 2 -> Welcome! - // XX -> What Language - val stateWelcomeAnswer = 0 - // finnish -> Numeric input - // suomi -> Numeric input - // XX -> What Language - val stateWhatLanguageAnswer: String = "finnish" - // 121 -> Things You can do - // < 121 -> Estimate 100 - // > 121 -> Numeric Input - // XX -> Numeric Input - val stateNumericInputAnswer = 121 - - if (!hasGeneralContinueButton) { - val interactionObject: InteractionObject = getDummyInteractionObject() - when (currentEphemeralState.state.interaction.id) { - END_EXPLORATION -> endExploration() - CONTINUE -> subscribeToAnswerOutcome(explorationProgressController.submitAnswer(createContinueButtonAnswer())) - MULTIPLE_CHOICE_INPUT -> subscribeToAnswerOutcome( - explorationProgressController.submitAnswer( - InteractionObject.newBuilder().setNonNegativeInt( - stateWelcomeAnswer - ).build() - ) - ) - FRACTION_INPUT, - ITEM_SELECT_INPUT, - NUMERIC_INPUT, - NUMERIC_WITH_UNITS, - TEXT_INPUT -> subscribeToAnswerOutcome( - explorationProgressController.submitAnswer(interactionObject) - ) - } - } else { - moveToNextState() - } + handleSubmitAnswer(viewModel.getPendingAnswer()) + } + + override fun onContinueButtonClicked() { + hideKeyboard() + moveToNextState() + } + + private fun handleSubmitAnswer(answer: InteractionObject) { + subscribeToAnswerOutcome(explorationProgressController.submitAnswer(answer)) } override fun onPreviousButtonClicked() { explorationProgressController.moveToPreviousState() } - override fun onNextButtonClicked() { - moveToNextState() - } + override fun onNextButtonClicked() = moveToNextState() private fun moveToNextState() { - checkAndUpdateOldStateNameList() - itemList.clear() - currentAnswerOutcome = null explorationProgressController.moveToNextState() } - private fun createContinueButtonAnswer(): InteractionObject { - return InteractionObject.newBuilder().setNormalizedString(DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER).build() + private fun addInteractionForPendingState( + pendingItemList: MutableList, interaction: Interaction + ) = addInteraction(pendingItemList, interaction) + + private fun addInteractionForCompletedState( + pendingItemList: MutableList, interaction: Interaction, existingAnswer: InteractionObject + ) = addInteraction(pendingItemList, interaction, existingAnswer = existingAnswer, isReadOnly = true) + + private fun addInteraction( + pendingItemList: MutableList, interaction: Interaction, existingAnswer: + InteractionObject? = null, isReadOnly: Boolean = false) { + val interactionViewModelFactory = interactionViewModelFactoryMap.getValue(interaction.id) + pendingItemList += interactionViewModelFactory( + explorationId, interaction, fragment as InteractionAnswerReceiver, existingAnswer, isReadOnly + ) } - private fun checkAndUpdateOldStateNameList() { - if (currentAnswerOutcome != null - && !currentAnswerOutcome!!.sameState - && !oldStateNameList.contains(currentEphemeralState.state.name) - ) { - oldStateNameList.add(currentEphemeralState.state.name) - } - } - - private fun checkAndAddContentItem() { - if (currentEphemeralState.state.hasContent()) { - addContentItem() - } else { - logger.e("StateFragment", "checkAndAddContentItem: State does not have content.") - } + private fun addContentItem(pendingItemList: MutableList, ephemeralState: EphemeralState) { + val contentSubtitledHtml: SubtitledHtml = ephemeralState.state.content + pendingItemList += ContentViewModel(contentSubtitledHtml.html) } - private fun addContentItem() { - val contentViewModel = ContentViewModel() - val contentSubtitledHtml: SubtitledHtml = currentEphemeralState.state.content - contentViewModel.contentId = contentSubtitledHtml.contentId - contentViewModel.htmlContent = contentSubtitledHtml.html - itemList.add(contentViewModel) - stateAdapter.notifyDataSetChanged() - } - - private fun addInteractionForPendingState() { - if (currentEphemeralState.stateTypeCase.number == EphemeralState.PENDING_STATE_FIELD_NUMBER) { - when (currentEphemeralState.state.interaction.id) { - MULTIPLE_CHOICE_INPUT, ITEM_SELECT_INPUT -> { - addSelectionInteraction() - } - } + private fun addPreviousAnswers( + pendingItemList: MutableList, interaction: Interaction, + answersAndResponses: List + ) { + // TODO: add support for displaying the previous answer, too. + for (answerAndResponse in answersAndResponses) { + addInteractionForCompletedState(pendingItemList, interaction, answerAndResponse.userAnswer) + addFeedbackItem(pendingItemList, answerAndResponse.feedback) } } - private fun addSelectionInteraction() { - val customizationArgsMap: Map = - currentEphemeralState.state.interaction.customizationArgsMap - val multipleChoiceInputInteractionViewModel = SelectionInteractionCustomizationArgsViewModel() - val allKeys: Set = customizationArgsMap.keys - - for (key in allKeys) { - logger.d(TAG_STATE_FRAGMENT, key) - } - if (customizationArgsMap.contains("choices")) { - if (customizationArgsMap.contains("maxAllowableSelectionCount")) { - multipleChoiceInputInteractionViewModel.maxAllowableSelectionCount = - currentEphemeralState.state.interaction.customizationArgsMap["maxAllowableSelectionCount"]!!.signedInt - multipleChoiceInputInteractionViewModel.minAllowableSelectionCount = - currentEphemeralState.state.interaction.customizationArgsMap["minAllowableSelectionCount"]!!.signedInt - } - multipleChoiceInputInteractionViewModel.interactionId = currentEphemeralState.state.interaction.id - multipleChoiceInputInteractionViewModel.choiceItems = - currentEphemeralState.state.interaction.customizationArgsMap["choices"]!!.setOfHtmlString.htmlList + private fun addFeedbackItem(pendingItemList: MutableList, feedback: SubtitledHtml) { + // Only show feedback if there's some to show. + if (feedback.html.isNotEmpty()) { + pendingItemList += FeedbackViewModel(feedback.html) } - itemList.add(multipleChoiceInputInteractionViewModel) - stateAdapter.notifyDataSetChanged() } private fun updateNavigationButtonVisibility( - interactionId: String, + pendingItemList: MutableList, hasPreviousState: Boolean, canContinueToNextState: Boolean, - hasGeneralContinueButton: Boolean + hasGeneralContinueButton: Boolean, + stateIsTerminal: Boolean ) { - getStateButtonViewModel().setPreviousButtonVisible(hasPreviousState) + val stateNavigationButtonViewModel = StateNavigationButtonViewModel(context, this as StateNavigationButtonListener) + stateNavigationButtonViewModel.updatePreviousButton(isEnabled = hasPreviousState) + // Set continuation button. when { hasGeneralContinueButton -> { - getStateButtonViewModel().clearObservableInteractionId() - getStateButtonViewModel().setObservableInteractionId(CONTINUE) + stateNavigationButtonViewModel.updateContinuationButton( + ContinuationNavigationButtonType.CONTINUE_BUTTON, isEnabled = true + ) } canContinueToNextState -> { - getStateButtonViewModel().clearObservableInteractionId() - getStateButtonViewModel().setNextButtonVisible(canContinueToNextState) + stateNavigationButtonViewModel.updateContinuationButton( + ContinuationNavigationButtonType.NEXT_BUTTON, isEnabled = canContinueToNextState + ) + } + stateIsTerminal -> { + stateNavigationButtonViewModel.updateContinuationButton( + ContinuationNavigationButtonType.RETURN_TO_TOPIC_BUTTON, isEnabled = true + ) + } + viewModel.doesMostRecentInteractionRequireExplicitSubmission(pendingItemList) -> { + stateNavigationButtonViewModel.updateContinuationButton( + ContinuationNavigationButtonType.SUBMIT_BUTTON, isEnabled = true + ) } else -> { - getStateButtonViewModel().setObservableInteractionId(interactionId) - // TODO(#163): This function controls whether the "Submit" button should be displayed or not. - // Remove this function in final implementation and control this whenever user selects some option in - // MultipleChoiceInput or InputSelectionInput. For now this is `true` because we do not have a mechanism to work - // with MultipleChoiceInput or InputSelectionInput, which will eventually be responsible for controlling this. - getStateButtonViewModel().optionSelected(true) + // No continuation button needs to be set since the interaction itself will push for answer submission. + stateNavigationButtonViewModel.updateContinuationButton( + ContinuationNavigationButtonType.CONTINUE_BUTTON, isEnabled = false + ) } } - itemList.add(getStateButtonViewModel()) - stateAdapter.notifyDataSetChanged() - } - - private fun getStateButtonViewModel(): StateButtonViewModel { - return stateButtonViewModelProvider.getForFragment(fragment, StateButtonViewModel::class.java) + pendingItemList += stateNavigationButtonViewModel } private fun hideKeyboard() { @@ -434,21 +453,14 @@ class StateFragmentPresenter @Inject constructor( inputManager.hideSoftInputFromWindow(fragment.view!!.windowToken, InputMethodManager.SHOW_FORCED) } - // TODO(#163): Remove this function, this is just for dummy testing purposes. - private fun updateDummyStateName() { - getStateViewModel().setStateName(currentEphemeralState.state.name) - } - - // TODO(#163): Remove this function and fetch this InteractionObject from [StateAdapter]. - private fun getDummyInteractionObject(): InteractionObject { - val interactionObjectBuilder: InteractionObject.Builder = InteractionObject.newBuilder() - when (currentEphemeralState.state.name) { - "Welcome!" -> interactionObjectBuilder.nonNegativeInt = 0 - "What language" -> interactionObjectBuilder.normalizedString = "finnish" - "Things you can do" -> createContinueButtonAnswer() - "Numeric input" -> interactionObjectBuilder.real = 121.0 - else -> InteractionObject.getDefaultInstance() - } - return interactionObjectBuilder.build() + private enum class ViewType { + VIEW_TYPE_CONTENT, + VIEW_TYPE_FEEDBACK, + VIEW_TYPE_STATE_NAVIGATION_BUTTON, + VIEW_TYPE_CONTINUE_INTERACTION, + VIEW_TYPE_SELECTION_INTERACTION, + VIEW_TYPE_FRACTION_INPUT_INTERACTION, + VIEW_TYPE_NUMERIC_INPUT_INTERACTION, + VIEW_TYPE_TEXT_INPUT_INTERACTION } } diff --git a/app/src/main/java/org/oppia/app/player/state/StateViewModel.kt b/app/src/main/java/org/oppia/app/player/state/StateViewModel.kt index 042e2ddefb5..bef2898310e 100644 --- a/app/src/main/java/org/oppia/app/player/state/StateViewModel.kt +++ b/app/src/main/java/org/oppia/app/player/state/StateViewModel.kt @@ -1,17 +1,37 @@ package org.oppia.app.player.state -import androidx.databinding.ObservableField +import androidx.databinding.ObservableArrayList +import androidx.databinding.ObservableList import androidx.lifecycle.ViewModel import org.oppia.app.fragment.FragmentScope +import org.oppia.app.model.InteractionObject +import org.oppia.app.player.state.answerhandling.InteractionAnswerHandler +import org.oppia.app.player.state.itemviewmodel.StateItemViewModel import org.oppia.app.viewmodel.ObservableViewModel import javax.inject.Inject /** [ViewModel] for state-fragment. */ @FragmentScope class StateViewModel @Inject constructor() : ObservableViewModel() { - var stateName = ObservableField() + val itemList: ObservableList = ObservableArrayList() - fun setStateName(state: String) { - stateName.set(state) + /** + * Returns whether there is currently a pending interaction that requires an additional user action to submit the + * answer. + */ + fun doesMostRecentInteractionRequireExplicitSubmission(itemList: List): Boolean { + return getPendingAnswerHandler(itemList)?.isExplicitAnswerSubmissionRequired() ?: true + } + + // TODO(#164): Add a hasPendingAnswer() that binds to the enabled state of the Submit button. + fun getPendingAnswer(): InteractionObject { + return getPendingAnswerHandler(itemList)?.getPendingAnswer() ?: InteractionObject.getDefaultInstance() + } + + private fun getPendingAnswerHandler(itemList: List): InteractionAnswerHandler? { + // Search through all items to find the latest InteractionAnswerHandler which should be the pending one. In the + // future, it may be ideal to make this more robust by actually tracking the handler corresponding to the pending + // interaction. + return itemList.findLast { it is InteractionAnswerHandler } as? InteractionAnswerHandler } } diff --git a/app/src/main/java/org/oppia/app/player/state/answerhandling/InteractionAnswerHandler.kt b/app/src/main/java/org/oppia/app/player/state/answerhandling/InteractionAnswerHandler.kt new file mode 100644 index 00000000000..cc196bcfe42 --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/answerhandling/InteractionAnswerHandler.kt @@ -0,0 +1,27 @@ +package org.oppia.app.player.state.answerhandling + +import org.oppia.app.model.InteractionObject + +/** + * A handler for interaction answers. Handlers can either require an additional user action before the answer can be + * processed, or they can push the answer directly to a [InteractionAnswerReceiver]. Implementations must indicate + * whether they require an explicit submit button. + */ +interface InteractionAnswerHandler { + /** + * Returns whether this handler requires explicit answer submission. Note that this is expected to be an invariant for + * the lifetime of this handler instance. + */ + fun isExplicitAnswerSubmissionRequired(): Boolean = true + + /** Return the current answer that is ready for handling. */ + fun getPendingAnswer(): InteractionObject +} + +/** + * A callback that will be called by [InteractionAnswerHandler]s when a user submits an answer. To be implemented by + * the parent fragment of the handler. + */ +interface InteractionAnswerReceiver { + fun onAnswerReadyForSubmission(answer: InteractionObject) +} diff --git a/app/src/main/java/org/oppia/app/player/state/customview/SelectionInputInteractionView.kt b/app/src/main/java/org/oppia/app/player/state/customview/SelectionInputInteractionView.kt deleted file mode 100755 index c5613eb5cc9..00000000000 --- a/app/src/main/java/org/oppia/app/player/state/customview/SelectionInputInteractionView.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.oppia.app.player.state.customview - -import android.content.Context -import android.util.AttributeSet -import android.widget.FrameLayout -import androidx.recyclerview.widget.RecyclerView -import org.oppia.app.R -import org.oppia.app.model.InteractionObject -import org.oppia.app.player.state.InteractionAdapter -import org.oppia.app.player.state.listener.InteractionAnswerRetriever -import org.oppia.app.player.state.listener.ItemClickListener - -internal class SelectionInputInteractionView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defaultStyle: Int = 0 -) : FrameLayout(context, attrs, defaultStyle), ItemClickListener, InteractionAnswerRetriever { - private var interactionObjectBuilder: InteractionObject = InteractionObject.newBuilder().build() - - override fun onItemClick(interactionObject: InteractionObject) { - interactionObjectBuilder = interactionObject - } - - private val recyclerView: RecyclerView = RecyclerView(context, attrs, defaultStyle) - - init { - recyclerView.id = R.id.selection_interaction_recyclerview - val params = LayoutParams( - LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT - ) - this.addView(recyclerView, params) - isClickable = true - isFocusable = true - } - - internal fun setAdapter(adapter: InteractionAdapter) { - recyclerView.adapter = adapter - } - - override fun getPendingAnswer(): InteractionObject { - return interactionObjectBuilder - } -} diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContentViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContentViewModel.kt old mode 100755 new mode 100644 index 8606d70ae38..5ba6823546c --- a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContentViewModel.kt +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContentViewModel.kt @@ -1,12 +1,4 @@ package org.oppia.app.player.state.itemviewmodel -import androidx.lifecycle.ViewModel -import org.oppia.app.fragment.FragmentScope -import javax.inject.Inject - /** [ViewModel] for content-card state. */ -@FragmentScope -class ContentViewModel @Inject constructor() : ViewModel() { - var contentId = "" - var htmlContent = "" -} +class ContentViewModel(val htmlContent: CharSequence): StateItemViewModel() diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt new file mode 100644 index 00000000000..07b49d040a6 --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt @@ -0,0 +1,28 @@ +package org.oppia.app.player.state.itemviewmodel + +import org.oppia.app.model.InteractionObject +import org.oppia.app.player.state.answerhandling.InteractionAnswerHandler +import org.oppia.app.player.state.answerhandling.InteractionAnswerReceiver +import org.oppia.domain.util.toAnswerString + +// For context: +// https://github.com/oppia/oppia/blob/37285a/extensions/interactions/Continue/directives/oppia-interactive-continue.directive.ts +private const val DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER = "Please continue." + +/** [ViewModel] for the 'Continue' button. */ +class ContinueInteractionViewModel( + private val interactionAnswerReceiver: InteractionAnswerReceiver, existingAnswer: InteractionObject?, + val isReadOnly: Boolean +): StateItemViewModel(), InteractionAnswerHandler { + val answerText: CharSequence = existingAnswer?.toAnswerString() ?: "" + + override fun isExplicitAnswerSubmissionRequired(): Boolean = false + + override fun getPendingAnswer(): InteractionObject { + return InteractionObject.newBuilder().setNormalizedString(DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER).build() + } + + fun handleButtonClicked() { + interactionAnswerReceiver.onAnswerReadyForSubmission(getPendingAnswer()) + } +} diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FeedbackViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FeedbackViewModel.kt new file mode 100644 index 00000000000..2cd98205645 --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FeedbackViewModel.kt @@ -0,0 +1,4 @@ +package org.oppia.app.player.state.itemviewmodel + +/** [ViewModel] for feedback blurbs. */ +class FeedbackViewModel(val htmlContent: CharSequence): StateItemViewModel() diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FractionInteractionViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FractionInteractionViewModel.kt new file mode 100644 index 00000000000..96a5b61243f --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/FractionInteractionViewModel.kt @@ -0,0 +1,20 @@ +package org.oppia.app.player.state.itemviewmodel + +import org.oppia.app.model.InteractionObject +import org.oppia.app.parser.StringToFractionParser +import org.oppia.app.player.state.answerhandling.InteractionAnswerHandler +import org.oppia.domain.util.toAnswerString + +class FractionInteractionViewModel( + existingAnswer: InteractionObject?, val isReadOnly: Boolean +): StateItemViewModel(), InteractionAnswerHandler { + var answerText: CharSequence = existingAnswer?.toAnswerString() ?: "" + + override fun getPendingAnswer(): InteractionObject { + val interactionObjectBuilder = InteractionObject.newBuilder() + if (answerText.isNotEmpty()) { + interactionObjectBuilder.fraction = StringToFractionParser().getFractionFromString(answerText.toString()) + } + return interactionObjectBuilder.build() + } +} diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/InteractionViewModelFactory.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/InteractionViewModelFactory.kt new file mode 100644 index 00000000000..dbaa0fe87b5 --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/InteractionViewModelFactory.kt @@ -0,0 +1,15 @@ +package org.oppia.app.player.state.itemviewmodel + +import org.oppia.app.model.Interaction +import org.oppia.app.model.InteractionObject +import org.oppia.app.player.state.answerhandling.InteractionAnswerReceiver + +/** + * Returns a new [StateItemViewModel] corresponding to this interaction with an initial, optional answer filled in, + * optionally read-only (e.g. if the interaction is no longer accepting new answers), a receiver for answers if this + * interaction pushes answers, the [Interaction] object corresponding to the interaction view, and the exploration ID. + */ +typealias InteractionViewModelFactory = ( + explorationId: String, interaction: Interaction, interactionAnswerReceiver: InteractionAnswerReceiver, + existingAnswer: InteractionObject?, isReadOnly: Boolean +) -> StateItemViewModel diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/InteractionViewModelModule.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/InteractionViewModelModule.kt new file mode 100644 index 00000000000..b2c1038d4ee --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/InteractionViewModelModule.kt @@ -0,0 +1,59 @@ +package org.oppia.app.player.state.itemviewmodel + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey + +/** + * Module to provide interaction view model-specific dependencies for intreactions that should be explicitly displayed + * to the user. + */ +@Module +class 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 + @IntoMap + @StringKey("Continue") + fun provideContinueInteractionViewModelFactory(): InteractionViewModelFactory { + return { _, _, interactionAnswerReceiver, existingAnswer, isReadOnly -> + ContinueInteractionViewModel(interactionAnswerReceiver, existingAnswer, isReadOnly) + } + } + + @Provides + @IntoMap + @StringKey("MultipleChoiceInput") + fun provideMultipleChoiceInputViewModelFactory(): InteractionViewModelFactory { + return ::SelectionInteractionViewModel + } + + @Provides + @IntoMap + @StringKey("ItemSelectionInput") + fun provideItemSelectionInputViewModelFactory(): InteractionViewModelFactory { + return ::SelectionInteractionViewModel + } + + @Provides + @IntoMap + @StringKey("FractionInput") + fun provideFractionInputViewModelFactory(): InteractionViewModelFactory { + return { _, _, _, existingAnswer, isReadOnly -> FractionInteractionViewModel(existingAnswer, isReadOnly) } + } + + @Provides + @IntoMap + @StringKey("NumericInput") + fun provideNumericInputViewModelFactory(): InteractionViewModelFactory { + return { _, _, _, existingAnswer, isReadOnly -> NumericInputViewModel(existingAnswer, isReadOnly) } + } + + @Provides + @IntoMap + @StringKey("TextInput") + fun provideTextInputViewModelFactory(): InteractionViewModelFactory { + return { _, _, _, existingAnswer, isReadOnly -> TextInputViewModel(existingAnswer, isReadOnly) } + } +} diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/NumericInputViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/NumericInputViewModel.kt new file mode 100644 index 00000000000..f37881b3cc8 --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/NumericInputViewModel.kt @@ -0,0 +1,19 @@ +package org.oppia.app.player.state.itemviewmodel + +import org.oppia.app.model.InteractionObject +import org.oppia.app.player.state.answerhandling.InteractionAnswerHandler +import org.oppia.domain.util.toAnswerString + +class NumericInputViewModel( + existingAnswer: InteractionObject?, val isReadOnly: Boolean +): StateItemViewModel(), InteractionAnswerHandler { + var answerText: CharSequence = existingAnswer?.toAnswerString() ?: "" + + override fun getPendingAnswer(): InteractionObject { + val interactionObjectBuilder = InteractionObject.newBuilder() + if (answerText.isNotEmpty()) { + interactionObjectBuilder.real = answerText.toString().toDouble() + } + return interactionObjectBuilder.build() + } +} diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SelectionInteractionContentViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SelectionInteractionContentViewModel.kt old mode 100755 new mode 100644 index 5bfb9e5b059..b65112e3acd --- a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SelectionInteractionContentViewModel.kt +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SelectionInteractionContentViewModel.kt @@ -1,9 +1,20 @@ package org.oppia.app.player.state.itemviewmodel -import androidx.lifecycle.ViewModel +import androidx.databinding.ObservableBoolean +import org.oppia.app.viewmodel.ObservableViewModel -/** [ViewModel] for MultipleChoiceInput values or ItemSelectionInput values. */ -class SelectionInteractionContentViewModel : ViewModel() { - var htmlContent: String = "" - var isAnswerSelected = false +/** [ObservableViewModel] for MultipleChoiceInput values or ItemSelectionInput values. */ +class SelectionInteractionContentViewModel( + val htmlContent: String, private val itemIndex: Int, isAnswerInitiallySelected: Boolean, val isReadOnly: Boolean, + private val selectionInteractionViewModel: SelectionInteractionViewModel +): ObservableViewModel() { + var isAnswerSelected = ObservableBoolean(isAnswerInitiallySelected) + + fun handleItemClicked() { + val isCurrentlySelected = isAnswerSelected.get() + val shouldNowBeSelected = selectionInteractionViewModel.updateSelection(itemIndex, isCurrentlySelected) + if (isCurrentlySelected != shouldNowBeSelected) { + isAnswerSelected.set(shouldNowBeSelected) + } + } } diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SelectionInteractionCustomizationArgsViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SelectionInteractionCustomizationArgsViewModel.kt deleted file mode 100755 index fbc4f4c1d38..00000000000 --- a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SelectionInteractionCustomizationArgsViewModel.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.oppia.app.player.state.itemviewmodel - -import androidx.lifecycle.ViewModel - -/** [ViewModel] for multiple or item-selection input choice list. */ -class SelectionInteractionCustomizationArgsViewModel : ViewModel() { - var choiceItems: MutableList? = null - var interactionId: String = "" - var maxAllowableSelectionCount: Int = 0 - var minAllowableSelectionCount: Int = 0 -} diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt new file mode 100644 index 00000000000..e52c4e31f37 --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt @@ -0,0 +1,123 @@ +package org.oppia.app.player.state.itemviewmodel + +import androidx.databinding.ObservableArrayList +import androidx.databinding.ObservableList +import org.oppia.app.model.Interaction +import org.oppia.app.model.InteractionObject +import org.oppia.app.model.StringList +import org.oppia.app.player.state.SelectionItemInputType +import org.oppia.app.player.state.answerhandling.InteractionAnswerHandler +import org.oppia.app.player.state.answerhandling.InteractionAnswerReceiver + +/** ViewModel for multiple or item-selection input choice list. */ +class SelectionInteractionViewModel( + val explorationId: String, interaction: Interaction, private val interactionAnswerReceiver: InteractionAnswerReceiver, + existingAnswer: InteractionObject?, val isReadOnly: Boolean +): StateItemViewModel(), InteractionAnswerHandler { + private val interactionId: String = interaction.id + private val choiceStrings: List by lazy { + interaction.customizationArgsMap["choices"]?.setOfHtmlString?.htmlList ?: listOf() + } + private val minAllowableSelectionCount: Int by lazy { + interaction.customizationArgsMap["minAllowableSelectionCount"]?.signedInt ?: 1 + } + private val maxAllowableSelectionCount: Int by lazy { + // Assume that at least 1 answer always needs to be submitted, and that the max can't be less than the min for cases + // when either of the counts are not specified. + interaction.customizationArgsMap["maxAllowableSelectionCount"]?.signedInt ?: minAllowableSelectionCount + } + private val selectedItems = computeSelectedItems( + existingAnswer ?: InteractionObject.getDefaultInstance(), interactionId, choiceStrings + ) + val choiceItems: ObservableList = computeChoiceItems( + choiceStrings, selectedItems, isReadOnly, this + ) + + override fun isExplicitAnswerSubmissionRequired(): Boolean { + // If more than one answer is allowed, then a submission button is needed. + return maxAllowableSelectionCount > 1 + } + + override fun getPendingAnswer(): InteractionObject { + val interactionObjectBuilder = InteractionObject.newBuilder() + if (interactionId == "ItemSelectionInput") { + interactionObjectBuilder.setOfHtmlString = StringList.newBuilder() + .addAllHtml(selectedItems.map(choiceItems::get).map { it.htmlContent }) + .build() + } else if (selectedItems.size == 1) { + interactionObjectBuilder.nonNegativeInt = selectedItems.first() + } + return interactionObjectBuilder.build() + } + + /** Returns the [SelectionItemInputType] that should be used to render items of this view model. */ + fun getSelectionItemInputType(): SelectionItemInputType { + return if (areCheckboxesBound()) { + SelectionItemInputType.CHECKBOXES + } else { + SelectionItemInputType.RADIO_BUTTONS + } + } + + /** Catalogs an item being clicked by the user and returns whether the item should be considered selected. */ + fun updateSelection(itemIndex: Int, isCurrentlySelected: Boolean): Boolean { + if (areCheckboxesBound()) { + if (isCurrentlySelected) { + selectedItems -= itemIndex + return false + } else if (selectedItems.size < maxAllowableSelectionCount) { + // TODO(#32): Add warning to user when they exceed the number of allowable selections or are under the minimum + // number required. + selectedItems += itemIndex + return true + } + } else { + // Disable all items to simulate a radio button group. + choiceItems.forEach { item -> item.isAnswerSelected.set(false) } + selectedItems.clear() + selectedItems += itemIndex + + // Only push the answer if explicit submission isn't required. + if (maxAllowableSelectionCount == 1) { + interactionAnswerReceiver.onAnswerReadyForSubmission(getPendingAnswer()) + } + return true + } + + // Do not change the current status if it isn't valid to do so. + return isCurrentlySelected + } + + private fun areCheckboxesBound(): Boolean { + return interactionId == "ItemSelectionInput" && maxAllowableSelectionCount > 1 + } + + companion object { + private fun computeSelectedItems( + answer: InteractionObject, interactionId: String, choiceStrings: List + ): MutableList { + return if (interactionId == "ItemSelectionInput") { + answer.setOfHtmlString.htmlList.map(choiceStrings::indexOf).toMutableList() + } else if (answer.objectTypeCase == InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT) { + mutableListOf(answer.nonNegativeInt) + } else { + mutableListOf() + } + } + + private fun computeChoiceItems( + choiceStrings: List, selectedItems: List, isReadOnly: Boolean, + selectionInteractionViewModel: SelectionInteractionViewModel + ): ObservableArrayList { + val observableList = ObservableArrayList() + observableList += choiceStrings.mapIndexed { index, choiceString -> + val isAnswerSelected = index in selectedItems + SelectionInteractionContentViewModel( + htmlContent = choiceString, itemIndex = index, isAnswerInitiallySelected = isAnswerSelected, + isReadOnly = isReadOnly, selectionInteractionViewModel = selectionInteractionViewModel + ) + } + return observableList + } + } +} diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/StateButtonViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/StateButtonViewModel.kt deleted file mode 100644 index eccdeea22de..00000000000 --- a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/StateButtonViewModel.kt +++ /dev/null @@ -1,108 +0,0 @@ -package org.oppia.app.player.state.itemviewmodel - -import android.content.Context -import android.widget.Button -import androidx.databinding.BindingAdapter -import androidx.databinding.ObservableField -import androidx.lifecycle.ViewModel -import org.oppia.app.fragment.FragmentScope -import javax.inject.Inject -import org.oppia.app.R -import org.oppia.app.viewmodel.ObservableViewModel - -private const val CONTINUE = "Continue" -private const val END_EXPLORATION = "EndExploration" -private const val LEARN_AGAIN = "LearnAgain" -private const val MULTIPLE_CHOICE_INPUT = "MultipleChoiceInput" -private const val ITEM_SELECT_INPUT = "ItemSelectionInput" -private const val TEXT_INPUT = "TextInput" -private const val FRACTION_INPUT = "FractionInput" -private const val NUMERIC_INPUT = "NumericInput" -private const val NUMERIC_WITH_UNITS = "NumberWithUnits" - -/** [ViewModel] for state-fragment. */ -@FragmentScope -class StateButtonViewModel @Inject constructor(val context: Context) : ObservableViewModel() { - companion object { - @JvmStatic - @BindingAdapter("android:button") - fun setBackgroundResource(button: Button, resource: Int) { - button.setBackgroundResource(resource) - } - } - - var isAudioFragmentVisible = ObservableField(false) - - var isNextButtonVisible = ObservableField(false) - var isPreviousButtonVisible = ObservableField(false) - - var observableInteractionId = ObservableField() - var isInteractionButtonActive = ObservableField(false) - var isInteractionButtonVisible = ObservableField(false) - var drawableResourceValue = ObservableField(R.drawable.state_button_primary_background) - - var name = ObservableField() - - fun setObservableInteractionId(interactionId: String) { - setNextButtonVisible(false) - observableInteractionId.set(interactionId) - // TODO(#249): Generalize this binding to make adding future interactions easier. - when (interactionId) { - CONTINUE -> { - isInteractionButtonActive.set(true) - isInteractionButtonVisible.set(true) - name.set(context.getString(R.string.state_continue_button)) - drawableResourceValue.set(R.drawable.state_button_primary_background) - } - END_EXPLORATION -> { - isInteractionButtonActive.set(true) - isInteractionButtonVisible.set(true) - name.set(context.getString(R.string.state_end_exploration_button)) - drawableResourceValue.set(R.drawable.state_button_primary_background) - } - LEARN_AGAIN -> { - isInteractionButtonActive.set(true) - isInteractionButtonVisible.set(true) - name.set(context.getString(R.string.state_learn_again_button)) - drawableResourceValue.set(R.drawable.state_button_blue_background) - } - ITEM_SELECT_INPUT, MULTIPLE_CHOICE_INPUT -> { - isInteractionButtonActive.set(true) - isInteractionButtonVisible.set(false) - name.set(context.getString(R.string.state_submit_button)) - drawableResourceValue.set(R.drawable.state_button_primary_background) - } - FRACTION_INPUT, NUMERIC_INPUT, NUMERIC_WITH_UNITS, TEXT_INPUT -> { - // TODO(#163): The value of isInteractionButtonVisible should be false in this case and it should be updated. - // We are keeping this true for now so that the submit button can work even without any interaction. - isInteractionButtonActive.set(true) - isInteractionButtonVisible.set(true) - name.set(context.getString(R.string.state_submit_button)) - // TODO(#163): The value of drawable should be R.drawable.state_button_transparent_background as per above explanation. - drawableResourceValue.set(R.drawable.state_button_primary_background) - } - } - } - - fun clearObservableInteractionId() { - observableInteractionId.set("") - isInteractionButtonVisible.set(false) - isInteractionButtonActive.set(false) - } - - fun setAudioFragmentVisible(isVisible: Boolean) { - isAudioFragmentVisible.set(isVisible) - } - - fun setNextButtonVisible(isVisible: Boolean) { - isNextButtonVisible.set(isVisible) - } - - fun setPreviousButtonVisible(isVisible: Boolean) { - isPreviousButtonVisible.set(isVisible) - } - - fun optionSelected(isOptionSelected: Boolean) { - isInteractionButtonVisible.set(isOptionSelected) - } -} diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/StateItemViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/StateItemViewModel.kt new file mode 100644 index 00000000000..af30fa463d4 --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/StateItemViewModel.kt @@ -0,0 +1,6 @@ +package org.oppia.app.player.state.itemviewmodel + +import org.oppia.app.viewmodel.ObservableViewModel + +/** The root [ViewModel] for all individual items that may be displayed in the state fragment recycler view. */ +abstract class StateItemViewModel: ObservableViewModel() diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/StateNavigationButtonViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/StateNavigationButtonViewModel.kt new file mode 100644 index 00000000000..6de79aac1af --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/StateNavigationButtonViewModel.kt @@ -0,0 +1,100 @@ +package org.oppia.app.player.state.itemviewmodel + +import android.content.Context +import android.widget.Button +import androidx.databinding.BindingAdapter +import androidx.databinding.ObservableField +import androidx.lifecycle.ViewModel +import org.oppia.app.R +import org.oppia.app.player.state.listener.StateNavigationButtonListener + +/** [ViewModel] for State navigation buttons. */ +class StateNavigationButtonViewModel( + val context: Context, val stateNavigationButtonListener: StateNavigationButtonListener +) : StateItemViewModel() { + companion object { + @JvmStatic + @BindingAdapter("android:button") + fun setBackgroundResource(button: Button, resource: Int) { + button.setBackgroundResource(resource) + } + } + + private var currentContinuationNavigationButtonType: ContinuationNavigationButtonType = + ContinuationNavigationButtonType.NO_CONTINUATION_BUTTON + + var isNextButtonVisible = ObservableField(false) + var isPreviousButtonVisible = ObservableField(false) + + var isInteractionButtonActive = ObservableField(false) + var isInteractionButtonVisible = ObservableField(false) + var drawableResourceValue = ObservableField(R.drawable.state_button_primary_background) + + var interactionButtonName = ObservableField() + + fun updatePreviousButton(isEnabled: Boolean) { + isPreviousButtonVisible.set(isEnabled) + } + + fun updateContinuationButton( + continuationNavigationButtonType: ContinuationNavigationButtonType, isEnabled: Boolean + ) { + currentContinuationNavigationButtonType = continuationNavigationButtonType + when (continuationNavigationButtonType) { + ContinuationNavigationButtonType.NEXT_BUTTON -> { + isInteractionButtonActive.set(false) + isInteractionButtonVisible.set(false) + isNextButtonVisible.set(isEnabled) + } + ContinuationNavigationButtonType.SUBMIT_BUTTON -> { + isNextButtonVisible.set(false) + isInteractionButtonActive.set(isEnabled) + isInteractionButtonVisible.set(isEnabled) + interactionButtonName.set(context.getString(R.string.state_submit_button)) + drawableResourceValue.set(R.drawable.state_button_primary_background) + } + ContinuationNavigationButtonType.CONTINUE_BUTTON -> { + isNextButtonVisible.set(false) + isInteractionButtonActive.set(isEnabled) + isInteractionButtonVisible.set(isEnabled) + interactionButtonName.set(context.getString(R.string.state_continue_button)) + drawableResourceValue.set(R.drawable.state_button_primary_background) + } + ContinuationNavigationButtonType.RETURN_TO_TOPIC_BUTTON -> { + isNextButtonVisible.set(false) + isInteractionButtonActive.set(isEnabled) + isInteractionButtonVisible.set(isEnabled) + interactionButtonName.set(context.getString(R.string.state_end_exploration_button)) + drawableResourceValue.set(R.drawable.state_button_primary_background) + } + ContinuationNavigationButtonType.NO_CONTINUATION_BUTTON -> { + isInteractionButtonActive.set(false) + isInteractionButtonVisible.set(false) + isNextButtonVisible.set(false) + } + } + } + + fun triggerContinuationNavigationButtonCallback() { + when (currentContinuationNavigationButtonType) { + ContinuationNavigationButtonType.NEXT_BUTTON -> stateNavigationButtonListener.onNextButtonClicked() + ContinuationNavigationButtonType.SUBMIT_BUTTON -> stateNavigationButtonListener.onSubmitButtonClicked() + ContinuationNavigationButtonType.CONTINUE_BUTTON -> stateNavigationButtonListener.onContinueButtonClicked() + ContinuationNavigationButtonType.RETURN_TO_TOPIC_BUTTON -> { + stateNavigationButtonListener.onReturnToTopicButtonClicked() + } + else -> throw IllegalStateException( + "Cannot trigger continuation for current button state: $currentContinuationNavigationButtonType" + ) + } + } + + /** The type of the state continue navigation button being shown. */ + enum class ContinuationNavigationButtonType { + NO_CONTINUATION_BUTTON, + NEXT_BUTTON, + SUBMIT_BUTTON, + CONTINUE_BUTTON, + RETURN_TO_TOPIC_BUTTON + } +} diff --git a/app/src/main/java/org/oppia/app/player/state/itemviewmodel/TextInputViewModel.kt b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/TextInputViewModel.kt new file mode 100644 index 00000000000..ab473fa85cf --- /dev/null +++ b/app/src/main/java/org/oppia/app/player/state/itemviewmodel/TextInputViewModel.kt @@ -0,0 +1,19 @@ +package org.oppia.app.player.state.itemviewmodel + +import org.oppia.app.model.InteractionObject +import org.oppia.app.player.state.answerhandling.InteractionAnswerHandler +import org.oppia.domain.util.toAnswerString + +class TextInputViewModel( + existingAnswer: InteractionObject?, val isReadOnly: Boolean +): StateItemViewModel(), InteractionAnswerHandler { + var answerText: CharSequence = existingAnswer?.toAnswerString() ?: "" + + override fun getPendingAnswer(): InteractionObject { + val interactionObjectBuilder = InteractionObject.newBuilder() + if (answerText.isNotEmpty()) { + interactionObjectBuilder.normalizedString = answerText.toString() + } + return interactionObjectBuilder.build() + } +} diff --git a/app/src/main/java/org/oppia/app/player/state/listener/InputInteractionListener.kt b/app/src/main/java/org/oppia/app/player/state/listener/InputInteractionListener.kt deleted file mode 100644 index 3da5bc02267..00000000000 --- a/app/src/main/java/org/oppia/app/player/state/listener/InputInteractionListener.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.oppia.app.player.state.listener - -/** - * This interface helps to check if the learner has selected some option, - * or if the learner has started typing in edit-text interactions. - */ -interface InputInteractionTextListener { - fun hasLearnerStartedAnswering(inputInteractionStarted: Boolean) -} diff --git a/app/src/main/java/org/oppia/app/player/state/listener/InteractionAnswerRetriever.kt b/app/src/main/java/org/oppia/app/player/state/listener/InteractionAnswerRetriever.kt deleted file mode 100644 index cb61bbe49c1..00000000000 --- a/app/src/main/java/org/oppia/app/player/state/listener/InteractionAnswerRetriever.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.oppia.app.player.state.listener - -import org.oppia.app.model.InteractionObject - -/** This interface helps to get pending answer of any learner interaction. */ -interface InteractionAnswerRetriever { - fun getPendingAnswer(): InteractionObject -} diff --git a/app/src/main/java/org/oppia/app/player/state/listener/ItemClickListener.kt b/app/src/main/java/org/oppia/app/player/state/listener/ItemClickListener.kt deleted file mode 100755 index 4963a1cd64f..00000000000 --- a/app/src/main/java/org/oppia/app/player/state/listener/ItemClickListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.oppia.app.player.state.listener - -import org.oppia.app.model.InteractionObject - -/** This interface helps to get pending answer of MultipleChoice/ItemSelection input interaction. */ -interface ItemClickListener { - fun onItemClick(interactionObject: InteractionObject) -} diff --git a/app/src/main/java/org/oppia/app/player/state/listener/ButtonInteractionListener.kt b/app/src/main/java/org/oppia/app/player/state/listener/StateNavigationButtonListener.kt similarity index 55% rename from app/src/main/java/org/oppia/app/player/state/listener/ButtonInteractionListener.kt rename to app/src/main/java/org/oppia/app/player/state/listener/StateNavigationButtonListener.kt index e69523f06e9..05c0dfa7f06 100644 --- a/app/src/main/java/org/oppia/app/player/state/listener/ButtonInteractionListener.kt +++ b/app/src/main/java/org/oppia/app/player/state/listener/StateNavigationButtonListener.kt @@ -1,8 +1,10 @@ package org.oppia.app.player.state.listener /** This interface helps to know when a button has been clicked. */ -interface ButtonInteractionListener { +interface StateNavigationButtonListener { fun onPreviousButtonClicked() fun onNextButtonClicked() - fun onInteractionButtonClicked() + fun onReturnToTopicButtonClicked() + fun onSubmitButtonClicked() + fun onContinueButtonClicked() } diff --git a/app/src/main/java/org/oppia/app/profile/AddProfileFragment.kt b/app/src/main/java/org/oppia/app/profile/AddProfileFragment.kt index 8551a091f77..118ced0d6d1 100644 --- a/app/src/main/java/org/oppia/app/profile/AddProfileFragment.kt +++ b/app/src/main/java/org/oppia/app/profile/AddProfileFragment.kt @@ -12,7 +12,7 @@ import javax.inject.Inject class AddProfileFragment : InjectableFragment() { @Inject lateinit var addProfileFragmentPresenter: AddProfileFragmentPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/profile/AdminAuthFragment.kt b/app/src/main/java/org/oppia/app/profile/AdminAuthFragment.kt index bdfc867d565..8fabb431deb 100644 --- a/app/src/main/java/org/oppia/app/profile/AdminAuthFragment.kt +++ b/app/src/main/java/org/oppia/app/profile/AdminAuthFragment.kt @@ -12,7 +12,7 @@ import javax.inject.Inject class AdminAuthFragment : InjectableFragment() { @Inject lateinit var adminAuthFragmentPresenter: AdminAuthFragmentPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/profile/AdminAuthFragmentPresenter.kt b/app/src/main/java/org/oppia/app/profile/AdminAuthFragmentPresenter.kt index fb6b6d6986b..d34e2c3fd1d 100644 --- a/app/src/main/java/org/oppia/app/profile/AdminAuthFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/profile/AdminAuthFragmentPresenter.kt @@ -1,6 +1,5 @@ package org.oppia.app.profile -import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup diff --git a/app/src/main/java/org/oppia/app/profile/ProfileChooserFragment.kt b/app/src/main/java/org/oppia/app/profile/ProfileChooserFragment.kt index c87e270732f..de45bff3918 100644 --- a/app/src/main/java/org/oppia/app/profile/ProfileChooserFragment.kt +++ b/app/src/main/java/org/oppia/app/profile/ProfileChooserFragment.kt @@ -12,7 +12,7 @@ import javax.inject.Inject class ProfileChooserFragment : InjectableFragment() { @Inject lateinit var profileChooserFragmentPresenter: ProfileChooserFragmentPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/profile/ProfileChooserFragmentPresenter.kt b/app/src/main/java/org/oppia/app/profile/ProfileChooserFragmentPresenter.kt index fe7736607eb..89ae3538fae 100644 --- a/app/src/main/java/org/oppia/app/profile/ProfileChooserFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/app/profile/ProfileChooserFragmentPresenter.kt @@ -1,6 +1,5 @@ package org.oppia.app.profile -import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup diff --git a/app/src/main/java/org/oppia/app/profile/ProfileChooserViewModel.kt b/app/src/main/java/org/oppia/app/profile/ProfileChooserViewModel.kt index 22bbfc5dc43..c80c6ae4e17 100644 --- a/app/src/main/java/org/oppia/app/profile/ProfileChooserViewModel.kt +++ b/app/src/main/java/org/oppia/app/profile/ProfileChooserViewModel.kt @@ -1,6 +1,5 @@ package org.oppia.app.profile -import androidx.lifecycle.ViewModel import org.oppia.app.fragment.FragmentScope import org.oppia.app.viewmodel.ObservableViewModel import javax.inject.Inject diff --git a/app/src/main/java/org/oppia/app/recyclerview/BindableAdapter.kt b/app/src/main/java/org/oppia/app/recyclerview/BindableAdapter.kt index 284cecea02e..858f4032a2b 100644 --- a/app/src/main/java/org/oppia/app/recyclerview/BindableAdapter.kt +++ b/app/src/main/java/org/oppia/app/recyclerview/BindableAdapter.kt @@ -5,6 +5,7 @@ import android.view.View import android.view.ViewGroup import androidx.databinding.ViewDataBinding import androidx.recyclerview.widget.RecyclerView +import org.oppia.app.recyclerview.BindableAdapter.Builder.Companion.newBuilder import kotlin.reflect.KClass /** A function that returns the type of view that can bind the specified data object. */ diff --git a/app/src/main/java/org/oppia/app/recyclerview/RecyclerViewBindingAdapter.kt b/app/src/main/java/org/oppia/app/recyclerview/RecyclerViewBindingAdapter.kt index 8eb836fd255..b3dea562365 100644 --- a/app/src/main/java/org/oppia/app/recyclerview/RecyclerViewBindingAdapter.kt +++ b/app/src/main/java/org/oppia/app/recyclerview/RecyclerViewBindingAdapter.kt @@ -1,6 +1,7 @@ package org.oppia.app.recyclerview import androidx.databinding.BindingAdapter +import androidx.databinding.ObservableList import androidx.lifecycle.LiveData import androidx.recyclerview.widget.RecyclerView @@ -10,11 +11,21 @@ import androidx.recyclerview.widget.RecyclerView * https://android.jlelse.eu/1bd08b4796b4. */ @BindingAdapter("data") -fun bindToRecyclerViewAdapter(recyclerView: RecyclerView, liveData: LiveData>) { +fun bindToRecyclerViewAdapterWithLiveData(recyclerView: RecyclerView, liveData: LiveData>) { liveData.value?.let { data -> - val adapter = recyclerView.adapter - checkNotNull(adapter) { "Cannot bind data to a RecyclerView missing its adapter." } - check(adapter is BindableAdapter<*>) { "Can only bind data to a BindableAdapter." } - adapter.setDataUnchecked(data) + bindToRecyclerViewAdapter(recyclerView, data) } } + +/** A variant of [bindToRecyclerViewAdapterWithLiveData] that instead uses an observable list. */ +@BindingAdapter("data") +fun bindToRecyclerViewAdapterWithObservableList(recyclerView: RecyclerView, dataList: ObservableList) { + bindToRecyclerViewAdapter(recyclerView, dataList) +} + +private fun bindToRecyclerViewAdapter(recyclerView: RecyclerView, dataList: List) { + val adapter = recyclerView.adapter + checkNotNull(adapter) { "Cannot bind data to a RecyclerView missing its adapter." } + check(adapter is BindableAdapter<*>) { "Can only bind data to a BindableAdapter." } + adapter.setDataUnchecked(dataList) +} diff --git a/app/src/main/java/org/oppia/app/story/StoryFragment.kt b/app/src/main/java/org/oppia/app/story/StoryFragment.kt index f5e88b9a3c4..13b5d6be25c 100644 --- a/app/src/main/java/org/oppia/app/story/StoryFragment.kt +++ b/app/src/main/java/org/oppia/app/story/StoryFragment.kt @@ -26,7 +26,7 @@ class StoryFragment : InjectableFragment(), ExplorationSelectionListener { @Inject lateinit var storyFragmentPresenter: StoryFragmentPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/testing/BindableAdapterTestFragment.kt b/app/src/main/java/org/oppia/app/testing/BindableAdapterTestFragment.kt index 4876e16dbd0..0a9c477dc1a 100644 --- a/app/src/main/java/org/oppia/app/testing/BindableAdapterTestFragment.kt +++ b/app/src/main/java/org/oppia/app/testing/BindableAdapterTestFragment.kt @@ -16,7 +16,7 @@ class BindableAdapterTestFragment: InjectableFragment() { @Inject lateinit var bindableAdapterTestFragmentPresenter: BindableAdapterTestFragmentPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/testing/HtmlParserTestActivity.kt b/app/src/main/java/org/oppia/app/testing/HtmlParserTestActivity.kt index a714468de15..af83ac604af 100644 --- a/app/src/main/java/org/oppia/app/testing/HtmlParserTestActivity.kt +++ b/app/src/main/java/org/oppia/app/testing/HtmlParserTestActivity.kt @@ -22,7 +22,7 @@ class HtmlParserTestActivity : InjectableAppCompatActivity() { val rawDummyString = "\u003cp\u003e\"Let's try one last question,\" said Mr. Baker. \"Here's a pineapple cake cut into pieces.\"\u003c/p\u003e\u003coppia-noninteractive-image alt-with-value=\"\u0026amp;quot;Pineapple cake with 7/9 having cherries.\u0026amp;quot;\" caption-with-value=\"\u0026amp;quot;\u0026amp;quot;\" filepath-with-value=\"\u0026amp;quot;pineapple_cake_height_479_width_480.png\u0026amp;quot;\"\u003e\u003c/oppia-noninteractive-image\u003e\u003cp\u003e\u00a0\u003c/p\u003e\u003cp\u003e\u003cstrong\u003eQuestion 6\u003c/strong\u003e: What fraction of the cake has big red cherries in the pineapple slices?\u003c/p\u003e" val htmlResult: Spannable = - htmlParserFactory.create( /* entityType= */ "exploration", /* entityId= */ "oppia-welcome") + htmlParserFactory.create( /* entityType= */ "exploration", /* entityId= */ "oppia") .parseOppiaHtml( rawDummyString, testHtmlContentTextView diff --git a/app/src/main/java/org/oppia/app/testing/InputInteractionViewTestActivity.kt b/app/src/main/java/org/oppia/app/testing/InputInteractionViewTestActivity.kt index daac9452205..749dbdabb3e 100644 --- a/app/src/main/java/org/oppia/app/testing/InputInteractionViewTestActivity.kt +++ b/app/src/main/java/org/oppia/app/testing/InputInteractionViewTestActivity.kt @@ -1,19 +1,30 @@ package org.oppia.app.testing -import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil import org.oppia.app.R +import org.oppia.app.customview.interaction.FractionInputInteractionView import org.oppia.app.customview.interaction.NumericInputInteractionView import org.oppia.app.customview.interaction.TextInputInteractionView -import org.oppia.app.customview.interaction.FractionInputInteractionView +import org.oppia.app.databinding.ActivityNumericInputInteractionViewTestBinding +import org.oppia.app.model.InteractionObject +import org.oppia.app.player.state.itemviewmodel.NumericInputViewModel /** * This is a dummy activity to test input interaction views. - * It contains [NumericInputInteractionView], [TextInputInteractionView] and [FractionInputInteractionView]. + * It contains [NumericInputInteractionView], [TextInputInteractionView], [FractionInputInteractionView] and [NumberWithUnitsInputInteractionView]. */ class InputInteractionViewTestActivity : AppCompatActivity() { + val numericInputViewModel = NumericInputViewModel( + existingAnswer = InteractionObject.getDefaultInstance(), isReadOnly = false + ) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_numeric_input_interaction_view_test) + val binding = DataBindingUtil.setContentView( + this, R.layout.activity_numeric_input_interaction_view_test + ) + binding.numericInputViewModel = numericInputViewModel } } diff --git a/app/src/main/java/org/oppia/app/topic/TopicFragment.kt b/app/src/main/java/org/oppia/app/topic/TopicFragment.kt index 81eaae69971..d4a1e9e71a3 100644 --- a/app/src/main/java/org/oppia/app/topic/TopicFragment.kt +++ b/app/src/main/java/org/oppia/app/topic/TopicFragment.kt @@ -15,7 +15,7 @@ class TopicFragment : InjectableFragment() { lateinit var topicFragmentPresenter: TopicFragmentPresenter lateinit var topicId: String - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/topic/ViewPagerAdapter.kt b/app/src/main/java/org/oppia/app/topic/ViewPagerAdapter.kt index c1590a2e940..126e35c418a 100644 --- a/app/src/main/java/org/oppia/app/topic/ViewPagerAdapter.kt +++ b/app/src/main/java/org/oppia/app/topic/ViewPagerAdapter.kt @@ -13,7 +13,7 @@ import org.oppia.app.topic.train.TopicTrainFragment class ViewPagerAdapter(fragmentManager: FragmentManager, private val topicId: String) : FragmentStatePagerAdapter(fragmentManager) { - override fun getItem(position: Int): Fragment? { + override fun getItem(position: Int): Fragment { val args = Bundle() args.putString(TOPIC_ID_ARGUMENT_KEY, topicId) when (TopicTab.getTabForPosition(position)) { diff --git a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragment.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragment.kt index a9c7b5f4c41..a47cdddcc0d 100644 --- a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragment.kt +++ b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragment.kt @@ -31,7 +31,7 @@ class ConceptCardFragment : InjectableDialogFragment() { @Inject lateinit var conceptCardPresenter: ConceptCardPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/topic/overview/TopicOverviewFragment.kt b/app/src/main/java/org/oppia/app/topic/overview/TopicOverviewFragment.kt index 589b41f92d0..f98e041bba5 100644 --- a/app/src/main/java/org/oppia/app/topic/overview/TopicOverviewFragment.kt +++ b/app/src/main/java/org/oppia/app/topic/overview/TopicOverviewFragment.kt @@ -13,7 +13,7 @@ class TopicOverviewFragment : InjectableFragment() { @Inject lateinit var topicOverviewFragmentPresenter: TopicOverviewFragmentPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/topic/play/ChapterSummaryAdapter.kt b/app/src/main/java/org/oppia/app/topic/play/ChapterSummaryAdapter.kt index 31b606bfb3f..c9f1322da9e 100755 --- a/app/src/main/java/org/oppia/app/topic/play/ChapterSummaryAdapter.kt +++ b/app/src/main/java/org/oppia/app/topic/play/ChapterSummaryAdapter.kt @@ -1,10 +1,10 @@ package org.oppia.app.topic.play +import android.view.LayoutInflater import android.view.ViewGroup +import androidx.databinding.DataBindingUtil import androidx.recyclerview.widget.RecyclerView import org.oppia.app.R -import androidx.databinding.DataBindingUtil -import android.view.LayoutInflater import org.oppia.app.databinding.PlayChapterViewBinding import org.oppia.app.model.ChapterSummary diff --git a/app/src/main/java/org/oppia/app/topic/play/TopicPlayFragment.kt b/app/src/main/java/org/oppia/app/topic/play/TopicPlayFragment.kt index 3b9be343d53..e68d4d8e7ed 100644 --- a/app/src/main/java/org/oppia/app/topic/play/TopicPlayFragment.kt +++ b/app/src/main/java/org/oppia/app/topic/play/TopicPlayFragment.kt @@ -17,7 +17,7 @@ class TopicPlayFragment : InjectableFragment(), ExpandedChapterListIndexListener private var currentExpandedChapterListIndex: Int? = null - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragment.kt b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragment.kt index c3b2ce22195..fa15d4c4fd0 100644 --- a/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragment.kt +++ b/app/src/main/java/org/oppia/app/topic/questionplayer/QuestionPlayerFragment.kt @@ -13,7 +13,7 @@ class QuestionPlayerFragment: InjectableFragment(){ @Inject lateinit var questionPlayerFragmentPresenter: QuestionPlayerFragmentPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/topic/review/ReviewSkillSelectionAdapter.kt b/app/src/main/java/org/oppia/app/topic/review/ReviewSkillSelectionAdapter.kt index 315a01edea6..29404ebf9e7 100644 --- a/app/src/main/java/org/oppia/app/topic/review/ReviewSkillSelectionAdapter.kt +++ b/app/src/main/java/org/oppia/app/topic/review/ReviewSkillSelectionAdapter.kt @@ -5,9 +5,9 @@ import android.view.ViewGroup import androidx.databinding.DataBindingUtil import androidx.databinding.library.baseAdapters.BR import androidx.recyclerview.widget.RecyclerView +import org.oppia.app.R import org.oppia.app.databinding.TopicReviewSummaryViewBinding import org.oppia.app.model.SkillSummary -import org.oppia.app.R // TODO(#216): Make use of generic data-binding-enabled RecyclerView adapter. /** Adapter to bind skills to [RecyclerView] inside [TopicReviewFragment]. */ diff --git a/app/src/main/java/org/oppia/app/topic/review/TopicReviewFragment.kt b/app/src/main/java/org/oppia/app/topic/review/TopicReviewFragment.kt index 538e90003a1..de34f33d933 100644 --- a/app/src/main/java/org/oppia/app/topic/review/TopicReviewFragment.kt +++ b/app/src/main/java/org/oppia/app/topic/review/TopicReviewFragment.kt @@ -14,7 +14,7 @@ class TopicReviewFragment : InjectableFragment(), ReviewSkillSelector { @Inject lateinit var topicReviewFragmentPresenter: TopicReviewFragmentPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/topic/train/TopicTrainFragment.kt b/app/src/main/java/org/oppia/app/topic/train/TopicTrainFragment.kt index dde27acf032..c643ac74695 100644 --- a/app/src/main/java/org/oppia/app/topic/train/TopicTrainFragment.kt +++ b/app/src/main/java/org/oppia/app/topic/train/TopicTrainFragment.kt @@ -15,7 +15,7 @@ class TopicTrainFragment : InjectableFragment() { @Inject lateinit var topicTrainFragmentPresenter: TopicTrainFragmentPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/view/ViewComponent.kt b/app/src/main/java/org/oppia/app/view/ViewComponent.kt new file mode 100644 index 00000000000..3985812066b --- /dev/null +++ b/app/src/main/java/org/oppia/app/view/ViewComponent.kt @@ -0,0 +1,21 @@ +package org.oppia.app.view + +import android.view.View +import dagger.BindsInstance +import dagger.Subcomponent +import org.oppia.app.player.state.SelectionInteractionView + +/** Root subcomponent for custom views. */ +@Subcomponent +@ViewScope +interface ViewComponent { + @Subcomponent.Builder + interface Builder { + @BindsInstance + fun setView(view: View): Builder + + fun build(): ViewComponent + } + + fun inject(selectionInteractionView: SelectionInteractionView) +} diff --git a/app/src/main/java/org/oppia/app/view/ViewScope.kt b/app/src/main/java/org/oppia/app/view/ViewScope.kt new file mode 100644 index 00000000000..de9b4bd348b --- /dev/null +++ b/app/src/main/java/org/oppia/app/view/ViewScope.kt @@ -0,0 +1,6 @@ +package org.oppia.app.view + +import javax.inject.Scope + +/** A custom scope corresponding to dependencies that should be recreated for each view. */ +@Scope annotation class ViewScope diff --git a/app/src/main/res/drawable/continue_button_answer_background.xml b/app/src/main/res/drawable/continue_button_answer_background.xml new file mode 100644 index 00000000000..ac93d6859f5 --- /dev/null +++ b/app/src/main/res/drawable/continue_button_answer_background.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_volume_off_48dp.xml b/app/src/main/res/drawable/ic_volume_off_48dp.xml new file mode 100644 index 00000000000..1f1aae9e628 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_off_48dp.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/layout/activity_numeric_input_interaction_view_test.xml b/app/src/main/res/layout/activity_numeric_input_interaction_view_test.xml index 807f98d0bb4..fd72b7db36b 100644 --- a/app/src/main/res/layout/activity_numeric_input_interaction_view_test.xml +++ b/app/src/main/res/layout/activity_numeric_input_interaction_view_test.xml @@ -1,51 +1,65 @@ - + - - - + + + + + - + android:layout_height="match_parent" + android:gravity="center" + android:orientation="vertical" + tools:context=".testing.InputInteractionViewTestActivity"> + + + + + + + + diff --git a/app/src/main/res/layout/add_profile_fragment.xml b/app/src/main/res/layout/add_profile_fragment.xml index acae5072cd2..7e42279a97a 100644 --- a/app/src/main/res/layout/add_profile_fragment.xml +++ b/app/src/main/res/layout/add_profile_fragment.xml @@ -1,8 +1,7 @@ - + + - + android:layout_height="match_parent"> diff --git a/app/src/main/res/layout/admin_auth_fragment.xml b/app/src/main/res/layout/admin_auth_fragment.xml index 8bcad6e3107..7e42279a97a 100644 --- a/app/src/main/res/layout/admin_auth_fragment.xml +++ b/app/src/main/res/layout/admin_auth_fragment.xml @@ -1,8 +1,7 @@ - + + - + android:layout_height="match_parent"> diff --git a/app/src/main/res/layout/audio_fragment.xml b/app/src/main/res/layout/audio_fragment.xml index 3ad1a24fe9c..fce30a0f2b9 100755 --- a/app/src/main/res/layout/audio_fragment.xml +++ b/app/src/main/res/layout/audio_fragment.xml @@ -1,15 +1,20 @@ + xmlns:app="http://schemas.android.com/apk/res-auto"> + - + + + + type="org.oppia.app.player.audio.AudioFragment" /> + + type="org.oppia.app.player.audio.AudioViewModel" /> + + + app:layout_constraintTop_toTopOf="parent" /> + + app:layout_constraintTop_toTopOf="parent" /> + + app:layout_constraintTop_toTopOf="parent" /> diff --git a/app/src/main/res/layout/audio_fragment_test_activity.xml b/app/src/main/res/layout/audio_fragment_test_activity.xml index 63a71bf40e3..bcab8fe29a9 100644 --- a/app/src/main/res/layout/audio_fragment_test_activity.xml +++ b/app/src/main/res/layout/audio_fragment_test_activity.xml @@ -1,6 +1,5 @@ - - + - + android:text="@string/cellular_data_alert_dialog_checkbox"> diff --git a/app/src/main/res/layout/concept_card_example_view.xml b/app/src/main/res/layout/concept_card_example_view.xml index a903c7e0c76..89f420b19ef 100644 --- a/app/src/main/res/layout/concept_card_example_view.xml +++ b/app/src/main/res/layout/concept_card_example_view.xml @@ -1,15 +1,18 @@ - + + + + + android:text="@{subtitledHtml.getHtml()}" /> diff --git a/app/src/main/res/layout/concept_card_fragment_test_activity.xml b/app/src/main/res/layout/concept_card_fragment_test_activity.xml index 8505adfd9d1..df200985f2f 100644 --- a/app/src/main/res/layout/concept_card_fragment_test_activity.xml +++ b/app/src/main/res/layout/concept_card_fragment_test_activity.xml @@ -1,12 +1,13 @@ - +