diff --git a/app/BUILD.bazel b/app/BUILD.bazel index 7405410cf3e..a022dfef002 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -146,6 +146,7 @@ LISTENERS = [ "src/main/java/org/oppia/android/app/player/state/listener/StateKeyboardButtonListener.kt", "src/main/java/org/oppia/android/app/player/state/listener/SubmitNavigationButtonListener.kt", "src/main/java/org/oppia/android/app/policies/RouteToPoliciesListener.kt", + "src/main/java/org/oppia/android/app/profile/ProfileClickListener.kt", "src/main/java/org/oppia/android/app/profile/RouteToAdminPinListener.kt", "src/main/java/org/oppia/android/app/profileprogress/ProfilePictureClickListener.kt", "src/main/java/org/oppia/android/app/profileprogress/RouteToCompletedStoryListListener.kt", @@ -233,6 +234,7 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt", "src/main/java/org/oppia/android/app/profile/AddProfileViewModel.kt", "src/main/java/org/oppia/android/app/profile/PinPasswordViewModel.kt", + "src/main/java/org/oppia/android/app/profile/ProfileItemViewModel.kt", "src/main/java/org/oppia/android/app/profile/ResetPinViewModel.kt", "src/main/java/org/oppia/android/app/profileprogress/ProfileProgressViewModel.kt", "src/main/java/org/oppia/android/app/profileprogress/RecentlyPlayedStorySummaryViewModel.kt", @@ -413,6 +415,7 @@ VIEWS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/customview/PromotedStoryCardView.kt", "src/main/java/org/oppia/android/app/customview/SegmentedCircularProgressView.kt", "src/main/java/org/oppia/android/app/customview/VerticalDashedLineView.kt", + "src/main/java/org/oppia/android/app/profile/ProfileListView.kt", "src/main/java/org/oppia/android/app/survey/SurveyMultipleChoiceOptionView.kt", "src/main/java/org/oppia/android/app/survey/SurveyNpsItemOptionView.kt", "src/main/java/org/oppia/android/app/utility/ClickableAreasImage.kt", diff --git a/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileFragmentPresenter.kt index 44c1aad1746..6e2663a0bc1 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/CreateProfileFragmentPresenter.kt @@ -169,6 +169,7 @@ class CreateProfileFragmentPresenter @Inject constructor( val params = IntroActivityParams.newBuilder() .setProfileNickname(profileName) + .setParentScreen(IntroActivityParams.ParentScreen.CREATE_PROFILE_SCREEN) .build() val intent = diff --git a/app/src/main/java/org/oppia/android/app/onboarding/IntroActivity.kt b/app/src/main/java/org/oppia/android/app/onboarding/IntroActivity.kt index 17daf8c3ec4..965e713b123 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/IntroActivity.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/IntroActivity.kt @@ -21,12 +21,14 @@ class IntroActivity : InjectableAutoLocalizedAppCompatActivity() { super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) - val profileNickname = - intent.getProtoExtra(PARAMS_KEY, IntroActivityParams.getDefaultInstance()).profileNickname + val activityParams = intent.getProtoExtra(PARAMS_KEY, IntroActivityParams.getDefaultInstance()) + val profileNickname = activityParams.profileNickname val profileId = intent.extractCurrentUserProfileId() - onboardingLearnerIntroActivityPresenter.handleOnCreate(profileNickname, profileId) + val parentScreen = activityParams.parentScreen + + onboardingLearnerIntroActivityPresenter.handleOnCreate(profileNickname, profileId, parentScreen) } companion object { diff --git a/app/src/main/java/org/oppia/android/app/onboarding/IntroActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/onboarding/IntroActivityPresenter.kt index 52bd6058eb3..225b35c971d 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/IntroActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/IntroActivityPresenter.kt @@ -1,22 +1,16 @@ package org.oppia.android.app.onboarding -import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.databinding.DataBindingUtil import org.oppia.android.R import org.oppia.android.app.activity.ActivityScope -import org.oppia.android.app.model.IntroFragmentArguments +import org.oppia.android.app.model.IntroActivityParams import org.oppia.android.app.model.ProfileId import org.oppia.android.databinding.IntroActivityBinding -import org.oppia.android.util.extensions.putProto -import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId import javax.inject.Inject private const val TAG_LEARNER_INTRO_FRAGMENT = "TAG_INTRO_FRAGMENT" -/** Argument key for bundling the profile nickname. */ -const val PROFILE_NICKNAME_ARGUMENT_KEY = "IntroFragment.Arguments" - /** The Presenter for [IntroActivity]. */ @ActivityScope class IntroActivityPresenter @Inject constructor( @@ -25,22 +19,16 @@ class IntroActivityPresenter @Inject constructor( private lateinit var binding: IntroActivityBinding /** Handle creation and binding of the [IntroActivity] layout. */ - fun handleOnCreate(profileNickname: String, profileId: ProfileId) { + fun handleOnCreate( + profileNickname: String, + profileId: ProfileId, + parentScreen: IntroActivityParams.ParentScreen + ) { binding = DataBindingUtil.setContentView(activity, R.layout.intro_activity) binding.lifecycleOwner = activity if (getIntroFragment() == null) { - val introFragment = IntroFragment() - - val argumentsProto = - IntroFragmentArguments.newBuilder().setProfileNickname(profileNickname).build() - - val args = Bundle().apply { - decorateWithUserProfileId(profileId) - putProto(PROFILE_NICKNAME_ARGUMENT_KEY, argumentsProto) - } - - introFragment.arguments = args + val introFragment = IntroFragment.newInstance(profileNickname, profileId, parentScreen) activity.supportFragmentManager.beginTransaction().add( R.id.learner_intro_fragment_placeholder, diff --git a/app/src/main/java/org/oppia/android/app/onboarding/IntroFragment.kt b/app/src/main/java/org/oppia/android/app/onboarding/IntroFragment.kt index 6c3e40bc529..d4bea0912cb 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/IntroFragment.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/IntroFragment.kt @@ -7,8 +7,12 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.FragmentComponentImpl import org.oppia.android.app.fragment.InjectableFragment +import org.oppia.android.app.model.IntroActivityParams import org.oppia.android.app.model.IntroFragmentArguments +import org.oppia.android.app.model.ProfileId import org.oppia.android.util.extensions.getProto +import org.oppia.android.util.extensions.putProto +import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId import javax.inject.Inject @@ -27,15 +31,19 @@ class IntroFragment : InjectableFragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View? { - val profileNickname = + val args = checkNotNull( arguments?.getProto( - PROFILE_NICKNAME_ARGUMENT_KEY, + INTRO_FRAGMENT_ARGUMENT_KEY, IntroFragmentArguments.getDefaultInstance() ) ) { - "Expected profileNickname to be included in the arguments for IntroFragment." - }.profileNickname + "Expected IntroFragment to have arguments." + } + + val profileNickname = args.profileNickname + + val parentScreen = args.parentScreen val profileId = checkNotNull(arguments?.extractCurrentUserProfileId()) { @@ -46,7 +54,38 @@ class IntroFragment : InjectableFragment() { inflater, container, profileNickname, - profileId + profileId, + parentScreen ) } + + companion object { + /** Argument key for bundling arguments into [IntroFragment] . */ + const val INTRO_FRAGMENT_ARGUMENT_KEY = "IntroFragment.Arguments" + + /** + * Creates a new instance of a IntroFragment. + * + * @param profileNickname the nickname associated with this learner profile + * @param parentScreen the parent screen opening this [IntroFragment] instance + * @return a new instance of [IntroFragment] + */ + fun newInstance( + profileNickname: String, + profileId: ProfileId, + parentScreen: IntroActivityParams.ParentScreen + ): IntroFragment { + val argumentsProto = + IntroFragmentArguments.newBuilder() + .setProfileNickname(profileNickname) + .setParentScreen(parentScreen) + .build() + return IntroFragment().apply { + arguments = Bundle().apply { + putProto(INTRO_FRAGMENT_ARGUMENT_KEY, argumentsProto) + decorateWithUserProfileId(profileId) + } + } + } + } } diff --git a/app/src/main/java/org/oppia/android/app/onboarding/IntroFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/onboarding/IntroFragmentPresenter.kt index d4a6a5fdcad..7d8dca10e14 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/IntroFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/IntroFragmentPresenter.kt @@ -7,6 +7,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import org.oppia.android.R import org.oppia.android.app.model.AudioLanguage +import org.oppia.android.app.model.IntroActivityParams import org.oppia.android.app.model.ProfileId import org.oppia.android.app.options.AudioLanguageActivity import org.oppia.android.app.translation.AppLanguageResourceHandler @@ -29,7 +30,8 @@ class IntroFragmentPresenter @Inject constructor( inflater: LayoutInflater, container: ViewGroup?, profileNickname: String, - profileId: ProfileId + profileId: ProfileId, + parentScreen: IntroActivityParams.ParentScreen ): View { binding = LearnerIntroFragmentBinding.inflate( inflater, @@ -43,6 +45,10 @@ class IntroFragmentPresenter @Inject constructor( profileManagementController.markProfileOnboardingStarted(profileId) + if (parentScreen == IntroActivityParams.ParentScreen.PROFILE_CHOOSER_SCREEN) { + binding.onboardingStepsCount?.visibility = View.GONE + } + binding.onboardingNavigationBack.setOnClickListener { activity.finish() } diff --git a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragment.kt b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragment.kt index cc328e1b34b..0eb34531bbe 100644 --- a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragment.kt +++ b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragment.kt @@ -7,13 +7,23 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.FragmentComponentImpl import org.oppia.android.app.fragment.InjectableFragment +import org.oppia.android.app.model.Profile +import org.oppia.android.util.platformparameter.EnableOnboardingFlowV2 +import org.oppia.android.util.platformparameter.PlatformParameterValue import javax.inject.Inject /** Fragment that allows user to select a profile or create new ones. */ -class ProfileChooserFragment : InjectableFragment(), RouteToAdminPinListener { +class ProfileChooserFragment : InjectableFragment(), RouteToAdminPinListener, ProfileClickListener { + @Inject + lateinit var profileChooserFragmentPresenterV1: ProfileChooserFragmentPresenterV1 + @Inject lateinit var profileChooserFragmentPresenter: ProfileChooserFragmentPresenter + @Inject + @field:EnableOnboardingFlowV2 + lateinit var enableOnboardingFlowV2: PlatformParameterValue + override fun onAttach(context: Context) { super.onAttach(context) (fragmentComponent as FragmentComponentImpl).inject(this) @@ -24,10 +34,22 @@ class ProfileChooserFragment : InjectableFragment(), RouteToAdminPinListener { container: ViewGroup?, savedInstanceState: Bundle? ): View? { - return profileChooserFragmentPresenter.handleCreateView(inflater, container) + return if (enableOnboardingFlowV2.value) { + profileChooserFragmentPresenter.handleCreateView(inflater, container) + } else { + profileChooserFragmentPresenterV1.handleCreateView(inflater, container) + } } override fun routeToAdminPin() { - profileChooserFragmentPresenter.routeToAdminPin() + if (enableOnboardingFlowV2.value) { + profileChooserFragmentPresenter.routeToAdminPin() + } else { + profileChooserFragmentPresenterV1.routeToAdminPin() + } + } + + override fun onProfileClicked(profile: Profile) { + profileChooserFragmentPresenter.onProfileClick(profile) } } 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 2cab08277ff..188919f96a9 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 @@ -1,17 +1,19 @@ package org.oppia.android.app.profile import android.content.Context +import android.content.res.Configuration +import android.content.res.Resources import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat -import androidx.databinding.ObservableField import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer import androidx.lifecycle.Transformations import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import org.oppia.android.R import org.oppia.android.app.administratorcontrols.AdministratorControlsActivity import org.oppia.android.app.classroom.ClassroomListActivity @@ -19,14 +21,13 @@ import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.home.HomeActivity import org.oppia.android.app.model.IntroActivityParams import org.oppia.android.app.model.Profile -import org.oppia.android.app.model.ProfileChooserUiModel import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.ProfileType import org.oppia.android.app.onboarding.IntroActivity import org.oppia.android.app.recyclerview.BindableAdapter -import org.oppia.android.databinding.ProfileChooserAddViewBinding -import org.oppia.android.databinding.ProfileChooserFragmentBinding -import org.oppia.android.databinding.ProfileChooserProfileViewBinding +import org.oppia.android.app.recyclerview.StartSnapHelper +import org.oppia.android.databinding.ProfileItemBinding +import org.oppia.android.databinding.ProfileSelectionFragmentBinding import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.oppialogger.analytics.AnalyticsController import org.oppia.android.domain.profile.ProfileManagementController @@ -77,74 +78,137 @@ class ProfileChooserFragmentPresenter @Inject constructor( private val profileManagementController: ProfileManagementController, private val oppiaLogger: OppiaLogger, private val analyticsController: AnalyticsController, - private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory, - @EnableMultipleClassrooms private val enableMultipleClassrooms: PlatformParameterValue, @EnableOnboardingFlowV2 private val enableOnboardingFlowV2: PlatformParameterValue, + private val singleTypeBuilderFactory: BindableAdapter.SingleTypeBuilder.Factory, + @EnableMultipleClassrooms private val enableMultipleClassrooms: PlatformParameterValue ) { - private lateinit var binding: ProfileChooserFragmentBinding - val hasProfileEverBeenAddedValue = ObservableField(true) + private lateinit var binding: ProfileSelectionFragmentBinding /** Binds ViewModel and sets up RecyclerView Adapter. */ fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View? { StatusBarColor.statusBarColorUpdate( R.color.component_color_shared_profile_status_bar_color, activity, false ) - binding = ProfileChooserFragmentBinding.inflate( - inflater, - container, - /* attachToRoot= */ false - ) + + binding = + ProfileSelectionFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) + .apply { + viewModel = chooserViewModel + lifecycleOwner = fragment + } + + logProfileChooserEvent() + binding.apply { - viewModel = chooserViewModel - lifecycleOwner = fragment + when (Resources.getSystem().configuration.orientation) { + Configuration.ORIENTATION_PORTRAIT -> setupPortraitMode() + Configuration.ORIENTATION_LANDSCAPE -> setupLandscapeMode() + } } - logProfileChooserEvent() - binding.profileRecyclerView.isNestedScrollingEnabled = false - binding.hasProfileEverBeenAddedValue = hasProfileEverBeenAddedValue - subscribeToWasProfileEverBeenAdded() - binding.profileRecyclerView.apply { + + binding.addProfileButton.setOnClickListener { addProfileButtonClickListener() } + binding.addProfilePrompt.setOnClickListener { addProfileButtonClickListener() } + + return binding.root + } + + private fun ProfileSelectionFragmentBinding.setupPortraitMode() { + subscribeToWasProfileEverAdded() + + profilesList?.apply { + isNestedScrollingEnabled = false adapter = createRecyclerViewAdapter() } - return binding.root } - private fun subscribeToWasProfileEverBeenAdded() { - wasProfileEverBeenAdded.observe( - activity, - Observer { - hasProfileEverBeenAddedValue.set(it) - val spanCount = if (it) { - activity.resources.getInteger(R.integer.profile_chooser_span_count) - } else { - activity.resources.getInteger(R.integer.profile_chooser_first_time_span_count) - } - val layoutManager = GridLayoutManager(activity, spanCount) - binding.profileRecyclerView.layoutManager = layoutManager + private fun ProfileSelectionFragmentBinding.setupLandscapeMode() { + val snapHelper = StartSnapHelper() + val layoutManager = profilesListLandscape?.layoutManager as LinearLayoutManager? + + profilesListLandscape?.onFlingListener = null + + profilesListLandscape?.viewTreeObserver?.addOnGlobalLayoutListener { + val landscapeList = profilesListLandscape as RecyclerView + if (landscapeList.shouldShowScrollArrows()) { + profileListScrollLeft?.visibility = View.VISIBLE + profileListScrollRight?.visibility = View.VISIBLE + } else { + profileListScrollLeft?.visibility = View.GONE + profileListScrollRight?.visibility = View.GONE } - ) + } + + profileListScrollLeft?.setOnClickListener { + snapRecyclerView(layoutManager, snapHelper, true) + } + + profileListScrollRight?.setOnClickListener { + snapRecyclerView(layoutManager, snapHelper, false) + } + } + + private fun RecyclerView.shouldShowScrollArrows(): Boolean { + val layoutManager = this.layoutManager as? LinearLayoutManager ?: return false + + val visibleItemCount = layoutManager.childCount + val totalItemCount = this.adapter?.itemCount + return totalItemCount != null && totalItemCount > visibleItemCount + } + + private fun snapRecyclerView( + layoutManager: LinearLayoutManager?, + snapHelper: StartSnapHelper, + isLeft: Boolean + ) { + val newLayoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + + val targetView = snapHelper.findSnapView(layoutManager ?: newLayoutManager) + targetView?.let { recyclerView -> + val distance = + snapHelper.calculateDistanceToFinalSnap(layoutManager ?: newLayoutManager, recyclerView) + val scrollDistance = distance?.get(0) ?: 0 + val scrollableWidth = binding.profilesListLandscape?.let { + it.width - (it.paddingStart + it.paddingEnd) + } ?: 0 + val offset = + if (isLeft) scrollDistance - scrollableWidth else scrollableWidth - scrollDistance + binding.profilesListLandscape?.smoothScrollBy(offset, 0) + } } - private val wasProfileEverBeenAdded: LiveData by lazy { + private val wasProfileEverAdded: LiveData by lazy { Transformations.map( profileManagementController.getWasProfileEverAdded().toLiveData(), - ::processWasProfileEverBeenAddedResult + ::processWasProfileEverAddedResult ) } - private fun processWasProfileEverBeenAddedResult( - wasProfileEverBeenAddedResult: AsyncResult + private fun subscribeToWasProfileEverAdded() { + wasProfileEverAdded.observe(activity) { + val spanCount = if (it) { + activity.resources.getInteger(R.integer.profile_chooser_span_count) + } else { + activity.resources.getInteger(R.integer.profile_chooser_first_time_span_count) + } + val layoutManager = GridLayoutManager(activity, spanCount) + binding.profilesList?.layoutManager = layoutManager + } + } + + private fun processWasProfileEverAddedResult( + wasProfileEverAddedResult: AsyncResult ): Boolean { - return when (wasProfileEverBeenAddedResult) { + return when (wasProfileEverAddedResult) { is AsyncResult.Failure -> { oppiaLogger.e( "ProfileChooserFragment", - "Failed to retrieve the information on wasProfileEverBeenAdded", - wasProfileEverBeenAddedResult.error + "Failed to retrieve the information on wasProfileEverAdded", + wasProfileEverAddedResult.error ) false } is AsyncResult.Pending -> false - is AsyncResult.Success -> wasProfileEverBeenAddedResult.value + is AsyncResult.Success -> wasProfileEverAddedResult.value } } @@ -155,77 +219,30 @@ class ProfileChooserFragmentPresenter @Inject constructor( }.minus(chooserViewModel.usedColors).random() } - private fun createRecyclerViewAdapter(): BindableAdapter { - return multiTypeBuilderFactory.create( - ProfileChooserUiModel::getModelTypeCase - ) + private fun createRecyclerViewAdapter(): BindableAdapter { + return singleTypeBuilderFactory.create() .registerViewDataBinderWithSameModelType( - viewType = ProfileChooserUiModel.ModelTypeCase.PROFILE, - inflateDataBinding = ProfileChooserProfileViewBinding::inflate, + inflateDataBinding = ProfileItemBinding::inflate, setViewModel = this::bindProfileView ) - .registerViewDataBinderWithSameModelType( - viewType = ProfileChooserUiModel.ModelTypeCase.ADD_PROFILE, - inflateDataBinding = ProfileChooserAddViewBinding::inflate, - setViewModel = this::bindAddView - ) .build() } private fun bindProfileView( - binding: ProfileChooserProfileViewBinding, - model: ProfileChooserUiModel - ) { - binding.viewModel = model - binding.hasProfileEverBeenAddedValue = hasProfileEverBeenAddedValue - binding.profileChooserItem.setOnClickListener { - updateLearnerIdIfAbsent(model.profile) - if (enableOnboardingFlowV2.value) { - ensureProfileOnboarded(model.profile) - } else { - logInToProfile(model.profile) - } - } - } - - private fun bindAddView( - binding: ProfileChooserAddViewBinding, - @Suppress("UNUSED_PARAMETER") model: ProfileChooserUiModel + binding: ProfileItemBinding, + viewModel: ProfileItemViewModel ) { - binding.hasProfileEverBeenAddedValue = hasProfileEverBeenAddedValue - binding.addProfileItem.setOnClickListener { - if (chooserViewModel.adminPin.isEmpty()) { - activity.startActivity( - AdminPinActivity.createAdminPinActivityIntent( - activity, - chooserViewModel.adminProfileId.internalId, - selectUniqueRandomColor(), - AdminAuthEnum.PROFILE_ADD_PROFILE.value - ) - ) - } else { - activity.startActivity( - AdminAuthActivity.createAdminAuthActivityIntent( - activity, - chooserViewModel.adminPin, - -1, - selectUniqueRandomColor(), - AdminAuthEnum.PROFILE_ADD_PROFILE.value - ) - ) - } - } + binding.viewModel = viewModel } - fun routeToAdminPin() { + private fun addProfileButtonClickListener() { if (chooserViewModel.adminPin.isEmpty()) { - val profileId = - ProfileId.newBuilder().setInternalId(chooserViewModel.adminProfileId.internalId).build() activity.startActivity( - AdministratorControlsActivity.createAdministratorControlsActivityIntent( + AdminPinActivity.createAdminPinActivityIntent( activity, - profileId + chooserViewModel.adminProfileId.internalId, + selectUniqueRandomColor(), + AdminAuthEnum.PROFILE_ADD_PROFILE.value ) ) } else { @@ -233,9 +250,9 @@ class ProfileChooserFragmentPresenter @Inject constructor( AdminAuthActivity.createAdminAuthActivityIntent( activity, chooserViewModel.adminPin, - chooserViewModel.adminProfileId.internalId, + profileId = 0, selectUniqueRandomColor(), - AdminAuthEnum.PROFILE_ADMIN_CONTROLS.value + AdminAuthEnum.PROFILE_ADD_PROFILE.value ) ) } @@ -266,6 +283,7 @@ class ProfileChooserFragmentPresenter @Inject constructor( private fun launchOnboardingScreen(profileId: ProfileId, profileName: String) { val introActivityParams = IntroActivityParams.newBuilder() .setProfileNickname(profileName) + .setParentScreen(IntroActivityParams.ParentScreen.PROFILE_CHOOSER_SCREEN) .build() val intent = IntroActivity.createIntroActivity(activity) @@ -279,22 +297,19 @@ class ProfileChooserFragmentPresenter @Inject constructor( private fun logInToProfile(profile: Profile) { if (profile.pin.isNullOrBlank()) { - profileManagementController.loginToProfile(profile.id).toLiveData().observe( - fragment, - { - if (it is AsyncResult.Success) { - if (enableMultipleClassrooms.value) { - activity.startActivity( - ClassroomListActivity.createClassroomListActivity(activity, profile.id) - ) - } else { - activity.startActivity( - HomeActivity.createHomeActivity(activity, profile.id) - ) - } + profileManagementController.loginToProfile(profile.id).toLiveData().observe(fragment) { + if (it is AsyncResult.Success) { + if (enableMultipleClassrooms.value) { + activity.startActivity( + ClassroomListActivity.createClassroomListActivity(activity, profile.id) + ) + } else { + activity.startActivity( + HomeActivity.createHomeActivity(activity, profile.id) + ) } } - ) + } } else { val pinPasswordIntent = PinPasswordActivity.createPinPasswordActivityIntent( activity, @@ -304,4 +319,34 @@ class ProfileChooserFragmentPresenter @Inject constructor( activity.startActivity(pinPasswordIntent) } } + + /** Handles navigation to either the [AdministratorControlsActivity] or [AdminAuthActivity]. */ + fun routeToAdminPin() { + if (chooserViewModel.adminPin.isEmpty()) { + val profileId = + ProfileId.newBuilder().setInternalId(chooserViewModel.adminProfileId.internalId).build() + activity.startActivity( + AdministratorControlsActivity.createAdministratorControlsActivityIntent( + activity, + profileId + ) + ) + } else { + activity.startActivity( + AdminAuthActivity.createAdminAuthActivityIntent( + activity, + chooserViewModel.adminPin, + chooserViewModel.adminProfileId.internalId, + selectUniqueRandomColor(), + AdminAuthEnum.PROFILE_ADMIN_CONTROLS.value + ) + ) + } + } + + /** Click listener for handling clicks to login to a profile. */ + fun onProfileClick(profile: Profile) { + updateLearnerIdIfAbsent(profile) + ensureProfileOnboarded(profile) + } } diff --git a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenterV1.kt b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenterV1.kt new file mode 100644 index 00000000000..0de05070bbf --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenterV1.kt @@ -0,0 +1,267 @@ +package org.oppia.android.app.profile + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.databinding.ObservableField +import androidx.fragment.app.Fragment +import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations +import androidx.recyclerview.widget.GridLayoutManager +import org.oppia.android.R +import org.oppia.android.app.administratorcontrols.AdministratorControlsActivity +import org.oppia.android.app.classroom.ClassroomListActivity +import org.oppia.android.app.fragment.FragmentScope +import org.oppia.android.app.home.HomeActivity +import org.oppia.android.app.model.Profile +import org.oppia.android.app.model.ProfileChooserUiModel +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.recyclerview.BindableAdapter +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 +import org.oppia.android.util.platformparameter.EnableMultipleClassrooms +import org.oppia.android.util.platformparameter.PlatformParameterValue +import org.oppia.android.util.statusbar.StatusBarColor +import javax.inject.Inject + +private val COLORS_LIST = listOf( + R.color.component_color_avatar_background_1_color, + R.color.component_color_avatar_background_2_color, + R.color.component_color_avatar_background_3_color, + R.color.component_color_avatar_background_4_color, + R.color.component_color_avatar_background_5_color, + R.color.component_color_avatar_background_6_color, + R.color.component_color_avatar_background_7_color, + R.color.component_color_avatar_background_8_color, + R.color.component_color_avatar_background_9_color, + R.color.component_color_avatar_background_10_color, + R.color.component_color_avatar_background_11_color, + R.color.component_color_avatar_background_12_color, + R.color.component_color_avatar_background_13_color, + R.color.component_color_avatar_background_14_color, + R.color.component_color_avatar_background_15_color, + R.color.component_color_avatar_background_16_color, + R.color.component_color_avatar_background_17_color, + R.color.component_color_avatar_background_18_color, + R.color.component_color_avatar_background_19_color, + R.color.component_color_avatar_background_20_color, + R.color.component_color_avatar_background_21_color, + R.color.component_color_avatar_background_22_color, + R.color.component_color_avatar_background_23_color, + R.color.component_color_avatar_background_24_color +) + +/** The presenter for [ProfileChooserFragment]. */ +@FragmentScope +class ProfileChooserFragmentPresenterV1 @Inject constructor( + private val fragment: Fragment, + private val activity: AppCompatActivity, + private val context: Context, + private val chooserViewModel: ProfileChooserViewModel, + private val profileManagementController: ProfileManagementController, + private val oppiaLogger: OppiaLogger, + private val analyticsController: AnalyticsController, + private val multiTypeBuilderFactory: BindableAdapter.MultiTypeBuilder.Factory, + @EnableMultipleClassrooms private val enableMultipleClassrooms: PlatformParameterValue +) { + private lateinit var binding: ProfileChooserFragmentBinding + val hasProfileEverBeenAddedValue = ObservableField(true) + + /** Binds ViewModel and sets up RecyclerView Adapter. */ + fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View? { + StatusBarColor.statusBarColorUpdate( + R.color.component_color_shared_profile_status_bar_color, activity, false + ) + binding = ProfileChooserFragmentBinding.inflate( + inflater, + container, + /* attachToRoot= */ false + ) + binding.apply { + viewModel = chooserViewModel + lifecycleOwner = fragment + } + logProfileChooserEvent() + binding.profileRecyclerView.isNestedScrollingEnabled = false + binding.hasProfileEverBeenAddedValue = hasProfileEverBeenAddedValue + subscribeToWasProfileEverBeenAdded() + binding.profileRecyclerView.apply { + adapter = createRecyclerViewAdapter() + } + return binding.root + } + + private fun subscribeToWasProfileEverBeenAdded() { + wasProfileEverBeenAdded.observe(activity) { + hasProfileEverBeenAddedValue.set(it) + val spanCount = if (it) { + activity.resources.getInteger(R.integer.profile_chooser_span_count) + } else { + activity.resources.getInteger(R.integer.profile_chooser_first_time_span_count) + } + val layoutManager = GridLayoutManager(activity, spanCount) + binding.profileRecyclerView.layoutManager = layoutManager + } + } + + private val wasProfileEverBeenAdded: LiveData by lazy { + Transformations.map( + profileManagementController.getWasProfileEverAdded().toLiveData(), + ::processWasProfileEverBeenAddedResult + ) + } + + private fun processWasProfileEverBeenAddedResult( + wasProfileEverBeenAddedResult: AsyncResult + ): Boolean { + return when (wasProfileEverBeenAddedResult) { + is AsyncResult.Failure -> { + oppiaLogger.e( + "ProfileChooserFragment", + "Failed to retrieve the information on wasProfileEverBeenAdded", + wasProfileEverBeenAddedResult.error + ) + false + } + is AsyncResult.Pending -> false + is AsyncResult.Success -> wasProfileEverBeenAddedResult.value + } + } + + /** Randomly selects a color for the new profile that is not already in use. */ + private fun selectUniqueRandomColor(): Int { + return COLORS_LIST.map { + ContextCompat.getColor(context, it) + }.minus(chooserViewModel.usedColors).random() + } + + private fun createRecyclerViewAdapter(): BindableAdapter { + return multiTypeBuilderFactory.create( + ProfileChooserUiModel::getModelTypeCase + ) + .registerViewDataBinderWithSameModelType( + viewType = ProfileChooserUiModel.ModelTypeCase.PROFILE, + inflateDataBinding = ProfileChooserProfileViewBinding::inflate, + setViewModel = this::bindProfileView + ) + .registerViewDataBinderWithSameModelType( + viewType = ProfileChooserUiModel.ModelTypeCase.ADD_PROFILE, + inflateDataBinding = ProfileChooserAddViewBinding::inflate, + setViewModel = this::bindAddView + ) + .build() + } + + private fun bindProfileView( + binding: ProfileChooserProfileViewBinding, + model: ProfileChooserUiModel + ) { + binding.viewModel = model + binding.hasProfileEverBeenAddedValue = hasProfileEverBeenAddedValue + binding.profileChooserItem.setOnClickListener { + updateLearnerIdIfAbsent(model.profile) + loginToProfile(model.profile) + } + } + + private fun bindAddView( + binding: ProfileChooserAddViewBinding, + @Suppress("UNUSED_PARAMETER") model: ProfileChooserUiModel + ) { + binding.hasProfileEverBeenAddedValue = hasProfileEverBeenAddedValue + binding.addProfileItem.setOnClickListener { + if (chooserViewModel.adminPin.isEmpty()) { + activity.startActivity( + AdminPinActivity.createAdminPinActivityIntent( + activity, + chooserViewModel.adminProfileId.internalId, + selectUniqueRandomColor(), + AdminAuthEnum.PROFILE_ADD_PROFILE.value + ) + ) + } else { + activity.startActivity( + AdminAuthActivity.createAdminAuthActivityIntent( + activity, + chooserViewModel.adminPin, + -1, + selectUniqueRandomColor(), + AdminAuthEnum.PROFILE_ADD_PROFILE.value + ) + ) + } + } + } + + fun routeToAdminPin() { + if (chooserViewModel.adminPin.isEmpty()) { + val profileId = + ProfileId.newBuilder().setInternalId(chooserViewModel.adminProfileId.internalId).build() + activity.startActivity( + AdministratorControlsActivity.createAdministratorControlsActivityIntent( + activity, + profileId + ) + ) + } else { + activity.startActivity( + AdminAuthActivity.createAdminAuthActivityIntent( + activity, + chooserViewModel.adminPin, + chooserViewModel.adminProfileId.internalId, + selectUniqueRandomColor(), + AdminAuthEnum.PROFILE_ADMIN_CONTROLS.value + ) + ) + } + } + + private fun logProfileChooserEvent() { + analyticsController.logImportantEvent( + oppiaLogger.createOpenProfileChooserContext(), + profileId = null // There's no profile currently logged in. + ) + } + + private fun updateLearnerIdIfAbsent(profile: Profile) { + if (profile.learnerId.isNullOrEmpty()) { + // TODO(#4345): Block on the following data provider before allowing the user to log in. + profileManagementController.initializeLearnerId(profile.id) + } + } + + private fun loginToProfile(profile: Profile) { + if (profile.pin.isNullOrBlank()) { + profileManagementController.loginToProfile(profile.id).toLiveData().observe(fragment) { + if (it is AsyncResult.Success) { + if (enableMultipleClassrooms.value) { + activity.startActivity( + ClassroomListActivity.createClassroomListActivity(activity, profile.id) + ) + } else { + activity.startActivity( + HomeActivity.createHomeActivity(activity, profile.id) + ) + } + } + } + } else { + val pinPasswordIntent = PinPasswordActivity.createPinPasswordActivityIntent( + activity, + chooserViewModel.adminPin, + profile.id.internalId + ) + activity.startActivity(pinPasswordIntent) + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserViewModel.kt b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserViewModel.kt index 266ad9983c8..6ff0085bd5b 100644 --- a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserViewModel.kt @@ -1,5 +1,6 @@ package org.oppia.android.app.profile +import androidx.databinding.ObservableField import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations @@ -26,16 +27,73 @@ class ProfileChooserViewModel @Inject constructor( ) : ObservableViewModel() { private val routeToAdminPinListener = fragment as RouteToAdminPinListener + private val profileClickListener = fragment as ProfileClickListener + /** Observable field to track if the add profile button should be shown. */ + val canAddProfile = ObservableField(true) + + /** Livedata representing the list of profiles on the app, and a model for the 'add' view. */ val profiles: LiveData> by lazy { Transformations.map( profileManagementController.getProfiles().toLiveData(), ::processGetProfilesResult ) } + /** Livedata representing the list of profiles on the app, to be bound to the recyclerview. */ + val profilesList: LiveData> by lazy { + Transformations.map( + profileManagementController.getProfiles().toLiveData(), ::retrieveProfiles + ) + } + + private fun retrieveProfiles(profilesResult: AsyncResult>): + List { + val profileList = when (profilesResult) { + is AsyncResult.Failure -> { + oppiaLogger.e( + "ProfileChooserViewModel", + "Failed to retrieve the list of profiles", profilesResult.error + ) + emptyList() + } + is AsyncResult.Pending -> emptyList() + is AsyncResult.Success -> profilesResult.value + }.map { + ProfileItemViewModel(it, profileClickListener::onProfileClicked) + } + + profileList.forEach { profileItemViewModel -> + if (profileItemViewModel.profile.avatar.avatarTypeCase + == ProfileAvatar.AvatarTypeCase.AVATAR_COLOR_RGB + ) { + usedColors.add(profileItemViewModel.profile.avatar.avatarColorRgb) + } + } + + val sortedProfileList = profileList.sortedBy { profileItemViewModel -> + machineLocale.run { profileItemViewModel.profile.name.toMachineLowerCase() } + }.toMutableList() + + val adminProfileViewModel = sortedProfileList.find { it.profile.isAdmin } ?: return listOf() + + sortedProfileList.remove(adminProfileViewModel) + adminPin = adminProfileViewModel.profile.pin + adminProfileId = adminProfileViewModel.profile.id + sortedProfileList.add(0, adminProfileViewModel) + + if (sortedProfileList.size == 10) { + canAddProfile.set(false) + } + return sortedProfileList + } + + /** The admin profile's PIN. */ lateinit var adminPin: String + + /** The [ProfileId] of the admin profile. */ lateinit var adminProfileId: ProfileId + /** List of RGB colors that have already been assigned to a profile. */ val usedColors = mutableListOf() /** Sorts profiles alphabetically by name and put Admin in front. */ @@ -79,6 +137,7 @@ class ProfileChooserViewModel @Inject constructor( return sortedProfileList } + /** Handles click events for the administrator controls button. */ fun onAdministratorControlsButtonClicked() { routeToAdminPinListener.routeToAdminPin() } diff --git a/app/src/main/java/org/oppia/android/app/profile/ProfileClickListener.kt b/app/src/main/java/org/oppia/android/app/profile/ProfileClickListener.kt new file mode 100644 index 00000000000..9c0f76583f3 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/profile/ProfileClickListener.kt @@ -0,0 +1,9 @@ +package org.oppia.android.app.profile + +import org.oppia.android.app.model.Profile + +/** Listener for when a profile is clicked. */ +interface ProfileClickListener { + /** Triggered when the profile is clicked. */ + fun onProfileClicked(profile: Profile) +} diff --git a/app/src/main/java/org/oppia/android/app/profile/ProfileItemViewModel.kt b/app/src/main/java/org/oppia/android/app/profile/ProfileItemViewModel.kt new file mode 100644 index 00000000000..54f35cf4a26 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/profile/ProfileItemViewModel.kt @@ -0,0 +1,16 @@ +package org.oppia.android.app.profile + +import org.oppia.android.app.model.Profile +import org.oppia.android.app.viewmodel.ObservableViewModel + +/** ViewModel for binding a profile data to the UI. */ +class ProfileItemViewModel( + val profile: Profile, + val onProfileClicked: (Profile) -> Unit +) : ObservableViewModel() { + + /** Called when a profile is clicked. */ + fun profileClicked() { + onProfileClicked(profile) + } +} diff --git a/app/src/main/java/org/oppia/android/app/profile/ProfileListView.kt b/app/src/main/java/org/oppia/android/app/profile/ProfileListView.kt new file mode 100644 index 00000000000..6782b2ec5de --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/profile/ProfileListView.kt @@ -0,0 +1,89 @@ +package org.oppia.android.app.profile + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.RecyclerView +import org.oppia.android.app.recyclerview.BindableAdapter +import org.oppia.android.app.shim.ViewBindingShim +import org.oppia.android.app.view.ViewComponentFactory +import org.oppia.android.app.view.ViewComponentImpl +import javax.inject.Inject + +/** A custom [RecyclerView] for displaying a list of profiles as a carousel. */ +class ProfileListView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RecyclerView(context, attrs, defStyleAttr) { + + @Inject + lateinit var bindingInterface: ViewBindingShim + + @Inject + lateinit var singleTypeBuilderFactory: BindableAdapter.SingleTypeBuilder.Factory + + private lateinit var profileDataList: List + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + val viewComponentFactory = FragmentManager.findFragment(this) as ViewComponentFactory + val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl + viewComponent.inject(this) + maybeInitializeAdapter() + } + + private fun maybeInitializeAdapter() { + if (::bindingInterface.isInitialized && + ::singleTypeBuilderFactory.isInitialized && + ::profileDataList.isInitialized + ) { + bindDataToAdapter() + } + } + + private fun bindDataToAdapter() { + // We manually set the data so we can first check for the adapter unlike when using an existing + // [RecyclerViewBindingAdapter]. + // This ensures that the adapter will only be created once and correctly rebinds the data. + // For more context: https://github.com/oppia/oppia-android/pull/2246#pullrequestreview-565964462 + if (adapter == null) { + adapter = createAdapter() + } + + (adapter as BindableAdapter<*>).setDataUnchecked(profileDataList) + } + + private fun createAdapter(): BindableAdapter { + return singleTypeBuilderFactory.create() + .registerViewBinder( + inflateView = { parent -> + bindingInterface.provideProfileItemInflatedView( + LayoutInflater.from(parent.context), + parent, + attachToParent = false + ) + }, + bindView = { view, viewModel -> + bindingInterface.provideProfileItemViewModel( + view, + viewModel + ) + } + ).build() + } + + /** + * Sets the list of profiles that this view shows. + * @param newDataList the new list of profiles to present + */ + + fun setProfileList(newDataList: List?) { + if (newDataList != null) { + profileDataList = newDataList + maybeInitializeAdapter() + } + } +} diff --git a/app/src/main/java/org/oppia/android/app/recyclerview/StartSnapHelper.kt b/app/src/main/java/org/oppia/android/app/recyclerview/StartSnapHelper.kt index 1b11dcc2023..a329860b287 100644 --- a/app/src/main/java/org/oppia/android/app/recyclerview/StartSnapHelper.kt +++ b/app/src/main/java/org/oppia/android/app/recyclerview/StartSnapHelper.kt @@ -58,28 +58,27 @@ class StartSnapHelper : LinearSnapHelper() { ): View? { if (layoutManager is LinearLayoutManager) { val firstChild = layoutManager.findFirstVisibleItemPosition() - val isLastItem = - layoutManager.findLastCompletelyVisibleItemPosition() == layoutManager.getItemCount() - 1 - if (firstChild == RecyclerView.NO_POSITION || isLastItem) { + val lastChild = layoutManager.findLastCompletelyVisibleItemPosition() + val isLastItemFullyVisible = lastChild == layoutManager.itemCount - 1 + + if (firstChild == RecyclerView.NO_POSITION) { return null } val child = layoutManager.findViewByPosition(firstChild) - return if (helper.getDecoratedEnd(child) >= - helper.getDecoratedMeasurement(child) / 2 && - helper.getDecoratedEnd( - child - ) > 0 + + // If the last item is fully visible, but we're still in the middle of the list, allow + // snapping to the start. + if (isLastItemFullyVisible && firstChild > 0) { + return child + } + + return if (helper.getDecoratedEnd(child) >= helper.getDecoratedMeasurement(child) / 2 && + helper.getDecoratedEnd(child) > 0 ) { child } else { - if (layoutManager.findLastCompletelyVisibleItemPosition() == - layoutManager.getItemCount() - 1 - ) { - null - } else { - layoutManager.findViewByPosition(firstChild + 1) - } + layoutManager.findViewByPosition(firstChild + 1) } } return super.findSnapView(layoutManager) diff --git a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt index acc6efcec4a..e3b5282d6c0 100644 --- a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt +++ b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt @@ -12,6 +12,7 @@ import org.oppia.android.app.home.promotedlist.PromotedStoryViewModel import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.itemviewmodel.DragDropInteractionContentViewModel import org.oppia.android.app.player.state.itemviewmodel.SelectionInteractionContentViewModel +import org.oppia.android.app.profile.ProfileItemViewModel import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel import org.oppia.android.util.parser.html.HtmlParser @@ -206,4 +207,20 @@ interface ViewBindingShim { view: View, viewModel: MultipleChoiceOptionContentViewModel ) + + /** Handles binding inflation for [org.oppia.android.app.profile.ProfileListView]. */ + fun provideProfileItemInflatedView( + inflater: LayoutInflater, + parent: ViewGroup, + attachToParent: Boolean + ): View + + /** + * Handles binding inflation for [org.oppia.android.app.profile.ProfileListView] + * and returns the view model. + */ + fun provideProfileItemViewModel( + view: View, + viewModel: ProfileItemViewModel + ) } diff --git a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt index 69b49ac4eea..1415930c720 100644 --- a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt +++ b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt @@ -14,6 +14,7 @@ import org.oppia.android.app.home.promotedlist.PromotedStoryViewModel import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.itemviewmodel.DragDropInteractionContentViewModel import org.oppia.android.app.player.state.itemviewmodel.SelectionInteractionContentViewModel +import org.oppia.android.app.profile.ProfileItemViewModel import org.oppia.android.app.survey.surveyitemviewmodel.MultipleChoiceOptionContentViewModel import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.databinding.ComingSoonTopicViewBinding @@ -21,6 +22,7 @@ import org.oppia.android.databinding.DragDropInteractionItemsBinding import org.oppia.android.databinding.DragDropSingleItemBinding import org.oppia.android.databinding.ItemSelectionInteractionItemsBinding import org.oppia.android.databinding.MultipleChoiceInteractionItemsBinding +import org.oppia.android.databinding.ProfileItemBinding import org.oppia.android.databinding.PromotedStoryCardBinding import org.oppia.android.databinding.SurveyMultipleChoiceItemBinding import org.oppia.android.databinding.SurveyNpsItemBinding @@ -195,6 +197,24 @@ class ViewBindingShimImpl @Inject constructor( binding.viewModel = viewModel } + override fun provideProfileItemInflatedView( + inflater: LayoutInflater, + parent: ViewGroup, + attachToParent: Boolean + ): View { + return ProfileItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ).root + } + + override fun provideProfileItemViewModel(view: View, viewModel: ProfileItemViewModel) { + val binding = + DataBindingUtil.findBinding(view)!! + binding.viewModel = viewModel + } + override fun provideDragDropSortInteractionInflatedView( inflater: LayoutInflater, parent: ViewGroup, diff --git a/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt b/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt index dc71fc0e90a..74a5afe2e1f 100644 --- a/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt +++ b/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt @@ -14,6 +14,7 @@ import org.oppia.android.app.home.promotedlist.PromotedStoryListView import org.oppia.android.app.player.state.DragDropSortInteractionView import org.oppia.android.app.player.state.ImageRegionSelectionInteractionView import org.oppia.android.app.player.state.SelectionInteractionView +import org.oppia.android.app.profile.ProfileListView import org.oppia.android.app.survey.SurveyMultipleChoiceOptionView import org.oppia.android.app.survey.SurveyNpsItemOptionView @@ -45,4 +46,5 @@ interface ViewComponentImpl : ViewComponent { fun inject(oppiaCurveBackgroundView: OppiaCurveBackgroundView) fun inject(surveyMultipleChoiceOptionView: SurveyMultipleChoiceOptionView) fun inject(surveyNpsItemOptionView: SurveyNpsItemOptionView) + fun inject(profileListView: ProfileListView) } diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 00000000000..54cb9f8bbd2 --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_chevron_left.xml b/app/src/main/res/drawable/ic_chevron_left.xml new file mode 100644 index 00000000000..3f7812863b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_left.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_chevron_right.xml b/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 00000000000..54aa85c0a3f --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout-land/profile_item.xml b/app/src/main/res/layout-land/profile_item.xml new file mode 100644 index 00000000000..25666837ba3 --- /dev/null +++ b/app/src/main/res/layout-land/profile_item.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-land/profile_selection_fragment.xml b/app/src/main/res/layout-land/profile_selection_fragment.xml new file mode 100644 index 00000000000..edabf7f3ae7 --- /dev/null +++ b/app/src/main/res/layout-land/profile_selection_fragment.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/profile_item.xml b/app/src/main/res/layout/profile_item.xml new file mode 100644 index 00000000000..636894916cb --- /dev/null +++ b/app/src/main/res/layout/profile_item.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/profile_selection_fragment.xml b/app/src/main/res/layout/profile_selection_fragment.xml new file mode 100644 index 00000000000..267fec9ca67 --- /dev/null +++ b/app/src/main/res/layout/profile_selection_fragment.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-land/dimens.xml b/app/src/main/res/values-land/dimens.xml index 38348620b97..94e1def87ac 100644 --- a/app/src/main/res/values-land/dimens.xml +++ b/app/src/main/res/values-land/dimens.xml @@ -27,6 +27,8 @@ 24dp 24dp + 70dp + 24dp 24dp 24dp @@ -272,6 +274,13 @@ 12dp 32dp + 28dp + 4dp + 16dp + 16dp + 12dp + 8dp + 72dp 72dp diff --git a/app/src/main/res/values-night/color_palette.xml b/app/src/main/res/values-night/color_palette.xml index 621edf22744..36cbe2ed20f 100644 --- a/app/src/main/res/values-night/color_palette.xml +++ b/app/src/main/res/values-night/color_palette.xml @@ -162,6 +162,8 @@ @color/color_def_oppia_silver @color/color_def_white @color/color_def_dark_silver + @color/color_def_charcoal + @color/color_def_light_orange @color/color_def_oppia_metallic_blue @color/color_def_grey @color/color_def_white diff --git a/app/src/main/res/values/color_defs.xml b/app/src/main/res/values/color_defs.xml index 17372308985..5c65cd2fff3 100644 --- a/app/src/main/res/values/color_defs.xml +++ b/app/src/main/res/values/color_defs.xml @@ -156,4 +156,6 @@ #F8BF74 #B3D8F1 #B8FFFFFF + #FEFFF0 + #2B2B2B diff --git a/app/src/main/res/values/color_palette.xml b/app/src/main/res/values/color_palette.xml index 73cbce2643c..d0cc7371583 100644 --- a/app/src/main/res/values/color_palette.xml +++ b/app/src/main/res/values/color_palette.xml @@ -167,6 +167,8 @@ @color/color_def_oppia_silver @color/color_def_white @color/color_def_dark_silver + @color/color_def_ivory + @color/color_def_light_orange @color/color_def_blue_sapphire @color/color_def_grey @color/color_def_light_grey diff --git a/app/src/main/res/values/component_colors.xml b/app/src/main/res/values/component_colors.xml index 821031690ad..f5da6612882 100644 --- a/app/src/main/res/values/component_colors.xml +++ b/app/src/main/res/values/component_colors.xml @@ -277,6 +277,10 @@ @color/color_palette_profile_chooser_activity_gradient_end_color @color/color_palette_profile_chooser_activity_gradient_center_color @color/color_palette_profile_chooser_activity_gradient_start_color + @color/color_palette_profile_selection_background_color + @color/color_palette_primary_color + @color/color_palette_profile_selection_profile_icon_background_color + @color/color_palette_secondary_1_text_color @color/color_palette_color_palette_walkthrough_status_bar_color @color/color_palette_walkthrough_activity_rounded_corners_color diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 398519bf857..518f4bff4dd 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -645,6 +645,29 @@ 28dp 28dp + + 20sp + 16sp + 14sp + 14sp + 14sp + 14sp + 14sp + 32dp + 8dp + 48dp + 48dp + 48dp + 4dp + 12dp + 12dp + 16dp + 4dp + 12dp + + 72dp + 2dp + 32dp 28dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 48ed440dcf6..782080a394b 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -225,6 +225,12 @@ Add up to 10 users to your account. Perfect for families and classrooms. Administrator Controls Language + Select your profile to explore the lessons + User\'s profile picture + Add another profile + Add a learner profile + Administrator + Administrator Controls Authorise to add profiles diff --git a/app/src/sharedTest/java/org/oppia/android/app/onboarding/CreateProfileFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/onboarding/CreateProfileFragmentTest.kt index f45cebfb42f..fedeb89b379 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/onboarding/CreateProfileFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/onboarding/CreateProfileFragmentTest.kt @@ -214,7 +214,12 @@ class CreateProfileFragmentTest { .perform(click()) testCoroutineDispatchers.runCurrent() - val expectedParams = IntroActivityParams.newBuilder().setProfileNickname("John").build() + val expectedParams = IntroActivityParams.newBuilder() + .setProfileNickname("John") + .setProfileId(0) + .setParentScreen(IntroActivityParams.ParentScreen.CREATE_PROFILE_SCREEN) + .build() + intended( allOf( hasComponent(IntroActivity::class.java.name), @@ -277,7 +282,13 @@ class CreateProfileFragmentTest { .perform(click()) testCoroutineDispatchers.runCurrent() - val expectedParams = IntroActivityParams.newBuilder().setProfileNickname("John").build() + val expectedParams = + IntroActivityParams.newBuilder() + .setProfileNickname("John") + .setProfileId(0) + .setParentScreen(IntroActivityParams.ParentScreen.CREATE_PROFILE_SCREEN) + .build() + intended( allOf( hasComponent(IntroActivity::class.java.name), @@ -324,7 +335,11 @@ class CreateProfileFragmentTest { .perform(click()) testCoroutineDispatchers.runCurrent() - val expectedParams = IntroActivityParams.newBuilder().setProfileNickname("John").build() + val expectedParams = IntroActivityParams.newBuilder() + .setProfileNickname("John") + .setProfileId(0) + .setParentScreen(IntroActivityParams.ParentScreen.CREATE_PROFILE_SCREEN) + .build() intended( allOf( hasComponent(IntroActivity::class.java.name), @@ -386,7 +401,12 @@ class CreateProfileFragmentTest { .perform(click()) testCoroutineDispatchers.runCurrent() - val expectedParams = IntroActivityParams.newBuilder().setProfileNickname("John").build() + val expectedParams = IntroActivityParams.newBuilder() + .setProfileNickname("John") + .setProfileId(0) + .setParentScreen(IntroActivityParams.ParentScreen.CREATE_PROFILE_SCREEN) + .build() + intended( allOf( hasComponent(IntroActivity::class.java.name), diff --git a/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroFragmentTest.kt index fdc6ba55566..2bb2216ed43 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/onboarding/IntroFragmentTest.kt @@ -233,6 +233,7 @@ class IntroFragmentTest { ActivityScenario? { val params = IntroActivityParams.newBuilder() .setProfileNickname(testProfileNickname) + .setParentScreen(IntroActivityParams.ParentScreen.CREATE_PROFILE_SCREEN) .build() val scenario = ActivityScenario.launch( diff --git a/app/src/sharedTest/java/org/oppia/android/app/profile/ProfileChooserFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profile/ProfileChooserFragmentTest.kt index c851c309879..961d7f962d9 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/profile/ProfileChooserFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/profile/ProfileChooserFragmentTest.kt @@ -10,14 +10,17 @@ import androidx.test.core.app.ActivityScenario.launch import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.contrib.RecyclerViewActions.scrollToPosition import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.Intents.intended import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtraWithKey +import androidx.test.espresso.matcher.ViewMatchers.Visibility import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -50,6 +53,7 @@ import org.oppia.android.app.model.ProfileType import org.oppia.android.app.onboarding.IntroActivity import org.oppia.android.app.player.state.itemviewmodel.SplitScreenInteractionModule import org.oppia.android.app.profile.AdminAuthActivity.Companion.ADMIN_AUTH_ACTIVITY_PARAMS_KEY +import org.oppia.android.app.profile.AdminPinActivity.Companion.ADMIN_PIN_ACTIVITY_PARAMS_KEY import org.oppia.android.app.recyclerview.RecyclerViewMatcher.Companion.atPosition import org.oppia.android.app.recyclerview.RecyclerViewMatcher.Companion.atPositionOnView import org.oppia.android.app.shim.ViewBindingShimModule @@ -87,9 +91,7 @@ import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.domain.question.QuestionModule import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule import org.oppia.android.testing.OppiaTestRule -import org.oppia.android.testing.RunOn import org.oppia.android.testing.TestLogReportingModule -import org.oppia.android.testing.TestPlatform import org.oppia.android.testing.firebase.TestAuthenticationModule import org.oppia.android.testing.junit.InitializeDefaultLocaleRule import org.oppia.android.testing.platformparameter.TestPlatformParameterModule @@ -148,6 +150,8 @@ class ProfileChooserFragmentTest { @Inject lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + private val testProfileId = ProfileId.newBuilder().setInternalId(0).build() + @Before fun setUp() { Intents.init() @@ -157,6 +161,7 @@ class ProfileChooserFragmentTest { @After fun tearDown() { + TestPlatformParameterModule.reset() testCoroutineDispatchers.unregisterIdlingResource() TestPlatformParameterModule.reset() Intents.release() @@ -177,44 +182,50 @@ class ProfileChooserFragmentTest { @Test fun testProfileChooserFragment_initializeProfiles_checkProfilesAreShown() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) profileTestHelper.initializeProfiles(autoLogIn = false) launch(ProfileChooserActivity::class.java).use { testCoroutineDispatchers.runCurrent() - scrollToPosition(position = 0) + scrollToPosition(position = 0, recyclerViewId = R.id.profile_recycler_view) verifyTextOnProfileListItemAtPosition( itemPosition = 0, targetView = R.id.profile_name_text, - stringToMatch = "Admin" + stringToMatch = "Admin", + recyclerViewId = R.id.profile_recycler_view, ) verifyTextOnProfileListItemAtPosition( itemPosition = 0, targetView = R.id.profile_is_admin_text, - stringToMatch = context.getString(R.string.profile_chooser_admin) + stringToMatch = context.getString(R.string.profile_chooser_admin), + recyclerViewId = R.id.profile_recycler_view ) - scrollToPosition(position = 1) + scrollToPosition(position = 1, recyclerViewId = R.id.profile_recycler_view) verifyTextOnProfileListItemAtPosition( itemPosition = 1, targetView = R.id.profile_name_text, - stringToMatch = "Ben" + stringToMatch = "Ben", + recyclerViewId = R.id.profile_recycler_view ) onView( atPositionOnView( recyclerViewId = R.id.profile_recycler_view, position = 1, - targetViewId = R.id.profile_is_admin_text + targetViewId = R.id.profile_is_admin_text, ) ).check(matches(not(isDisplayed()))) - scrollToPosition(position = 3) + scrollToPosition(position = 3, recyclerViewId = R.id.profile_recycler_view) verifyTextOnProfileListItemAtPosition( itemPosition = 4, targetView = R.id.add_profile_text, - stringToMatch = context.getString(R.string.profile_chooser_add) + stringToMatch = context.getString(R.string.profile_chooser_add), + recyclerViewId = R.id.profile_recycler_view ) } } @Test fun testProfileChooserFragment_afterVisitingHomeActivity_showsJustNowText() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) // Note that the auto-log in here is simulating HomeActivity having been visited before (i.e. // that a profile was previously logged in). profileTestHelper.initializeProfiles(autoLogIn = true) @@ -230,13 +241,15 @@ class ProfileChooserFragmentTest { verifyTextOnProfileListItemAtPosition( itemPosition = 0, targetView = R.id.profile_last_visited, - stringToMatch = "${context.getString(R.string.profile_last_used)} just now" + stringToMatch = "${context.getString(R.string.profile_last_used)} just now", + recyclerViewId = R.id.profile_recycler_view ) } } @Test fun testProfileChooserFragment_afterVisitingHomeActivity_changeConfiguration_showsJustNowText() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) // Note that the auto-log in here is simulating HomeActivity having been visited before (i.e. // that a profile was previously logged in). profileTestHelper.initializeProfiles(autoLogIn = true) @@ -253,76 +266,88 @@ class ProfileChooserFragmentTest { verifyTextOnProfileListItemAtPosition( itemPosition = 0, targetView = R.id.profile_last_visited, - stringToMatch = "${context.getString(R.string.profile_last_used)} just now" + stringToMatch = "${context.getString(R.string.profile_last_used)} just now", + recyclerViewId = R.id.profile_recycler_view ) } } @Test fun testProfileChooserFragment_addManyProfiles_checkProfilesSortedAndNoAddProfile() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) profileTestHelper.initializeProfiles(autoLogIn = false) profileTestHelper.addMoreProfiles(8) launch(ProfileChooserActivity::class.java).use { testCoroutineDispatchers.runCurrent() - scrollToPosition(position = 0) + scrollToPosition(position = 0, recyclerViewId = R.id.profile_recycler_view) verifyTextOnProfileListItemAtPosition( itemPosition = 0, targetView = R.id.profile_name_text, - stringToMatch = "Admin" + stringToMatch = "Admin", + recyclerViewId = R.id.profile_recycler_view ) - scrollToPosition(position = 1) + scrollToPosition(position = 1, recyclerViewId = R.id.profile_recycler_view) verifyTextOnProfileListItemAtPosition( itemPosition = 1, targetView = R.id.profile_name_text, - stringToMatch = "A" + stringToMatch = "A", + recyclerViewId = R.id.profile_recycler_view ) - scrollToPosition(position = 2) + scrollToPosition(position = 2, recyclerViewId = R.id.profile_recycler_view) verifyTextOnProfileListItemAtPosition( itemPosition = 2, targetView = R.id.profile_name_text, - stringToMatch = "B" + stringToMatch = "B", + recyclerViewId = R.id.profile_recycler_view ) - scrollToPosition(position = 3) + scrollToPosition(position = 3, recyclerViewId = R.id.profile_recycler_view) verifyTextOnProfileListItemAtPosition( itemPosition = 3, targetView = R.id.profile_name_text, - stringToMatch = "Ben" + stringToMatch = "Ben", + recyclerViewId = R.id.profile_recycler_view ) - scrollToPosition(position = 4) + scrollToPosition(position = 4, recyclerViewId = R.id.profile_recycler_view) verifyTextOnProfileListItemAtPosition( itemPosition = 4, targetView = R.id.profile_name_text, - stringToMatch = "C" + stringToMatch = "C", + recyclerViewId = R.id.profile_recycler_view ) - scrollToPosition(position = 5) + scrollToPosition(position = 5, recyclerViewId = R.id.profile_recycler_view) verifyTextOnProfileListItemAtPosition( itemPosition = 5, targetView = R.id.profile_name_text, - stringToMatch = "D" + stringToMatch = "D", + recyclerViewId = R.id.profile_recycler_view ) - scrollToPosition(position = 6) + scrollToPosition(position = 6, recyclerViewId = R.id.profile_recycler_view) verifyTextOnProfileListItemAtPosition( itemPosition = 6, targetView = R.id.profile_name_text, - stringToMatch = "E" + stringToMatch = "E", + recyclerViewId = R.id.profile_recycler_view ) - scrollToPosition(position = 7) + scrollToPosition(position = 7, recyclerViewId = R.id.profile_recycler_view) verifyTextOnProfileListItemAtPosition( itemPosition = 7, targetView = R.id.profile_name_text, - stringToMatch = "F" + stringToMatch = "F", + recyclerViewId = R.id.profile_recycler_view ) - scrollToPosition(position = 8) + scrollToPosition(position = 8, recyclerViewId = R.id.profile_recycler_view) verifyTextOnProfileListItemAtPosition( itemPosition = 8, targetView = R.id.profile_name_text, - stringToMatch = "G" + stringToMatch = "G", + recyclerViewId = R.id.profile_recycler_view ) - scrollToPosition(position = 9) + scrollToPosition(position = 9, recyclerViewId = R.id.profile_recycler_view) verifyTextOnProfileListItemAtPosition( itemPosition = 9, targetView = R.id.profile_name_text, - stringToMatch = "H" + stringToMatch = "H", + recyclerViewId = R.id.profile_recycler_view ) } } @@ -347,17 +372,13 @@ class ProfileChooserFragmentTest { fun testMigrateProfiles_onboardingV2_clickAdminProfile_checkOpensPinPasswordActivity() { TestPlatformParameterModule.forceEnableOnboardingFlowV2(true) profileTestHelper.initializeProfiles(autoLogIn = true) - val adminProfileId = ProfileId.newBuilder().setInternalId(0).build() - profileTestHelper.updateProfileType( - profileId = adminProfileId, - profileType = ProfileType.SUPERVISOR - ) + profileTestHelper.updateProfileType(testProfileId, ProfileType.SUPERVISOR) launch(ProfileChooserActivity::class.java).use { testCoroutineDispatchers.runCurrent() onView( atPosition( - recyclerViewId = R.id.profile_recycler_view, + recyclerViewId = R.id.profiles_list, position = 0 ) ).perform(click()) @@ -374,7 +395,7 @@ class ProfileChooserFragmentTest { testCoroutineDispatchers.runCurrent() onView( atPosition( - recyclerViewId = R.id.profile_recycler_view, + recyclerViewId = R.id.profiles_list, position = 1 ) ).perform(click()) @@ -391,7 +412,7 @@ class ProfileChooserFragmentTest { testCoroutineDispatchers.runCurrent() onView( atPosition( - recyclerViewId = R.id.profile_recycler_view, + recyclerViewId = R.id.profiles_list, position = 0 ) ).perform(click()) @@ -416,7 +437,7 @@ class ProfileChooserFragmentTest { testCoroutineDispatchers.runCurrent() onView( atPosition( - recyclerViewId = R.id.profile_recycler_view, + recyclerViewId = R.id.profiles_list, position = 1 ) ).perform(click()) @@ -425,15 +446,55 @@ class ProfileChooserFragmentTest { } @Test - fun testProfileChooserFragment_clickAdminControlsWithNoPin_checkOpensAdminControlsActivity() { + fun testMigrateProfiles_onboardingV2_clickLearnerWithoutPin_checkIntroActivityHasNoStepCount() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(true) + profileTestHelper.addOnlyAdminProfile() profileManagementController.addProfile( - name = "Admin", + name = "Learner", pin = "", avatarImagePath = null, allowDownloadAccess = true, colorRgb = -10710042, - isAdmin = true + isAdmin = false ) + + launch(ProfileChooserActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + onView( + atPosition( + recyclerViewId = R.id.profiles_list, + position = 1 + ) + ).perform(click()) + + testCoroutineDispatchers.runCurrent() + + onView(withText(R.string.onboarding_step_count_four)).check(doesNotExist()) + } + } + + @Test + fun testProfileChooserFragment_clickAdminProfileWithNoPin_checkOpensAdminPinActivity() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) + profileTestHelper.addOnlyAdminProfileWithoutPin() + launch(createProfileChooserActivityIntent()).use { + testCoroutineDispatchers.runCurrent() + onView( + atPositionOnView( + recyclerViewId = R.id.profile_recycler_view, + position = 1, + targetViewId = R.id.add_profile_item + ) + ).perform(click()) + intended(hasComponent(AdminPinActivity::class.java.name)) + intended(hasExtraWithKey(ADMIN_PIN_ACTIVITY_PARAMS_KEY)) + } + } + + @Test + fun testProfileChooserFragment_clickAdminControlsWithNoPin_checkOpensAdminControlsActivity() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) + profileTestHelper.addOnlyAdminProfileWithoutPin() launch(createProfileChooserActivityIntent()).use { testCoroutineDispatchers.runCurrent() onView(withId(R.id.administrator_controls_linear_layout)).perform(click()) @@ -448,6 +509,7 @@ class ProfileChooserFragmentTest { @Test fun testProfileChooserFragment_checkLayoutManager_isLinearLayoutManager() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) profileTestHelper.addOnlyAdminProfile() launch(createProfileChooserActivityIntent()).use { testCoroutineDispatchers.runCurrent() @@ -464,19 +526,22 @@ class ProfileChooserFragmentTest { @Test fun testProfileChooserFragment_onlyAdminProfile_checkText_setUpMultipleProfilesIsVisible() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) profileTestHelper.addOnlyAdminProfile() launch(createProfileChooserActivityIntent()).use { testCoroutineDispatchers.runCurrent() verifyTextOnProfileListItemAtPosition( itemPosition = 1, targetView = R.id.add_profile_text, - stringToMatch = context.getString(R.string.set_up_multiple_profiles) + stringToMatch = context.getString(R.string.set_up_multiple_profiles), + recyclerViewId = R.id.profile_recycler_view ) } } @Test fun testProfileChooserFragment_onlyAdminProfile_checkDescriptionText_isDisplayed() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) profileTestHelper.addOnlyAdminProfile() launch(createProfileChooserActivityIntent()).use { testCoroutineDispatchers.runCurrent() @@ -492,19 +557,22 @@ class ProfileChooserFragmentTest { @Test fun testProfileChooserFragment_multipleProfiles_checkText_addProfileIsVisible() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) profileTestHelper.initializeProfiles(autoLogIn = false) launch(createProfileChooserActivityIntent()).use { testCoroutineDispatchers.runCurrent() verifyTextOnProfileListItemAtPosition( itemPosition = 4, targetView = R.id.add_profile_text, - stringToMatch = context.getString(R.string.profile_chooser_add) + stringToMatch = context.getString(R.string.profile_chooser_add), + recyclerViewId = R.id.profile_recycler_view ) } } @Test fun testProfileChooserFragment_multipleProfiles_checkDescriptionText_isDisplayed() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) profileTestHelper.initializeProfiles(autoLogIn = false) launch(createProfileChooserActivityIntent()).use { testCoroutineDispatchers.runCurrent() @@ -520,6 +588,7 @@ class ProfileChooserFragmentTest { @Test fun testProfileChooserFragment_clickAdminControls_opensAdminAuthActivity() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) profileTestHelper.initializeProfiles(autoLogIn = false) launch(createProfileChooserActivityIntent()).use { testCoroutineDispatchers.runCurrent() @@ -531,6 +600,7 @@ class ProfileChooserFragmentTest { @Test fun testProfileChooserFragment_clickAddProfile_opensAdminAuthActivity() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) profileTestHelper.initializeProfiles(autoLogIn = false) launch(createProfileChooserActivityIntent()).use { testCoroutineDispatchers.runCurrent() @@ -546,16 +616,10 @@ class ProfileChooserFragmentTest { } @Test - @RunOn(TestPlatform.ESPRESSO) fun testProfileChooserFragment_clickProfile_opensHomeActivity() { - profileManagementController.addProfile( - name = "Admin", - pin = "", - avatarImagePath = null, - allowDownloadAccess = true, - colorRgb = -10710042, - isAdmin = true - ) + TestPlatformParameterModule.forceEnableMultipleClassrooms(false) + TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) + profileTestHelper.addOnlyAdminProfileWithoutPin() launch(createProfileChooserActivityIntent()).use { testCoroutineDispatchers.runCurrent() onView( @@ -565,23 +629,18 @@ class ProfileChooserFragmentTest { targetViewId = R.id.profile_chooser_item ) ).perform(click()) + testCoroutineDispatchers.runCurrent() + intended(hasComponent(HomeActivity::class.java.name)) hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR) } } @Test - @RunOn(TestPlatform.ESPRESSO) fun testProfileChooserFragment_enableClassrooms_clickProfile_opensClassroomListActivity() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(false) TestPlatformParameterModule.forceEnableMultipleClassrooms(true) - profileManagementController.addProfile( - name = "Admin", - pin = "", - avatarImagePath = null, - allowDownloadAccess = true, - colorRgb = -10710042, - isAdmin = true - ) + profileTestHelper.addOnlyAdminProfileWithoutPin() launch(createProfileChooserActivityIntent()).use { testCoroutineDispatchers.runCurrent() onView( @@ -591,38 +650,280 @@ class ProfileChooserFragmentTest { targetViewId = R.id.profile_chooser_item ) ).perform(click()) + testCoroutineDispatchers.runCurrent() + intended(hasComponent(ClassroomListActivity::class.java.name)) hasExtraWithKey(PROFILE_ID_INTENT_DECORATOR) } } - private fun createProfileChooserActivityIntent(): Intent { - return ProfileChooserActivity - .createProfileChooserActivity(ApplicationProvider.getApplicationContext()) + @Test + fun testFragment_enableOnboardingV2_checkAddProfileTextIsDisplayed() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(true) + profileTestHelper.initializeProfiles() + launch(ProfileChooserActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + onView(withText(R.string.profile_selection_add_profile_text)).check(matches(isDisplayed())) + } } - private fun scrollToPosition(position: Int) { - onView(withId(R.id.profile_recycler_view)).perform( - scrollToPosition( - position + @Test + fun testFragment_enableOnboardingV2_configChange_checkAddProfileTextIsDisplayed() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(true) + profileTestHelper.initializeProfiles() + launch(ProfileChooserActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + orientationLandscape() + testCoroutineDispatchers.runCurrent() + onView(withText(R.string.profile_selection_add_profile_text)).check(matches(isDisplayed())) + } + } + + @Test + @Config(qualifiers = "land") + fun testFragment_enableOnboardingV2_landscapeMode_checkScrollArrowsAreDisplayed() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(true) + profileTestHelper.addOnlyAdminProfile() + profileTestHelper.addMoreProfiles(8) + launch(ProfileChooserActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + onView(withId(R.id.profile_list_scroll_left)).check(matches(isDisplayed())) + onView(withId(R.id.profile_list_scroll_right)).check(matches(isDisplayed())) + } + } + + @Test + @Config(qualifiers = "land") + fun testFragment_enableOnboardingV2_landscape_shortList_checkScrollArrowsAreNotDisplayed() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(true) + profileTestHelper.addOnlyAdminProfile() + launch(ProfileChooserActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + onView(withId(R.id.profile_list_scroll_left)).check( + matches(withEffectiveVisibility(Visibility.GONE)) ) - ) + onView(withId(R.id.profile_list_scroll_right)).check( + matches( + withEffectiveVisibility( + Visibility.GONE + ) + ) + ) + } + } + + @Test + fun testProfileChooserFragment_enableOnboardingV2_clickAddProfileButton_opensAdminAuthActivity() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(true) + profileTestHelper.addOnlyAdminProfile() + launch(createProfileChooserActivityIntent()).use { + testCoroutineDispatchers.runCurrent() + onView(withId(R.id.add_profile_button)).perform(click()) + intended(hasComponent(AdminAuthActivity::class.java.name)) + } + } + + @Test + fun testProfileChooserFragment_enableOnboardingV2_clickAddProfilePrompt_opensAdminAuthActivity() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(true) + profileTestHelper.addOnlyAdminProfile() + launch(createProfileChooserActivityIntent()).use { + testCoroutineDispatchers.runCurrent() + onView(withId(R.id.add_profile_prompt)).perform(click()) + intended(hasComponent(AdminAuthActivity::class.java.name)) + } + } + + @Test + fun testProfileChooserFragment_enableOnboardingV2_initializeProfiles_checkProfilesAreShown() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(true) + profileTestHelper.initializeProfiles(autoLogIn = false) + launch(ProfileChooserActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + scrollToPosition(position = 0) + verifyTextOnProfileListItemAtPosition( + itemPosition = 0, + targetView = R.id.profile_name_text, + stringToMatch = "Admin" + ) + verifyTextOnProfileListItemAtPosition( + itemPosition = 0, + targetView = R.id.profile_is_admin_text, + stringToMatch = context.getString(R.string.profile_chooser_admin) + ) + scrollToPosition(position = 1) + verifyTextOnProfileListItemAtPosition( + itemPosition = 1, + targetView = R.id.profile_name_text, + stringToMatch = "Ben" + ) + } + } + + @Test + fun testProfileChooserFragment_enableOnboardingV2_afterVisitingHomeActivity_showsJustNowText() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(true) + // Note that the auto-log in here is simulating HomeActivity having been visited before (i.e. + // that a profile was previously logged in). + profileTestHelper.initializeProfiles(autoLogIn = true) + launch(createProfileChooserActivityIntent()).use { + testCoroutineDispatchers.runCurrent() + onView( + atPositionOnView( + recyclerViewId = R.id.profiles_list, + position = 0, + targetViewId = R.id.profile_last_visited + ) + ).check(matches(isDisplayed())) + verifyTextOnProfileListItemAtPosition( + itemPosition = 0, + targetView = R.id.profile_last_visited, + stringToMatch = "${context.getString(R.string.profile_last_used)} just now" + ) + } + } + + @Test + @Config(qualifiers = "land") + fun testFragment_enableOnboardingV2_landscapeMode_afterVisitingHome_showsJustNowText() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(true) + // Note that the auto-log in here is simulating HomeActivity having been visited before (i.e. + // that a profile was previously logged in). + profileTestHelper.addOnlyAdminProfile() + profileTestHelper.addMoreProfiles(8) + launch(ProfileChooserActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + onView( + atPositionOnView( + recyclerViewId = R.id.profiles_list_landscape, + position = 0, + targetViewId = R.id.profile_last_visited + ) + ).check(matches(isDisplayed())) + verifyTextOnProfileListItemAtPosition( + itemPosition = 0, + targetView = R.id.profile_last_visited, + stringToMatch = "${context.getString(R.string.profile_last_used)} just now", + recyclerViewId = R.id.profiles_list_landscape, + ) + } + } + + @Test + fun testFragment_enableOnboardingV2_addManyProfiles_checkProfilesSortedAndNoAddProfile() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(true) + profileTestHelper.initializeProfiles(autoLogIn = false) + profileTestHelper.addMoreProfiles(8) + launch(ProfileChooserActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + scrollToPosition(position = 0) + verifyTextOnProfileListItemAtPosition( + itemPosition = 0, + targetView = R.id.profile_name_text, + stringToMatch = "Admin" + ) + scrollToPosition(position = 1) + verifyTextOnProfileListItemAtPosition( + itemPosition = 1, + targetView = R.id.profile_name_text, + stringToMatch = "A" + ) + scrollToPosition(position = 2) + verifyTextOnProfileListItemAtPosition( + itemPosition = 2, + targetView = R.id.profile_name_text, + stringToMatch = "B" + ) + scrollToPosition(position = 3) + verifyTextOnProfileListItemAtPosition( + itemPosition = 3, + targetView = R.id.profile_name_text, + stringToMatch = "Ben" + ) + scrollToPosition(position = 4) + verifyTextOnProfileListItemAtPosition( + itemPosition = 4, + targetView = R.id.profile_name_text, + stringToMatch = "C" + ) + scrollToPosition(position = 5) + verifyTextOnProfileListItemAtPosition( + itemPosition = 5, + targetView = R.id.profile_name_text, + stringToMatch = "D" + ) + scrollToPosition(position = 6) + verifyTextOnProfileListItemAtPosition( + itemPosition = 6, + targetView = R.id.profile_name_text, + stringToMatch = "E" + ) + scrollToPosition(position = 7) + verifyTextOnProfileListItemAtPosition( + itemPosition = 7, + targetView = R.id.profile_name_text, + stringToMatch = "F" + ) + scrollToPosition(position = 8) + verifyTextOnProfileListItemAtPosition( + itemPosition = 8, + targetView = R.id.profile_name_text, + stringToMatch = "G" + ) + scrollToPosition(position = 9) + verifyTextOnProfileListItemAtPosition( + itemPosition = 9, + targetView = R.id.profile_name_text, + stringToMatch = "H" + ) + } + } + + @Test + fun testFragment_enableOnboardingV2_clickProfileWithPin_checkOpensPinPasswordActivity() { + TestPlatformParameterModule.forceEnableOnboardingFlowV2(true) + profileTestHelper.addOnlyAdminProfile() + profileTestHelper.updateProfileType(testProfileId, ProfileType.SUPERVISOR) + launch(ProfileChooserActivity::class.java).use { + testCoroutineDispatchers.runCurrent() + onView( + atPosition( + recyclerViewId = R.id.profiles_list, + position = 0 + ) + ).perform(click()) + intended(hasComponent(PinPasswordActivity::class.java.name)) + } } private fun verifyTextOnProfileListItemAtPosition( itemPosition: Int, targetView: Int, - stringToMatch: String + stringToMatch: String, + recyclerViewId: Int = R.id.profiles_list ) { onView( atPositionOnView( - recyclerViewId = R.id.profile_recycler_view, + recyclerViewId = recyclerViewId, position = itemPosition, targetViewId = targetView ) ).check(matches(withText(stringToMatch))) } + private fun createProfileChooserActivityIntent(): Intent { + return ProfileChooserActivity + .createProfileChooserActivity(ApplicationProvider.getApplicationContext()) + } + + private fun scrollToPosition(recyclerViewId: Int = R.id.profiles_list, position: Int) { + onView(withId(recyclerViewId)).perform( + scrollToPosition( + position + ) + ) + } + // TODO(#59): Figure out a way to reuse modules instead of needing to re-declare them. @Singleton @Component( diff --git a/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt b/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt index 8ba807cdbf5..ac362468824 100644 --- a/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt +++ b/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt @@ -43,6 +43,7 @@ import org.oppia.android.util.profile.ProfileNameValidator import org.oppia.android.util.system.OppiaClock import java.io.File import java.io.FileOutputStream +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -887,7 +888,7 @@ class ProfileManagementController @Inject constructor( ProfileAvatar.newBuilder().setAvatarImageUri(imageUri).build() } else { updatedProfile.avatar = - ProfileAvatar.newBuilder().setAvatarColorRgb(colorRgb).build() + colorRgb.let { color -> ProfileAvatar.newBuilder().setAvatarColorRgb(color).build() } } if (profileType == ProfileType.PROFILE_TYPE_UNSPECIFIED) { @@ -1225,7 +1226,7 @@ class ProfileManagementController @Inject constructor( // TODO(#3616): Migrate to the proper SDK 29+ APIs. @Suppress("DEPRECATION") // The code is correct for targeted versions of Android. val bitmap = MediaStore.Images.Media.getBitmap(context.contentResolver, avatarImagePath) - val fileName = avatarImagePath.pathSegments.last() + val fileName = UUID.randomUUID().toString() val imageFile = File(profileDir, fileName) try { FileOutputStream(imageFile).use { fos -> diff --git a/gradle.properties b/gradle.properties index 171b9cb7ba1..62ca491ebe0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,4 +23,3 @@ android.enableJetifier=false kotlin.code.style=official # Needed to enable Android data binding. android.databinding.enableV2=true - diff --git a/model/src/main/proto/arguments.proto b/model/src/main/proto/arguments.proto index 8540563d3ee..ca14910dd97 100644 --- a/model/src/main/proto/arguments.proto +++ b/model/src/main/proto/arguments.proto @@ -891,12 +891,33 @@ message WalkthroughFinalFragmentArguments { message IntroActivityParams { // The nickname associated with a newly created profile. string profile_nickname = 1; + + // The internal Id associated with the newly created profile. + int32 profile_id = 2; + + // The screen from which the introduction activity was opened. + ParentScreen parent_screen = 3; + + // Different parent screens that can open a new onboarding introduction activity instance. + enum ParentScreen { + // Indicates that the originating screen isn't actually known. + PARENT_SCREEN_UNSPECIFIED = 0; + + // Corresponds to the Create Profile Screen in the onboarding flow. + CREATE_PROFILE_SCREEN = 1; + + // Corresponds to the profile list screen. + PROFILE_CHOOSER_SCREEN = 2; + } } // Arguments required when creating a new IntroFragment. message IntroFragmentArguments { // The nickname associated with a newly created profile. string profile_nickname = 1; + + // The screen from which the introduction fragment was opened. + IntroActivityParams.ParentScreen parent_screen = 2; } // Params required when creating a new CreateProfileActivity. diff --git a/scripts/assets/kdoc_validity_exemptions.textproto b/scripts/assets/kdoc_validity_exemptions.textproto index c82f70cd255..dad7546967f 100644 --- a/scripts/assets/kdoc_validity_exemptions.textproto +++ b/scripts/assets/kdoc_validity_exemptions.textproto @@ -153,8 +153,7 @@ exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/PinPassword exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/PinPasswordActivityPresenter.kt" exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/PinPasswordViewModel.kt" exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/ProfileChooserActivity.kt" -exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenter.kt" -exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/ProfileChooserViewModel.kt" +exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenterV1.kt" exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/ResetPinDialogFragment.kt" exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/ResetPinDialogFragmentPresenter.kt" exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/ResetPinViewModel.kt" diff --git a/scripts/assets/test_file_exemptions.textproto b/scripts/assets/test_file_exemptions.textproto index c358c7d68a4..60adc99c3ea 100644 --- a/scripts/assets/test_file_exemptions.textproto +++ b/scripts/assets/test_file_exemptions.textproto @@ -1874,10 +1874,22 @@ test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/AddProfileActivityPresenter.kt" test_file_not_required: true } +test_file_exemption { + exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/ProfileClickListener.kt" + test_file_not_required: true +} +test_file_exemption { + exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/ProfileListView.kt" + test_file_not_required: true +} test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/AddProfileViewModel.kt" test_file_not_required: true } +test_file_exemption { + exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/ProfileItemViewModel.kt" + test_file_not_required: true +} test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/AdminAuthActivity.kt" source_file_is_incompatible_with_code_coverage: true @@ -1946,6 +1958,10 @@ test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenter.kt" test_file_not_required: true } +test_file_exemption { + exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragmentPresenterV1.kt" + test_file_not_required: true +} test_file_exemption { exempted_file_path: "app/src/main/java/org/oppia/android/app/profile/ProfileChooserViewModel.kt" test_file_not_required: true