diff --git a/app/BUILD.bazel b/app/BUILD.bazel index 0217dd131b4..008ff7a779a 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -189,6 +189,7 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/DeviceIdItemViewModel.kt", "src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileLearnerIdItemViewModel.kt", "src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileListViewModel.kt", + "src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ShareIdsViewModel.kt", "src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/SyncStatusItemViewModel.kt", "src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeViewModel.kt", "src/main/java/org/oppia/android/app/devoptions/mathexpressionparser/MathExpressionParserViewModel.kt", @@ -200,7 +201,8 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/help/thirdparty/LicenseListViewModel.kt", "src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewModel.kt", "src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListViewModel.kt", - "src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt", + "src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionViewModel.kt", + "src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt", "src/main/java/org/oppia/android/app/home/HomeViewModel.kt", "src/main/java/org/oppia/android/app/home/WelcomeViewModel.kt", "src/main/java/org/oppia/android/app/home/promotedlist/ComingSoonTopicsViewModel.kt", @@ -283,9 +285,9 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/help/HelpItems.kt", "src/main/java/org/oppia/android/app/help/thirdparty/LicenseItemViewModel.kt", "src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyItemViewModel.kt", + "src/main/java/org/oppia/android/app/hintsandsolution/HintViewModel.kt", "src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionItemViewModel.kt", "src/main/java/org/oppia/android/app/hintsandsolution/ReturnToLessonViewModel.kt", - "src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt", "src/main/java/org/oppia/android/app/home/HomeItemViewModel.kt", "src/main/java/org/oppia/android/app/home/promotedlist/ComingSoonTopicListViewModel.kt", "src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedItemViewModel.kt", @@ -874,6 +876,7 @@ TEST_DEPS = [ "//testing/src/main/java/org/oppia/android/testing/espresso:konfetti_view_matcher", "//testing/src/main/java/org/oppia/android/testing/espresso:text_input_action", "//testing/src/main/java/org/oppia/android/testing/junit:initialize_default_locale_rule", + "//testing/src/main/java/org/oppia/android/testing/logging:event_log_subject", "//testing/src/main/java/org/oppia/android/testing/math:math_equation_subject", "//testing/src/main/java/org/oppia/android/testing/math:math_expression_subject", "//testing/src/main/java/org/oppia/android/testing/mockito", diff --git a/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdFragmentPresenter.kt index e75218bb232..dd008b8aaad 100644 --- a/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileAndDeviceIdFragmentPresenter.kt @@ -10,6 +10,7 @@ import org.oppia.android.app.recyclerview.BindableAdapter import org.oppia.android.databinding.ProfileAndDeviceIdFragmentBinding import org.oppia.android.databinding.ProfileListDeviceIdItemBinding import org.oppia.android.databinding.ProfileListLearnerIdItemBinding +import org.oppia.android.databinding.ProfileListShareIdsItemBinding import org.oppia.android.databinding.ProfileListSyncStatusItemBinding import javax.inject.Inject @@ -46,27 +47,29 @@ class ProfileAndDeviceIdFragmentPresenter @Inject constructor( is DeviceIdItemViewModel -> ProfileListItemViewType.DEVICE_ID is ProfileLearnerIdItemViewModel -> ProfileListItemViewType.LEARNER_ID is SyncStatusItemViewModel -> ProfileListItemViewType.SYNC_STATUS + is ShareIdsViewModel -> ProfileListItemViewType.SHARE_IDS else -> error("Encountered unexpected view model: $viewModel") } - } - .registerViewDataBinder( - viewType = ProfileListItemViewType.DEVICE_ID, - inflateDataBinding = ProfileListDeviceIdItemBinding::inflate, - setViewModel = ProfileListDeviceIdItemBinding::setViewModel, - transformViewModel = { it as DeviceIdItemViewModel } - ) - .registerViewDataBinder( - viewType = ProfileListItemViewType.LEARNER_ID, - inflateDataBinding = ProfileListLearnerIdItemBinding::inflate, - setViewModel = ProfileListLearnerIdItemBinding::setViewModel, - transformViewModel = { it as ProfileLearnerIdItemViewModel } - ) - .registerViewDataBinder( - viewType = ProfileListItemViewType.SYNC_STATUS, - inflateDataBinding = ProfileListSyncStatusItemBinding::inflate, - setViewModel = ProfileListSyncStatusItemBinding::setViewModel, - transformViewModel = { it as SyncStatusItemViewModel } - ) - .build() + }.registerViewDataBinder( + viewType = ProfileListItemViewType.DEVICE_ID, + inflateDataBinding = ProfileListDeviceIdItemBinding::inflate, + setViewModel = ProfileListDeviceIdItemBinding::setViewModel, + transformViewModel = { it as DeviceIdItemViewModel } + ).registerViewDataBinder( + viewType = ProfileListItemViewType.LEARNER_ID, + inflateDataBinding = ProfileListLearnerIdItemBinding::inflate, + setViewModel = ProfileListLearnerIdItemBinding::setViewModel, + transformViewModel = { it as ProfileLearnerIdItemViewModel } + ).registerViewDataBinder( + viewType = ProfileListItemViewType.SYNC_STATUS, + inflateDataBinding = ProfileListSyncStatusItemBinding::inflate, + setViewModel = ProfileListSyncStatusItemBinding::setViewModel, + transformViewModel = { it as SyncStatusItemViewModel } + ).registerViewDataBinder( + viewType = ProfileListItemViewType.SHARE_IDS, + inflateDataBinding = ProfileListShareIdsItemBinding::inflate, + setViewModel = ProfileListShareIdsItemBinding::setViewModel, + transformViewModel = { it as ShareIdsViewModel } + ).build() } } diff --git a/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileListViewModel.kt b/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileListViewModel.kt index 895cb20d696..ff3778b85d8 100644 --- a/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileListViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ProfileListViewModel.kt @@ -23,20 +23,18 @@ class ProfileListViewModel private constructor( private val oppiaLogger: OppiaLogger, private val deviceIdItemViewModelFactory: DeviceIdItemViewModel.Factory, private val syncStatusItemViewModelFactory: SyncStatusItemViewModel.Factory, - private val profileLearnerIdItemViewModelFactory: ProfileLearnerIdItemViewModel.Factory + private val profileLearnerIdItemViewModelFactory: ProfileLearnerIdItemViewModel.Factory, + private val shareIdsViewModelFactory: ShareIdsViewModel.Factory ) : ObservableViewModel() { /** The list of [ProfileListItemViewModel] to display. */ val profileModels: LiveData> by lazy { - Transformations.map( - profileManagementController.getProfiles().toLiveData(), ::processProfiles - ) + Transformations.map(profileManagementController.getProfiles().toLiveData(), ::processProfiles) } private fun processProfiles( profilesResult: AsyncResult> ): List { val deviceIdViewModel = deviceIdItemViewModelFactory.create() - val syncStatusViewModel = syncStatusItemViewModelFactory.create() val learnerIdModels = when (profilesResult) { is AsyncResult.Pending -> listOf() @@ -56,7 +54,10 @@ class ProfileListViewModel private constructor( } } - return listOf(deviceIdViewModel) + learnerIdModels + listOf(syncStatusViewModel) + val idModels = listOf(deviceIdViewModel) + learnerIdModels + val shareIdsViewModel = shareIdsViewModelFactory.create(idModels) + val syncStatusViewModel = syncStatusItemViewModelFactory.create() + return idModels + listOf(syncStatusViewModel, shareIdsViewModel) } /** @@ -80,7 +81,10 @@ class ProfileListViewModel private constructor( LEARNER_ID, /** Corresponds to [SyncStatusItemViewModel]. */ - SYNC_STATUS + SYNC_STATUS, + + /** Corresponds to [ShareIdsViewModel]. */ + SHARE_IDS } /** Factory to create new [ProfileListViewModel]s. */ @@ -89,7 +93,8 @@ class ProfileListViewModel private constructor( private val oppiaLogger: OppiaLogger, private val deviceIdItemViewModelFactory: DeviceIdItemViewModel.Factory, private val syncStatusItemViewModelFactory: SyncStatusItemViewModel.Factory, - private val profileLearnerIdItemViewModelFactory: ProfileLearnerIdItemViewModel.Factory + private val profileLearnerIdItemViewModelFactory: ProfileLearnerIdItemViewModel.Factory, + private val shareIdsViewModelFactory: ShareIdsViewModel.Factory ) { /** Returns a new [ProfileListViewModel]. */ fun create(): ProfileListViewModel { @@ -98,7 +103,8 @@ class ProfileListViewModel private constructor( oppiaLogger, deviceIdItemViewModelFactory, syncStatusItemViewModelFactory, - profileLearnerIdItemViewModelFactory + profileLearnerIdItemViewModelFactory, + shareIdsViewModelFactory ) } } diff --git a/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ShareIdsViewModel.kt b/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ShareIdsViewModel.kt new file mode 100644 index 00000000000..435315c705c --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/administratorcontrols/learneranalytics/ShareIdsViewModel.kt @@ -0,0 +1,58 @@ +package org.oppia.android.app.administratorcontrols.learneranalytics + +import android.annotation.SuppressLint +import android.content.ActivityNotFoundException +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity +import org.oppia.android.app.administratorcontrols.learneranalytics.ProfileListViewModel.ProfileListItemViewModel +import org.oppia.android.domain.oppialogger.OppiaLogger +import javax.inject.Inject + +/** + * [ProfileListItemViewModel] that represents the portion of the learner analytics admin page which + * provides a button to the user that allows them to share the device's installation ID, along with + * profile IDs, to an external app for record keeping. + */ +@SuppressLint("StaticFieldLeak") // False positive (Android doesn't manage this model's lifecycle). +class ShareIdsViewModel private constructor( + private val oppiaLogger: OppiaLogger, + private val activity: AppCompatActivity, + private val viewModels: List +) : ProfileListItemViewModel(ProfileListViewModel.ProfileListItemViewType.SHARE_IDS) { + /** Indicates the user wants to share learner & the device IDs with another app. */ + fun onShareIdsButtonClicked() { + // Reference: https://developer.android.com/guide/components/intents-common#Email from + // https://stackoverflow.com/a/15022153/3689782. + val sharedText = viewModels.mapNotNull { viewModel -> + when (viewModel) { + is DeviceIdItemViewModel -> "Oppia app installation ID: ${viewModel.deviceId.value}" + is ProfileLearnerIdItemViewModel -> { + val profile = viewModel.profile + "- Profile name: ${profile.name}, learner ID: ${profile.learnerId}" + } + else -> null + } + }.joinToString(separator = "\n") + try { + activity.startActivity( + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(Intent.EXTRA_TEXT, sharedText) + } + ) + } catch (e: ActivityNotFoundException) { + oppiaLogger.e("ProfileAndDeviceIdViewModel", "No activity found to receive shared IDs.", e) + } + } + + /** Factory for creating new [ShareIdsViewModel]s. */ + class Factory @Inject constructor( + private val oppiaLogger: OppiaLogger, + private val activity: AppCompatActivity + ) { + /** Returns a new [ShareIdsViewModel]. */ + fun create(viewModels: List): ShareIdsViewModel = + ShareIdsViewModel(oppiaLogger, activity, viewModels) + } +} diff --git a/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivity.kt index 2d6b82eb17b..bf8f287edb7 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivity.kt @@ -47,8 +47,9 @@ class DeveloperOptionsActivity : override fun routeToMarkChaptersCompleted() { startActivity( - MarkChaptersCompletedActivity - .createMarkChaptersCompletedIntent(this, internalProfileId) + MarkChaptersCompletedActivity.createMarkChaptersCompletedIntent( + context = this, internalProfileId, showConfirmationNotice = false + ) ) } @@ -86,10 +87,6 @@ class DeveloperOptionsActivity : decorateWithScreenName(DEVELOPER_OPTIONS_ACTIVITY) } } - - fun getIntentKey(): String { - return NAVIGATION_PROFILE_ID_ARGUMENT_KEY - } } override fun forceCrash() { diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/ChapterSelector.kt b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/ChapterSelector.kt deleted file mode 100644 index b5214304acb..00000000000 --- a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/ChapterSelector.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.oppia.android.app.devoptions.markchapterscompleted - -/** Interface to update the selectedChapterList in [MarkChaptersCompletedFragmentPresenter]. */ -interface ChapterSelector { - /** This chapter will get added to selectedTopicList in [MarkChaptersCompletedFragmentPresenter]. */ - fun chapterSelected(chapterIndex: Int, nextStoryIndex: Int, explorationId: String) - - /** - * Chapters from 'chapterIndex' until 'nextStoryIndex' will get removed from selectedTopicList in - * [MarkChaptersCompletedFragmentPresenter]. - */ - fun chapterUnselected(chapterIndex: Int, nextStoryIndex: Int) -} diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivity.kt index 27997cc5bb0..e8d24483432 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivity.kt @@ -21,13 +21,13 @@ class MarkChaptersCompletedActivity : InjectableAppCompatActivity() { @Inject lateinit var resourceHandler: AppLanguageResourceHandler - private var internalProfileId = -1 - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) - internalProfileId = intent.getIntExtra(PROFILE_ID_EXTRA_KEY, -1) - markChaptersCompletedActivityPresenter.handleOnCreate(internalProfileId) + val internalProfileId = intent.getIntExtra(PROFILE_ID_EXTRA_KEY, /* defaultValue= */ -1) + val showConfirmationNotice = + intent.getBooleanExtra(SHOW_CONFIRMATION_NOTICE_EXTRA_KEY, /* defaultValue= */ false) + markChaptersCompletedActivityPresenter.handleOnCreate(internalProfileId, showConfirmationNotice) title = resourceHandler.getStringInLocale(R.string.mark_chapters_completed_activity_title) } @@ -39,11 +39,18 @@ class MarkChaptersCompletedActivity : InjectableAppCompatActivity() { } companion object { - const val PROFILE_ID_EXTRA_KEY = "MarkChaptersCompletedActivity.profile_id" - - fun createMarkChaptersCompletedIntent(context: Context, internalProfileId: Int): Intent { + private const val PROFILE_ID_EXTRA_KEY = "MarkChaptersCompletedActivity.profile_id" + private const val SHOW_CONFIRMATION_NOTICE_EXTRA_KEY = + "MarkChaptersCompletedActivity.show_confirmation_notice" + + fun createMarkChaptersCompletedIntent( + context: Context, + internalProfileId: Int, + showConfirmationNotice: Boolean + ): Intent { return Intent(context, MarkChaptersCompletedActivity::class.java).apply { putExtra(PROFILE_ID_EXTRA_KEY, internalProfileId) + putExtra(SHOW_CONFIRMATION_NOTICE_EXTRA_KEY, showConfirmationNotice) decorateWithScreenName(MARK_CHAPTERS_COMPLETED_ACTIVITY) } } diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivityPresenter.kt index a2714817cb2..6605456b86e 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivityPresenter.kt @@ -11,14 +11,14 @@ class MarkChaptersCompletedActivityPresenter @Inject constructor( private val activity: AppCompatActivity ) { - fun handleOnCreate(internalProfileId: Int) { + fun handleOnCreate(internalProfileId: Int, showConfirmationNotice: Boolean) { activity.supportActionBar?.setDisplayHomeAsUpEnabled(true) activity.supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_arrow_back_white_24dp) activity.setContentView(R.layout.mark_chapters_completed_activity) if (getMarkChaptersCompletedFragment() == null) { - val markChaptersCompletedFragment = MarkChaptersCompletedFragment - .newInstance(internalProfileId) + val markChaptersCompletedFragment = + MarkChaptersCompletedFragment.newInstance(internalProfileId, showConfirmationNotice) activity.supportFragmentManager.beginTransaction().add( R.id.mark_chapters_completed_container, markChaptersCompletedFragment diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragment.kt b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragment.kt index e36bfbdd844..9ea2d5af880 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragment.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragment.kt @@ -15,17 +15,23 @@ class MarkChaptersCompletedFragment : InjectableFragment() { lateinit var markChaptersCompletedFragmentPresenter: MarkChaptersCompletedFragmentPresenter companion object { - internal const val PROFILE_ID_ARGUMENT_KEY = - "MarkChaptersCompletedFragment.profile_id" - + private const val PROFILE_ID_ARGUMENT_KEY = "MarkChaptersCompletedFragment.profile_id" + private const val SHOW_CONFIRMATION_NOTICE_ARGUMENT_KEY = + "MarkChaptersCompletedFragment.show_confirmation_notice" private const val EXPLORATION_ID_LIST_ARGUMENT_KEY = "MarkChaptersCompletedFragment.exploration_id_list" + private const val EXPLORATION_TITLE_LIST_ARGUMENT_KEY = + "MarkChaptersCompletedFragment.exploration_title_list" /** Returns a new [MarkChaptersCompletedFragment]. */ - fun newInstance(internalProfileId: Int): MarkChaptersCompletedFragment { + fun newInstance( + internalProfileId: Int, + showConfirmationNotice: Boolean + ): MarkChaptersCompletedFragment { val markChaptersCompletedFragment = MarkChaptersCompletedFragment() val args = Bundle() args.putInt(PROFILE_ID_ARGUMENT_KEY, internalProfileId) + args.putBoolean(SHOW_CONFIRMATION_NOTICE_ARGUMENT_KEY, showConfirmationNotice) markChaptersCompletedFragment.arguments = args return markChaptersCompletedFragment } @@ -43,18 +49,16 @@ class MarkChaptersCompletedFragment : InjectableFragment() { ): View? { val args = checkNotNull(arguments) { "Expected arguments to be passed to MarkChaptersCompletedFragment" } - val internalProfileId = args - .getInt(PROFILE_ID_ARGUMENT_KEY, -1) - var selectedExplorationIdList = ArrayList() - if (savedInstanceState != null) { - selectedExplorationIdList = - savedInstanceState.getStringArrayList(EXPLORATION_ID_LIST_ARGUMENT_KEY)!! - } + val internalProfileId = args.getInt(PROFILE_ID_ARGUMENT_KEY, /* defaultValue= */ -1) + val showConfirmationNotice = + args.getBoolean(SHOW_CONFIRMATION_NOTICE_ARGUMENT_KEY, /* defaultValue= */ false) return markChaptersCompletedFragmentPresenter.handleCreateView( inflater, container, internalProfileId, - selectedExplorationIdList + showConfirmationNotice, + savedInstanceState?.getStringArrayList(EXPLORATION_ID_LIST_ARGUMENT_KEY) ?: listOf(), + savedInstanceState?.getStringArrayList(EXPLORATION_TITLE_LIST_ARGUMENT_KEY) ?: listOf() ) } @@ -62,7 +66,11 @@ class MarkChaptersCompletedFragment : InjectableFragment() { super.onSaveInstanceState(outState) outState.putStringArrayList( EXPLORATION_ID_LIST_ARGUMENT_KEY, - markChaptersCompletedFragmentPresenter.selectedExplorationIdList + markChaptersCompletedFragmentPresenter.serializableSelectedExplorationIds + ) + outState.putStringArrayList( + EXPLORATION_TITLE_LIST_ARGUMENT_KEY, + markChaptersCompletedFragmentPresenter.serializableSelectedExplorationTitles ) } } diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragmentPresenter.kt index 6c6e46c8266..98a5d8f35fc 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragmentPresenter.kt @@ -3,13 +3,16 @@ package org.oppia.android.app.devoptions.markchapterscompleted import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.model.ProfileId import org.oppia.android.app.recyclerview.BindableAdapter +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.databinding.MarkChaptersCompletedChapterSummaryViewBinding import org.oppia.android.databinding.MarkChaptersCompletedFragmentBinding import org.oppia.android.databinding.MarkChaptersCompletedStorySummaryViewBinding @@ -23,19 +26,29 @@ class MarkChaptersCompletedFragmentPresenter @Inject constructor( private val fragment: Fragment, private val viewModel: MarkChaptersCompletedViewModel, private val modifyLessonProgressController: ModifyLessonProgressController, - private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory -) : ChapterSelector { + private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory, + private val resourceHandler: AppLanguageResourceHandler +) { private lateinit var binding: MarkChaptersCompletedFragmentBinding private lateinit var linearLayoutManager: LinearLayoutManager private lateinit var bindingAdapter: BindableAdapter - lateinit var selectedExplorationIdList: ArrayList private lateinit var profileId: ProfileId + private lateinit var alertDialog: AlertDialog + private val selectedExplorationIds = mutableListOf() + private val selectedExplorationTitles = mutableListOf() + + val serializableSelectedExplorationIds: ArrayList + get() = ArrayList(selectedExplorationIds) + val serializableSelectedExplorationTitles: ArrayList + get() = ArrayList(selectedExplorationTitles) fun handleCreateView( inflater: LayoutInflater, container: ViewGroup?, internalProfileId: Int, - selectedExplorationIdList: ArrayList + showConfirmationNotice: Boolean, + selectedExplorationIds: List, + selectedExplorationTitles: List ): View? { binding = MarkChaptersCompletedFragmentBinding.inflate( inflater, @@ -52,7 +65,8 @@ class MarkChaptersCompletedFragmentPresenter @Inject constructor( this.viewModel = this@MarkChaptersCompletedFragmentPresenter.viewModel } - this.selectedExplorationIdList = selectedExplorationIdList + this.selectedExplorationIds += selectedExplorationIds + this.selectedExplorationTitles += selectedExplorationTitles profileId = ProfileId.newBuilder().setInternalId(internalProfileId).build() viewModel.setProfileId(profileId) @@ -74,7 +88,8 @@ class MarkChaptersCompletedFragmentPresenter @Inject constructor( chapterSelected( viewModel.chapterIndex, viewModel.nextStoryIndex, - viewModel.chapterSummary.explorationId + viewModel.chapterSummary.explorationId, + viewModel.chapterTitle ) } } @@ -91,11 +106,9 @@ class MarkChaptersCompletedFragmentPresenter @Inject constructor( } binding.markChaptersCompletedMarkCompletedTextView.setOnClickListener { - modifyLessonProgressController.markMultipleChaptersCompleted( - profileId = profileId, - chapterMap = viewModel.getChapterMap().filterKeys { selectedExplorationIdList.contains(it) } - ) - activity.finish() + if (showConfirmationNotice && this.selectedExplorationIds.isNotEmpty()) { + showConfirmationDialog() + } else markChaptersAsCompleted() } return binding.root @@ -140,21 +153,19 @@ class MarkChaptersCompletedFragmentPresenter @Inject constructor( binding.isChapterChecked = true binding.isChapterCheckboxEnabled = false } else { - binding.isChapterChecked = - selectedExplorationIdList.contains(model.chapterSummary.explorationId) + binding.isChapterChecked = model.chapterSummary.explorationId in selectedExplorationIds binding.isChapterCheckboxEnabled = !model.chapterSummary.hasMissingPrerequisiteChapter() || model.chapterSummary.hasMissingPrerequisiteChapter() && - selectedExplorationIdList.contains( - model.chapterSummary.missingPrerequisiteChapter.explorationId - ) + model.chapterSummary.missingPrerequisiteChapter.explorationId in selectedExplorationIds binding.markChaptersCompletedChapterCheckBox.setOnCheckedChangeListener { _, isChecked -> if (isChecked) { chapterSelected( model.chapterIndex, model.nextStoryIndex, - model.chapterSummary.explorationId + model.chapterSummary.explorationId, + model.chapterTitle ) } else { chapterUnselected(model.chapterIndex, model.nextStoryIndex) @@ -163,39 +174,34 @@ class MarkChaptersCompletedFragmentPresenter @Inject constructor( } } - override fun chapterSelected(chapterIndex: Int, nextStoryIndex: Int, explorationId: String) { - if (!selectedExplorationIdList.contains(explorationId)) { - selectedExplorationIdList.add(explorationId) + private fun chapterSelected(chapterIdx: Int, nextStoryIdx: Int, expId: String, expTitle: String) { + if (expId !in selectedExplorationIds) { + selectedExplorationIds += expId + selectedExplorationTitles += expTitle } - if (selectedExplorationIdList.size == - viewModel.getItemList().count { - it is ChapterSummaryViewModel && !it.checkIfChapterIsCompleted() - } - ) { + if (selectedExplorationIds.size == viewModel.getItemList().countIncompleteChapters()) { binding.isAllChecked = true } if (!binding.markChaptersCompletedRecyclerView.isComputingLayout && binding.markChaptersCompletedRecyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE ) { - bindingAdapter.notifyItemChanged(chapterIndex) - if (chapterIndex + 1 < nextStoryIndex) - bindingAdapter.notifyItemChanged(chapterIndex + 1) + bindingAdapter.notifyItemChanged(chapterIdx) + if (chapterIdx + 1 < nextStoryIdx) + bindingAdapter.notifyItemChanged(chapterIdx + 1) } } - override fun chapterUnselected(chapterIndex: Int, nextStoryIndex: Int) { + private fun chapterUnselected(chapterIndex: Int, nextStoryIndex: Int) { for (index in chapterIndex until nextStoryIndex) { val explorationId = (viewModel.getItemList()[index] as ChapterSummaryViewModel).chapterSummary.explorationId - if (selectedExplorationIdList.contains(explorationId)) { - selectedExplorationIdList.remove(explorationId) + val expIndex = selectedExplorationIds.indexOf(explorationId) + if (expIndex != -1) { + selectedExplorationIds.removeAt(expIndex) + selectedExplorationTitles.removeAt(expIndex) } } - if (selectedExplorationIdList.size != - viewModel.getItemList().count { - it is ChapterSummaryViewModel && !it.checkIfChapterIsCompleted() - } - ) { + if (selectedExplorationIds.size != viewModel.getItemList().countIncompleteChapters()) { binding.isAllChecked = false } if (!binding.markChaptersCompletedRecyclerView.isComputingLayout && @@ -208,8 +214,54 @@ class MarkChaptersCompletedFragmentPresenter @Inject constructor( } } + private fun showConfirmationDialog() { + alertDialog = AlertDialog.Builder(activity, R.style.OppiaAlertDialogTheme).apply { + setTitle(R.string.mark_chapters_completed_confirm_setting_dialog_title) + setMessage( + resourceHandler.getStringInLocaleWithWrapping( + R.string.mark_chapters_completed_confirm_setting_dialog_message, + selectedExplorationTitles.joinToReadableString() + ) + ) + setNegativeButton( + R.string.mark_chapters_completed_confirm_setting_dialog_cancel_button_text + ) { dialog, _ -> dialog.dismiss() } + setPositiveButton( + R.string.mark_chapters_completed_confirm_setting_dialog_confirm_button_text + ) { dialog, _ -> + dialog.dismiss() + markChaptersAsCompleted() + } + }.create().also { + it.setCanceledOnTouchOutside(true) + it.show() + } + } + + private fun markChaptersAsCompleted() { + modifyLessonProgressController.markMultipleChaptersCompleted( + profileId = profileId, + chapterMap = viewModel.getChapterMap().filterKeys { it in selectedExplorationIds } + ) + activity.finish() + } + private enum class ViewType { VIEW_TYPE_STORY, VIEW_TYPE_CHAPTER } + + private companion object { + private fun List.joinToReadableString(): String { + return when (size) { + 0 -> "" + 1 -> single() + 2 -> "${this[0]} and ${this[1]}" + else -> "${asSequence().take(size - 1).joinToString()}, and ${last()}" + } + } + + private fun List.countIncompleteChapters() = + filterIsInstance().count { !it.checkIfChapterIsCompleted() } + } } diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/testing/MarkChaptersCompletedTestActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/testing/MarkChaptersCompletedTestActivity.kt index 66bb84cb5e0..2cd8e78e37d 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/testing/MarkChaptersCompletedTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/testing/MarkChaptersCompletedTestActivity.kt @@ -10,19 +10,18 @@ import org.oppia.android.app.devoptions.markchapterscompleted.MarkChaptersComple /** The activity for testing [MarkChaptersCompletedFragment]. */ class MarkChaptersCompletedTestActivity : InjectableAppCompatActivity() { - - private var internalProfileId = -1 - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_arrow_back_white_24dp) setContentView(R.layout.mark_chapters_completed_activity) - internalProfileId = intent.getIntExtra(PROFILE_ID_EXTRA_KEY, -1) + val internalProfileId = intent.getIntExtra(PROFILE_ID_EXTRA_KEY, /* default= */ -1) + val showConfirmationNotice = + intent.getBooleanExtra(SHOW_CONFIRMATION_NOTICE_EXTRA_KEY, /* default= */ false) if (getMarkChaptersCompletedFragment() == null) { - val markChaptersCompletedFragment = MarkChaptersCompletedFragment - .newInstance(internalProfileId) + val markChaptersCompletedFragment = + MarkChaptersCompletedFragment.newInstance(internalProfileId, showConfirmationNotice) supportFragmentManager.beginTransaction().add( R.id.mark_chapters_completed_container, markChaptersCompletedFragment @@ -36,12 +35,19 @@ class MarkChaptersCompletedTestActivity : InjectableAppCompatActivity() { } companion object { - const val PROFILE_ID_EXTRA_KEY = "MarkChaptersCompletedTestActivity.profile_id" + private const val PROFILE_ID_EXTRA_KEY = "MarkChaptersCompletedTestActivity.profile_id" + private const val SHOW_CONFIRMATION_NOTICE_EXTRA_KEY = + "MarkChaptersCompletedTestActivity.show_confirmation_notice" /** Returns an [Intent] for [MarkChaptersCompletedTestActivity]. */ - fun createMarkChaptersCompletedTestIntent(context: Context, internalProfileId: Int): Intent { + fun createMarkChaptersCompletedTestIntent( + context: Context, + internalProfileId: Int, + showConfirmationNotice: Boolean + ): Intent { val intent = Intent(context, MarkChaptersCompletedTestActivity::class.java) intent.putExtra(PROFILE_ID_EXTRA_KEY, internalProfileId) + intent.putExtra(SHOW_CONFIRMATION_NOTICE_EXTRA_KEY, showConfirmationNotice) return intent } } diff --git a/app/src/main/java/org/oppia/android/app/devoptions/testing/DeveloperOptionsTestActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/testing/DeveloperOptionsTestActivity.kt index 046bb5ee117..b9dae33e7d8 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/testing/DeveloperOptionsTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/testing/DeveloperOptionsTestActivity.kt @@ -50,8 +50,9 @@ class DeveloperOptionsTestActivity : override fun routeToMarkChaptersCompleted() { startActivity( - MarkChaptersCompletedActivity - .createMarkChaptersCompletedIntent(this, internalProfileId) + MarkChaptersCompletedActivity.createMarkChaptersCompletedIntent( + context = this, internalProfileId, showConfirmationNotice = false + ) ) } diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintViewModel.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintViewModel.kt new file mode 100644 index 00000000000..57b4b8a2f47 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintViewModel.kt @@ -0,0 +1,29 @@ +package org.oppia.android.app.hintsandsolution + +import androidx.databinding.ObservableBoolean +import org.oppia.android.util.parser.html.CustomHtmlContentHandler + +/** + * [HintsAndSolutionItemViewModel] that represents a single hint that can be shown to the user. + * + * @property title the title of this hint, relative to others (this is generated by the app) + * @property hintSummary the core hint text (which may contain HTML) to show the user + * @property isHintRevealed whether the hint is currently expanded and viewable + */ +class HintViewModel( + val title: String, + val hintSummary: String, + val isHintRevealed: ObservableBoolean +) : HintsAndSolutionItemViewModel() { + /** + * A screenreader-friendly version of [hintSummary] that should be used for readout, in place of + * the original summary. + */ + val hintContentDescription: String by lazy { + CustomHtmlContentHandler.fromHtml( + hintSummary, + imageRetriever = null, + customTagHandlers = mapOf() + ).toString() + } +} diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt index 0552263f5a4..532b1dac6b4 100644 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt @@ -9,6 +9,7 @@ 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.HelpIndex +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.State import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.util.extensions.getProto @@ -47,6 +48,8 @@ class HintsAndSolutionDialogFragment : internal const val HELP_INDEX_KEY = "HintsAndSolutionDialogFragment.help_index" internal const val WRITTEN_TRANSLATION_CONTEXT_KEY = "HintsAndSolutionDialogFragment.written_translation_context" + internal const val PROFILE_ID_KEY = + "HintsAndSolutionDialogFragment.profile_id" /** * Creates a new instance of a DialogFragment to display hints and solution @@ -57,13 +60,15 @@ class HintsAndSolutionDialogFragment : * @param helpIndex the [HelpIndex] corresponding to the current hints/solution configuration * @param writtenTranslationContext the [WrittenTranslationContext] needed to translate the * hints/solution + * @param profileId the ID of the profile viewing the hint/solution * @return [HintsAndSolutionDialogFragment]: DialogFragment */ fun newInstance( id: String, state: State, helpIndex: HelpIndex, - writtenTranslationContext: WrittenTranslationContext + writtenTranslationContext: WrittenTranslationContext, + profileId: ProfileId ): HintsAndSolutionDialogFragment { return HintsAndSolutionDialogFragment().apply { arguments = Bundle().apply { @@ -71,6 +76,7 @@ class HintsAndSolutionDialogFragment : putProto(STATE_KEY, state) putProto(HELP_INDEX_KEY, helpIndex) putProto(WRITTEN_TRANSLATION_CONTEXT_KEY, writtenTranslationContext) + putProto(PROFILE_ID_KEY, profileId) } } } @@ -114,6 +120,7 @@ class HintsAndSolutionDialogFragment : val helpIndex = args.getProto(HELP_INDEX_KEY, HelpIndex.getDefaultInstance()) val writtenTranslationContext = args.getProto(WRITTEN_TRANSLATION_CONTEXT_KEY, WrittenTranslationContext.getDefaultInstance()) + val profileId = args.getProto(PROFILE_ID_KEY, ProfileId.getDefaultInstance()) return hintsAndSolutionDialogFragmentPresenter.handleCreateView( inflater, @@ -127,7 +134,8 @@ class HintsAndSolutionDialogFragment : index, isHintRevealed, solutionIndex, - isSolutionRevealed + isSolutionRevealed, + profileId ) } 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 d0518034c59..c1bf05fb212 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 @@ -7,17 +7,14 @@ import androidx.fragment.app.Fragment import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.model.HelpIndex -import org.oppia.android.app.model.HelpIndex.IndexTypeCase.EVERYTHING_REVEALED -import org.oppia.android.app.model.HelpIndex.IndexTypeCase.LATEST_REVEALED_HINT_INDEX -import org.oppia.android.app.model.HelpIndex.IndexTypeCase.NEXT_AVAILABLE_HINT_INDEX -import org.oppia.android.app.model.HelpIndex.IndexTypeCase.SHOW_SOLUTION +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.State import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.recyclerview.BindableAdapter +import org.oppia.android.app.topic.conceptcard.ConceptCardFragment import org.oppia.android.app.translation.AppLanguageResourceHandler -import org.oppia.android.app.viewmodel.ViewModelProvider +import org.oppia.android.databinding.HintSummaryBinding import org.oppia.android.databinding.HintsAndSolutionFragmentBinding -import org.oppia.android.databinding.HintsSummaryBinding import org.oppia.android.databinding.ReturnToLessonButtonItemBinding import org.oppia.android.databinding.SolutionSummaryBinding import org.oppia.android.util.accessibility.AccessibilityService @@ -32,32 +29,30 @@ const val TAG_REVEAL_SOLUTION_DIALOG = "REVEAL_SOLUTION_DIALOG" @FragmentScope class HintsAndSolutionDialogFragmentPresenter @Inject constructor( private val fragment: Fragment, - private val viewModelProvider: ViewModelProvider, private val htmlParserFactory: HtmlParser.Factory, @DefaultResourceBucketName private val resourceBucketName: String, @ExplorationHtmlParserEntityType private val entityType: String, private val resourceHandler: AppLanguageResourceHandler, - private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory -) { + private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory, + private val hintsAndSolutionViewModelFactory: HintsAndSolutionViewModel.Factory +) : HtmlParser.CustomOppiaTagActionListener { @Inject lateinit var accessibilityService: AccessibilityService + private var index: Int? = null - private var expandedItemsList = ArrayList() + private val expandedItemIndexes = mutableListOf() private var isHintRevealed: Boolean? = null private var solutionIndex: Int? = null private var isSolutionRevealed: Boolean? = null private lateinit var expandedHintListIndexListener: ExpandedHintListIndexListener - private lateinit var binding: HintsAndSolutionFragmentBinding private lateinit var state: State private lateinit var helpIndex: HelpIndex private lateinit var writtenTranslationContext: WrittenTranslationContext - private lateinit var itemList: List + private lateinit var profileId: ProfileId private lateinit var bindingAdapter: BindableAdapter - - val viewModel by lazy { - getHintsAndSolutionViewModel() - } + private lateinit var explorationId: String + private lateinit var viewModel: HintsAndSolutionViewModel /** * Sets up data binding and toolbar. @@ -69,22 +64,32 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( state: State, helpIndex: HelpIndex, writtenTranslationContext: WrittenTranslationContext, - id: String?, + explorationId: String, expandedItemsList: ArrayList?, expandedHintListIndexListener: ExpandedHintListIndexListener, index: Int?, isHintRevealed: Boolean?, solutionIndex: Int?, - isSolutionRevealed: Boolean? + isSolutionRevealed: Boolean?, + profileId: ProfileId ): View { - binding = - HintsAndSolutionFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) - this.expandedItemsList = expandedItemsList ?: ArrayList() + expandedItemIndexes += expandedItemsList ?: listOf() this.expandedHintListIndexListener = expandedHintListIndexListener this.index = index this.isHintRevealed = isHintRevealed this.solutionIndex = solutionIndex this.isSolutionRevealed = isSolutionRevealed + this.state = state + this.helpIndex = helpIndex + this.writtenTranslationContext = writtenTranslationContext + this.profileId = profileId + this.explorationId = explorationId + + // Check if hints are available for this state. + viewModel = hintsAndSolutionViewModelFactory.create(state, helpIndex, writtenTranslationContext) + + val binding = + 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 @@ -97,66 +102,14 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( it.lifecycleOwner = fragment } - this.state = state - this.helpIndex = helpIndex - this.writtenTranslationContext = writtenTranslationContext - - val newAvailableHintIndex = computeNewAvailableHintIndex(helpIndex) - viewModel.newAvailableHintIndex.set(newAvailableHintIndex) - viewModel.allHintsExhausted.set(computeWhetherAllHintsAreExhausted(helpIndex)) - viewModel.explorationId.set(id) - - loadHintsAndSolution(state) - - return binding.root - } - - private fun computeNewAvailableHintIndex(helpIndex: HelpIndex): Int { - return when (helpIndex.indexTypeCase) { - NEXT_AVAILABLE_HINT_INDEX -> helpIndex.nextAvailableHintIndex - LATEST_REVEALED_HINT_INDEX -> helpIndex.latestRevealedHintIndex - SHOW_SOLUTION, EVERYTHING_REVEALED -> { - // 1 is subtracted from the hint count because hints are indexed from 0. - state.interaction.hintCount - 1 - } - else -> - throw IllegalStateException( - "Encountered invalid type for showing hints: ${helpIndex.indexTypeCase}" - ) - } - } - - private fun computeWhetherAllHintsAreExhausted(helpIndex: HelpIndex): Boolean { - return when (helpIndex.indexTypeCase) { - NEXT_AVAILABLE_HINT_INDEX, LATEST_REVEALED_HINT_INDEX -> false - SHOW_SOLUTION, EVERYTHING_REVEALED -> true - else -> - throw IllegalStateException( - "Encountered invalid type for showing hints: ${helpIndex.indexTypeCase}" - ) - } - } - - private fun loadHintsAndSolution(state: State) { - // Check if hints are available for this state. - if (state.interaction.hintList.isNotEmpty()) { - viewModel.initialize( - helpIndex, state.interaction.hintList, state.interaction.solution, writtenTranslationContext - ) - - itemList = viewModel.processHintList() - + if (state.interaction.hintList.isNotEmpty() || state.interaction.hasSolution()) { binding.hintsAndSolutionRecyclerView.apply { bindingAdapter = createRecyclerViewAdapter() adapter = bindingAdapter } - if (viewModel.newAvailableHintIndex.get() != -1) { - handleNewAvailableHint(viewModel.newAvailableHintIndex.get()) - } - if (viewModel.allHintsExhausted.get()!!) { - handleAllHintsExhausted(viewModel.allHintsExhausted.get()!!) - } } + + return binding.root } private enum class ViewType { @@ -168,16 +121,16 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( private fun createRecyclerViewAdapter(): BindableAdapter { return multiTypeBuilderFactory.create { viewModel -> when (viewModel) { - is HintsViewModel -> ViewType.VIEW_TYPE_HINT_ITEM + is HintViewModel -> ViewType.VIEW_TYPE_HINT_ITEM is SolutionViewModel -> ViewType.VIEW_TYPE_SOLUTION_ITEM is ReturnToLessonViewModel -> ViewType.VIEW_TYPE_RETURN_TO_LESSON_ITEM else -> throw IllegalArgumentException("Encountered unexpected view model: $viewModel") } }.registerViewDataBinder( viewType = ViewType.VIEW_TYPE_HINT_ITEM, - inflateDataBinding = HintsSummaryBinding::inflate, - setViewModel = this::bindHintsViewModel, - transformViewModel = { it as HintsViewModel } + inflateDataBinding = HintSummaryBinding::inflate, + setViewModel = this::bindHintViewModel, + transformViewModel = { it as HintViewModel } ).registerViewDataBinder( viewType = ViewType.VIEW_TYPE_SOLUTION_ITEM, inflateDataBinding = SolutionSummaryBinding::inflate, @@ -191,20 +144,17 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( ).build() } - private fun bindHintsViewModel( - binding: HintsSummaryBinding, - hintsViewModel: HintsViewModel - ) { - binding.viewModel = hintsViewModel + private fun bindHintViewModel(binding: HintSummaryBinding, hintViewModel: HintViewModel) { + binding.viewModel = hintViewModel - val position: Int = itemList.indexOf(hintsViewModel) + val position: Int = viewModel.itemList.indexOf(hintViewModel) - binding.isListExpanded = expandedItemsList.contains(position) + binding.isListExpanded = position in expandedItemIndexes index?.let { index -> isHintRevealed?.let { isHintRevealed -> if (index == position && isHintRevealed) { - hintsViewModel.isHintRevealed.set(true) + hintViewModel.isHintRevealed.set(true) } } } @@ -213,32 +163,31 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( htmlParserFactory.create( resourceBucketName, entityType, - hintsViewModel.explorationId.get()!!, - /* imageCenterAlign= */ true, + explorationId, + customOppiaTagActionListener = this, + imageCenterAlign = true, displayLocale = resourceHandler.getDisplayLocale() ).parseOppiaHtml( - hintsViewModel.hintsAndSolutionSummary.get()!!, - binding.hintsAndSolutionSummary + hintViewModel.hintSummary, + binding.hintsAndSolutionSummary, + supportsLinks = true, + supportsConceptCards = true ) - if (hintsViewModel.hintCanBeRevealed.get()!!) { - binding.root.visibility = View.VISIBLE - binding.revealHintButton.setOnClickListener { - hintsViewModel.isHintRevealed.set(true) - expandedHintListIndexListener.onRevealHintClicked(position, /* isHintRevealed= */ true) - (fragment.requireActivity() as? RevealHintListener)?.revealHint(hintIndex = position) - expandOrCollapseItem(position) - } + binding.revealHintButton.setOnClickListener { + hintViewModel.isHintRevealed.set(true) + expandedHintListIndexListener.onRevealHintClicked(position, isHintRevealed = true) + (fragment.requireActivity() as? RevealHintListener)?.revealHint(hintIndex = position) + expandOrCollapseItem(position) } - binding.expandHintListIcon.setOnClickListener { - if (hintsViewModel.isHintRevealed.get()!!) { + binding.expandableHintHeader.setOnClickListener { + if (hintViewModel.isHintRevealed.get()) { expandOrCollapseItem(position) } } - - binding.root.setOnClickListener { - if (hintsViewModel.isHintRevealed.get()!!) { + binding.expandHintListIcon.setOnClickListener { + if (hintViewModel.isHintRevealed.get()) { expandOrCollapseItem(position) } } @@ -253,13 +202,13 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( } private fun expandOrCollapseItem(position: Int) { - if (expandedItemsList.contains(position)) { - expandedItemsList.remove(position) + if (position in expandedItemIndexes) { + expandedItemIndexes -= position } else { - expandedItemsList.add(position) + expandedItemIndexes += position } bindingAdapter.notifyItemChanged(position) - expandedHintListIndexListener.onExpandListIconClicked(expandedItemsList) + expandedHintListIndexListener.onExpandListIconClicked(ArrayList(expandedItemIndexes)) } private fun bindSolutionViewModel( @@ -268,8 +217,8 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( ) { binding.viewModel = solutionViewModel - val position: Int = itemList.indexOf(solutionViewModel) - binding.isListExpanded = expandedItemsList.contains(position) + val position: Int = viewModel.itemList.indexOf(solutionViewModel) + binding.isListExpanded = expandedItemIndexes.contains(position) solutionIndex?.let { solutionIndex -> isSolutionRevealed?.let { isSolutionRevealed -> @@ -279,41 +228,43 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( } } - binding.solutionTitle.text = - resourceHandler.capitalizeForHumans(solutionViewModel.title.get()!!) - // TODO(#1050): Update to display answers for any answer type. - if (solutionViewModel.correctAnswer.get().isNullOrEmpty()) { - binding.solutionCorrectAnswer.text = - resourceHandler.getStringInLocaleWithoutWrapping( - R.string.hints_android_solution_correct_answer, - solutionViewModel.numerator.get().toString(), - solutionViewModel.denominator.get().toString() - ) - } else { - binding.solutionCorrectAnswer.text = solutionViewModel.correctAnswer.get() - } - binding.solutionSummary.text = htmlParserFactory.create( - resourceBucketName, entityType, viewModel.explorationId.get()!!, /* imageCenterAlign= */ true, - displayLocale = resourceHandler.getDisplayLocale() - ).parseOppiaHtml( - solutionViewModel.solutionSummary.get()!!, binding.solutionSummary - ) + binding.solutionCorrectAnswer.text = + htmlParserFactory.create( + resourceBucketName, + entityType, + explorationId, + imageCenterAlign = true, + displayLocale = resourceHandler.getDisplayLocale() + ).parseOppiaHtml( + solutionViewModel.correctAnswerHtml, + binding.solutionCorrectAnswer + ) + binding.solutionSummary.text = + htmlParserFactory.create( + resourceBucketName, + entityType, + explorationId, + customOppiaTagActionListener = this, + imageCenterAlign = true, + displayLocale = resourceHandler.getDisplayLocale() + ).parseOppiaHtml( + solutionViewModel.solutionSummary, + binding.solutionSummary, + supportsLinks = true, + supportsConceptCards = true + ) - if (solutionViewModel.solutionCanBeRevealed.get()!!) { - binding.root.visibility = View.VISIBLE - binding.showSolutionButton.setOnClickListener { - showRevealSolutionDialogFragment() - } + binding.showSolutionButton.setOnClickListener { + showRevealSolutionDialogFragment() } - binding.expandSolutionListIcon.setOnClickListener { - if (solutionViewModel.isSolutionRevealed.get()!!) { + binding.expandableSolutionHeader.setOnClickListener { + if (solutionViewModel.isSolutionRevealed.get()) { expandOrCollapseItem(position) } } - - binding.root.setOnClickListener { - if (solutionViewModel.isSolutionRevealed.get()!!) { + binding.expandSolutionListIcon.setOnClickListener { + if (solutionViewModel.isSolutionRevealed.get()) { expandOrCollapseItem(position) } } @@ -338,16 +289,6 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( } } - private fun handleAllHintsExhausted(allHintsExhausted: Boolean) { - // The last item of the list is ReturnToLessonViewModel and therefore second last item is - // SolutionViewModel as a result subtracting 2 from itemList size. - if (itemList[itemList.size - 2] is SolutionViewModel) { - val solutionViewModel = itemList[itemList.size - 2] as SolutionViewModel - solutionViewModel.solutionCanBeRevealed.set(allHintsExhausted) - bindingAdapter.notifyItemChanged(itemList.size - 2) - } - } - private fun showRevealSolutionDialogFragment() { val previousFragment = fragment.childFragmentManager.findFragmentByTag(TAG_REVEAL_SOLUTION_DIALOG) @@ -358,29 +299,14 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( dialogFragment.showNow(fragment.childFragmentManager, TAG_REVEAL_SOLUTION_DIALOG) } - private fun getHintsAndSolutionViewModel(): HintsViewModel { - return viewModelProvider.getForFragment(fragment, HintsViewModel::class.java) - } - fun handleRevealSolution() { - if (itemList[itemList.size - 2] is SolutionViewModel) { - val solutionViewModel = itemList[itemList.size - 2] as SolutionViewModel - solutionViewModel.isSolutionRevealed.set(true) - expandedHintListIndexListener.onRevealSolutionClicked( - /* solutionIndex= */ itemList.size - 2, - /* isSolutionRevealed= */ true - ) - (fragment.requireActivity() as? RevealSolutionInterface)?.revealSolution() - expandOrCollapseItem(itemList.size - 2) - } - } - - private fun handleNewAvailableHint(hintIndex: Int?) { - if (itemList[hintIndex!!] is HintsViewModel) { - val hintsViewModel = itemList[hintIndex] as HintsViewModel - hintsViewModel.hintCanBeRevealed.set(true) - bindingAdapter.notifyItemChanged(hintIndex) - } + viewModel.isSolutionRevealed.set(true) + expandedHintListIndexListener.onRevealSolutionClicked( + solutionIndex = viewModel.solutionIndex, + isSolutionRevealed = true + ) + (fragment.requireActivity() as? RevealSolutionInterface)?.revealSolution() + expandOrCollapseItem(position = viewModel.solutionIndex) } fun onRevealHintClicked(index: Int?, isHintRevealed: Boolean?) { @@ -392,4 +318,10 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( this.solutionIndex = solutionIndex this.isSolutionRevealed = isSolutionRevealed } + + override fun onConceptCardLinkClicked(view: View, skillId: String) { + ConceptCardFragment + .newInstance(skillId, profileId) + .showNow(fragment.childFragmentManager, ConceptCardFragment.CONCEPT_CARD_DIALOG_FRAGMENT_TAG) + } } diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionViewModel.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionViewModel.kt new file mode 100644 index 00000000000..f604f7f6547 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionViewModel.kt @@ -0,0 +1,121 @@ +package org.oppia.android.app.hintsandsolution + +import androidx.databinding.ObservableBoolean +import org.oppia.android.R +import org.oppia.android.app.model.HelpIndex +import org.oppia.android.app.model.Hint +import org.oppia.android.app.model.Solution +import org.oppia.android.app.model.State +import org.oppia.android.app.model.WrittenTranslationContext +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.app.viewmodel.ObservableViewModel +import org.oppia.android.domain.hintsandsolution.dropLastUnavailable +import org.oppia.android.domain.hintsandsolution.isHintRevealed +import org.oppia.android.domain.hintsandsolution.isSolutionAvailable +import org.oppia.android.domain.hintsandsolution.isSolutionRevealed +import org.oppia.android.domain.translation.TranslationController +import javax.inject.Inject + +/** + * View model for a lesson's hints/solution list. + * + * Instances of this class are created using its [Factory]. + */ +class HintsAndSolutionViewModel private constructor( + private val state: State, + private val helpIndex: HelpIndex, + private val writtenTranslationContext: WrittenTranslationContext, + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController, + private val solutionViewModelFactory: SolutionViewModel.Factory +) : ObservableViewModel() { + private val hintList by lazy { helpIndex.dropLastUnavailable(state.interaction.hintList) } + private val solution by lazy { + state.interaction.solution.takeIf { it.hasExplanation() && helpIndex.isSolutionAvailable() } + } + + /** + * The [ObservableBoolean] which indicates whether the solution is currently available to be + * viewed (that is, available to & revealed by the user). + */ + val isSolutionRevealed: ObservableBoolean by lazy { + ObservableBoolean(helpIndex.isSolutionRevealed()) + } + + /** + * The list of [HintsAndSolutionItemViewModel]s to display in the hint list represented by this + * model. + */ + val itemList: List by lazy { createViewModels() } + + /** + * The index into [itemList] where the solution would be present, if it's available to view + * (otherwise, this index corresponds to the most recently available hint). + */ + val solutionIndex: Int get() = itemList.lastIndex - 1 + + private fun createViewModels(): List { + return hintList.mapIndexed { index, hint -> + createHintViewModel( + index, hint, isHintRevealed = ObservableBoolean(helpIndex.isHintRevealed(index, hintList)) + ) + } + listOfNotNull(solution?.let(this::createSolutionViewModel)) + ReturnToLessonViewModel + } + + private fun createHintViewModel( + hintIndex: Int, + hint: Hint, + isHintRevealed: ObservableBoolean + ): HintViewModel { + return HintViewModel( + title = resourceHandler.getStringInLocaleWithWrapping( + R.string.hint_list_item_number, + resourceHandler.toHumanReadableString(hintIndex + 1) + ), + hintSummary = translationController.extractString( + hint.hintContent, writtenTranslationContext + ), + isHintRevealed = isHintRevealed + ) + } + + private fun createSolutionViewModel(solution: Solution): SolutionViewModel { + return solutionViewModelFactory.create( + solutionSummary = translationController.extractString( + solution.explanation, writtenTranslationContext + ), + isSolutionRevealed = isSolutionRevealed, + isSolutionExclusive = solution.answerIsExclusive, + correctAnswer = solution.correctAnswer, + interaction = state.interaction, + writtenTranslationContext = writtenTranslationContext + ) + } + + /** Application-injectable factory for creating [HintsAndSolutionViewModel]s (see [create]). */ + class Factory @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController, + private val solutionViewModelFactory: SolutionViewModel.Factory + ) { + /** + * Returns a new [HintsAndSolutionViewModel] that populates a list of item view models (to be + * bound to a recycler view) using the included [state] and [helpIndex] to source the current + * hints/solution state, and the provided [writtenTranslationContext] for localization. + */ + fun create( + state: State, + helpIndex: HelpIndex, + writtenTranslationContext: WrittenTranslationContext + ): HintsAndSolutionViewModel { + return HintsAndSolutionViewModel( + state, + helpIndex, + writtenTranslationContext, + resourceHandler, + translationController, + solutionViewModelFactory + ) + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt deleted file mode 100644 index b722a9f4a0b..00000000000 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt +++ /dev/null @@ -1,122 +0,0 @@ -package org.oppia.android.app.hintsandsolution - -import androidx.databinding.ObservableField -import androidx.lifecycle.ViewModel -import org.oppia.android.R -import org.oppia.android.app.fragment.FragmentScope -import org.oppia.android.app.model.HelpIndex -import org.oppia.android.app.model.Hint -import org.oppia.android.app.model.Solution -import org.oppia.android.app.model.WrittenTranslationContext -import org.oppia.android.app.translation.AppLanguageResourceHandler -import org.oppia.android.domain.hintsandsolution.isHintRevealed -import org.oppia.android.domain.hintsandsolution.isSolutionRevealed -import org.oppia.android.domain.translation.TranslationController -import org.oppia.android.util.parser.html.CustomHtmlContentHandler -import javax.inject.Inject - -private const val DEFAULT_HINT_AND_SOLUTION_SUMMARY = "" - -/** [ViewModel] for Hints in [HintsAndSolutionDialogFragment]. */ -@FragmentScope -class HintsViewModel @Inject constructor( - private val resourceHandler: AppLanguageResourceHandler, - private val translationController: TranslationController -) : HintsAndSolutionItemViewModel() { - - val newAvailableHintIndex = ObservableField(-1) - val allHintsExhausted = ObservableField(false) - val explorationId = ObservableField("") - - val title = ObservableField("") - val hintsAndSolutionSummary = ObservableField(DEFAULT_HINT_AND_SOLUTION_SUMMARY) - val isHintRevealed = ObservableField(false) - val hintCanBeRevealed = ObservableField(false) - - private lateinit var hintList: List - private lateinit var solution: Solution - private lateinit var helpIndex: HelpIndex - private lateinit var writtenTranslationContext: WrittenTranslationContext - val itemList: MutableList = ArrayList() - - /** Initializes the view model to display hints and a solution. */ - fun initialize( - helpIndex: HelpIndex, - hintList: List, - solution: Solution, - writtenTranslationContext: WrittenTranslationContext - ) { - this.helpIndex = helpIndex - this.hintList = hintList - this.solution = solution - this.writtenTranslationContext = writtenTranslationContext - } - - fun processHintList(): List { - itemList.clear() - for (index in hintList.indices) { - if (itemList.isEmpty()) { - addHintToList(index, hintList[index]) - } else { - val isPriorHintRevealed = (itemList.last() as HintsViewModel).isHintRevealed.get() ?: false - val availableHintIndex = newAvailableHintIndex.get() ?: 0 - if (isPriorHintRevealed && index <= availableHintIndex) { - addHintToList(index, hintList[index]) - } else break - } - } - if (itemList.isNotEmpty()) { - val isLastHintRevealed = (itemList.last() as HintsViewModel).isHintRevealed.get() ?: false - val areAllHintsExhausted = allHintsExhausted.get() ?: false - if (solution.hasExplanation() && - hintList.size == itemList.size && - isLastHintRevealed && - areAllHintsExhausted - ) { - addSolutionToList(solution) - } - } - itemList.add(ReturnToLessonViewModel()) - return itemList - } - - fun computeHintContentDescription(): String { - return hintsAndSolutionSummary.get()?.let { - CustomHtmlContentHandler.fromHtml( - it, - imageRetriever = null, - customTagHandlers = mapOf() - ).toString() - } ?: DEFAULT_HINT_AND_SOLUTION_SUMMARY - } - - private fun addHintToList(hintIndex: Int, hint: Hint) { - val hintsViewModel = HintsViewModel(resourceHandler, translationController) - hintsViewModel.title.set( - resourceHandler.getStringInLocaleWithWrapping( - R.string.hint_list_item_number, - resourceHandler.toHumanReadableString(hintIndex + 1) - ) - ) - val hintContentHtml = - translationController.extractString(hint.hintContent, writtenTranslationContext) - hintsViewModel.hintsAndSolutionSummary.set(hintContentHtml) - hintsViewModel.isHintRevealed.set(helpIndex.isHintRevealed(hintIndex, hintList)) - itemList.add(hintsViewModel) - } - - private fun addSolutionToList(solution: Solution) { - val solutionViewModel = SolutionViewModel() - solutionViewModel.title.set(solution.explanation.contentId) - solutionViewModel.correctAnswer.set(solution.correctAnswer.correctAnswer) - solutionViewModel.numerator.set(solution.correctAnswer.numerator) - solutionViewModel.denominator.set(solution.correctAnswer.denominator) - solutionViewModel.wholeNumber.set(solution.correctAnswer.wholeNumber) - solutionViewModel.isNegative.set(solution.correctAnswer.isNegative) - val explanationHtml = - translationController.extractString(solution.explanation, writtenTranslationContext) - solutionViewModel.solutionSummary.set(explanationHtml) - solutionViewModel.isSolutionRevealed.set(helpIndex.isSolutionRevealed()) - itemList.add(solutionViewModel) - } -} diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/ReturnToLessonViewModel.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/ReturnToLessonViewModel.kt index 374bb6fd1ef..2c8d4ee846c 100644 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/ReturnToLessonViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/ReturnToLessonViewModel.kt @@ -3,4 +3,4 @@ package org.oppia.android.app.hintsandsolution import androidx.lifecycle.ViewModel /** [ViewModel] for return to lesson button in [HintsAndSolutionDialogFragment]. */ -class ReturnToLessonViewModel : HintsAndSolutionItemViewModel() +object ReturnToLessonViewModel : HintsAndSolutionItemViewModel() diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt index 82c1c38d997..cd70ceaae8e 100644 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt @@ -1,28 +1,266 @@ package org.oppia.android.app.hintsandsolution -import androidx.databinding.ObservableField -import androidx.lifecycle.ViewModel +import androidx.databinding.ObservableBoolean +import org.oppia.android.R +import org.oppia.android.app.hintsandsolution.HintsAndSolutionViewModel.Factory +import org.oppia.android.app.model.Interaction +import org.oppia.android.app.model.InteractionObject +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.BOOL_VALUE +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.CLICK_ON_IMAGE +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.FRACTION +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.IMAGE_WITH_REGIONS +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.LIST_OF_SETS_OF_HTML_STRING +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.LIST_OF_SETS_OF_TRANSLATABLE_HTML_CONTENT_IDS +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.MATH_EXPRESSION +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.NON_NEGATIVE_INT +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.NORMALIZED_STRING +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.NUMBER_WITH_UNITS +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.OBJECTTYPE_NOT_SET +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.RATIO_EXPRESSION +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.REAL +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.SET_OF_HTML_STRING +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.SET_OF_TRANSLATABLE_HTML_CONTENT_IDS +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.SIGNED_INT +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.TRANSLATABLE_HTML_CONTENT_ID +import org.oppia.android.app.model.InteractionObject.ObjectTypeCase.TRANSLATABLE_SET_OF_NORMALIZED_STRING +import org.oppia.android.app.model.MathEquation +import org.oppia.android.app.model.MathExpression +import org.oppia.android.app.model.WrittenTranslationContext +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.app.utility.math.MathExpressionAccessibilityUtil +import org.oppia.android.app.utility.toAccessibleAnswerString +import org.oppia.android.util.math.MathExpressionParser.Companion.ErrorCheckingMode.REQUIRED_ONLY +import org.oppia.android.util.math.MathExpressionParser.Companion.MathParsingResult +import org.oppia.android.util.math.MathExpressionParser.Companion.parseAlgebraicEquation +import org.oppia.android.util.math.MathExpressionParser.Companion.parseAlgebraicExpression +import org.oppia.android.util.math.MathExpressionParser.Companion.parseNumericExpression +import org.oppia.android.util.math.isApproximatelyEqualTo +import org.oppia.android.util.math.toAnswerString +import org.oppia.android.util.math.toPlainString +import org.oppia.android.util.math.toRawLatex import org.oppia.android.util.parser.html.CustomHtmlContentHandler +import javax.inject.Inject -/** [ViewModel] for Solution in [HintsAndSolutionDialogFragment]. */ -class SolutionViewModel : HintsAndSolutionItemViewModel() { - val solutionSummary = ObservableField("") - val correctAnswer = ObservableField("") - val numerator = ObservableField() - val wholeNumber = ObservableField() - val denominator = ObservableField() - val isNegative = ObservableField(false) - val title = ObservableField("") - val isSolutionRevealed = ObservableField(false) - val solutionCanBeRevealed = ObservableField(false) - - fun computeSolutionContentDescription(): String { - return solutionSummary.get()?.let { - CustomHtmlContentHandler.fromHtml( - it, - imageRetriever = null, - customTagHandlers = mapOf() - ).toString() - } ?: "" +/** + * [HintsAndSolutionItemViewModel] that represents a solution that the user may reveal. + * + * Instances of this class are created using its [Factory]. + * + * @property solutionSummary the solution's explanation text (which may contain HTML) + * @property isSolutionRevealed whether the solution is currently expanded and viewable + */ +class SolutionViewModel private constructor( + val solutionSummary: String, + private val correctAnswer: InteractionObject, + val isSolutionRevealed: ObservableBoolean, + isSolutionExclusive: Boolean, + private val interaction: Interaction, + private val writtenTranslationContext: WrittenTranslationContext, + private val appLanguageResourceHandler: AppLanguageResourceHandler, + private val mathExpressionAccessibilityUtil: MathExpressionAccessibilityUtil +) : HintsAndSolutionItemViewModel() { + /** + * A screenreader-friendly version of [solutionSummary] that should be used for readout, in place + * of the original summary. + */ + val solutionSummaryContentDescription by lazy { + CustomHtmlContentHandler.fromHtml( + solutionSummary, + imageRetriever = null, + customTagHandlers = mapOf() + ).toString() + } + + /** A displayable HTML representation of the correct answer presented by this model's solution. */ + val correctAnswerHtml: String by lazy { computeCorrectAnswerHtml() } + + /** A screenreader-friendly readable version of [correctAnswerHtml]. */ + val correctAnswerContentDescription: String by lazy { computeCorrectAnswerContentDescription() } + + private val correctAnswerTextStringResId = if (isSolutionExclusive) { + R.string.hints_list_exclusive_solution_text + } else R.string.hints_list_possible_solution_text + + private fun computeCorrectAnswerHtml(): String { + val answerTextHtml = when (correctAnswer.objectTypeCase) { + NORMALIZED_STRING -> correctAnswer.normalizedString + REAL -> correctAnswer.real.toSimplifiedPlainString() + FRACTION -> correctAnswer.fraction.toAnswerString() + RATIO_EXPRESSION -> correctAnswer.ratioExpression.toAnswerString() + MATH_EXPRESSION -> when (interaction.id) { + "NumericExpressionInput" -> correctAnswer.mathExpression.toNumericExpressionHtml() + "AlgebraicExpressionInput" -> correctAnswer.mathExpression.toAlgebraicExpressionHtml() + "MathEquationInput" -> correctAnswer.mathExpression.toAlgebraicEquationHtml() + else -> error("Interaction ID not valid for math expressions: ${interaction.id}.") + } + LIST_OF_SETS_OF_TRANSLATABLE_HTML_CONTENT_IDS, SIGNED_INT, NON_NEGATIVE_INT, + TRANSLATABLE_SET_OF_NORMALIZED_STRING, TRANSLATABLE_HTML_CONTENT_ID, + SET_OF_TRANSLATABLE_HTML_CONTENT_IDS, SET_OF_HTML_STRING, LIST_OF_SETS_OF_HTML_STRING, + IMAGE_WITH_REGIONS, CLICK_ON_IMAGE, NUMBER_WITH_UNITS, BOOL_VALUE, OBJECTTYPE_NOT_SET, null -> + error("Invalid answer type for solution: $correctAnswer") + } + return appLanguageResourceHandler.getStringInLocaleWithWrapping( + correctAnswerTextStringResId, answerTextHtml + ) + } + + private fun computeCorrectAnswerContentDescription(): String { + val readableAnswerText = when (correctAnswer.objectTypeCase) { + NORMALIZED_STRING -> correctAnswer.normalizedString + REAL -> correctAnswer.real.toSimplifiedReadableString() + FRACTION -> correctAnswer.fraction.toAnswerString() + RATIO_EXPRESSION -> + correctAnswer.ratioExpression.toAccessibleAnswerString(appLanguageResourceHandler) + MATH_EXPRESSION -> when (interaction.id) { + "NumericExpressionInput" -> correctAnswer.mathExpression.toReadableNumericExpression() + "AlgebraicExpressionInput" -> correctAnswer.mathExpression.toReadableAlgebraicExpression() + "MathEquationInput" -> correctAnswer.mathExpression.toReadableAlgebraicEquation() + else -> error("Interaction ID not valid for math expressions: ${interaction.id}.") + } + LIST_OF_SETS_OF_TRANSLATABLE_HTML_CONTENT_IDS, SIGNED_INT, NON_NEGATIVE_INT, + TRANSLATABLE_SET_OF_NORMALIZED_STRING, TRANSLATABLE_HTML_CONTENT_ID, + SET_OF_TRANSLATABLE_HTML_CONTENT_IDS, SET_OF_HTML_STRING, LIST_OF_SETS_OF_HTML_STRING, + IMAGE_WITH_REGIONS, CLICK_ON_IMAGE, NUMBER_WITH_UNITS, BOOL_VALUE, OBJECTTYPE_NOT_SET, null -> + error("Invalid answer type for solution: $correctAnswer") + } + return appLanguageResourceHandler.getStringInLocaleWithWrapping( + correctAnswerTextStringResId, readableAnswerText + ) + } + + private fun String.toNumericExpressionHtml() = + parseNumericExpression().expectSuccess().toRawLatex().wrapAsLatexHtml() + + private fun String.toReadableNumericExpression() = + parseNumericExpression().expectSuccess().toReadableString() ?: this + + private fun String.toAlgebraicExpressionHtml() = + parseAlgebraicExpression().expectSuccess().toRawLatex().wrapAsLatexHtml() + + private fun String.toReadableAlgebraicExpression() = + parseAlgebraicExpression().expectSuccess().toReadableString() ?: this + + private fun String.toAlgebraicEquationHtml() = + parseAlgebraicEquation().expectSuccess().toRawLatex().wrapAsLatexHtml() + + private fun String.toReadableAlgebraicEquation() = + parseAlgebraicEquation().expectSuccess().toReadableString() ?: this + + private fun String.parseAlgebraicExpression(): MathParsingResult { + return parseAlgebraicExpression( + rawExpression = this, + allowedVariables = interaction.extractAllowedVariables(), + errorCheckingMode = REQUIRED_ONLY + ) + } + + private fun String.parseAlgebraicEquation(): MathParsingResult { + return parseAlgebraicEquation( + rawExpression = this, + allowedVariables = interaction.extractAllowedVariables(), + errorCheckingMode = REQUIRED_ONLY + ) + } + + private fun MathExpression.toRawLatex() = + toRawLatex(divAsFraction = interaction.extractUseFractionsForDivision()) + + private fun MathEquation.toRawLatex() = + toRawLatex(divAsFraction = interaction.extractUseFractionsForDivision()) + + private fun MathExpression.toReadableString(): String? { + return mathExpressionAccessibilityUtil.convertToHumanReadableString( + expression = this, + language = writtenTranslationContext.language, + divAsFraction = interaction.extractUseFractionsForDivision() + ) + } + + private fun MathEquation.toReadableString(): String? { + return mathExpressionAccessibilityUtil.convertToHumanReadableString( + equation = this, + language = writtenTranslationContext.language, + divAsFraction = interaction.extractUseFractionsForDivision() + ) + } + + private fun Double.toSimplifiedReadableString(): String { + val longPart = toLong() + return if (isApproximatelyEqualTo(longPart.toDouble())) { + appLanguageResourceHandler.formatLong(longPart) + } else appLanguageResourceHandler.formatDouble(this) + } + + private companion object { + /** + * Returns a plain-string representation of this [Double] with a preference toward dropping the + * decimal if it isn't needed for the final answer. + */ + private fun Double.toSimplifiedPlainString(): String { + val longPart = toLong() + return if (isApproximatelyEqualTo(longPart.toDouble())) { + longPart.toString() + } else toPlainString() + } + + private fun String.parseNumericExpression() = + parseNumericExpression(rawExpression = this, errorCheckingMode = REQUIRED_ONLY) + + private fun MathParsingResult.expectSuccess(): T { + return when (this) { + is MathParsingResult.Success -> result + is MathParsingResult.Failure -> error("Invalid parsing result: $error.") + } + } + + private fun String.wrapAsLatexHtml(): String { + val mathContentValue = + "{&quot;raw_latex&quot;:&quot;${this.replace("\\", "\\\\")}&quot;}" + return "" + } + + private fun Interaction.extractAllowedVariables(): List { + return customizationArgsMap["customOskLetters"] + ?.schemaObjectList + ?.schemaObjectList + ?.map { it.normalizedString } + ?: listOf() + } + + private fun Interaction.extractUseFractionsForDivision() = + customizationArgsMap["useFractionForDivision"]?.boolValue ?: false + } + + /** Application-injectable factory to create [SolutionViewModel]s (see [create]). */ + class Factory @Inject constructor( + private val appLanguageResourceHandler: AppLanguageResourceHandler, + private val mathExpressionAccessibilityUtil: MathExpressionAccessibilityUtil + ) { + /** + * Returns a new [SolutionViewModel] with the specified summary HTML text, correct answer, + * [isSolutionRevealed] tracking [ObservableBoolean], whether the solution is exclusive, and its + * answer context (such as the [interaction] that the answer can be submitted to and the + * displayed state's current [writtenTranslationContext] for localization). + */ + fun create( + solutionSummary: String, + correctAnswer: InteractionObject, + isSolutionRevealed: ObservableBoolean, + isSolutionExclusive: Boolean, + interaction: Interaction, + writtenTranslationContext: WrittenTranslationContext + ): SolutionViewModel { + return SolutionViewModel( + solutionSummary, + correctAnswer, + isSolutionRevealed, + isSolutionExclusive, + interaction, + writtenTranslationContext, + appLanguageResourceHandler, + mathExpressionAccessibilityUtil + ) + } } } diff --git a/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt index ae37501e728..eedc0483a0b 100644 --- a/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt @@ -13,6 +13,7 @@ import org.oppia.android.app.home.promotedlist.ComingSoonTopicListViewModel import org.oppia.android.app.home.promotedlist.PromotedStoryListViewModel import org.oppia.android.app.home.topiclist.AllTopicsViewModel import org.oppia.android.app.home.topiclist.TopicSummaryViewModel +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.TopicSummary import org.oppia.android.app.recyclerview.BindableAdapter import org.oppia.android.app.translation.AppLanguageResourceHandler @@ -24,6 +25,7 @@ import org.oppia.android.databinding.PromotedStoryListBinding import org.oppia.android.databinding.TopicSummaryViewBinding import org.oppia.android.databinding.WelcomeBinding 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.topic.TopicListController import org.oppia.android.domain.translation.TranslationController @@ -39,6 +41,7 @@ class HomeFragmentPresenter @Inject constructor( private val profileManagementController: ProfileManagementController, private val topicListController: TopicListController, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, @TopicHtmlParserEntityType private val topicEntityType: String, @StoryHtmlParserEntityType private val storyEntityType: String, private val resourceHandler: AppLanguageResourceHandler, @@ -157,6 +160,9 @@ class HomeFragmentPresenter @Inject constructor( } private fun logHomeActivityEvent() { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenHomeContext()) + analyticsController.logImportantEvent( + oppiaLogger.createOpenHomeContext(), + ProfileId.newBuilder().apply { internalId = internalProfileId }.build() + ) } } diff --git a/app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt b/app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt index 8de03ce0ca3..33708dcebb9 100644 --- a/app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt @@ -117,7 +117,7 @@ class AudioViewModel @Inject constructor( val ensuredLanguageCode = if (languages.contains("en")) "en" else languages.first() fallbackLanguageCode = ensuredLanguageCode audioPlayerController.changeDataSource( - voiceOverToUri(voiceoverMap[ensuredLanguageCode]), currentContentId + voiceOverToUri(voiceoverMap[ensuredLanguageCode]), currentContentId, ensuredLanguageCode ) } } @@ -128,20 +128,20 @@ class AudioViewModel @Inject constructor( selectedLanguageCode = languageCode currentLanguageCode.set(languageCode) audioPlayerController.changeDataSource( - voiceOverToUri(voiceoverMap[languageCode]), currentContentId + voiceOverToUri(voiceoverMap[languageCode]), currentContentId, languageCode ) } /** Plays or pauses AudioController depending on passed in state */ fun togglePlayPause(type: UiAudioPlayStatus?) { if (type == UiAudioPlayStatus.PLAYING) { - audioPlayerController.pause() + audioPlayerController.pause(isFromExplicitUserAction = true) } else { audioPlayerController.play(isPlayingFromAutoPlay = false, reloadingMainContent = false) } } - fun pauseAudio() = audioPlayerController.pause() + fun pauseAudio() = audioPlayerController.pause(isFromExplicitUserAction = false) fun handleSeekTo(position: Int) = audioPlayerController.seekTo(position) fun handleRelease() = audioPlayerController.releaseMediaPlayer() diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt index 0698e869113..cedf1abaf54 100755 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt @@ -45,7 +45,6 @@ class ExplorationActivity : RequestVoiceOverIconSpotlightListener { @Inject lateinit var explorationActivityPresenter: ExplorationActivityPresenter - private lateinit var state: State private lateinit var writtenTranslationContext: WrittenTranslationContext @@ -53,7 +52,7 @@ class ExplorationActivity : super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) - val params = intent.getProtoExtra(PARAMS_KEY, ExplorationActivityParams.getDefaultInstance()) + val params = intent.extractParams() explorationActivityPresenter.handleOnCreate( this, params.profileId, @@ -103,6 +102,9 @@ class ExplorationActivity : decorateWithScreenName(EXPLORATION_ACTIVITY) } } + + private fun Intent.extractParams() = + getProtoExtra(PARAMS_KEY, ExplorationActivityParams.getDefaultInstance()) } override fun onBackPressed() { @@ -162,7 +164,8 @@ class ExplorationActivity : explorationId, state, helpIndex, - writtenTranslationContext + writtenTranslationContext, + intent.extractParams().profileId ) hintsAndSolutionDialogFragment.showNow(supportFragmentManager, TAG_HINTS_AND_SOLUTION_DIALOG) } diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragmentPresenter.kt index a28f18c0664..8796b96b672 100755 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragmentPresenter.kt @@ -11,6 +11,7 @@ import androidx.fragment.app.Fragment import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.model.ExplorationFragmentArguments +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.player.state.StateFragment @@ -21,6 +22,7 @@ import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.utility.FontScaleConfigurationUtil import org.oppia.android.databinding.ExplorationFragmentBinding 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.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData @@ -33,6 +35,7 @@ import javax.inject.Inject class ExplorationFragmentPresenter @Inject constructor( private val fragment: Fragment, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, private val fontScaleConfigurationUtil: FontScaleConfigurationUtil, private val profileManagementController: ProfileManagementController, private val resourceHandler: AppLanguageResourceHandler @@ -144,8 +147,9 @@ class ExplorationFragmentPresenter @Inject constructor( } private fun logPracticeFragmentEvent(topicId: String, storyId: String, explorationId: String) { - oppiaLogger.logImportantEvent( - oppiaLogger.createOpenExplorationActivityContext(topicId, storyId, explorationId) + analyticsController.logImportantEvent( + oppiaLogger.createOpenExplorationActivityContext(topicId, storyId, explorationId), + ProfileId.newBuilder().apply { internalId = internalProfileId }.build() ) } diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerFragmentPresenter.kt index 2f7e4613911..59ad3ac71eb 100644 --- a/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerFragmentPresenter.kt @@ -53,9 +53,10 @@ class HintsAndSolutionExplorationManagerFragmentPresenter @Inject constructor( is AsyncResult.Success -> { // Check if hints are available for this state. val ephemeralState = result.value - if (ephemeralState.state.interaction.hintList.size != 0) { + val state = ephemeralState.state + if (state.interaction.hintList.isNotEmpty() || state.interaction.hasSolution()) { (activity as HintsAndSolutionExplorationManagerListener).onExplorationStateLoaded( - ephemeralState.state, ephemeralState.writtenTranslationContext + state, ephemeralState.writtenTranslationContext ) } } 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 2dd09f26969..725ae21d1d6 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 @@ -38,7 +38,6 @@ import org.oppia.android.app.topic.conceptcard.ConceptCardFragment.Companion.CON import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.utility.SplitScreenManager import org.oppia.android.app.utility.lifecycle.LifecycleSafeTimerFactory -import org.oppia.android.app.viewmodel.ViewModelProvider import org.oppia.android.databinding.StateFragmentBinding import org.oppia.android.domain.exploration.ExplorationProgressController import org.oppia.android.domain.oppialogger.OppiaLogger @@ -68,7 +67,6 @@ class StateFragmentPresenter @Inject constructor( private val fragment: Fragment, private val context: Context, private val lifecycleSafeTimerFactory: LifecycleSafeTimerFactory, - private val viewModelProvider: ViewModelProvider, private val explorationProgressController: ExplorationProgressController, private val storyProgressController: StoryProgressController, private val oppiaLogger: OppiaLogger, @@ -76,6 +74,7 @@ class StateFragmentPresenter @Inject constructor( private val assemblerBuilderFactory: StatePlayerRecyclerViewAssembler.Builder.Factory, private val splitScreenManager: SplitScreenManager, private val oppiaClock: OppiaClock, + private val viewModel: StateViewModel, private val accessibilityService: AccessibilityService, private val resourceHandler: AppLanguageResourceHandler ) { @@ -94,9 +93,6 @@ class StateFragmentPresenter @Inject constructor( private lateinit var helpIndex: HelpIndex private var forceAnnouncedForHintsBar = false - private val viewModel: StateViewModel by lazy { - getStateViewModel() - } private lateinit var recyclerViewAssembler: StatePlayerRecyclerViewAssembler private val ephemeralStateLiveData: LiveData> by lazy { explorationProgressController.getCurrentState().toLiveData() @@ -116,6 +112,7 @@ class StateFragmentPresenter @Inject constructor( this.topicId = topicId this.storyId = storyId this.explorationId = explorationId + viewModel.initializeProfile(profileId) binding = StateFragmentBinding.inflate( inflater, @@ -266,10 +263,6 @@ class StateFragmentPresenter @Inject constructor( subscribeToHintSolution(explorationProgressController.submitSolutionIsRevealed()) } - private fun getStateViewModel(): StateViewModel { - return viewModelProvider.getForFragment(fragment, StateViewModel::class.java) - } - private fun getAudioFragment(): Fragment? { return fragment.childFragmentManager.findFragmentByTag(TAG_AUDIO_FRAGMENT) } @@ -437,8 +430,7 @@ class StateFragmentPresenter @Inject constructor( ) } - fun setAudioBarVisibility(visibility: Boolean) = - getStateViewModel().setAudioBarVisibility(visibility) + fun setAudioBarVisibility(visibility: Boolean) = viewModel.setAudioBarVisibility(visibility) fun scrollToTop() { binding.stateRecyclerView.smoothScrollToPosition(0) diff --git a/app/src/main/java/org/oppia/android/app/player/state/StateViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/StateViewModel.kt index a46208966b1..b180c0e61d7 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/StateViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StateViewModel.kt @@ -2,19 +2,45 @@ package org.oppia.android.app.player.state import androidx.databinding.ObservableField import androidx.databinding.ObservableList +import androidx.fragment.app.Fragment +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel import org.oppia.android.app.fragment.FragmentScope +import org.oppia.android.app.model.EphemeralState +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.Profile +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.WrittenTranslationLanguageSelection import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.itemviewmodel.StateItemViewModel import org.oppia.android.app.viewmodel.ObservableArrayList import org.oppia.android.app.viewmodel.ObservableViewModel +import org.oppia.android.domain.exploration.ExplorationProgressController +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.profile.ProfileManagementController +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.locale.OppiaLocale +import org.oppia.android.util.platformparameter.EnableLearnerStudyAnalytics +import org.oppia.android.util.platformparameter.PlatformParameterValue import javax.inject.Inject /** [ViewModel] for state-fragment. */ @FragmentScope -class StateViewModel @Inject constructor() : ObservableViewModel() { +class StateViewModel @Inject constructor( + private val explorationProgressController: ExplorationProgressController, + private val translationController: TranslationController, + private val machineLocale: OppiaLocale.MachineLocale, + private val oppiaLogger: OppiaLogger, + private val fragment: Fragment, + private val profileManagementController: ProfileManagementController, + @EnableLearnerStudyAnalytics private val enableLearnerStudy: PlatformParameterValue +) : ObservableViewModel() { val itemList: ObservableList = ObservableArrayList() val rightItemList: ObservableList = ObservableArrayList() @@ -26,9 +52,35 @@ class StateViewModel @Inject constructor() : ObservableViewModel() { val isHintBulbVisible = ObservableField(false) val isHintOpenedAndUnRevealed = ObservableField(false) + val hasSupportForSwitchingToSwahili: Boolean = enableLearnerStudy.value + val hasSwahiliTranslations: LiveData by lazy { + Transformations.map( + explorationProgressController.getCurrentState().toLiveData(), + ::processWhetherSwahiliIsSupported + ) + } + val hasEnabledSwahiliTranslations: LiveData by lazy { + Transformations.map( + translationController.getWrittenTranslationContentLanguage(profileId).toLiveData(), + ::processIsCurrentLanguageSwahili + ) + } + + val allowInLessonQuickLanguageSwitching: LiveData by lazy { + Transformations.map( + profileManagementController.getProfile(profileId).toLiveData(), + ::processAllowInLessonQuickLanguageSwitching + ) + } + var currentStateName = ObservableField(null as? String?) private val canSubmitAnswer = ObservableField(false) + private lateinit var profileId: ProfileId + + fun initializeProfile(profileId: ProfileId) { + this.profileId = profileId + } fun setAudioBarVisibility(audioBarVisible: Boolean) { isAudioBarVisible.set(audioBarVisible) @@ -56,6 +108,47 @@ class StateViewModel @Inject constructor() : ObservableViewModel() { ) ?: UserAnswer.getDefaultInstance() } + fun canQuicklyToggleBetweenSwahiliAndEnglish( + hasSwahiliTranslations: Boolean, + allowInLessonLangSwitching: Boolean + ): Boolean { + // This logic has to be done in Kotlin since there seems to be a bug in the generated Java by + // the databinding compiler that can result in a NPE being thrown in code that shouldn't + // actually be throwing it (see https://issuetracker.google.com/issues/144246528 for context). + // Essentially, the following example of generated code results in an NPE unexpectedly: + // Boolean value = boolean_value ? Boolean_value : false (Boolean_value can be null) + return hasSwahiliTranslations && hasSupportForSwitchingToSwahili && allowInLessonLangSwitching + } + + fun toggleContentLanguage(isSwahiliEnabled: Boolean) { + val languageSelection = WrittenTranslationLanguageSelection.newBuilder().apply { + selectedLanguage = if (isSwahiliEnabled) OppiaLanguage.ENGLISH else OppiaLanguage.SWAHILI + }.build() + val updateResultProvider = + explorationProgressController.updateWrittenTranslationContentLanguageMidLesson( + profileId, languageSelection + ) + val updateResultLiveData = updateResultProvider.toLiveData() + updateResultLiveData.observe( + fragment, + object : Observer> { + override fun onChanged(result: AsyncResult?) { + if (result is AsyncResult.Failure) { + oppiaLogger.e( + "StateViewModel", + "Failed to update content language to:" + + " ${languageSelection.selectedLanguage.ordinal}.", + result.error + ) + } + if (result !is AsyncResult.Pending) { + updateResultLiveData.removeObserver(this) + } + } + } + ) + } + private fun getPendingAnswerWithoutError( answerHandler: InteractionAnswerHandler? ): UserAnswer? { @@ -73,4 +166,55 @@ class StateViewModel @Inject constructor() : ObservableViewModel() { itemList } } + + private fun processIsCurrentLanguageSwahili(languageResult: AsyncResult): Boolean { + return when (languageResult) { + is AsyncResult.Pending -> false + is AsyncResult.Success -> languageResult.value == OppiaLanguage.SWAHILI + is AsyncResult.Failure -> { + oppiaLogger.e( + "StateViewModel", "Failed to retrieve content language.", languageResult.error + ) + false + } + } + } + + private fun processAllowInLessonQuickLanguageSwitching( + profileResult: AsyncResult + ): Boolean { + return when (profileResult) { + is AsyncResult.Pending -> false // Assume the setting is off until verified. + is AsyncResult.Success -> profileResult.value.allowInLessonQuickLanguageSwitching + is AsyncResult.Failure -> { + oppiaLogger.e( + "StateViewModel", + "Failed to retrieve profile for current ID: $profileId.", + profileResult.error + ) + false // Assume the setting is off since a retrieval error occurred. + } + } + } + + private fun processWhetherSwahiliIsSupported(stateResult: AsyncResult): Boolean { + return when (stateResult) { + is AsyncResult.Pending -> false + is AsyncResult.Success -> { + // It would be nice if there was a domain utility to do this if it's needed elsewhere (or, + // better yet, just using the language protos directly in the state structure so no raw + // language codes need to be processed). + val state = stateResult.value.state + state.writtenTranslationsMap[state.content.contentId]?.translationMappingMap?.keys?.any { + // Only enable in-lesson language switching if the main content of a state is available in + // Swahili. + machineLocale.run { it.toMachineLowerCase() == "sw" } + } ?: false + } + is AsyncResult.Failure -> { + oppiaLogger.e("StateViewModel", "Failed to retrieve state.", stateResult.error) + false + } + } + } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt b/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt index fcecfe7d311..26e077ab277 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt @@ -10,6 +10,7 @@ import org.oppia.android.app.hintsandsolution.HintsAndSolutionListener import org.oppia.android.app.hintsandsolution.RevealHintListener import org.oppia.android.app.hintsandsolution.RevealSolutionInterface 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.WrittenTranslationContext import org.oppia.android.app.player.audio.AudioButtonListener @@ -46,10 +47,14 @@ class StateFragmentTestActivity : lateinit var stateFragmentTestActivityPresenter: StateFragmentTestActivityPresenter private lateinit var state: State private lateinit var writtenTranslationContext: WrittenTranslationContext + private lateinit var profileId: ProfileId override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) + profileId = ProfileId.newBuilder().apply { + internalId = intent.getIntExtra(TEST_ACTIVITY_PROFILE_ID_EXTRA_KEY, -1) + }.build() stateFragmentTestActivityPresenter.handleOnCreate() } @@ -113,7 +118,8 @@ class StateFragmentTestActivity : explorationId, state, helpIndex, - writtenTranslationContext + writtenTranslationContext, + profileId ) hintsAndSolutionFragment.showNow(supportFragmentManager, TAG_HINTS_AND_SOLUTION_DIALOG) } diff --git a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenter.kt index eefe86c8c99..801ea6f23d9 100644 --- a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenter.kt @@ -24,6 +24,7 @@ import org.oppia.android.databinding.ProfileChooserAddViewBinding import org.oppia.android.databinding.ProfileChooserFragmentBinding import org.oppia.android.databinding.ProfileChooserProfileViewBinding 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.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData @@ -66,6 +67,7 @@ class ProfileChooserFragmentPresenter @Inject constructor( private val viewModelProvider: ViewModelProvider, private val profileManagementController: ProfileManagementController, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory ) { private lateinit var binding: ProfileChooserFragmentBinding @@ -254,7 +256,10 @@ class ProfileChooserFragmentPresenter @Inject constructor( } private fun logProfileChooserEvent() { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenProfileChooserContext()) + analyticsController.logImportantEvent( + oppiaLogger.createOpenProfileChooserContext(), + profileId = null // There's no profile currently logged in. + ) } private fun updateLearnerIdIfAbsent(profile: Profile) { diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditActivity.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditActivity.kt index f53b355b630..9399596698b 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditActivity.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditActivity.kt @@ -19,7 +19,7 @@ const val IS_MULTIPANE_EXTRA_KEY = "ProfileEditActivity.is_multipane" const val IS_PROFILE_DELETION_DIALOG_VISIBLE_KEY = "ProfileEditActivity.is_profile_deletion_dialog_visible" -/** Activity [ProfileEditActivity] that allows user to edit a profile. */ +/** Activity that allows admins to edit a profile. */ class ProfileEditActivity : InjectableAppCompatActivity() { @Inject lateinit var profileEditActivityPresenter: ProfileEditActivityPresenter diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditFragmentPresenter.kt index 914bf3bade4..f1fb73492b1 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditFragmentPresenter.kt @@ -10,6 +10,7 @@ import androidx.lifecycle.Observer import org.oppia.android.R import org.oppia.android.app.administratorcontrols.AdministratorControlsActivity import org.oppia.android.app.administratorcontrols.ProfileEditDeletionDialogListener +import org.oppia.android.app.devoptions.markchapterscompleted.MarkChaptersCompletedActivity import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.model.ProfileId import org.oppia.android.databinding.ProfileEditFragmentBinding @@ -74,42 +75,57 @@ class ProfileEditFragmentPresenter @Inject constructor( ) } + binding.profileMarkChaptersForCompletionButton?.setOnClickListener { + activity.startActivity( + MarkChaptersCompletedActivity.createMarkChaptersCompletedIntent( + activity, internalProfileId, showConfirmationNotice = true + ) + ) + } + binding.profileDeleteButton.setOnClickListener { showDeletionDialog(internalProfileId) } - profileEditViewModel.profile.observe( - fragment, - Observer { - if (activity is ProfileEditActivity) { - activity.title = it.name - } + profileEditViewModel.profile.observe(fragment) { profile -> + if (activity is ProfileEditActivity) { + activity.title = profile.name } - ) - profileEditViewModel.isAllowedDownloadAccess.observe( - fragment, - Observer { - binding.profileEditAllowDownloadSwitch.isChecked = it - } - ) + binding.profileEditAllowDownloadSwitch.isChecked = profile.allowDownloadAccess + binding.profileEditEnableInLessonLanguageSwitchingSwitch.isChecked = + profile.allowInLessonQuickLanguageSwitching + } binding.profileEditAllowDownloadContainer.setOnClickListener { - binding.profileEditAllowDownloadSwitch.isChecked = - !binding.profileEditAllowDownloadSwitch.isChecked + val enableDownloads = !binding.profileEditAllowDownloadSwitch.isChecked + binding.profileEditAllowDownloadSwitch.isChecked = enableDownloads profileManagementController.updateAllowDownloadAccess( ProfileId.newBuilder().setInternalId(internalProfileId).build(), - binding.profileEditAllowDownloadSwitch.isChecked - ).toLiveData().observe( - activity, - Observer { - if (it is AsyncResult.Failure) { - oppiaLogger.e( - "ProfileEditActivityPresenter", "Failed to updated allow download access", it.error - ) - } + enableDownloads + ).toLiveData().observe(activity) { + if (it is AsyncResult.Failure) { + oppiaLogger.e( + "ProfileEditActivityPresenter", "Failed to updated allow download access", it.error + ) } - ) + } + } + binding.profileEditEnableInLessonLanguageSwitchingContainer.setOnClickListener { + val enableLangSwitching = !binding.profileEditEnableInLessonLanguageSwitchingSwitch.isChecked + binding.profileEditEnableInLessonLanguageSwitchingSwitch.isChecked = enableLangSwitching + profileManagementController.updateEnableInLessonQuickLanguageSwitching( + ProfileId.newBuilder().setInternalId(internalProfileId).build(), + enableLangSwitching + ).toLiveData().observe(activity) { + if (it is AsyncResult.Failure) { + oppiaLogger.e( + "ProfileEditActivityPresenter", + "Failed to updated allow quick language switching", + it.error + ) + } + } } return binding.root } diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditViewModel.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditViewModel.kt index c15aa25d21b..8aa02c1d428 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditViewModel.kt @@ -1,7 +1,6 @@ package org.oppia.android.app.settings.profile import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.model.Profile @@ -12,6 +11,7 @@ import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData import org.oppia.android.util.platformparameter.EnableDownloadsSupport +import org.oppia.android.util.platformparameter.EnableLearnerStudyAnalytics import org.oppia.android.util.platformparameter.PlatformParameterValue import javax.inject.Inject @@ -20,14 +20,16 @@ import javax.inject.Inject class ProfileEditViewModel @Inject constructor( private val oppiaLogger: OppiaLogger, private val profileManagementController: ProfileManagementController, - @EnableDownloadsSupport private val enableDownloadsSupport: PlatformParameterValue + @EnableDownloadsSupport private val enableDownloadsSupport: PlatformParameterValue, + @EnableLearnerStudyAnalytics private val enableLearnerStudy: PlatformParameterValue ) : ObservableViewModel() { private lateinit var profileId: ProfileId - private val isAllowedDownloadAccessMutableLiveData = MutableLiveData() + /** Whether the admin is allowed to mark chapters as finished. */ + val isAllowedToMarkFinishedChapters: Boolean = enableLearnerStudy.value - /** Download access enabled for the profile by the administrator. */ - val isAllowedDownloadAccess: LiveData = isAllowedDownloadAccessMutableLiveData + /** Whether the admin can allow learners to quickly switch content languages within a lesson. */ + val isAllowedToEnableQuickLessonLanguageSwitching: Boolean = enableLearnerStudy.value /** List of all the current profiles registered in the app [ProfileListFragment]. */ val profile: LiveData by lazy { @@ -66,7 +68,6 @@ class ProfileEditViewModel @Inject constructor( is AsyncResult.Pending -> Profile.getDefaultInstance() is AsyncResult.Success -> profileResult.value } - isAllowedDownloadAccessMutableLiveData.value = profile.allowDownloadAccess isAdmin = profile.isAdmin return profile } diff --git a/app/src/main/java/org/oppia/android/app/story/StoryFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/story/StoryFragmentPresenter.kt index 7958dfcc8b0..7ec6ce8317d 100644 --- a/app/src/main/java/org/oppia/android/app/story/StoryFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/story/StoryFragmentPresenter.kt @@ -35,6 +35,7 @@ import org.oppia.android.databinding.StoryFragmentBinding import org.oppia.android.databinding.StoryHeaderViewBinding import org.oppia.android.domain.exploration.ExplorationDataController import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.util.accessibility.AccessibilityService import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData @@ -48,6 +49,7 @@ class StoryFragmentPresenter @Inject constructor( private val activity: AppCompatActivity, private val fragment: Fragment, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, private val htmlParserFactory: HtmlParser.Factory, private val explorationDataController: ExplorationDataController, @DefaultResourceBucketName private val resourceBucketName: String, @@ -61,12 +63,10 @@ class StoryFragmentPresenter @Inject constructor( private lateinit var binding: StoryFragmentBinding private lateinit var linearLayoutManager: LinearLayoutManager private lateinit var linearSmoothScroller: RecyclerView.SmoothScroller + private lateinit var profileId: ProfileId - @Inject - lateinit var storyViewModel: StoryViewModel - - @Inject - lateinit var accessibilityService: AccessibilityService + @Inject lateinit var storyViewModel: StoryViewModel + @Inject lateinit var accessibilityService: AccessibilityService fun handleCreateView( inflater: LayoutInflater, @@ -80,6 +80,7 @@ class StoryFragmentPresenter @Inject constructor( container, /* attachToRoot= */ false ) + profileId = ProfileId.newBuilder().apply { internalId = internalProfileId }.build() storyViewModel.setInternalProfileId(internalProfileId) storyViewModel.setTopicId(topicId) storyViewModel.setStoryId(storyId) @@ -255,7 +256,10 @@ class StoryFragmentPresenter @Inject constructor( } private fun logStoryActivityEvent(topicId: String, storyId: String) { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenStoryActivityContext(topicId, storyId)) + analyticsController.logImportantEvent( + oppiaLogger.createOpenStoryActivityContext(topicId, storyId), + profileId + ) } private fun playExploration( diff --git a/app/src/main/java/org/oppia/android/app/topic/TopicFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/TopicFragmentPresenter.kt index f04092a39b6..b94b7db6029 100644 --- a/app/src/main/java/org/oppia/android/app/topic/TopicFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/TopicFragmentPresenter.kt @@ -11,6 +11,7 @@ import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope +import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.Spotlight import org.oppia.android.app.spotlight.SpotlightManager import org.oppia.android.app.spotlight.SpotlightShape @@ -18,6 +19,7 @@ import org.oppia.android.app.spotlight.SpotlightTarget import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.databinding.TopicFragmentBinding import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.util.platformparameter.EnableExtraTopicTabsUi import org.oppia.android.util.platformparameter.PlatformParameterValue import javax.inject.Inject @@ -29,6 +31,7 @@ class TopicFragmentPresenter @Inject constructor( private val fragment: Fragment, private val viewModel: TopicViewModel, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, @EnableExtraTopicTabsUi private val enableExtraTopicTabsUi: PlatformParameterValue, private val resourceHandler: AppLanguageResourceHandler ) { @@ -111,7 +114,6 @@ class TopicFragmentPresenter @Inject constructor( private fun setCurrentTab(tab: TopicTab) { viewPager.setCurrentItem(computeTabPosition(tab), true) - logTopicEvents(tab) } private fun computeTabPosition(tab: TopicTab): Int { @@ -135,30 +137,22 @@ class TopicFragmentPresenter @Inject constructor( setCurrentTab(TopicTab.LESSONS) } } + viewPager2.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + logTopicEvents(TopicTab.getTabForPosition(position, enableExtraTopicTabsUi.value)) + } + }) } private fun logTopicEvents(tab: TopicTab) { - when (tab) { - TopicTab.INFO -> logInfoFragmentEvent(topicId) - TopicTab.LESSONS -> logLessonsFragmentEvent(topicId) - TopicTab.PRACTICE -> logPracticeFragmentEvent(topicId) - TopicTab.REVISION -> logRevisionFragmentEvent(topicId) + val eventContext = when (tab) { + TopicTab.INFO -> oppiaLogger.createOpenInfoTabContext(topicId) + TopicTab.LESSONS -> oppiaLogger.createOpenLessonsTabContext(topicId) + TopicTab.PRACTICE -> oppiaLogger.createOpenPracticeTabContext(topicId) + TopicTab.REVISION -> oppiaLogger.createOpenRevisionTabContext(topicId) } - } - - private fun logInfoFragmentEvent(topicId: String) { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenInfoTabContext(topicId)) - } - - private fun logLessonsFragmentEvent(topicId: String) { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenLessonsTabContext(topicId)) - } - - private fun logPracticeFragmentEvent(topicId: String) { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenPracticeTabContext(topicId)) - } - - private fun logRevisionFragmentEvent(topicId: String) { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenRevisionTabContext(topicId)) + analyticsController.logImportantEvent( + eventContext, ProfileId.newBuilder().apply { internalId = internalProfileId }.build() + ) } } diff --git a/app/src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragmentPresenter.kt index 4b731ea971f..be54a5e57af 100644 --- a/app/src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragmentPresenter.kt @@ -11,6 +11,7 @@ import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.viewmodel.ViewModelProvider import org.oppia.android.databinding.ConceptCardFragmentBinding import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.domain.translation.TranslationController import org.oppia.android.util.gcsresource.DefaultResourceBucketName import org.oppia.android.util.parser.html.ConceptCardHtmlParserEntityType @@ -22,13 +23,16 @@ import javax.inject.Inject class ConceptCardFragmentPresenter @Inject constructor( private val fragment: Fragment, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, private val htmlParserFactory: HtmlParser.Factory, @ConceptCardHtmlParserEntityType private val entityType: String, @DefaultResourceBucketName private val resourceBucketName: String, private val viewModelProvider: ViewModelProvider, private val translationController: TranslationController, private val appLanguageResourceHandler: AppLanguageResourceHandler -) { +) : HtmlParser.CustomOppiaTagActionListener { + private lateinit var profileId: ProfileId + /** * Sets up data binding and toolbar. * Host activity must inherit ConceptCardListener to dismiss this fragment. @@ -39,6 +43,7 @@ class ConceptCardFragmentPresenter @Inject constructor( skillId: String, profileId: ProfileId ): View? { + this.profileId = profileId val binding = ConceptCardFragmentBinding.inflate( inflater, container, @@ -64,26 +69,28 @@ class ConceptCardFragmentPresenter @Inject constructor( } viewModel.conceptCardLiveData.observe( - fragment, - { ephemeralConceptCard -> - val explanationHtml = - translationController.extractString( - ephemeralConceptCard.conceptCard.explanation, - ephemeralConceptCard.writtenTranslationContext - ) - view.text = htmlParserFactory - .create( - resourceBucketName, - entityType, - skillId, - imageCenterAlign = true, - displayLocale = appLanguageResourceHandler.getDisplayLocale() - ) - .parseOppiaHtml( - explanationHtml, view - ) - } - ) + fragment + ) { ephemeralConceptCard -> + val explanationHtml = + translationController.extractString( + ephemeralConceptCard.conceptCard.explanation, + ephemeralConceptCard.writtenTranslationContext + ) + view.text = + htmlParserFactory.create( + resourceBucketName, + entityType, + skillId, + customOppiaTagActionListener = this, + imageCenterAlign = true, + displayLocale = appLanguageResourceHandler.getDisplayLocale() + ).parseOppiaHtml( + explanationHtml, + view, + supportsLinks = true, + supportsConceptCards = true + ) + } return binding.root } @@ -93,6 +100,14 @@ class ConceptCardFragmentPresenter @Inject constructor( } private fun logConceptCardEvent(skillId: String) { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenConceptCardContext(skillId)) + analyticsController.logImportantEvent( + oppiaLogger.createOpenConceptCardContext(skillId), profileId + ) + } + + override fun onConceptCardLinkClicked(view: View, skillId: String) { + ConceptCardFragment + .newInstance(skillId, profileId) + .showNow(fragment.childFragmentManager, ConceptCardFragment.CONCEPT_CARD_DIALOG_FRAGMENT_TAG) } } diff --git a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivity.kt b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivity.kt index e586b89ba6b..752b1f1cf17 100644 --- a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivity.kt +++ b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivity.kt @@ -5,7 +5,6 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAppCompatActivity -import org.oppia.android.app.hintsandsolution.HintsAndSolutionDialogFragment import org.oppia.android.app.hintsandsolution.HintsAndSolutionListener import org.oppia.android.app.hintsandsolution.RevealHintListener import org.oppia.android.app.hintsandsolution.RevealSolutionInterface @@ -14,7 +13,6 @@ import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.ScreenName.QUESTION_PLAYER_ACTIVITY import org.oppia.android.app.model.State import org.oppia.android.app.model.WrittenTranslationContext -import org.oppia.android.app.player.exploration.TAG_HINTS_AND_SOLUTION_DIALOG import org.oppia.android.app.player.state.listener.RouteToHintsAndSolutionListener import org.oppia.android.app.player.state.listener.StateKeyboardButtonListener import org.oppia.android.app.player.stopplaying.RestartPlayingSessionListener @@ -48,9 +46,6 @@ class QuestionPlayerActivity : @Inject lateinit var questionPlayerActivityPresenter: QuestionPlayerActivityPresenter - private lateinit var state: State - private lateinit var writtenTranslationContext: WrittenTranslationContext - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) @@ -108,39 +103,19 @@ class QuestionPlayerActivity : questionPlayerActivityPresenter.revealSolution() } - private fun getHintsAndSolution(): HintsAndSolutionDialogFragment? { - return supportFragmentManager.findFragmentByTag( - TAG_HINTS_AND_SOLUTION_DIALOG - ) as HintsAndSolutionDialogFragment? - } - override fun routeToHintsAndSolution( - questionId: String, + id: String, helpIndex: HelpIndex ) { - if (getHintsAndSolution() == null) { - val hintsAndSolutionDialogFragment = - HintsAndSolutionDialogFragment.newInstance( - questionId, - state, - helpIndex, - writtenTranslationContext - ) - hintsAndSolutionDialogFragment.showNow(supportFragmentManager, TAG_HINTS_AND_SOLUTION_DIALOG) - } + questionPlayerActivityPresenter.routeToHintsAndSolution(id, helpIndex) } - override fun dismiss() { - getHintsAndSolution()?.dismiss() - } + override fun dismiss() = questionPlayerActivityPresenter.dismissHintsAndSolutionDialog() override fun onQuestionStateLoaded( state: State, writtenTranslationContext: WrittenTranslationContext - ) { - this.state = state - this.writtenTranslationContext = writtenTranslationContext - } + ) = questionPlayerActivityPresenter.loadQuestionState(state, writtenTranslationContext) override fun dismissConceptCard() { questionPlayerActivityPresenter.dismissConceptCard() diff --git a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivityPresenter.kt index 6e58b27a68e..b9685035387 100644 --- a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivityPresenter.kt @@ -5,7 +5,12 @@ 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.hintsandsolution.HintsAndSolutionDialogFragment +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.WrittenTranslationContext +import org.oppia.android.app.player.exploration.TAG_HINTS_AND_SOLUTION_DIALOG import org.oppia.android.databinding.QuestionPlayerActivityBinding import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.question.QuestionTrainingController @@ -24,6 +29,8 @@ class QuestionPlayerActivityPresenter @Inject constructor( private val oppiaLogger: OppiaLogger ) { private lateinit var profileId: ProfileId + private lateinit var state: State + private lateinit var writtenTranslationContext: WrittenTranslationContext fun handleOnCreate(profileId: ProfileId) { this.profileId = profileId @@ -155,6 +162,30 @@ class QuestionPlayerActivityPresenter @Inject constructor( ) as QuestionPlayerFragment? } + fun loadQuestionState(state: State, writtenTranslationContext: WrittenTranslationContext) { + this.state = state + this.writtenTranslationContext = writtenTranslationContext + } + + fun routeToHintsAndSolution( + questionId: String, + helpIndex: HelpIndex + ) { + if (getHintsAndSolutionDialogFragment() == null) { + val hintsAndSolutionDialogFragment = + HintsAndSolutionDialogFragment.newInstance( + questionId, + state, + helpIndex, + writtenTranslationContext, + profileId + ) + hintsAndSolutionDialogFragment.showNow( + activity.supportFragmentManager, TAG_HINTS_AND_SOLUTION_DIALOG + ) + } + } + fun revealHint(hintIndex: Int) { val questionPlayerFragment = activity.supportFragmentManager.findFragmentByTag( @@ -171,5 +202,15 @@ class QuestionPlayerActivityPresenter @Inject constructor( questionPlayerFragment.revealSolution() } + fun dismissHintsAndSolutionDialog() { + getHintsAndSolutionDialogFragment()?.dismiss() + } + fun dismissConceptCard() = getQuestionPlayerFragment()?.dismissConceptCard() + + private fun getHintsAndSolutionDialogFragment(): HintsAndSolutionDialogFragment? { + return activity.supportFragmentManager.findFragmentByTag( + TAG_HINTS_AND_SOLUTION_DIALOG + ) as? HintsAndSolutionDialogFragment + } } diff --git a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt index 596ee8287d8..a9d3ceb558e 100644 --- a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragmentPresenter.kt @@ -30,6 +30,7 @@ import org.oppia.android.app.utility.SplitScreenManager import org.oppia.android.app.viewmodel.ViewModelProvider import org.oppia.android.databinding.QuestionPlayerFragmentBinding import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.domain.question.QuestionAssessmentProgressController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProvider @@ -45,6 +46,7 @@ class QuestionPlayerFragmentPresenter @Inject constructor( private val viewModelProvider: ViewModelProvider, private val questionAssessmentProgressController: QuestionAssessmentProgressController, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, @QuestionResourceBucketName private val resourceBucketName: String, private val assemblerBuilderFactory: StatePlayerRecyclerViewAssembler.Builder.Factory, private val splitScreenManager: SplitScreenManager @@ -64,6 +66,7 @@ class QuestionPlayerFragmentPresenter @Inject constructor( private lateinit var questionId: String private lateinit var currentQuestionState: State private lateinit var helpIndex: HelpIndex + private lateinit var profileId: ProfileId fun handleCreateView( inflater: LayoutInflater, @@ -75,6 +78,7 @@ class QuestionPlayerFragmentPresenter @Inject constructor( container, /* attachToRoot= */ false ) + this.profileId = profileId recyclerViewAssembler = createRecyclerViewAssembler( assemblerBuilderFactory.create(resourceBucketName, "skill", profileId), @@ -354,8 +358,9 @@ class QuestionPlayerFragmentPresenter @Inject constructor( } private fun logQuestionPlayerEvent(questionId: String, skillIds: List) { - oppiaLogger.logImportantEvent( - oppiaLogger.createOpenQuestionPlayerContext(questionId, skillIds) + analyticsController.logImportantEvent( + oppiaLogger.createOpenQuestionPlayerContext(questionId, skillIds), + profileId ) } diff --git a/app/src/main/java/org/oppia/android/app/topic/revisioncard/ReturnToTopicClickListener.kt b/app/src/main/java/org/oppia/android/app/topic/revisioncard/ReturnToTopicClickListener.kt index 1477507296a..29401c9ef87 100644 --- a/app/src/main/java/org/oppia/android/app/topic/revisioncard/ReturnToTopicClickListener.kt +++ b/app/src/main/java/org/oppia/android/app/topic/revisioncard/ReturnToTopicClickListener.kt @@ -1,6 +1,7 @@ package org.oppia.android.app.topic.revisioncard -/** Listener to route to [TopicActivity] when clicked on Return to Topic button. */ +/** Listener for when user wishes to navigate back from a revision card to the topic screen. */ interface ReturnToTopicClickListener { - fun onReturnToTopicClicked() + /** Indicates that the user wishes to return to the topic screen from a revision card. */ + fun onReturnToTopicRequested() } diff --git a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivity.kt b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivity.kt index 27a94b1a706..627d85ce6b6 100644 --- a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivity.kt +++ b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivity.kt @@ -88,11 +88,16 @@ class RevisionCardActivity : this.finish() } - override fun onReturnToTopicClicked() { - onBackPressed() + override fun onReturnToTopicRequested() { + revisionCardActivityPresenter.logExitRevisionCard() + finish() } override fun dismissConceptCard() { revisionCardActivityPresenter.dismissConceptCard() } + + override fun onBackPressed() { + onReturnToTopicRequested() + } } diff --git a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityPresenter.kt index a307c330cbc..1cc5c331db5 100644 --- a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityPresenter.kt @@ -16,6 +16,7 @@ import org.oppia.android.app.options.OptionsActivity import org.oppia.android.app.player.exploration.BottomSheetOptionsMenu import org.oppia.android.databinding.RevisionCardActivityBinding import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.domain.topic.TopicController import org.oppia.android.domain.translation.TranslationController import org.oppia.android.util.data.AsyncResult @@ -27,6 +28,7 @@ import javax.inject.Inject class RevisionCardActivityPresenter @Inject constructor( private val activity: AppCompatActivity, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, private val topicController: TopicController, private val translationController: TranslationController ) { @@ -62,7 +64,7 @@ class RevisionCardActivityPresenter @Inject constructor( activity.supportActionBar?.setDisplayShowTitleEnabled(false) binding.revisionCardToolbar.setNavigationOnClickListener { - (activity as RevisionCardActivity).finish() + (activity as ReturnToTopicClickListener).onReturnToTopicRequested() } binding.revisionCardToolbarTitle.setOnClickListener { binding.revisionCardToolbarTitle.isSelected = true @@ -106,6 +108,13 @@ class RevisionCardActivityPresenter @Inject constructor( /** Dismisses the concept card fragment if it's currently active in this activity. */ fun dismissConceptCard() = getReviewCardFragment()?.dismissConceptCard() + fun logExitRevisionCard() { + analyticsController.logImportantEvent( + oppiaLogger.createCloseRevisionCardContext(topicId, subtopicId), + profileId + ) + } + private fun subscribeToSubtopicTitle() { subtopicLiveData.observe( activity, diff --git a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragmentPresenter.kt index 57931980e48..266645ae3fc 100755 --- a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragmentPresenter.kt @@ -11,6 +11,7 @@ import org.oppia.android.app.topic.conceptcard.ConceptCardFragment.Companion.CON import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.databinding.RevisionCardFragmentBinding import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.domain.translation.TranslationController import org.oppia.android.util.gcsresource.DefaultResourceBucketName import org.oppia.android.util.parser.html.HtmlParser @@ -22,6 +23,7 @@ import javax.inject.Inject class RevisionCardFragmentPresenter @Inject constructor( private val fragment: Fragment, private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, private val htmlParserFactory: HtmlParser.Factory, @DefaultResourceBucketName private val resourceBucketName: String, @TopicHtmlParserEntityType private val entityType: String, @@ -92,7 +94,10 @@ class RevisionCardFragmentPresenter @Inject constructor( } private fun logRevisionCardEvent(topicId: String, subTopicId: Int) { - oppiaLogger.logImportantEvent(oppiaLogger.createOpenRevisionCardContext(topicId, subTopicId)) + analyticsController.logImportantEvent( + oppiaLogger.createOpenRevisionCardContext(topicId, subTopicId), + profileId + ) } override fun onConceptCardLinkClicked(view: View, skillId: String) { diff --git a/app/src/main/java/org/oppia/android/app/translation/BUILD.bazel b/app/src/main/java/org/oppia/android/app/translation/BUILD.bazel index e5bc20b3d81..0e6bf561ab0 100644 --- a/app/src/main/java/org/oppia/android/app/translation/BUILD.bazel +++ b/app/src/main/java/org/oppia/android/app/translation/BUILD.bazel @@ -29,6 +29,7 @@ kt_android_library( deps = [ ":app_language_locale_handler", ":dagger", + "//model/src/main/proto:profile_java_proto_lite", "//third_party:androidx_appcompat_appcompat", "//utility/src/main/java/org/oppia/android/util/locale:oppia_locale", ], diff --git a/app/src/main/res/drawable/start_over_button_background.xml b/app/src/main/res/drawable/secondary_button_background.xml similarity index 78% rename from app/src/main/res/drawable/start_over_button_background.xml rename to app/src/main/res/drawable/secondary_button_background.xml index b06f4205776..2debad327a0 100644 --- a/app/src/main/res/drawable/start_over_button_background.xml +++ b/app/src/main/res/drawable/secondary_button_background.xml @@ -5,5 +5,5 @@ + android:color="@color/component_color_shared_secondary_button_background_trim_color" /> 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 65703ff695e..09ea627140b 100644 --- a/app/src/main/res/layout-land/profile_edit_fragment.xml +++ b/app/src/main/res/layout-land/profile_edit_fragment.xml @@ -111,6 +111,87 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/profile_rename_button" /> +