diff --git a/app/BUILD.bazel b/app/BUILD.bazel index b2317c099fa..ba205a1040c 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -155,6 +155,7 @@ LISTENERS = [ "src/main/java/org/oppia/android/app/recyclerview/OnItemDragListener.kt", "src/main/java/org/oppia/android/app/settings/profile/LoadProfileEditDeletionDialogListener.kt", "src/main/java/org/oppia/android/app/settings/profile/RouteToProfileEditListener.kt", + "src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt", "src/main/java/org/oppia/android/app/topic/RouteToRevisionCardListener.kt", "src/main/java/org/oppia/android/app/topic/lessons/ChapterSummarySelector.kt", "src/main/java/org/oppia/android/app/topic/lessons/StorySummarySelector.kt", @@ -209,6 +210,7 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryListViewModel.kt", "src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryViewModel.kt", "src/main/java/org/oppia/android/app/home/recentlyplayed/PromotedStoryViewModel.kt", + "src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedViewModel.kt", "src/main/java/org/oppia/android/app/home/topiclist/TopicSummaryViewModel.kt", "src/main/java/org/oppia/android/app/onboarding/OnboadingSlideViewModel.kt", "src/main/java/org/oppia/android/app/onboarding/OnboardingViewModel.kt", @@ -235,11 +237,18 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/profileprogress/RecentlyPlayedStorySummaryViewModel.kt", "src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt", "src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryHeaderViewModel.kt", + "src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt", + "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt", + "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt", + "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt", + "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt", + "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt", "src/main/java/org/oppia/android/app/topic/TopicViewModel.kt", "src/main/java/org/oppia/android/app/topic/info/TopicInfoViewModel.kt", "src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt", "src/main/java/org/oppia/android/app/topic/lessons/StorySummaryViewModel.kt", "src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerViewModel.kt", + "src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardViewModel.kt", "src/main/java/org/oppia/android/app/utility/RatioExtensions.kt", "src/main/java/org/oppia/android/app/walkthrough/topiclist/topiclistviewmodel/WalkthroughTopicSummaryViewModel.kt", ] @@ -342,6 +351,7 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/story/StoryFragmentScroller.kt", "src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryItemViewModel.kt", "src/main/java/org/oppia/android/app/story/StoryViewModel.kt", + "src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt", "src/main/java/org/oppia/android/app/testing/BindableAdapterTestDataModel.kt", "src/main/java/org/oppia/android/app/testing/BindableAdapterTestViewModel.kt", "src/main/java/org/oppia/android/app/testing/CircularProgressIndicatorAdaptersTestViewModel.kt", @@ -356,7 +366,6 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/topic/practice/TopicPracticeViewModel.kt", "src/main/java/org/oppia/android/app/topic/revision/revisionitemviewmodel/TopicRevisionItemViewModel.kt", "src/main/java/org/oppia/android/app/topic/revision/TopicRevisionViewModel.kt", - "src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardViewModel.kt", "src/main/java/org/oppia/android/app/walkthrough/end/WalkthroughFinalViewModel.kt", "src/main/java/org/oppia/android/app/walkthrough/topiclist/topiclistviewmodel/WalkthroughTopicHeaderViewModel.kt", "src/main/java/org/oppia/android/app/walkthrough/topiclist/WalkthroughTopicItemViewModel.kt", @@ -402,7 +411,10 @@ VIEWS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/customview/LessonThumbnailImageView.kt", "src/main/java/org/oppia/android/app/customview/PromotedStoryCardView.kt", "src/main/java/org/oppia/android/app/customview/SegmentedCircularProgressView.kt", + "src/main/java/org/oppia/android/app/customview/SurveyOnboardingBackgroundView.kt", "src/main/java/org/oppia/android/app/customview/VerticalDashedLineView.kt", + "src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt", + "src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt", "src/main/java/org/oppia/android/app/utility/ClickableAreasImage.kt", ] @@ -621,6 +633,7 @@ kt_android_library( "//domain/src/main/java/org/oppia/android/domain/audio:cellular_audio_dialog_controller", "//model/src/main/proto:arguments_java_proto_lite", "//model/src/main/proto:question_java_proto_lite", + "//model/src/main/proto:survey_java_proto_lite", "//model/src/main/proto:topic_java_proto_lite", "//third_party:androidx_recyclerview_recyclerview", ], @@ -781,6 +794,8 @@ kt_android_library( "//domain/src/main/java/org/oppia/android/domain/oppialogger:startup_listener", "//domain/src/main/java/org/oppia/android/domain/profile:profile_management_controller", "//domain/src/main/java/org/oppia/android/domain/spotlight:spotlight_state_controller", + "//domain/src/main/java/org/oppia/android/domain/survey:gating_controller", + "//domain/src/main/java/org/oppia/android/domain/survey:survey_controller", "//model/src/main/proto:arguments_java_proto_lite", "//third_party:androidx_databinding_databinding-adapters", "//third_party:androidx_databinding_databinding-common", diff --git a/app/build.gradle b/app/build.gradle index 34aeccd28b1..71b759c6475 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -210,6 +210,7 @@ dependencies { 'androidx.test.espresso:espresso-core:3.2.0', 'androidx.test.espresso:espresso-intents:3.1.0', 'androidx.test.ext:junit:1.1.1', + 'androidx.test.ext:truth:1.4.0', 'com.github.bumptech.glide:mocks:4.11.0', 'com.google.truth:truth:1.1.3', 'androidx.work:work-testing:2.4.0', diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1f1fc369830..a27551a2005 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -307,6 +307,11 @@ + (this) as ViewComponentFactory + val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl + viewComponent.inject(this) + } +} diff --git a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt index 36b83c8244d..d89e5b1297b 100644 --- a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt +++ b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt @@ -65,6 +65,10 @@ import org.oppia.android.app.shim.IntentFactoryShimModule import org.oppia.android.app.shim.ViewBindingShimModule import org.oppia.android.app.spotlight.SpotlightFragment import org.oppia.android.app.story.StoryFragment +import org.oppia.android.app.survey.ExitSurveyConfirmationDialogFragment +import org.oppia.android.app.survey.SurveyFragment +import org.oppia.android.app.survey.SurveyOutroDialogFragment +import org.oppia.android.app.survey.SurveyWelcomeDialogFragment import org.oppia.android.app.testing.DragDropTestFragment import org.oppia.android.app.testing.ExplorationTestActivityPresenter import org.oppia.android.app.testing.ImageRegionSelectionTestFragment @@ -177,4 +181,8 @@ interface FragmentComponentImpl : FragmentComponent, ViewComponentBuilderInjecto fun inject(walkthroughFinalFragment: WalkthroughFinalFragment) fun inject(walkthroughTopicListFragment: WalkthroughTopicListFragment) fun inject(walkthroughWelcomeFragment: WalkthroughWelcomeFragment) + fun inject(surveyFragment: SurveyFragment) + fun inject(exitSurveyConfirmationDialogFragment: ExitSurveyConfirmationDialogFragment) + fun inject(surveyWelcomeDialogFragment: SurveyWelcomeDialogFragment) + fun inject(surveyOutroDialogFragment: SurveyOutroDialogFragment) } diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt index c7581956509..670afb8c3c5 100644 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt @@ -92,7 +92,7 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( HintsAndSolutionFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) binding.hintsAndSolutionToolbar.setNavigationIcon(R.drawable.ic_close_white_24dp) binding.hintsAndSolutionToolbar.setNavigationContentDescription( - R.string.hints_andSolution_close_icon_description + R.string.hints_and_solution_close_icon_description ) binding.hintsAndSolutionToolbar.setNavigationOnClickListener { (fragment.requireActivity() as? HintsAndSolutionListener)?.dismiss() diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/PromotedStoryListAdapter.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/PromotedStoryListAdapter.kt deleted file mode 100644 index 61bcf6bf5e6..00000000000 --- a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/PromotedStoryListAdapter.kt +++ /dev/null @@ -1,110 +0,0 @@ -package org.oppia.android.app.home.recentlyplayed - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import org.oppia.android.databinding.RecentlyPlayedStoryCardBinding -import org.oppia.android.databinding.SectionTitleBinding - -private const val VIEW_TYPE_SECTION_TITLE_TEXT = 1 -private const val VIEW_TYPE_SECTION_STORY_ITEM = 2 - -/** - * Adapter to inflate different items/views inside [RecyclerView] for Ongoing Story List. - * - * @property itemList the items that may be displayed in [RecentlyPlayedFragment]'s recycler view - */ -class PromotedStoryListAdapter( - private val itemList: MutableList -) : RecyclerView.Adapter() { - - private var titleIndex: Int = 0 - private var storyGridPosition: Int = 0 - private var spanCount = 0 - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - // TODO(#632): Generalize this binding to make adding future items easier. - VIEW_TYPE_SECTION_TITLE_TEXT -> { - val inflater = LayoutInflater.from(parent.context) - val binding = - SectionTitleBinding.inflate( - inflater, - parent, - /* attachToParent= */ false - ) - SectionTitleViewHolder(binding) - } - VIEW_TYPE_SECTION_STORY_ITEM -> { - val inflater = LayoutInflater.from(parent.context) - val binding = - RecentlyPlayedStoryCardBinding.inflate( - inflater, - parent, - /* attachToParent= */ false - ) - PromotedStoryViewHolder(binding) - } - else -> throw IllegalArgumentException("Invalid view type: $viewType") - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) = - when (holder.itemViewType) { - VIEW_TYPE_SECTION_TITLE_TEXT -> { - titleIndex = position - (holder as SectionTitleViewHolder).bind(itemList[position] as SectionTitleViewModel) - } - VIEW_TYPE_SECTION_STORY_ITEM -> { - storyGridPosition = position - titleIndex - (holder as PromotedStoryViewHolder).bind(itemList[position] as PromotedStoryViewModel) - } - else -> throw IllegalArgumentException("Invalid item view type: ${holder.itemViewType}") - } - - override fun getItemViewType(position: Int): Int { - return when (itemList[position]) { - is SectionTitleViewModel -> { - VIEW_TYPE_SECTION_TITLE_TEXT - } - is PromotedStoryViewModel -> { - VIEW_TYPE_SECTION_STORY_ITEM - } - else -> throw IllegalArgumentException( - "Invalid type of data $position with item ${itemList[position]}" - ) - } - } - - override fun getItemCount(): Int { - return itemList.size - } - -/** - * Specifies the number of columns of recently played stories shown in the recently played stories - * list. - * - * @param spanCount specifies the number of spaces this item should occupy, based on screen size - */ - fun setSpanCount(spanCount: Int) { - this.spanCount = spanCount - } - - private class SectionTitleViewHolder( - val binding: SectionTitleBinding - ) : RecyclerView.ViewHolder(binding.root) { - /** Binds the view model that sets section titles. */ - fun bind(sectionTitleViewModel: SectionTitleViewModel) { - binding.viewModel = sectionTitleViewModel - } - } - - private class PromotedStoryViewHolder( - val binding: RecentlyPlayedStoryCardBinding - ) : RecyclerView.ViewHolder(binding.root) { - /** Binds the view model that sets recently played items. */ - fun bind(promotedStoryViewModel: PromotedStoryViewModel) { - binding.viewModel = promotedStoryViewModel - } - } -} diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt index 5bc07a28483..fcd8a8b3c7c 100755 --- a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt @@ -5,9 +5,7 @@ import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment -import androidx.lifecycle.LiveData import androidx.lifecycle.Observer -import androidx.lifecycle.Transformations import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import org.oppia.android.R @@ -17,19 +15,17 @@ import org.oppia.android.app.model.ChapterPlayState import org.oppia.android.app.model.ExplorationActivityParams import org.oppia.android.app.model.ExplorationCheckpoint import org.oppia.android.app.model.ProfileId -import org.oppia.android.app.model.PromotedActivityList import org.oppia.android.app.model.PromotedStory +import org.oppia.android.app.recyclerview.BindableAdapter import org.oppia.android.app.topic.RouteToResumeLessonListener -import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.databinding.RecentlyPlayedFragmentBinding +import org.oppia.android.databinding.RecentlyPlayedStoryCardBinding +import org.oppia.android.databinding.SectionTitleBinding import org.oppia.android.domain.exploration.ExplorationDataController import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationCheckpointController import org.oppia.android.domain.oppialogger.OppiaLogger -import org.oppia.android.domain.topic.TopicListController -import org.oppia.android.domain.translation.TranslationController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData -import org.oppia.android.util.parser.html.StoryHtmlParserEntityType import javax.inject.Inject /** The presenter for [RecentlyPlayedFragment]. */ @@ -39,11 +35,9 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( private val fragment: Fragment, private val oppiaLogger: OppiaLogger, private val explorationDataController: ExplorationDataController, - private val topicListController: TopicListController, private val explorationCheckpointController: ExplorationCheckpointController, - @StoryHtmlParserEntityType private val entityType: String, - private val resourceHandler: AppLanguageResourceHandler, - private val translationController: TranslationController + private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory, + private val recentlyPlayedViewModelFactory: RecentlyPlayedViewModel.Factory ) { private val routeToResumeLessonListener = activity as RouteToResumeLessonListener @@ -51,178 +45,40 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( private lateinit var profileId: ProfileId private lateinit var binding: RecentlyPlayedFragmentBinding - private lateinit var promotedStoryListAdapter: PromotedStoryListAdapter - private val itemList: MutableList = ArrayList() fun handleCreateView( inflater: LayoutInflater, container: ViewGroup?, - profileId: ProfileId + internalProfileId: Int ): View? { - binding = RecentlyPlayedFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) - - this.profileId = profileId - - promotedStoryListAdapter = PromotedStoryListAdapter(itemList) - binding.ongoingStoryRecyclerView.apply { - adapter = promotedStoryListAdapter - } - binding.lifecycleOwner = fragment - - subscribeToPromotedStoryList() - return binding.root - } - - private val promotedStoryListSummaryResultLiveData: - LiveData> - by lazy { - topicListController.getPromotedActivityList( - profileId - ).toLiveData() - } - - private fun subscribeToPromotedStoryList() { - getAssumedSuccessfulPromotedActivityList().observe( - fragment, - { - if (it.promotedStoryList.recentlyPlayedStoryList.isNotEmpty()) { - addRecentlyPlayedStoryListSection(it.promotedStoryList.recentlyPlayedStoryList) - } - - if (it.promotedStoryList.olderPlayedStoryList.isNotEmpty()) { - addOlderStoryListSection(it.promotedStoryList.olderPlayedStoryList) - } - - if (it.promotedStoryList.suggestedStoryList.isNotEmpty()) { - addRecommendedStoryListSection(it.promotedStoryList.suggestedStoryList) - } - - binding.ongoingStoryRecyclerView.layoutManager = - createLayoutManager( - it.promotedStoryList.recentlyPlayedStoryCount, - it.promotedStoryList.olderPlayedStoryCount, - it.promotedStoryList.suggestedStoryCount - ) - promotedStoryListAdapter.notifyDataSetChanged() - } - ) - } - - private fun addRecentlyPlayedStoryListSection( - recentlyPlayedStoryList: MutableList - ) { - itemList.clear() - val recentSectionTitleViewModel = - SectionTitleViewModel( - resourceHandler.getStringInLocale(R.string.ongoing_story_last_week), false - ) - itemList.add(recentSectionTitleViewModel) - recentlyPlayedStoryList.forEachIndexed { index, promotedStory -> - val ongoingStoryViewModel = createOngoingStoryViewModel(promotedStory, index) - itemList.add(ongoingStoryViewModel) - } - } - - private fun createOngoingStoryViewModel( - promotedStory: PromotedStory, - index: Int - ): RecentlyPlayedItemViewModel { - return PromotedStoryViewModel( - activity, - promotedStory, - entityType, + this.profileId = ProfileId.newBuilder().setInternalId(internalProfileId).build() + val recentlyPlayedViewModel = recentlyPlayedViewModelFactory.create( fragment as PromotedStoryClickListener, - index, - resourceHandler, - translationController + this.profileId ) - } - - private fun addOlderStoryListSection(olderPlayedStoryList: List) { - val showDivider = itemList.isNotEmpty() - val olderSectionTitleViewModel = - SectionTitleViewModel( - resourceHandler.getStringInLocale(R.string.ongoing_story_last_month), - showDivider - ) - itemList.add(olderSectionTitleViewModel) - olderPlayedStoryList.forEachIndexed { index, promotedStory -> - val ongoingStoryViewModel = createOngoingStoryViewModel(promotedStory, index) - itemList.add(ongoingStoryViewModel) - } - } - - private fun addRecommendedStoryListSection(suggestedStoryList: List) { - val showDivider = itemList.isNotEmpty() - val recommendedSectionTitleViewModel = - SectionTitleViewModel( - resourceHandler.getStringInLocale(R.string.recommended_stories), - showDivider - ) - itemList.add(recommendedSectionTitleViewModel) - suggestedStoryList.forEachIndexed { index, suggestedStory -> - val ongoingStoryViewModel = createOngoingStoryViewModel(suggestedStory, index) - itemList.add(ongoingStoryViewModel) - } - } - - private fun getAssumedSuccessfulPromotedActivityList(): LiveData { - return Transformations.map(promotedStoryListSummaryResultLiveData) { - when (it) { - // If there's an error loading the data, assume the default. - is AsyncResult.Failure, is AsyncResult.Pending -> PromotedActivityList.getDefaultInstance() - is AsyncResult.Success -> it.value + binding = + RecentlyPlayedFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false).apply { + lifecycleOwner = fragment + viewModel = recentlyPlayedViewModel + val adapter = createRecyclerViewAdapter() + ongoingStoryRecyclerView.layoutManager = createLayoutManager(adapter) + ongoingStoryRecyclerView.adapter = adapter } - } + + return binding.root } private fun createLayoutManager( - recentStoryCount: Int, - oldStoryCount: Int, - suggestedStoryCount: Int + adapter: BindableAdapter ): RecyclerView.LayoutManager { - val sectionTitle0Position = if (recentStoryCount == 0) { - // If recent story count is 0, that means that section title 0 will not be visible. - -1 - } else { - 0 - } - val sectionTitle1Position = if (oldStoryCount == 0) { - // If old story count is 0, that means that section title 1 will not be visible. - -1 - } else if (recentStoryCount == 0) { - 0 - } else { - recentStoryCount + 1 - } - val sectionTitle2Position = when { - suggestedStoryCount == 0 -> { - -1 // If suggested story count is 0, that means that section title 1 will not be visible. - } - oldStoryCount == 0 && recentStoryCount == 0 -> { - 0 - } - oldStoryCount > 0 && recentStoryCount > 0 -> { - recentStoryCount + oldStoryCount + 2 - } - else -> { - recentStoryCount + oldStoryCount + 1 - } - } - val spanCount = activity.resources.getInteger(R.integer.recently_played_span_count) - promotedStoryListAdapter.setSpanCount(spanCount) - val layoutManager = GridLayoutManager(activity.applicationContext, spanCount) layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { - return when (position) { - sectionTitle0Position, sectionTitle1Position, sectionTitle2Position -> { - /* number of spaces this item should occupy = */ spanCount - } - else -> { - /* number of spaces this item should occupy = */ 1 - } + return if (adapter.getItemViewType(position) == ViewType.VIEW_TYPE_TITLE.ordinal) { + spanCount + } else { + 1 } } } @@ -280,6 +136,31 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( } } + private enum class ViewType { + VIEW_TYPE_TITLE, + VIEW_TYPE_PROMOTED_STORY + } + + private fun createRecyclerViewAdapter(): BindableAdapter { + return multiTypeBuilderFactory.create { viewModel -> + when (viewModel) { + is PromotedStoryViewModel -> ViewType.VIEW_TYPE_PROMOTED_STORY + is SectionTitleViewModel -> ViewType.VIEW_TYPE_TITLE + else -> throw IllegalArgumentException("Encountered unexpected view model: $viewModel") + } + }.registerViewDataBinder( + viewType = ViewType.VIEW_TYPE_TITLE, + inflateDataBinding = SectionTitleBinding::inflate, + setViewModel = SectionTitleBinding::setViewModel, + transformViewModel = { it as SectionTitleViewModel } + ).registerViewDataBinder( + viewType = ViewType.VIEW_TYPE_PROMOTED_STORY, + inflateDataBinding = RecentlyPlayedStoryCardBinding::inflate, + setViewModel = RecentlyPlayedStoryCardBinding::setViewModel, + transformViewModel = { it as PromotedStoryViewModel } + ).build() + } + private fun playExploration( topicId: String, storyId: String, @@ -293,13 +174,13 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( // cases, lessons played from this fragment are known to be in progress, and that progress // can't be resumed here (hence the restart). explorationDataController.restartExploration( - profileId = this.profileId, topicId, storyId, explorationId + profileId.internalId, topicId, storyId, explorationId ) } else { // The only lessons that can't have their progress saved are those that were already // completed. explorationDataController.replayExploration( - profileId = this.profileId, topicId, storyId, explorationId + profileId.internalId, topicId, storyId, explorationId ) } startPlayingProvider.toLiveData().observe(fragment) { result -> diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedViewModel.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedViewModel.kt new file mode 100644 index 00000000000..7716fdf11f5 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedViewModel.kt @@ -0,0 +1,172 @@ +package org.oppia.android.app.home.recentlyplayed + +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations +import org.oppia.android.R +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.PromotedActivityList +import org.oppia.android.app.model.PromotedStory +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.domain.topic.TopicListController +import org.oppia.android.domain.translation.TranslationController +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProviders.Companion.toLiveData +import org.oppia.android.util.parser.html.StoryHtmlParserEntityType +import javax.inject.Inject + +/** View model for [RecentlyPlayedFragment]. */ +class RecentlyPlayedViewModel private constructor( + private val activity: AppCompatActivity, + private val topicListController: TopicListController, + @StoryHtmlParserEntityType private val entityType: String, + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController, + private val promotedStoryClickListener: PromotedStoryClickListener, + private val profileId: ProfileId, +) { + + /** Factory of RecentlyPlayedViewModel. */ + class Factory @Inject constructor( + private val activity: AppCompatActivity, + private val topicListController: TopicListController, + @StoryHtmlParserEntityType private val entityType: String, + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController, + ) { + + /** Creates an instance of [RecentlyPlayedViewModel]. */ + fun create( + promotedStoryClickListener: PromotedStoryClickListener, + profileId: ProfileId + ): RecentlyPlayedViewModel { + return RecentlyPlayedViewModel( + activity, + topicListController, + entityType, + resourceHandler, + translationController, + promotedStoryClickListener, + profileId, + ) + } + } + + /** + * [LiveData] with the list of recently played items for a ProfileId, organized in sections. + */ + val recentlyPlayedItems: LiveData> by lazy { + Transformations.map(promotedActivityListLiveData, ::processPromotedStoryList) + } + + private val promotedActivityListLiveData: LiveData by lazy { + getAssumedSuccessfulPromotedActivityList() + } + + private val promotedStoryListSummaryResultLiveData: + LiveData> + by lazy { + topicListController.getPromotedActivityList(profileId).toLiveData() + } + + private fun getAssumedSuccessfulPromotedActivityList(): LiveData { + return Transformations.map(promotedStoryListSummaryResultLiveData) { + when (it) { + // If there's an error loading the data, assume the default. + is AsyncResult.Failure, is AsyncResult.Pending -> PromotedActivityList.getDefaultInstance() + is AsyncResult.Success -> it.value + } + } + } + + private fun processPromotedStoryList( + promotedActivityList: PromotedActivityList + ): List { + val itemList: MutableList = mutableListOf() + if (promotedActivityList.promotedStoryList.recentlyPlayedStoryList.isNotEmpty()) { + addRecentlyPlayedStoryListSection( + promotedActivityList.promotedStoryList.recentlyPlayedStoryList, + itemList + ) + } + + if (promotedActivityList.promotedStoryList.olderPlayedStoryList.isNotEmpty()) { + addOlderStoryListSection( + promotedActivityList.promotedStoryList.olderPlayedStoryList, + itemList + ) + } + + if (promotedActivityList.promotedStoryList.suggestedStoryList.isNotEmpty()) { + addRecommendedStoryListSection( + promotedActivityList.promotedStoryList.suggestedStoryList, + itemList + ) + } + return itemList + } + + private fun addRecentlyPlayedStoryListSection( + recentlyPlayedStoryList: MutableList, + itemList: MutableList + ) { + val recentSectionTitleViewModel = + SectionTitleViewModel( + resourceHandler.getStringInLocale(R.string.ongoing_story_last_week), false + ) + itemList.add(recentSectionTitleViewModel) + recentlyPlayedStoryList.forEachIndexed { index, promotedStory -> + val ongoingStoryViewModel = createOngoingStoryViewModel(promotedStory, index) + itemList.add(ongoingStoryViewModel) + } + } + + private fun addOlderStoryListSection( + olderPlayedStoryList: List, + itemList: MutableList + ) { + val showDivider = itemList.isNotEmpty() + val olderSectionTitleViewModel = + SectionTitleViewModel( + resourceHandler.getStringInLocale(R.string.ongoing_story_last_month), + showDivider + ) + itemList.add(olderSectionTitleViewModel) + olderPlayedStoryList.forEachIndexed { index, promotedStory -> + val ongoingStoryViewModel = createOngoingStoryViewModel(promotedStory, index) + itemList.add(ongoingStoryViewModel) + } + } + + private fun addRecommendedStoryListSection( + suggestedStoryList: List, + itemList: MutableList + ) { + val showDivider = itemList.isNotEmpty() + val recommendedSectionTitleViewModel = + SectionTitleViewModel( + resourceHandler.getStringInLocale(R.string.recommended_stories), + showDivider + ) + itemList.add(recommendedSectionTitleViewModel) + suggestedStoryList.forEachIndexed { index, suggestedStory -> + val ongoingStoryViewModel = createOngoingStoryViewModel(suggestedStory, index) + itemList.add(ongoingStoryViewModel) + } + } + + private fun createOngoingStoryViewModel( + promotedStory: PromotedStory, + index: Int + ): RecentlyPlayedItemViewModel { + return PromotedStoryViewModel( + activity, + promotedStory, + entityType, + promotedStoryClickListener, + index, + resourceHandler, + translationController + ) + } +} diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt index 803d5f86e8d..cc1b92301b8 100644 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivityPresenter.kt @@ -19,6 +19,7 @@ import org.oppia.android.app.model.ExplorationActivityParams import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.ReadingTextSize import org.oppia.android.app.model.Spotlight +import org.oppia.android.app.model.SurveyQuestionName import org.oppia.android.app.options.OptionsActivity import org.oppia.android.app.player.stopplaying.ProgressDatabaseFullDialogFragment import org.oppia.android.app.player.stopplaying.UnsavedExplorationDialogFragment @@ -26,6 +27,8 @@ import org.oppia.android.app.spotlight.SpotlightFragment import org.oppia.android.app.spotlight.SpotlightManager import org.oppia.android.app.spotlight.SpotlightShape import org.oppia.android.app.spotlight.SpotlightTarget +import org.oppia.android.app.survey.SurveyWelcomeDialogFragment +import org.oppia.android.app.survey.TAG_SURVEY_WELCOME_DIALOG import org.oppia.android.app.topic.TopicActivity import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.utility.FontScaleConfigurationUtil @@ -33,6 +36,7 @@ import org.oppia.android.app.viewmodel.ViewModelProvider import org.oppia.android.databinding.ExplorationActivityBinding import org.oppia.android.domain.exploration.ExplorationDataController import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.survey.SurveyGatingController import org.oppia.android.domain.translation.TranslationController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData @@ -54,7 +58,8 @@ class ExplorationActivityPresenter @Inject constructor( private val fontScaleConfigurationUtil: FontScaleConfigurationUtil, private val translationController: TranslationController, private val oppiaLogger: OppiaLogger, - private val resourceHandler: AppLanguageResourceHandler + private val resourceHandler: AppLanguageResourceHandler, + private val surveyGatingController: SurveyGatingController ) { private lateinit var explorationToolbar: Toolbar private lateinit var explorationToolbarTitle: TextView @@ -279,8 +284,11 @@ class ExplorationActivityPresenter @Inject constructor( oppiaLogger.e("ExplorationActivity", "Failed to stop exploration", it.error) is AsyncResult.Success -> { oppiaLogger.d("ExplorationActivity", "Successfully stopped exploration") - backPressActivitySelector() - (activity as ExplorationActivity).finish() + if (isCompletion) { + maybeShowSurveyDialog(profileId, topicId) + } else { + backPressActivitySelector() + } } } } @@ -500,4 +508,52 @@ class ExplorationActivityPresenter @Inject constructor( } } } + + private fun maybeShowSurveyDialog(profileId: ProfileId, topicId: String) { + surveyGatingController.maybeShowSurvey(profileId, topicId).toLiveData() + .observe( + activity, + { gatingResult -> + when (gatingResult) { + is AsyncResult.Pending -> { + oppiaLogger.d("ExplorationActivity", "A gating decision is pending") + } + is AsyncResult.Failure -> { + oppiaLogger.e( + "ExplorationActivity", + "Failed to retrieve gating decision", + gatingResult.error + ) + backPressActivitySelector() + } + is AsyncResult.Success -> { + if (gatingResult.value) { + val dialogFragment = + SurveyWelcomeDialogFragment.newInstance( + profileId, + topicId, + explorationId, + SURVEY_QUESTIONS + ) + val transaction = activity.supportFragmentManager.beginTransaction() + transaction + .add(dialogFragment, TAG_SURVEY_WELCOME_DIALOG) + .addToBackStack(null) + .commit() + } else { + backPressActivitySelector() + } + } + } + } + ) + } + + companion object { + private val SURVEY_QUESTIONS = listOf( + SurveyQuestionName.USER_TYPE, + SurveyQuestionName.MARKET_FIT, + SurveyQuestionName.NPS + ) + } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt index 2ca6d7bb061..224d938e683 100755 --- a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt @@ -25,6 +25,7 @@ import org.oppia.android.app.model.EphemeralState import org.oppia.android.app.model.HelpIndex import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.State +import org.oppia.android.app.model.SurveyQuestionName import org.oppia.android.app.model.UserAnswer import org.oppia.android.app.player.audio.AudioButtonListener import org.oppia.android.app.player.audio.AudioFragment @@ -34,6 +35,8 @@ import org.oppia.android.app.player.state.ConfettiConfig.MEDIUM_CONFETTI_BURST import org.oppia.android.app.player.state.ConfettiConfig.MINI_CONFETTI_BURST import org.oppia.android.app.player.state.listener.RouteToHintsAndSolutionListener import org.oppia.android.app.player.stopplaying.StopStatePlayingSessionWithSavedProgressListener +import org.oppia.android.app.survey.SurveyWelcomeDialogFragment +import org.oppia.android.app.survey.TAG_SURVEY_WELCOME_DIALOG import org.oppia.android.app.topic.conceptcard.ConceptCardFragment import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.utility.SplitScreenManager @@ -41,6 +44,7 @@ import org.oppia.android.app.utility.lifecycle.LifecycleSafeTimerFactory import org.oppia.android.databinding.StateFragmentBinding import org.oppia.android.domain.exploration.ExplorationProgressController import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.survey.SurveyGatingController import org.oppia.android.domain.topic.StoryProgressController import org.oppia.android.util.accessibility.AccessibilityService import org.oppia.android.util.data.AsyncResult @@ -74,7 +78,8 @@ class StateFragmentPresenter @Inject constructor( private val oppiaClock: OppiaClock, private val viewModel: StateViewModel, private val accessibilityService: AccessibilityService, - private val resourceHandler: AppLanguageResourceHandler + private val resourceHandler: AppLanguageResourceHandler, + private val surveyGatingController: SurveyGatingController ) { private val routeToHintsAndSolutionListener = activity as RouteToHintsAndSolutionListener @@ -182,8 +187,7 @@ class StateFragmentPresenter @Inject constructor( fun onReturnToTopicButtonClicked() { hideKeyboard() markExplorationCompleted() - (activity as StopStatePlayingSessionWithSavedProgressListener) - .deleteCurrentProgressAndStopSession(isCompletion = true) + maybeShowSurveyDialog(profileId, topicId) } private fun showOrHideAudioByState(state: State) { @@ -522,6 +526,47 @@ class StateFragmentPresenter @Inject constructor( } } + private fun maybeShowSurveyDialog(profileId: ProfileId, topicId: String) { + surveyGatingController.maybeShowSurvey(profileId, topicId).toLiveData() + .observe( + activity, + { gatingResult -> + when (gatingResult) { + is AsyncResult.Pending -> { + oppiaLogger.d("StateFragment", "A gating decision is pending") + } + is AsyncResult.Failure -> { + oppiaLogger.e( + "StateFragment", + "Failed to retrieve gating decision", + gatingResult.error + ) + (activity as StopStatePlayingSessionWithSavedProgressListener) + .deleteCurrentProgressAndStopSession(isCompletion = true) + } + is AsyncResult.Success -> { + if (gatingResult.value) { + val dialogFragment = + SurveyWelcomeDialogFragment.newInstance( + profileId, + topicId, + explorationId, + SURVEY_QUESTIONS + ) + val transaction = activity.supportFragmentManager.beginTransaction() + transaction + .add(dialogFragment, TAG_SURVEY_WELCOME_DIALOG) + .commitNow() + } else { + (activity as StopStatePlayingSessionWithSavedProgressListener) + .deleteCurrentProgressAndStopSession(isCompletion = true) + } + } + } + } + ) + } + /** * An [Interpolator] when performs a reversed, then regular bounce interpolation using * [BounceInterpolator]. @@ -543,4 +588,12 @@ class StateFragmentPresenter @Inject constructor( } else bounceInterpolator.getInterpolation(input * 2f - 1f) } } + + companion object { + private val SURVEY_QUESTIONS = listOf( + SurveyQuestionName.USER_TYPE, + SurveyQuestionName.MARKET_FIT, + SurveyQuestionName.NPS + ) + } } diff --git a/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressActivityPresenter.kt index 90dbfa317d4..8f28851bcf2 100644 --- a/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressActivityPresenter.kt @@ -1,7 +1,6 @@ package org.oppia.android.app.profileprogress import android.content.Intent -import android.provider.MediaStore import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.Toolbar @@ -37,9 +36,9 @@ class ProfileProgressActivityPresenter @Inject constructor( R.id.profile_progress_activity_toolbar ) as Toolbar activity.setSupportActionBar(toolbar) - activity.supportActionBar!!.setTitle(R.string.profile) - activity.supportActionBar!!.setDisplayShowHomeEnabled(true) - activity.supportActionBar!!.setDisplayHomeAsUpEnabled(true) + (activity.supportActionBar ?: return).setTitle(R.string.profile) + (activity.supportActionBar ?: return).setDisplayShowHomeEnabled(true) + (activity.supportActionBar ?: return).setDisplayHomeAsUpEnabled(true) toolbar.setNavigationOnClickListener { activity.finish() } @@ -52,7 +51,7 @@ class ProfileProgressActivityPresenter @Inject constructor( } fun openGalleryIntent() { - val galleryIntent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) + val galleryIntent = Intent(Intent.ACTION_GET_CONTENT).apply { type = "image/*" } activity.startActivityForResult(galleryIntent, GALLERY_INTENT_RESULT_CODE) } diff --git a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt index cb57334b06d..acc6efcec4a 100644 --- a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt +++ b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt @@ -12,6 +12,7 @@ import org.oppia.android.app.home.promotedlist.PromotedStoryViewModel import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.itemviewmodel.DragDropInteractionContentViewModel import org.oppia.android.app.player.state.itemviewmodel.SelectionInteractionContentViewModel +import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel import org.oppia.android.util.parser.html.HtmlParser /** @@ -167,4 +168,42 @@ interface ViewBindingShim { entityId: String, writtenTranslationContext: WrittenTranslationContext ) + + /** + * Handles binding inflation for [SurveyMultipleChoiceOptionView]'s MultipleChoiceOption and + * returns the binding's view. + */ + fun provideMultipleChoiceItemsInflatedView( + inflater: LayoutInflater, + parent: ViewGroup, + attachToParent: Boolean + ): View + + /** + * Handles binding inflation for [SurveyMultipleChoiceOptionView]'s MultipleChoiceOption and + * returns the binding's view model. + */ + fun provideMultipleChoiceOptionViewModel( + view: View, + viewModel: MultipleChoiceOptionContentViewModel + ) + + /** + * Handles binding inflation for [SurveyNpsItemOptionView]'s MultipleChoiceOption and + * returns the binding's view. + */ + fun provideNpsItemsInflatedView( + inflater: LayoutInflater, + parent: ViewGroup, + attachToParent: Boolean + ): View + + /** + * Handles binding inflation for [SurveyNpsItemOptionView]'s MultipleChoiceOption and + * returns the binding's view model. + */ + fun provideNpsItemsViewModel( + view: View, + viewModel: MultipleChoiceOptionContentViewModel + ) } diff --git a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt index cd577465e07..69b49ac4eea 100644 --- a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt +++ b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt @@ -14,6 +14,7 @@ import org.oppia.android.app.home.promotedlist.PromotedStoryViewModel import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.itemviewmodel.DragDropInteractionContentViewModel import org.oppia.android.app.player.state.itemviewmodel.SelectionInteractionContentViewModel +import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.databinding.ComingSoonTopicViewBinding import org.oppia.android.databinding.DragDropInteractionItemsBinding @@ -21,6 +22,8 @@ import org.oppia.android.databinding.DragDropSingleItemBinding import org.oppia.android.databinding.ItemSelectionInteractionItemsBinding import org.oppia.android.databinding.MultipleChoiceInteractionItemsBinding import org.oppia.android.databinding.PromotedStoryCardBinding +import org.oppia.android.databinding.SurveyMultipleChoiceItemBinding +import org.oppia.android.databinding.SurveyNpsItemBinding import org.oppia.android.domain.translation.TranslationController import org.oppia.android.util.parser.html.HtmlParser import javax.inject.Inject @@ -148,6 +151,50 @@ class ViewBindingShimImpl @Inject constructor( binding.viewModel = viewModel } + override fun provideMultipleChoiceItemsInflatedView( + inflater: LayoutInflater, + parent: ViewGroup, + attachToParent: Boolean + ): View { + return SurveyMultipleChoiceItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ).root + } + + override fun provideMultipleChoiceOptionViewModel( + view: View, + viewModel: MultipleChoiceOptionContentViewModel + ) { + val binding = + DataBindingUtil.findBinding(view)!! + binding.optionContent = viewModel.optionContent + binding.viewModel = viewModel + } + + override fun provideNpsItemsInflatedView( + inflater: LayoutInflater, + parent: ViewGroup, + attachToParent: Boolean + ): View { + return SurveyNpsItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ).root + } + + override fun provideNpsItemsViewModel( + view: View, + viewModel: MultipleChoiceOptionContentViewModel + ) { + val binding = + DataBindingUtil.findBinding(view)!! + binding.scoreContent = viewModel.optionContent + binding.viewModel = viewModel + } + override fun provideDragDropSortInteractionInflatedView( inflater: LayoutInflater, parent: ViewGroup, diff --git a/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt new file mode 100644 index 00000000000..f111bafbec6 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragment.kt @@ -0,0 +1,77 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableDialogFragment +import org.oppia.android.app.model.ProfileId +import org.oppia.android.util.extensions.getProto +import org.oppia.android.util.extensions.putProto +import javax.inject.Inject + +/** Fragment that displays a dialog for survey exit confirmation. */ +class ExitSurveyConfirmationDialogFragment : InjectableDialogFragment() { + @Inject + lateinit var exitSurveyConfirmationDialogFragmentPresenter: + ExitSurveyConfirmationDialogFragmentPresenter + + companion object { + internal const val PROFILE_ID_KEY = "ExitSurveyConfirmationDialogFragment.profile_id" + + /** + * Creates a new instance of a DialogFragment to display an exit confirmation in a survey. + * + * @param profileId the ID of the profile viewing the survey + * @return [ExitSurveyConfirmationDialogFragment]: DialogFragment + */ + fun newInstance( + profileId: ProfileId + ): ExitSurveyConfirmationDialogFragment { + return ExitSurveyConfirmationDialogFragment().apply { + arguments = Bundle().apply { + putProto(PROFILE_ID_KEY, profileId) + } + } + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.ExitSurveyConfirmationDialogStyle) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val args = + checkNotNull( + arguments + ) { "Expected arguments to be passed to ExitSurveyConfirmationDialogFragment" } + + val profileId = args.getProto(PROFILE_ID_KEY, ProfileId.getDefaultInstance()) + + dialog?.setCanceledOnTouchOutside(false) + dialog?.setCancelable(false) + + return exitSurveyConfirmationDialogFragmentPresenter.handleCreateView( + inflater, + container + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.setWindowAnimations(R.style.ExitSurveyConfirmationDialogStyle) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt new file mode 100644 index 00000000000..3d5f3dd66a5 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/ExitSurveyConfirmationDialogFragmentPresenter.kt @@ -0,0 +1,75 @@ +package org.oppia.android.app.survey + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import org.oppia.android.app.fragment.FragmentScope +import org.oppia.android.databinding.SurveyExitConfirmationDialogBinding +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.survey.SurveyController +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProviders.Companion.toLiveData +import javax.inject.Inject + +const val TAG_EXIT_SURVEY_CONFIRMATION_DIALOG = "EXIT_SURVEY_CONFIRMATION_DIALOG" + +/** Presenter for [ExitSurveyConfirmationDialogFragment], sets up bindings from ViewModel. */ +@FragmentScope +class ExitSurveyConfirmationDialogFragmentPresenter @Inject constructor( + private val fragment: Fragment, + private val activity: AppCompatActivity, + private val surveyController: SurveyController, + private val oppiaLogger: OppiaLogger +) { + + /** Sets up data binding. */ + fun handleCreateView( + inflater: LayoutInflater, + container: ViewGroup? + ): View { + val binding = + SurveyExitConfirmationDialogBinding.inflate(inflater, container, /* attachToRoot= */ false) + + binding.lifecycleOwner = fragment + + binding.continueSurveyButton.setOnClickListener { + fragment.parentFragmentManager.beginTransaction() + .remove(fragment) + .commitNow() + } + + binding.exitSurveyButton.setOnClickListener { + endSurveyWithCallback { closeSurveyDialogAndActivity() } + } + + return binding.root + } + + private fun closeSurveyDialogAndActivity() { + activity.finish() + fragment.parentFragmentManager.beginTransaction() + .remove(fragment) + .commitNow() + } + + private fun endSurveyWithCallback(callback: () -> Unit) { + surveyController.stopSurveySession(surveyCompleted = false).toLiveData().observe( + activity, + { + when (it) { + is AsyncResult.Pending -> oppiaLogger.d("SurveyActivity", "Stopping survey session") + is AsyncResult.Failure -> { + oppiaLogger.d("SurveyActivity", "Failed to stop the survey session") + activity.finish() // Can't recover from the session failing to stop. + } + is AsyncResult.Success -> { + oppiaLogger.d("SurveyActivity", "Stopped the survey session") + callback() + } + } + } + ) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt b/app/src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt new file mode 100644 index 00000000000..d0d1c16b079 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SelectedAnswerAvailabilityReceiver.kt @@ -0,0 +1,29 @@ +package org.oppia.android.app.survey + +import org.oppia.android.app.model.SurveySelectedAnswer + +/** A handler for receiving any change in answer availability to update the 'next' button. */ +interface SelectedAnswerAvailabilityReceiver { + /** Called when the input answer availability changes. */ + fun onPendingAnswerAvailabilityCheck(inputAnswerAvailable: Boolean) +} + +/** A callback that will be called when a user submits an answer. */ +interface SelectedAnswerHandler { + /** Return the current selected answer that is ready for submission. */ + fun getMultipleChoiceAnswer(selectedAnswer: SurveySelectedAnswer) + + /** Return the current text answer that is ready for submission. */ + fun getFreeFormAnswer(answer: SurveySelectedAnswer) +} + +/** A handler for restoring the previous saved answer for a question on back/forward navigation. */ +interface PreviousAnswerHandler { + /** Called when an ephemeral question is loaded to retrieve the previously saved answer. */ + fun getPreviousAnswer(): SurveySelectedAnswer? { + return null + } + + /** Called after a previously saved answer is retrieved to update the UI. */ + fun restorePreviousAnswer(previousAnswer: SurveySelectedAnswer) +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt new file mode 100644 index 00000000000..12d5b7220ee --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyActivity.kt @@ -0,0 +1,77 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.ScreenName +import org.oppia.android.app.model.SurveyActivityParams +import org.oppia.android.util.extensions.getProtoExtra +import org.oppia.android.util.extensions.putProtoExtra +import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.decorateWithScreenName +import javax.inject.Inject + +/** The activity for showing a survey. */ +class SurveyActivity : InjectableAutoLocalizedAppCompatActivity() { + @Inject + lateinit var surveyActivityPresenter: SurveyActivityPresenter + + private lateinit var profileId: ProfileId + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + (activityComponent as ActivityComponentImpl).inject(this) + + val params = intent.extractParams() + this.profileId = params.profileId ?: ProfileId.newBuilder().setInternalId(-1).build() + + surveyActivityPresenter.handleOnCreate( + profileId, + params.topicId, + params.explorationId + ) + } + + override fun onBackPressed() { + val dialogFragment = ExitSurveyConfirmationDialogFragment.newInstance(profileId) + dialogFragment.showNow(supportFragmentManager, TAG_EXIT_SURVEY_CONFIRMATION_DIALOG) + } + + companion object { + private const val PARAMS_KEY = "SurveyActivity.params" + + /** + * A convenience function for creating a new [SurveyActivity] intent by prefilling common + * params needed by the activity. + */ + fun createSurveyActivityIntent( + context: Context, + profileId: ProfileId, + topicId: String, + explorationId: String + ): Intent { + val params = SurveyActivityParams.newBuilder().apply { + this.profileId = profileId + this.topicId = topicId + this.explorationId = explorationId + }.build() + return createSurveyActivityIntent(context, params) + } + + /** Returns a new [Intent] open a [SurveyActivity] with the specified [params]. */ + fun createSurveyActivityIntent( + context: Context, + params: SurveyActivityParams + ): Intent { + return Intent(context, SurveyActivity::class.java).apply { + putProtoExtra(PARAMS_KEY, params) + decorateWithScreenName(ScreenName.SURVEY_ACTIVITY) + } + } + + private fun Intent.extractParams() = + getProtoExtra(PARAMS_KEY, SurveyActivityParams.getDefaultInstance()) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt new file mode 100644 index 00000000000..8020dfe8796 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyActivityPresenter.kt @@ -0,0 +1,53 @@ +package org.oppia.android.app.survey + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import org.oppia.android.R +import org.oppia.android.app.activity.ActivityScope +import org.oppia.android.app.model.ProfileId +import org.oppia.android.databinding.SurveyActivityBinding +import javax.inject.Inject + +private const val TAG_SURVEY_FRAGMENT = "TAG_SURVEY_FRAGMENT" + +const val PROFILE_ID_ARGUMENT_KEY = "profile_id" +const val TOPIC_ID_ARGUMENT_KEY = "topic_id" +const val EXPLORATION_ID_ARGUMENT_KEY = "exploration_id" + +/** The Presenter for [SurveyActivity]. */ +@ActivityScope +class SurveyActivityPresenter @Inject constructor(private val activity: AppCompatActivity) { + private lateinit var binding: SurveyActivityBinding + + fun handleOnCreate( + profileId: ProfileId, + topicId: String, + explorationId: String + ) { + binding = DataBindingUtil.setContentView(activity, R.layout.survey_activity) + binding.apply { + lifecycleOwner = activity + } + + if (getSurveyFragment() == null) { + val surveyFragment = SurveyFragment() + val args = Bundle() + args.putInt(PROFILE_ID_ARGUMENT_KEY, profileId.internalId) + args.putString(TOPIC_ID_ARGUMENT_KEY, topicId) + args.putString(EXPLORATION_ID_ARGUMENT_KEY, explorationId) + + surveyFragment.arguments = args + activity.supportFragmentManager.beginTransaction().add( + R.id.survey_fragment_placeholder, + surveyFragment, TAG_SURVEY_FRAGMENT + ).commitNow() + } + } + + private fun getSurveyFragment(): SurveyFragment? { + return activity.supportFragmentManager.findFragmentByTag( + TAG_SURVEY_FRAGMENT + ) as? SurveyFragment + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt new file mode 100644 index 00000000000..c6b012fd855 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyFragment.kt @@ -0,0 +1,79 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableFragment +import org.oppia.android.app.model.SurveySelectedAnswer +import org.oppia.android.util.extensions.getStringFromBundle +import javax.inject.Inject + +/** Fragment that represents the current state of a survey. */ +class SurveyFragment : + InjectableFragment(), + SelectedAnswerAvailabilityReceiver, + SelectedAnswerHandler { + + companion object { + /** + * Creates a new instance of a SurveyFragment. + * + * @param internalProfileId used by SurveyFragment to record the survey action taken by user + * @param topicId used by SurveyFragment for logging purposes + * @return a new instance of [SurveyFragment] + */ + fun newInstance( + internalProfileId: Int, + topicId: String + ): SurveyFragment { + val surveyFragment = SurveyFragment() + val args = Bundle() + args.putInt(PROFILE_ID_ARGUMENT_KEY, internalProfileId) + args.putString(TOPIC_ID_ARGUMENT_KEY, topicId) + surveyFragment.arguments = args + return surveyFragment + } + } + + @Inject + lateinit var surveyFragmentPresenter: SurveyFragmentPresenter + + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val internalProfileId = arguments!!.getInt(PROFILE_ID_ARGUMENT_KEY, -1) + val topicId = arguments!!.getStringFromBundle(TOPIC_ID_ARGUMENT_KEY)!! + val explorationId = arguments!!.getStringFromBundle(EXPLORATION_ID_ARGUMENT_KEY)!! + + return surveyFragmentPresenter.handleCreateView( + inflater, + container, + internalProfileId, + explorationId, + topicId, + this + ) + } + + override fun onPendingAnswerAvailabilityCheck(inputAnswerAvailable: Boolean) { + surveyFragmentPresenter.updateNextButton(inputAnswerAvailable) + } + + override fun getMultipleChoiceAnswer(selectedAnswer: SurveySelectedAnswer) { + surveyFragmentPresenter.getPendingAnswer(selectedAnswer) + } + + override fun getFreeFormAnswer(answer: SurveySelectedAnswer) { + surveyFragmentPresenter.submitFreeFormAnswer(answer) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt new file mode 100644 index 00000000000..2d2b1ccca0f --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyFragmentPresenter.kt @@ -0,0 +1,355 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.Toolbar +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment +import androidx.lifecycle.LiveData +import com.google.android.flexbox.FlexDirection +import com.google.android.flexbox.FlexboxLayoutManager +import com.google.android.flexbox.JustifyContent +import org.oppia.android.app.model.EphemeralSurveyQuestion +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.SurveyQuestionName +import org.oppia.android.app.model.SurveySelectedAnswer +import org.oppia.android.app.recyclerview.BindableAdapter +import org.oppia.android.app.survey.surveyitemviewmodel.FreeFormItemsViewModel +import org.oppia.android.app.survey.surveyitemviewmodel.MarketFitItemsViewModel +import org.oppia.android.app.survey.surveyitemviewmodel.NpsItemsViewModel +import org.oppia.android.app.survey.surveyitemviewmodel.SurveyAnswerItemViewModel +import org.oppia.android.app.survey.surveyitemviewmodel.UserTypeItemsViewModel +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.databinding.SurveyFragmentBinding +import org.oppia.android.databinding.SurveyFreeFormLayoutBinding +import org.oppia.android.databinding.SurveyMarketFitQuestionLayoutBinding +import org.oppia.android.databinding.SurveyNpsScoreLayoutBinding +import org.oppia.android.databinding.SurveyUserTypeQuestionLayoutBinding +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController +import org.oppia.android.domain.survey.SurveyProgressController +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProviders.Companion.toLiveData +import javax.inject.Inject + +/** The presenter for [SurveyFragment]. */ +class SurveyFragmentPresenter @Inject constructor( + private val activity: AppCompatActivity, + private val fragment: Fragment, + private val oppiaLogger: OppiaLogger, + private val surveyProgressController: SurveyProgressController, + private val surveyViewModel: SurveyViewModel, + private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory, + private val resourceHandler: AppLanguageResourceHandler, + private val analyticsController: AnalyticsController +) { + private val ephemeralQuestionLiveData: LiveData> by lazy { + surveyProgressController.getCurrentQuestion().toLiveData() + } + + private lateinit var profileId: ProfileId + private lateinit var binding: SurveyFragmentBinding + private lateinit var surveyToolbar: Toolbar + private lateinit var answerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver + private lateinit var answerHandler: SelectedAnswerHandler + private lateinit var questionSelectedAnswer: SurveySelectedAnswer + private var isCurrentQuestionTerminal: Boolean = false + + /** Sets up data binding. */ + fun handleCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + internalProfileId: Int, + explorationId: String, + topicId: String, + fragment: SurveyFragment + ): View? { + profileId = ProfileId.newBuilder().setInternalId(internalProfileId).build() + this.answerAvailabilityReceiver = fragment + this.answerHandler = fragment + + binding = SurveyFragmentBinding.inflate( + inflater, + container, + /* attachToRoot= */ false + ) + binding.apply { + lifecycleOwner = fragment + viewModel = surveyViewModel + } + + surveyToolbar = binding.surveyToolbar + activity.setSupportActionBar(surveyToolbar) + surveyToolbar.setNavigationOnClickListener { + val dialogFragment = ExitSurveyConfirmationDialogFragment.newInstance(profileId) + dialogFragment.showNow(fragment.childFragmentManager, TAG_EXIT_SURVEY_CONFIRMATION_DIALOG) + } + + binding.surveyAnswersRecyclerView.apply { + adapter = createRecyclerViewAdapter() + } + + binding.surveyNextButton.setOnClickListener { + if (::questionSelectedAnswer.isInitialized) { + surveyProgressController.submitAnswer(questionSelectedAnswer) + } + } + + binding.surveyPreviousButton.setOnClickListener { + surveyProgressController.moveToPreviousQuestion() + } + + logBeginSurveyEvent(explorationId, topicId, profileId) + + subscribeToCurrentQuestion() + + return binding.root + } + + private fun createRecyclerViewAdapter(): BindableAdapter { + return multiTypeBuilderFactory + .create { viewModel -> + when (viewModel) { + is MarketFitItemsViewModel -> { + SurveyAnswerItemViewModel.ViewType.MARKET_FIT_OPTIONS + } + is UserTypeItemsViewModel -> { + SurveyAnswerItemViewModel.ViewType.USER_TYPE_OPTIONS + } + is NpsItemsViewModel -> { + SurveyAnswerItemViewModel.ViewType.NPS_OPTIONS + } + is FreeFormItemsViewModel -> { + SurveyAnswerItemViewModel.ViewType.FREE_FORM_ANSWER + } + else -> { + throw IllegalStateException("Invalid ViewType") + } + } + } + .registerViewDataBinder( + viewType = SurveyAnswerItemViewModel.ViewType.USER_TYPE_OPTIONS, + inflateDataBinding = SurveyUserTypeQuestionLayoutBinding::inflate, + setViewModel = SurveyUserTypeQuestionLayoutBinding::setViewModel, + transformViewModel = { it as UserTypeItemsViewModel } + ) + .registerViewDataBinder( + viewType = SurveyAnswerItemViewModel.ViewType.MARKET_FIT_OPTIONS, + inflateDataBinding = SurveyMarketFitQuestionLayoutBinding::inflate, + setViewModel = SurveyMarketFitQuestionLayoutBinding::setViewModel, + transformViewModel = { it as MarketFitItemsViewModel } + ) + .registerViewBinder( + viewType = SurveyAnswerItemViewModel.ViewType.NPS_OPTIONS, + inflateView = { parent -> + SurveyNpsScoreLayoutBinding.inflate( + LayoutInflater.from(parent.context), + parent, + /* attachToParent= */ false + ).root + }, + bindView = { view, viewModel -> + val binding = DataBindingUtil.findBinding(view)!! + val npsViewModel = viewModel as NpsItemsViewModel + binding.viewModel = npsViewModel + + val flexLayoutManager = FlexboxLayoutManager(activity) + flexLayoutManager.flexDirection = FlexDirection.ROW + flexLayoutManager.justifyContent = JustifyContent.CENTER + + binding.surveyNpsButtonsContainer.layoutManager = flexLayoutManager + } + ) + .registerViewDataBinder( + viewType = SurveyAnswerItemViewModel.ViewType.FREE_FORM_ANSWER, + inflateDataBinding = SurveyFreeFormLayoutBinding::inflate, + setViewModel = SurveyFreeFormLayoutBinding::setViewModel, + transformViewModel = { it as FreeFormItemsViewModel } + ) + .build() + } + + private fun subscribeToCurrentQuestion() { + ephemeralQuestionLiveData.observe( + fragment, + { + processEphemeralQuestionResult(it) + } + ) + } + + private fun processEphemeralQuestionResult(result: AsyncResult) { + when (result) { + is AsyncResult.Failure -> { + oppiaLogger.e( + "SurveyFragment", "Failed to retrieve ephemeral question", result.error + ) + } + is AsyncResult.Pending -> {} // Display nothing until a valid result is available. + is AsyncResult.Success -> processEphemeralQuestion(result.value) + } + } + + private fun processEphemeralQuestion(ephemeralQuestion: EphemeralSurveyQuestion) { + val questionName = ephemeralQuestion.question.questionName + surveyViewModel.itemList.clear() + when (questionName) { + SurveyQuestionName.USER_TYPE -> surveyViewModel.itemList.add( + UserTypeItemsViewModel( + resourceHandler, + answerAvailabilityReceiver, + answerHandler + ) + ) + SurveyQuestionName.MARKET_FIT -> surveyViewModel.itemList.add( + MarketFitItemsViewModel( + resourceHandler, + answerAvailabilityReceiver, + answerHandler + ) + ) + SurveyQuestionName.NPS -> surveyViewModel.itemList.add( + NpsItemsViewModel( + answerAvailabilityReceiver, + answerHandler + ) + ) + SurveyQuestionName.PROMOTER_FEEDBACK -> surveyViewModel.itemList.add( + FreeFormItemsViewModel( + answerAvailabilityReceiver, + questionName, + answerHandler + ) + ) + SurveyQuestionName.PASSIVE_FEEDBACK -> surveyViewModel.itemList.add( + FreeFormItemsViewModel( + answerAvailabilityReceiver, + questionName, + answerHandler + ) + ) + SurveyQuestionName.DETRACTOR_FEEDBACK -> surveyViewModel.itemList.add( + FreeFormItemsViewModel( + answerAvailabilityReceiver, + questionName, + answerHandler + ) + ) + else -> {} + } + + this.isCurrentQuestionTerminal = ephemeralQuestion.terminalQuestion + updateProgress(ephemeralQuestion.currentQuestionIndex, ephemeralQuestion.totalQuestionCount) + updateQuestionText(questionName) + + if (ephemeralQuestion.selectedAnswer != SurveySelectedAnswer.getDefaultInstance()) { + surveyViewModel.retrievePreviousAnswer( + ephemeralQuestion.selectedAnswer, + ::getPreviousAnswerHandler + ) + } + } + + private fun getPreviousAnswerHandler( + itemList: List + ): PreviousAnswerHandler? { + return itemList.findLast { it is PreviousAnswerHandler } as? PreviousAnswerHandler + } + + private fun updateProgress(currentQuestionIndex: Int, questionCount: Int) { + surveyViewModel.updateQuestionProgress( + progressPercentage = (((currentQuestionIndex + 1) / questionCount.toDouble()) * 100).toInt() + ) + toggleNavigationButtonVisibility(currentQuestionIndex, questionCount) + } + + private fun updateQuestionText(questionName: SurveyQuestionName) { + surveyViewModel.updateQuestionText(questionName) + } + + /** + * Updates whether the 'next' button should be active based on whether an answer to the current + * question has been provided. + */ + fun updateNextButton(inputAnswerAvailable: Boolean) { + surveyViewModel.setCanMoveToNextQuestion(inputAnswerAvailable) + } + + /** Retrieves the answer that was selected by the user for a question. */ + fun getPendingAnswer(answer: SurveySelectedAnswer) { + this.questionSelectedAnswer = answer + } + + /** + * Retrieves and submits the free text answer that was provided by the user, then navigates to the + * final screen. + */ + fun submitFreeFormAnswer(answer: SurveySelectedAnswer) { + hideKeyboard() + surveyProgressController.submitAnswer(answer).toLiveData().observe( + fragment, + { result -> + when (result) { + is AsyncResult.Failure -> { + oppiaLogger.e( + "SurveyFragment", "Failed to submit free form answer", result.error + ) + } + is AsyncResult.Pending -> {} // Do nothing until a valid result is available. + is AsyncResult.Success -> { + val dialogFragment = SurveyOutroDialogFragment.newInstance() + val transaction = activity.supportFragmentManager.beginTransaction() + transaction + .add(dialogFragment, TAG_SURVEY_OUTRO_DIALOG) + .commit() + activity.supportFragmentManager.executePendingTransactions() + } + } + } + ) + } + + private fun toggleNavigationButtonVisibility(questionIndex: Int, questionCount: Int) { + when (questionIndex) { + 0 -> { + binding.surveyNextButton.visibility = View.VISIBLE + binding.surveyPreviousButton.visibility = View.GONE + } + (questionCount - 1) -> { + binding.surveyNextButton.visibility = View.GONE + binding.surveyPreviousButton.visibility = View.VISIBLE + } + else -> { + binding.surveyNextButton.visibility = View.VISIBLE + binding.surveyPreviousButton.visibility = View.VISIBLE + } + } + } + + private fun hideKeyboard() { + val inputManager: InputMethodManager = + activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputManager.hideSoftInputFromWindow( + fragment.view!!.windowToken, + InputMethodManager.SHOW_FORCED + ) + } + + private fun logBeginSurveyEvent( + explorationId: String, + topicId: String, + profileId: ProfileId + ) { + analyticsController.logImportantEvent( + oppiaLogger.createBeginSurveyContext( + explorationId, + topicId + ), + profileId = profileId + ) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt new file mode 100644 index 00000000000..9823860f2a6 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt @@ -0,0 +1,79 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.databinding.ObservableList +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.RecyclerView +import org.oppia.android.app.recyclerview.BindableAdapter +import org.oppia.android.app.shim.ViewBindingShim +import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel +import org.oppia.android.app.view.ViewComponentFactory +import org.oppia.android.app.view.ViewComponentImpl +import javax.inject.Inject + +/** + * A custom [RecyclerView] for displaying a variable list of items that may be selected by a user as + * part of the multiple choice option selection. + */ +class SurveyMultipleChoiceOptionView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RecyclerView(context, attrs, defStyleAttr) { + @Inject + lateinit var bindingInterface: ViewBindingShim + + @Inject + lateinit var singleTypeBuilderFactory: BindableAdapter.SingleTypeBuilder.Factory + private lateinit var dataList: ObservableList + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + val viewComponentFactory = FragmentManager.findFragment(this) as ViewComponentFactory + val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl + viewComponent.inject(this) + maybeInitializeAdapter() + } + + /** + * Sets the view's RecyclerView [MultipleChoiceOptionContentViewModel] data list. + * + * Note that this needs to be used instead of the generic RecyclerView 'data' binding adapter + * since this one takes into account initialization order with other binding properties. + */ + fun setSelectionData(dataList: ObservableList) { + this.dataList = dataList + maybeInitializeAdapter() + } + + private fun maybeInitializeAdapter() { + if (::singleTypeBuilderFactory.isInitialized && + ::dataList.isInitialized + ) { + adapter = createAdapter().also { it.setData(dataList) } + } + } + + private fun createAdapter(): BindableAdapter { + return singleTypeBuilderFactory.create() + .registerViewBinder( + inflateView = { parent -> + bindingInterface.provideMultipleChoiceItemsInflatedView( + LayoutInflater.from(parent.context), + parent, + /* attachToParent= */ false + ) + }, + bindView = { view, viewModel -> + bindingInterface.provideMultipleChoiceOptionViewModel( + view, + viewModel + ) + } + ) + .build() + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt new file mode 100644 index 00000000000..85625f9a071 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt @@ -0,0 +1,79 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.databinding.ObservableList +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.RecyclerView +import org.oppia.android.app.recyclerview.BindableAdapter +import org.oppia.android.app.shim.ViewBindingShim +import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel +import org.oppia.android.app.view.ViewComponentFactory +import org.oppia.android.app.view.ViewComponentImpl +import javax.inject.Inject + +/** + * A custom [RecyclerView] for displaying a list of radio buttons that may be selected by a user as + * a score for the nps score survey question. + */ +class SurveyNpsItemOptionView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RecyclerView(context, attrs, defStyleAttr) { + @Inject + lateinit var bindingInterface: ViewBindingShim + + @Inject + lateinit var singleTypeBuilderFactory: BindableAdapter.SingleTypeBuilder.Factory + private lateinit var dataList: ObservableList + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + val viewComponentFactory = FragmentManager.findFragment(this) as ViewComponentFactory + val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl + viewComponent.inject(this) + maybeInitializeAdapter() + } + + /** + * Sets the view's RecyclerView [MultipleChoiceOptionContentViewModel] data list. + * + * Note that this needs to be used instead of the generic RecyclerView 'data' binding adapter + * since this one takes into account initialization order with other binding properties. + */ + fun setSelectionData(dataList: ObservableList) { + this.dataList = dataList + maybeInitializeAdapter() + } + + private fun maybeInitializeAdapter() { + if (::singleTypeBuilderFactory.isInitialized && + ::dataList.isInitialized + ) { + adapter = createAdapter().also { it.setData(dataList) } + } + } + + private fun createAdapter(): BindableAdapter { + return singleTypeBuilderFactory.create() + .registerViewBinder( + inflateView = { parent -> + bindingInterface.provideNpsItemsInflatedView( + LayoutInflater.from(parent.context), + parent, + /* attachToParent= */ false + ) + }, + bindView = { view, viewModel -> + bindingInterface.provideNpsItemsViewModel( + view, + viewModel + ) + } + ) + .build() + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragment.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragment.kt new file mode 100644 index 00000000000..b988415c547 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragment.kt @@ -0,0 +1,54 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableDialogFragment +import javax.inject.Inject + +/** Fragment that displays a fullscreen dialog for survey on-boarding. */ +class SurveyOutroDialogFragment : InjectableDialogFragment() { + @Inject + lateinit var surveyOutroDialogFragmentPresenter: SurveyOutroDialogFragmentPresenter + + companion object { + /** + * Creates a new instance of a DialogFragment to display the survey thank you message. + * + * @return [SurveyOutroDialogFragment]: DialogFragment + */ + fun newInstance(): SurveyOutroDialogFragment { + return SurveyOutroDialogFragment() + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.SurveyOnboardingDialogStyle) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return surveyOutroDialogFragmentPresenter.handleCreateView( + inflater, + container + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.setWindowAnimations(R.style.SurveyOnboardingDialogStyle) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt new file mode 100644 index 00000000000..8399bb9a0e5 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyOutroDialogFragmentPresenter.kt @@ -0,0 +1,74 @@ +package org.oppia.android.app.survey + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentScope +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.databinding.SurveyOutroDialogFragmentBinding +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.survey.SurveyController +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProviders.Companion.toLiveData +import javax.inject.Inject + +const val TAG_SURVEY_OUTRO_DIALOG = "SURVEY_OUTRO_DIALOG" + +/** Presenter for [SurveyWelcomeDialogFragment], sets up bindings. */ +@FragmentScope +class SurveyOutroDialogFragmentPresenter @Inject constructor( + private val activity: AppCompatActivity, + private val fragment: Fragment, + private val resourceHandler: AppLanguageResourceHandler, + private val surveyController: SurveyController, + private val oppiaLogger: OppiaLogger +) { + /** Sets up data binding. */ + fun handleCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + ): View { + val binding = + SurveyOutroDialogFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) + + binding.lifecycleOwner = fragment + + val appName = resourceHandler.getStringInLocale(R.string.app_name) + binding.surveyOnboardingText.text = resourceHandler.getStringInLocaleWithWrapping( + R.string.survey_thank_you_message_text, appName + ) + + binding.finishSurveyButton.setOnClickListener { + endSurveyWithCallback { closeSurveyDialogAndActivity() } + } + + return binding.root + } + + private fun closeSurveyDialogAndActivity() { + activity.finish() + activity.supportFragmentManager.beginTransaction().remove(fragment).commitNow() + } + + private fun endSurveyWithCallback(callback: () -> Unit) { + surveyController.stopSurveySession(surveyCompleted = true).toLiveData().observe( + activity, + { + when (it) { + is AsyncResult.Pending -> oppiaLogger.d("SurveyActivity", "Stopping survey session") + is AsyncResult.Failure -> { + oppiaLogger.d("SurveyActivity", "Failed to stop the survey session") + activity.finish() // Can't recover from the session failing to stop. + } + is AsyncResult.Success -> { + oppiaLogger.d("SurveyActivity", "Stopped the survey session") + callback() + } + } + } + ) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt new file mode 100644 index 00000000000..0979177affe --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyViewModel.kt @@ -0,0 +1,97 @@ +package org.oppia.android.app.survey + +import androidx.databinding.ObservableField +import androidx.databinding.ObservableList +import org.oppia.android.R +import org.oppia.android.app.model.SurveyQuestionName +import org.oppia.android.app.model.SurveySelectedAnswer +import org.oppia.android.app.survey.surveyitemviewmodel.SurveyAnswerItemViewModel +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.app.viewmodel.ObservableArrayList +import org.oppia.android.app.viewmodel.ObservableViewModel +import javax.inject.Inject + +class SurveyViewModel @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler +) : ObservableViewModel() { + val itemList: ObservableList = ObservableArrayList() + val itemIndex = ObservableField() + private val canMoveToNextQuestion = ObservableField(false) + + val progressPercentage = ObservableField(0) + + val questionProgressText: ObservableField = + ObservableField("$DEFAULT_QUESTION_PROGRESS%") + + val questionText: ObservableField = + ObservableField(DEFAULT_QUESTION) + + fun updateQuestionProgress( + progressPercentage: Int + ) { + this.progressPercentage.set(progressPercentage) + questionProgressText.set("$progressPercentage%") + } + + fun updateQuestionText(questionName: SurveyQuestionName) { + questionText.set(getQuestionText(questionName)) + setCanMoveToNextQuestion(false) + } + + fun setCanMoveToNextQuestion(canMoveToNext: Boolean) = + this.canMoveToNextQuestion.set(canMoveToNext) + + fun getCanMoveToNextQuestion(): ObservableField = canMoveToNextQuestion + + fun retrievePreviousAnswer( + previousAnswer: SurveySelectedAnswer, + retrieveAnswerHandler: (List) -> PreviousAnswerHandler? + ) { + restorePreviousAnswer( + previousAnswer, + retrieveAnswerHandler( + itemList + ) + ) + } + + private fun restorePreviousAnswer( + previousAnswer: SurveySelectedAnswer, + answerHandler: PreviousAnswerHandler? + ) { + answerHandler?.restorePreviousAnswer(previousAnswer) + } + + private fun getQuestionText( + questionName: SurveyQuestionName + ): String { + val appName = resourceHandler.getStringInLocale(R.string.app_name) + return when (questionName) { + SurveyQuestionName.USER_TYPE -> resourceHandler.getStringInLocale( + R.string.user_type_question + ) + SurveyQuestionName.MARKET_FIT -> resourceHandler.getStringInLocaleWithWrapping( + R.string.market_fit_question, appName + ) + SurveyQuestionName.NPS -> resourceHandler.getStringInLocaleWithWrapping( + R.string.nps_score_question, appName + ) + SurveyQuestionName.PROMOTER_FEEDBACK -> resourceHandler.getStringInLocaleWithWrapping( + R.string.nps_promoter_feedback_question, appName + ) + SurveyQuestionName.PASSIVE_FEEDBACK -> resourceHandler.getStringInLocaleWithWrapping( + R.string.nps_passive_feedback_question + ) + SurveyQuestionName.DETRACTOR_FEEDBACK -> resourceHandler.getStringInLocaleWithWrapping( + R.string.nps_detractor_feedback_question + ) + SurveyQuestionName.UNRECOGNIZED, SurveyQuestionName.QUESTION_NAME_UNSPECIFIED -> + DEFAULT_QUESTION + } + } + + private companion object { + private const val DEFAULT_QUESTION_PROGRESS = 25 + private const val DEFAULT_QUESTION = "some_question" + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt new file mode 100644 index 00000000000..bad65338f3b --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragment.kt @@ -0,0 +1,105 @@ +package org.oppia.android.app.survey + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableDialogFragment +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.SurveyQuestionName +import org.oppia.android.util.extensions.getProto +import org.oppia.android.util.extensions.getStringFromBundle +import org.oppia.android.util.extensions.putProto +import javax.inject.Inject + +/** Fragment that displays a fullscreen dialog for survey on-boarding. */ +class SurveyWelcomeDialogFragment : InjectableDialogFragment() { + @Inject + lateinit var surveyWelcomeDialogFragmentPresenter: SurveyWelcomeDialogFragmentPresenter + + companion object { + internal const val PROFILE_ID_KEY = "SurveyWelcomeDialogFragment.profile_id" + internal const val TOPIC_ID_KEY = "SurveyWelcomeDialogFragment.topic_id" + internal const val EXPLORATION_ID_KEY = "SurveyWelcomeDialogFragment.exploration_id" + internal const val MANDATORY_QUESTION_NAMES_KEY = "SurveyWelcomeDialogFragment.question_names" + + /** + * Creates a new instance of a DialogFragment to display the survey on-boarding message. + * + * @param profileId the ID of the profile viewing the survey prompt + * @return [SurveyWelcomeDialogFragment]: DialogFragment + */ + fun newInstance( + profileId: ProfileId, + topicId: String, + explorationId: String, + mandatoryQuestionNames: List + ): SurveyWelcomeDialogFragment { + return SurveyWelcomeDialogFragment().apply { + arguments = Bundle().apply { + putProto(PROFILE_ID_KEY, profileId) + putString(TOPIC_ID_KEY, topicId) + putString(EXPLORATION_ID_KEY, explorationId) + putQuestions(MANDATORY_QUESTION_NAMES_KEY, extractQuestions(mandatoryQuestionNames)) + } + } + } + } + + private fun Bundle.putQuestions(name: String, nameList: IntArray) { + putSerializable(name, nameList) + } + + private fun extractQuestions(questionNames: List): IntArray { + return questionNames.map { questionName -> questionName.number }.toIntArray() + } + + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.SurveyOnboardingDialogStyle) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val args = + checkNotNull( + arguments + ) { "Expected arguments to be passed to SurveyWelcomeDialogFragment" } + + val profileId = args.getProto(PROFILE_ID_KEY, ProfileId.getDefaultInstance()) + val topicId = args.getStringFromBundle(TOPIC_ID_KEY)!! + val explorationId = args.getStringFromBundle(EXPLORATION_ID_KEY)!! + val surveyQuestions = args.getQuestions() + + return surveyWelcomeDialogFragmentPresenter.handleCreateView( + inflater, + container, + profileId, + topicId, + explorationId, + surveyQuestions + ) + } + + private fun Bundle.getQuestions(): List { + val questionArgs = getIntArray(MANDATORY_QUESTION_NAMES_KEY) + return questionArgs?.map { number -> SurveyQuestionName.forNumber(number) } + ?: listOf() + } + + override fun onStart() { + super.onStart() + dialog?.window?.setWindowAnimations(R.style.SurveyOnboardingDialogStyle) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt new file mode 100644 index 00000000000..bc24dfacd60 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/SurveyWelcomeDialogFragmentPresenter.kt @@ -0,0 +1,112 @@ +package org.oppia.android.app.survey + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import org.oppia.android.app.fragment.FragmentScope +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.SurveyQuestionName +import org.oppia.android.databinding.SurveyWelcomeDialogFragmentBinding +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController +import org.oppia.android.domain.profile.ProfileManagementController +import org.oppia.android.domain.survey.SurveyController +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProviders.Companion.toLiveData +import javax.inject.Inject + +const val TAG_SURVEY_WELCOME_DIALOG = "SURVEY_WELCOME_DIALOG" + +/** Presenter for [SurveyWelcomeDialogFragment], sets up bindings. */ +@FragmentScope +class SurveyWelcomeDialogFragmentPresenter @Inject constructor( + private val activity: AppCompatActivity, + private val fragment: Fragment, + private val surveyController: SurveyController, + private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, + private val profileManagementController: ProfileManagementController +) { + private lateinit var explorationId: String + + /** Sets up data binding. */ + fun handleCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + profileId: ProfileId, + topicId: String, + explorationId: String, + questionNames: List, + ): View { + this.explorationId = explorationId + val binding = + SurveyWelcomeDialogFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) + + binding.lifecycleOwner = fragment + + binding.beginSurveyButton.setOnClickListener { + startSurveySession(profileId, topicId, questionNames) + } + + binding.maybeLaterButton.setOnClickListener { + activity.supportFragmentManager.beginTransaction() + .remove(fragment) + .commitNow() + } + + profileManagementController.updateSurveyLastShownTimestamp(profileId) + logSurveyPopUpShownEvent(explorationId, topicId, profileId) + + return binding.root + } + + private fun startSurveySession( + profileId: ProfileId, + topicId: String, + questions: List + ) { + val startDataProvider = surveyController.startSurveySession(questions, profileId = profileId) + startDataProvider.toLiveData().observe( + activity, + { + when (it) { + is AsyncResult.Pending -> + oppiaLogger.d("SurveyWelcomeDialogFragment", "Starting a survey session") + is AsyncResult.Failure -> { + oppiaLogger.e( + "SurveyWelcomeDialogFragment", + "Failed to start a survey session", + it.error + ) + activity.finish() // Can't recover from the session failing to start. + } + is AsyncResult.Success -> { + oppiaLogger.d("SurveyWelcomeDialogFragment", "Successfully started a survey session") + val intent = + SurveyActivity.createSurveyActivityIntent(activity, profileId, topicId, explorationId) + fragment.startActivity(intent) + activity.finish() + val transaction = activity.supportFragmentManager.beginTransaction() + transaction.remove(fragment).commitAllowingStateLoss() + } + } + } + ) + } + + private fun logSurveyPopUpShownEvent( + explorationId: String, + topicId: String, + profileId: ProfileId + ) { + analyticsController.logImportantEvent( + oppiaLogger.createShowSurveyPopupContext( + explorationId, + topicId + ), + profileId = profileId + ) + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt new file mode 100644 index 00000000000..752fd524f5a --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/FreeFormItemsViewModel.kt @@ -0,0 +1,60 @@ +package org.oppia.android.app.survey.surveyitemviewmodel + +import android.text.Editable +import android.text.TextWatcher +import androidx.databinding.Observable +import androidx.databinding.ObservableField +import org.oppia.android.app.model.SurveyQuestionName +import org.oppia.android.app.model.SurveySelectedAnswer +import org.oppia.android.app.survey.SelectedAnswerAvailabilityReceiver +import org.oppia.android.app.survey.SelectedAnswerHandler +import javax.inject.Inject + +class FreeFormItemsViewModel @Inject constructor( + private val answerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver, + private val questionName: SurveyQuestionName, + private val answerHandler: SelectedAnswerHandler +) : SurveyAnswerItemViewModel(ViewType.FREE_FORM_ANSWER) { + var answerText: CharSequence = "" + val isAnswerAvailable = ObservableField(false) + + init { + val callback: Observable.OnPropertyChangedCallback = + object : Observable.OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable, propertyId: Int) { + answerAvailabilityReceiver.onPendingAnswerAvailabilityCheck( + answerText.isNotEmpty() + ) + } + } + isAnswerAvailable.addOnPropertyChangedCallback(callback) + } + + fun getAnswerTextWatcher(): TextWatcher { + return object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + } + + override fun onTextChanged(answer: CharSequence, start: Int, before: Int, count: Int) { + answerText = answer.toString().trim() + val isAnswerTextAvailable = answerText.isNotEmpty() + if (isAnswerTextAvailable != isAnswerAvailable.get()) { + isAnswerAvailable.set(isAnswerTextAvailable) + } + } + + override fun afterTextChanged(s: Editable) { + } + } + } + + fun handleSubmitButtonClicked() { + if (answerText.isNotEmpty()) { + val answer = SurveySelectedAnswer.newBuilder() + .setQuestionName(questionName) + .setFreeFormAnswer(answerText.toString()) + .build() + answerHandler.getFreeFormAnswer(answer) + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt new file mode 100644 index 00000000000..e2b7fe43a5e --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MarketFitItemsViewModel.kt @@ -0,0 +1,153 @@ +package org.oppia.android.app.survey.surveyitemviewmodel + +import androidx.databinding.Observable +import androidx.databinding.ObservableArrayList +import androidx.databinding.ObservableField +import androidx.databinding.ObservableList +import org.oppia.android.R +import org.oppia.android.app.model.MarketFitAnswer +import org.oppia.android.app.model.SurveyQuestionName +import org.oppia.android.app.model.SurveySelectedAnswer +import org.oppia.android.app.survey.PreviousAnswerHandler +import org.oppia.android.app.survey.SelectedAnswerAvailabilityReceiver +import org.oppia.android.app.survey.SelectedAnswerHandler +import org.oppia.android.app.translation.AppLanguageResourceHandler +import javax.inject.Inject + +/** [SurveyAnswerItemViewModel] for the market fit question options. */ +class MarketFitItemsViewModel @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler, + private val selectedAnswerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver, + private val answerHandler: SelectedAnswerHandler +) : SurveyAnswerItemViewModel(ViewType.MARKET_FIT_OPTIONS), PreviousAnswerHandler { + val optionItems: ObservableList = getMarketFitOptions() + + private val selectedItems: MutableList = mutableListOf() + + override fun updateSelection(itemIndex: Int): Boolean { + optionItems.forEach { item -> item.isAnswerSelected.set(false) } + if (!selectedItems.contains(itemIndex)) { + selectedItems.clear() + selectedItems += itemIndex + } else { + selectedItems.clear() + } + + updateIsAnswerAvailable() + + if (selectedItems.isNotEmpty()) { + getPendingAnswer(itemIndex) + } + + return selectedItems.isNotEmpty() + } + + val isAnswerAvailable = ObservableField(false) + + init { + val callback: Observable.OnPropertyChangedCallback = + object : Observable.OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable, propertyId: Int) { + selectedAnswerAvailabilityReceiver.onPendingAnswerAvailabilityCheck( + selectedItems.isNotEmpty() + ) + } + } + isAnswerAvailable.addOnPropertyChangedCallback(callback) + } + + private fun updateIsAnswerAvailable() { + val selectedItemListWasEmpty = isAnswerAvailable.get() + if (selectedItems.isNotEmpty() != selectedItemListWasEmpty) { + isAnswerAvailable.set(selectedItems.isNotEmpty()) + } + } + + private fun getPendingAnswer(itemIndex: Int) { + val typeCase = itemIndex + 1 + val answerValue = MarketFitAnswer.forNumber(typeCase) + val answer = SurveySelectedAnswer.newBuilder() + .setQuestionName(SurveyQuestionName.MARKET_FIT) + .setMarketFit(answerValue) + .build() + answerHandler.getMultipleChoiceAnswer(answer) + } + + override fun getPreviousAnswer(): SurveySelectedAnswer { + return SurveySelectedAnswer.getDefaultInstance() + } + + override fun restorePreviousAnswer(previousAnswer: SurveySelectedAnswer) { + // Index 0 corresponds to ANSWER_UNSPECIFIED which is not a valid option so it's filtered out. + // Valid enum type numbers start from 1 while list item indices start from 0, hence the minus(1) + // to get the correct index to update. Notice that for [getPendingAnswer] we increment the index + // to get the correct typeCase to save. + val previousSelection = previousAnswer.marketFit.number.takeIf { it != 0 }?.minus(1) + + selectedItems.apply { + clear() + previousSelection?.let { optionIndex -> + add(optionIndex) + updateIsAnswerAvailable() + getPendingAnswer(optionIndex) + optionItems[optionIndex].isAnswerSelected.set(true) + } + } + } + + private fun getMarketFitOptions(): ObservableList { + val appName = resourceHandler.getStringInLocale(R.string.app_name) + val observableList = ObservableArrayList() + observableList += MarketFitAnswer.values() + .filter { it.isValid() } + .mapIndexed { index, marketFitAnswer -> + when (marketFitAnswer) { + MarketFitAnswer.VERY_DISAPPOINTED -> MultipleChoiceOptionContentViewModel( + resourceHandler.getStringInLocale( + R.string.market_fit_answer_very_disappointed + ), + index, + this + ) + + MarketFitAnswer.DISAPPOINTED -> MultipleChoiceOptionContentViewModel( + resourceHandler.getStringInLocale( + R.string.market_fit_answer_somewhat_disappointed + ), + index, + this + ) + + MarketFitAnswer.NOT_DISAPPOINTED -> MultipleChoiceOptionContentViewModel( + resourceHandler.getStringInLocale( + R.string.market_fit_answer_not_disappointed + ), + index, + this + ) + + MarketFitAnswer.NOT_APPLICABLE_WONT_USE_OPPIA_ANYMORE -> + MultipleChoiceOptionContentViewModel( + resourceHandler.getStringInLocaleWithWrapping( + R.string.market_fit_answer_wont_use_oppia, + appName + ), + index, + this + ) + else -> throw IllegalStateException("Invalid MarketFitAnswer") + } + } + return observableList + } + + companion object { + /** Returns whether a [MarketFitAnswer] is valid. */ + fun MarketFitAnswer.isValid(): Boolean { + return when (this) { + MarketFitAnswer.UNRECOGNIZED, MarketFitAnswer.MARKET_FIT_ANSWER_UNSPECIFIED -> false + else -> true + } + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt new file mode 100644 index 00000000000..be29f812ce0 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/MultipleChoiceOptionContentViewModel.kt @@ -0,0 +1,22 @@ +package org.oppia.android.app.survey.surveyitemviewmodel + +import androidx.databinding.ObservableBoolean +import org.oppia.android.app.viewmodel.ObservableViewModel + +/** [ObservableViewModel] for MultipleChoiceInput values. */ +class MultipleChoiceOptionContentViewModel( + val optionContent: String, + val itemIndex: Int, + private val optionsViewModel: SurveyAnswerItemViewModel +) : ObservableViewModel() { + var isAnswerSelected = ObservableBoolean() + + fun handleItemClicked() { + val isCurrentlySelected = isAnswerSelected.get() + val shouldNowBeSelected = + optionsViewModel.updateSelection(itemIndex) + if (isCurrentlySelected != shouldNowBeSelected) { + isAnswerSelected.set(shouldNowBeSelected) + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt new file mode 100644 index 00000000000..1469228b49a --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/NpsItemsViewModel.kt @@ -0,0 +1,99 @@ +package org.oppia.android.app.survey.surveyitemviewmodel + +import androidx.databinding.Observable +import androidx.databinding.ObservableField +import androidx.databinding.ObservableList +import org.oppia.android.app.model.SurveyQuestionName +import org.oppia.android.app.model.SurveySelectedAnswer +import org.oppia.android.app.survey.PreviousAnswerHandler +import org.oppia.android.app.survey.SelectedAnswerAvailabilityReceiver +import org.oppia.android.app.survey.SelectedAnswerHandler +import org.oppia.android.app.viewmodel.ObservableArrayList +import javax.inject.Inject + +class NpsItemsViewModel @Inject constructor( + private val selectedAnswerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver, + private val answerHandler: SelectedAnswerHandler +) : SurveyAnswerItemViewModel(ViewType.NPS_OPTIONS), PreviousAnswerHandler { + val optionItems: ObservableList = getNpsOptions() + + private val selectedItems: MutableList = mutableListOf() + + override fun updateSelection(itemIndex: Int): Boolean { + optionItems.forEach { item -> item.isAnswerSelected.set(false) } + if (!selectedItems.contains(itemIndex)) { + selectedItems.clear() + selectedItems += itemIndex + } else { + selectedItems.clear() + } + + updateIsAnswerAvailable() + + if (selectedItems.isNotEmpty()) { + getPendingAnswer(itemIndex) + } + + return selectedItems.isNotEmpty() + } + + val isAnswerAvailable = ObservableField(false) + + init { + val callback: Observable.OnPropertyChangedCallback = + object : Observable.OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable, propertyId: Int) { + selectedAnswerAvailabilityReceiver.onPendingAnswerAvailabilityCheck( + selectedItems.isNotEmpty() + ) + } + } + isAnswerAvailable.addOnPropertyChangedCallback(callback) + } + + private fun updateIsAnswerAvailable() { + val selectedItemListWasEmpty = isAnswerAvailable.get() + if (selectedItems.isNotEmpty() != selectedItemListWasEmpty) { + isAnswerAvailable.set(selectedItems.isNotEmpty()) + } + } + + private fun getPendingAnswer(npsScore: Int) { + val answer = SurveySelectedAnswer.newBuilder() + .setQuestionName(SurveyQuestionName.NPS) + .setNpsScore(npsScore) + .build() + answerHandler.getMultipleChoiceAnswer(answer) + } + + override fun getPreviousAnswer(): SurveySelectedAnswer { + return SurveySelectedAnswer.getDefaultInstance() + } + + override fun restorePreviousAnswer(previousAnswer: SurveySelectedAnswer) { + val selectedAnswerOption = previousAnswer.npsScore + selectedItems.apply { + clear() + add(selectedAnswerOption) + } + + updateIsAnswerAvailable() + + selectedAnswerOption.let { optionIndex -> + getPendingAnswer(optionIndex) + optionItems[optionIndex].isAnswerSelected.set(true) + } + } + + private fun getNpsOptions(): ObservableArrayList { + val observableList = ObservableArrayList() + observableList += (0..10).mapIndexed { index, score -> + MultipleChoiceOptionContentViewModel( + optionContent = score.toString(), + itemIndex = index, + this + ) + } + return observableList + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt new file mode 100644 index 00000000000..85de88ec26f --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/SurveyAnswerItemViewModel.kt @@ -0,0 +1,22 @@ +package org.oppia.android.app.survey.surveyitemviewmodel + +import org.oppia.android.app.viewmodel.ObservableViewModel + +/** + * The root [ObservableViewModel] for all individual items that may be displayed in the survey + * fragment recycler view. + */ +abstract class SurveyAnswerItemViewModel(val viewType: ViewType) : ObservableViewModel() { + + open fun updateSelection(itemIndex: Int): Boolean { + return true + } + + /** Corresponds to the type of the view model. */ + enum class ViewType { + MARKET_FIT_OPTIONS, + USER_TYPE_OPTIONS, + FREE_FORM_ANSWER, + NPS_OPTIONS + } +} diff --git a/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt new file mode 100644 index 00000000000..14482c2775e --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/survey/surveyitemviewmodel/UserTypeItemsViewModel.kt @@ -0,0 +1,153 @@ +package org.oppia.android.app.survey.surveyitemviewmodel + +import androidx.databinding.Observable +import androidx.databinding.ObservableField +import androidx.databinding.ObservableList +import org.oppia.android.R +import org.oppia.android.app.model.SurveyQuestionName +import org.oppia.android.app.model.SurveySelectedAnswer +import org.oppia.android.app.model.UserTypeAnswer +import org.oppia.android.app.survey.PreviousAnswerHandler +import org.oppia.android.app.survey.SelectedAnswerAvailabilityReceiver +import org.oppia.android.app.survey.SelectedAnswerHandler +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.app.viewmodel.ObservableArrayList +import javax.inject.Inject + +/** [SurveyAnswerItemViewModel] for providing the type of user question options. */ +class UserTypeItemsViewModel @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler, + private val selectedAnswerAvailabilityReceiver: SelectedAnswerAvailabilityReceiver, + private val answerHandler: SelectedAnswerHandler +) : SurveyAnswerItemViewModel(ViewType.USER_TYPE_OPTIONS), PreviousAnswerHandler { + val optionItems: ObservableList = getUserTypeOptions() + + private val selectedItems: MutableList = mutableListOf() + + override fun updateSelection(itemIndex: Int): Boolean { + optionItems.forEach { item -> item.isAnswerSelected.set(false) } + if (!selectedItems.contains(itemIndex)) { + selectedItems.clear() + selectedItems += itemIndex + } else { + selectedItems.clear() + } + + updateIsAnswerAvailable() + + if (selectedItems.isNotEmpty()) { + getPendingAnswer(itemIndex) + } + + return selectedItems.isNotEmpty() + } + + val isAnswerAvailable = ObservableField(false) + + init { + val callback: Observable.OnPropertyChangedCallback = + object : Observable.OnPropertyChangedCallback() { + override fun onPropertyChanged(sender: Observable, propertyId: Int) { + selectedAnswerAvailabilityReceiver.onPendingAnswerAvailabilityCheck( + selectedItems.isNotEmpty() + ) + } + } + isAnswerAvailable.addOnPropertyChangedCallback(callback) + } + + private fun updateIsAnswerAvailable() { + val selectedItemListWasEmpty = isAnswerAvailable.get() + if (selectedItems.isNotEmpty() != selectedItemListWasEmpty) { + isAnswerAvailable.set(selectedItems.isNotEmpty()) + } + } + + private fun getPendingAnswer(itemIndex: Int) { + val typeCase = itemIndex + 1 + val answerValue = UserTypeAnswer.forNumber(typeCase) + val answer = SurveySelectedAnswer.newBuilder() + .setQuestionName(SurveyQuestionName.USER_TYPE) + .setUserType(answerValue) + .build() + answerHandler.getMultipleChoiceAnswer(answer) + } + + override fun getPreviousAnswer(): SurveySelectedAnswer { + return SurveySelectedAnswer.getDefaultInstance() + } + + override fun restorePreviousAnswer(previousAnswer: SurveySelectedAnswer) { + // Index 0 corresponds to ANSWER_UNSPECIFIED which is not a valid option so it's filtered out. + // Valid enum type numbers start from 1 while list item indices start from 0, hence the minus(1) + // to get the correct index to update. Notice that for [getPendingAnswer] we increment the index + // to get the correct typeCase to save. + val previousSelection = previousAnswer.userType.number.takeIf { it != 0 }?.minus(1) + + selectedItems.apply { + clear() + previousSelection?.let { optionIndex -> + add(optionIndex) + updateIsAnswerAvailable() + getPendingAnswer(optionIndex) + optionItems[optionIndex].isAnswerSelected.set(true) + } + } + } + + private fun getUserTypeOptions(): ObservableArrayList { + val observableList = ObservableArrayList() + observableList += UserTypeAnswer.values() + .filter { it.isValid() } + .mapIndexed { index, userTypeOption -> + when (userTypeOption) { + UserTypeAnswer.LEARNER -> + MultipleChoiceOptionContentViewModel( + resourceHandler.getStringInLocale( + R.string.user_type_answer_learner + ), + index, + this + ) + UserTypeAnswer.TEACHER -> MultipleChoiceOptionContentViewModel( + resourceHandler.getStringInLocale( + R.string.user_type_answer_teacher + ), + index, + this + ) + + UserTypeAnswer.PARENT -> + MultipleChoiceOptionContentViewModel( + resourceHandler.getStringInLocale( + R.string.user_type_answer_parent + ), + index, + this + ) + + UserTypeAnswer.OTHER -> + MultipleChoiceOptionContentViewModel( + resourceHandler.getStringInLocale( + R.string.user_type_answer_other + ), + index, + this + ) + else -> throw IllegalStateException("Invalid UserTypeAnswer") + } + } + return observableList + } + + companion object { + + /** Returns whether a [UserTypeAnswer] is valid. */ + fun UserTypeAnswer.isValid(): Boolean { + return when (this) { + UserTypeAnswer.UNRECOGNIZED, UserTypeAnswer.USER_TYPE_UNSPECIFIED -> false + else -> true + } + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt b/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt index 33a0fb04bc4..857bf87d91a 100644 --- a/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt @@ -33,7 +33,11 @@ class ChapterSummaryViewModel( ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES -> { if (previousChapterTitle != null) { resourceHandler.getStringInLocaleWithWrapping( - R.string.chapter_prerequisite_title_label, index.toString(), previousChapterTitle + R.string.chapter_locked_prerequisite_title_label, + (index + 1).toString(), + chapterTitle, + index.toString(), + previousChapterTitle ) } else { resourceHandler.getStringInLocaleWithWrapping( diff --git a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardViewModel.kt b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardViewModel.kt index 077fd0f6aea..e6f0ee7104c 100755 --- a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardViewModel.kt @@ -3,11 +3,13 @@ package org.oppia.android.app.topic.revisioncard import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations +import org.oppia.android.R import org.oppia.android.app.model.EphemeralRevisionCard import org.oppia.android.app.model.EphemeralSubtopic import org.oppia.android.app.model.EphemeralTopic import org.oppia.android.app.model.ProfileId import org.oppia.android.app.topic.RouteToRevisionCardListener +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.viewmodel.ObservableViewModel import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.topic.TopicController @@ -36,6 +38,7 @@ class RevisionCardViewModel private constructor( val topicId: String, val subtopicId: Int, val profileId: ProfileId, + private val appLanguageResourceHandler: AppLanguageResourceHandler, val subtopicListSize: Int ) : ObservableViewModel() { @@ -100,6 +103,21 @@ class RevisionCardViewModel private constructor( } ?: "" } + /** Returns the content description of the subtopic. */ + fun computeContentDescriptionText(subtopicLiveData: LiveData): String { + return when (subtopicLiveData) { + previousSubtopicLiveData -> appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.previous_subtopic_talkback_text, + computeTitleText(previousSubtopicLiveData.value) + ) + nextSubtopicLiveData -> appLanguageResourceHandler.getStringInLocaleWithWrapping( + R.string.next_subtopic_talkback_text, + computeTitleText(nextSubtopicLiveData.value) + ) + else -> "" + } + } + private fun processPreviousSubtopicData( topicLiveData: AsyncResult ): EphemeralSubtopic { @@ -157,6 +175,7 @@ class RevisionCardViewModel private constructor( private val topicController: TopicController, private val oppiaLogger: OppiaLogger, @TopicHtmlParserEntityType private val entityType: String, + private val appLanguageResourceHandler: AppLanguageResourceHandler, private val translationController: TranslationController ) { /** Returns a new [RevisionCardViewModel]. */ @@ -175,6 +194,7 @@ class RevisionCardViewModel private constructor( topicId, subtopicId, profileId, + appLanguageResourceHandler, subtopicListSize ) } diff --git a/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt b/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt index d9883ed21c5..08726cc7f49 100644 --- a/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt +++ b/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt @@ -8,11 +8,14 @@ import org.oppia.android.app.customview.ContinueButtonView import org.oppia.android.app.customview.LessonThumbnailImageView import org.oppia.android.app.customview.PromotedStoryCardView import org.oppia.android.app.customview.SegmentedCircularProgressView +import org.oppia.android.app.customview.SurveyOnboardingBackgroundView import org.oppia.android.app.home.promotedlist.ComingSoonTopicsListView import org.oppia.android.app.home.promotedlist.PromotedStoryListView import org.oppia.android.app.player.state.DragDropSortInteractionView import org.oppia.android.app.player.state.ImageRegionSelectionInteractionView import org.oppia.android.app.player.state.SelectionInteractionView +import org.oppia.android.app.survey.SurveyMultipleChoiceOptionView +import org.oppia.android.app.survey.SurveyNpsItemOptionView // TODO(#59): Restrict access to this implementation by introducing injectors in each view. @@ -39,4 +42,7 @@ interface ViewComponentImpl : ViewComponent { fun inject(promotedStoryCardView: PromotedStoryCardView) fun inject(promotedStoryListView: PromotedStoryListView) fun inject(segmentedCircularProgressView: SegmentedCircularProgressView) + fun inject(surveyOnboardingBackgroundView: SurveyOnboardingBackgroundView) + fun inject(surveyMultipleChoiceOptionView: SurveyMultipleChoiceOptionView) + fun inject(surveyNpsItemOptionView: SurveyNpsItemOptionView) } diff --git a/app/src/main/res/color/component_color_shared_survey_option_selector.xml b/app/src/main/res/color/component_color_shared_survey_option_selector.xml new file mode 100644 index 00000000000..fe393056c34 --- /dev/null +++ b/app/src/main/res/color/component_color_shared_survey_option_selector.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/nps_survey_button_background.xml b/app/src/main/res/drawable/nps_survey_button_background.xml new file mode 100644 index 00000000000..a5590eebf92 --- /dev/null +++ b/app/src/main/res/drawable/nps_survey_button_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/nps_survey_button_inactive_background.xml b/app/src/main/res/drawable/nps_survey_button_inactive_background.xml new file mode 100644 index 00000000000..c6d563ecc99 --- /dev/null +++ b/app/src/main/res/drawable/nps_survey_button_inactive_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_button_white_background_color.xml b/app/src/main/res/drawable/rounded_button_white_background_color.xml new file mode 100644 index 00000000000..196f7707443 --- /dev/null +++ b/app/src/main/res/drawable/rounded_button_white_background_color.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_button_white_outline_color.xml b/app/src/main/res/drawable/rounded_button_white_outline_color.xml new file mode 100644 index 00000000000..e0062e45189 --- /dev/null +++ b/app/src/main/res/drawable/rounded_button_white_outline_color.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_primary_button_grey_shadow_color.xml b/app/src/main/res/drawable/rounded_primary_button_grey_shadow_color.xml new file mode 100644 index 00000000000..cb6db57bdcf --- /dev/null +++ b/app/src/main/res/drawable/rounded_primary_button_grey_shadow_color.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/secondary_button_background.xml b/app/src/main/res/drawable/secondary_button_background.xml index 98cd3d0fdb6..2debad327a0 100644 --- a/app/src/main/res/drawable/secondary_button_background.xml +++ b/app/src/main/res/drawable/secondary_button_background.xml @@ -2,7 +2,7 @@ - + diff --git a/app/src/main/res/drawable/state_button_primary_background.xml b/app/src/main/res/drawable/state_button_primary_background.xml index 4b3b30c74b2..3e499d7f9cd 100644 --- a/app/src/main/res/drawable/state_button_primary_background.xml +++ b/app/src/main/res/drawable/state_button_primary_background.xml @@ -4,6 +4,6 @@ diff --git a/app/src/main/res/drawable/survey_confirmation_dialog_background.xml b/app/src/main/res/drawable/survey_confirmation_dialog_background.xml new file mode 100644 index 00000000000..4ccc77e6490 --- /dev/null +++ b/app/src/main/res/drawable/survey_confirmation_dialog_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/survey_edit_text_background_border.xml b/app/src/main/res/drawable/survey_edit_text_background_border.xml new file mode 100644 index 00000000000..84258c626cf --- /dev/null +++ b/app/src/main/res/drawable/survey_edit_text_background_border.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/survey_nps_radio_button_background.xml b/app/src/main/res/drawable/survey_nps_radio_button_background.xml new file mode 100644 index 00000000000..57745803dad --- /dev/null +++ b/app/src/main/res/drawable/survey_nps_radio_button_background.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/survey_nps_radio_selected_color.xml b/app/src/main/res/drawable/survey_nps_radio_selected_color.xml new file mode 100644 index 00000000000..0019620da24 --- /dev/null +++ b/app/src/main/res/drawable/survey_nps_radio_selected_color.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/survey_nps_radio_text_color.xml b/app/src/main/res/drawable/survey_nps_radio_text_color.xml new file mode 100644 index 00000000000..80786dea646 --- /dev/null +++ b/app/src/main/res/drawable/survey_nps_radio_text_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/survey_nps_radio_unselected_color.xml b/app/src/main/res/drawable/survey_nps_radio_unselected_color.xml new file mode 100644 index 00000000000..ac3f393f2fe --- /dev/null +++ b/app/src/main/res/drawable/survey_nps_radio_unselected_color.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/survey_progress_bar.xml b/app/src/main/res/drawable/survey_progress_bar.xml new file mode 100644 index 00000000000..3eab6c3074c --- /dev/null +++ b/app/src/main/res/drawable/survey_progress_bar.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/survey_rounded_disabled_button_background.xml b/app/src/main/res/drawable/survey_rounded_disabled_button_background.xml new file mode 100644 index 00000000000..b58f04d8764 --- /dev/null +++ b/app/src/main/res/drawable/survey_rounded_disabled_button_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/layout-land/profile_edit_fragment.xml b/app/src/main/res/layout-land/profile_edit_fragment.xml index 09ea627140b..352b3c2ce86 100644 --- a/app/src/main/res/layout-land/profile_edit_fragment.xml +++ b/app/src/main/res/layout-land/profile_edit_fragment.xml @@ -108,6 +108,7 @@ android:textColor="@color/component_color_shared_primary_text_color" android:textSize="16sp" app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="1.0" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/profile_rename_button" /> @@ -128,6 +129,7 @@ android:textSize="16sp" android:visibility="@{viewModel.isAllowedToMarkFinishedChapters ? View.VISIBLE : View.GONE}" app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/profile_reset_button" /> @@ -216,10 +218,10 @@ android:text="@string/profile_edit_allow_download_heading" android:textColor="@color/component_color_shared_primary_text_color" android:textSize="16sp" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@+id/profile_edit_allow_download_switch" app:layout_constraintHorizontal_bias="0.0" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintStart_toStartOf="parent" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/profile_edit_enable_in_lesson_language_switching_container" /> diff --git a/app/src/main/res/layout-land/resume_lesson_fragment.xml b/app/src/main/res/layout-land/resume_lesson_fragment.xml index b620e949ba8..41675fc3d42 100644 --- a/app/src/main/res/layout-land/resume_lesson_fragment.xml +++ b/app/src/main/res/layout-land/resume_lesson_fragment.xml @@ -1,7 +1,7 @@ - + @@ -76,36 +76,36 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@drawable/secondary_button_background" - android:gravity="center" android:fontFamily="sans-serif-medium" + android:gravity="center" android:minWidth="144dp" android:minHeight="@dimen/clickable_item_min_height" + android:text="@string/start_over_lesson_button" android:textAllCaps="true" android:textColor="@color/component_color_shared_secondary_button_background_trim_color" android:textSize="14sp" - android:text="@string/start_over_lesson_button" app:backgroundTint="@null" app:icon="@drawable/ic_start_over_24dp" - app:iconTint="@color/component_color_shared_secondary_button_background_trim_color" - app:iconGravity="textStart" /> - + app:iconGravity="textStart" + app:iconTint="@color/component_color_shared_secondary_button_background_trim_color" /> + app:backgroundTint="@null" + app:icon="@drawable/ic_arrow_right_alt_24dp" + app:iconGravity="textEnd" /> diff --git a/app/src/main/res/layout-land/survey_fragment.xml b/app/src/main/res/layout-land/survey_fragment.xml new file mode 100644 index 00000000000..d1358390ce6 --- /dev/null +++ b/app/src/main/res/layout-land/survey_fragment.xml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +