Skip to content

Commit

Permalink
Fix Part of #4938: Language Selection Config and New Profile Creation…
Browse files Browse the repository at this point in the history
… Flow (#5457)

<!-- READ ME FIRST: Please fill in the explanation section below and
check off every point from the Essential Checklist! -->
## Explanation
Fixes Part of #4938: Modifies the profile creation flow and sets the
app/audio language selection during onboarding.

### Default Profile Creation
A default empty profile is created when the Onboarding screen is opened
for the first time. This is necessary to provide a ProfileId to use when
saving the selected app language.

Because this will be the first profile on the app in onboarding v2, it
is an admin.

Provisions have been made so that, should the user exit the app before
completing the onboarding flow, this profile will be fetched, to prevent
multiple profile creation.




### App Language Selection
The language selector will be shown to the user on initial app launch,
or if profile onboarding is not complete.

- There will be a pre-filled language option based on the locale of the
device when the app is installed. If the locale is unsupported, English
will be the default selection.
- A user can select any preferred supported app language from the
dropdown list.
- The existing language functionality/behavior will be retained.

Tests have been added to verify these requirements, and efforts have
been made to ensure language selection persists on configuration change.
I noticed during testing that failure to do this resulted in an
unpleasant UX.

### Profile Nickname and Picture
The "Create profile Screen" has been repurposed to update the default
profile instead, providing the remaining profile properties(ProfileType,
name, avatar)

Checks have been added to check for profile creation errors, consistent
with the legacy flow.

A new function has been added to the ProfileManagementController to
allow for batch update of these fields, and corresponding tests have
been added.

### Audio Language Selection
The final step of onboarding is the audio language selection, and there
will be a pre-filled language selectionas follows:

- Selected app language(from first onboarding screen) if available as
audio language. (Audio language need not be completed)
- Otherwise (if available as audio language), audio language of the
administrator account -- this will be added downstream in M3, as it only
impacts additional learners.
- Otherwise (if available as audio language), device language.
- Else, English.

There have been some incidental changes in AudioLanguageFragment and
OptionsFragment, and their related tests due to sharing of the classes
between the existing screens and the new screens.

### ProfileTestHelper.kt
A new function, and related tests, have been added to create a default
profile to be used in tests.

## Essential Checklist
<!-- Please tick the relevant boxes by putting an "x" in them. -->
- [x] The PR title and explanation each start with "Fix #bugnum: " (If
this PR fixes part of an issue, prefix the title with "Fix part of
#bugnum: ...".)
- [x] Any changes to
[scripts/assets](https://github.com/oppia/oppia-android/tree/develop/scripts/assets)
files have their rationale included in the PR explanation.
- [x] The PR follows the [style
guide](https://github.com/oppia/oppia-android/wiki/Coding-style-guide).
- [x] The PR does not contain any unnecessary code changes from Android
Studio
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#undo-unnecessary-changes)).
- [x] The PR is made from a branch that's **not** called "develop" and
is up-to-date with "develop".
- [x] The PR is **assigned** to the appropriate reviewers
([reference](https://github.com/oppia/oppia-android/wiki/Guidance-on-submitting-a-PR#clarification-regarding-assignees-and-reviewers-section)).

## For UI-specific PRs only
The screen recordings are in [this drive
location](https://drive.google.com/drive/folders/1CXTAALPgpCKfekOQKjRFTb5G2fPgWkRt?usp=sharing),
since github does not support webm.

---------

Co-authored-by: Ben Henning <[email protected]>
  • Loading branch information
adhiamboperes and BenHenning authored Oct 18, 2024
1 parent eb16e59 commit 95699f9
Show file tree
Hide file tree
Showing 65 changed files with 2,270 additions and 252 deletions.
3 changes: 3 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,9 @@
android:name=".app.onboarding.IntroActivity"
android:label="@string/onboarding_learner_intro_activity_title"
android:theme="@style/OppiaThemeWithoutActionBar" />
<activity
android:name=".app.testing.TextInputLayoutBindingAdaptersTestActivity"
android:theme="@style/OppiaThemeWithoutActionBar" />
<provider
android:name="androidx.work.impl.WorkManagerInitializer"
android:authorities="${applicationId}.workmanager-init"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
package org.oppia.android.app.databinding;

import android.app.Activity;
import android.content.Context;
import android.content.ContextWrapper;
import android.view.View;
import android.widget.AutoCompleteTextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.databinding.BindingAdapter;
import com.google.android.material.textfield.TextInputLayout;
import org.oppia.android.app.model.OppiaLanguage;
import org.oppia.android.app.translation.AppLanguageActivityInjectorProvider;
import org.oppia.android.app.translation.AppLanguageResourceHandler;

/** Holds all custom binding adapters that bind to [TextInputLayout]. */
public final class TextInputLayoutBindingAdapters {
Expand All @@ -15,4 +24,37 @@ public static void setErrorMessage(
) {
textInputLayout.setError(errorMessage);
}

/** Binding adapter for setting the text of an [AutoCompleteTextView]. */
@BindingAdapter({"languageSelection", "filter"})
public static void setLanguageSelection(
@NonNull AutoCompleteTextView textView,
@Nullable OppiaLanguage selectedItem,
Boolean filter) {
textView.setText(getAppLanguageResourceHandler(textView)
.computeLocalizedDisplayName(selectedItem), filter);
}

private static AppLanguageResourceHandler getAppLanguageResourceHandler(View view) {
AppLanguageActivityInjectorProvider provider =
(AppLanguageActivityInjectorProvider) getAttachedActivity(view);
return provider.getAppLanguageActivityInjector().getAppLanguageResourceHandler();
}

private static Activity getAttachedActivity(View view) {
Context context = view.getContext();
while (context != null && !(context instanceof Activity)) {
if (!(context instanceof ContextWrapper)) {
throw new IllegalStateException(
"Encountered context in view (" + view + ") that doesn't wrap a parent context: "
+ context
);
}
context = ((ContextWrapper) context).getBaseContext();
}
if (context == null) {
throw new IllegalStateException("Failed to find base Activity for view: " + view);
}
return (Activity) context;
}
}
Original file line number Diff line number Diff line change
@@ -1,37 +1,56 @@
package org.oppia.android.app.onboarding

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import com.google.android.material.appbar.AppBarLayout
import org.oppia.android.R
import org.oppia.android.app.home.HomeActivity
import org.oppia.android.app.model.AudioLanguageFragmentStateBundle
import org.oppia.android.app.model.AudioTranslationLanguageSelection
import org.oppia.android.app.model.OppiaLanguage
import org.oppia.android.app.model.ProfileId
import org.oppia.android.app.options.AudioLanguageFragment.Companion.FRAGMENT_SAVED_STATE_KEY
import org.oppia.android.app.options.AudioLanguageSelectionViewModel
import org.oppia.android.app.translation.AppLanguageResourceHandler
import org.oppia.android.databinding.AudioLanguageSelectionFragmentBinding
import org.oppia.android.domain.oppialogger.OppiaLogger
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.extensions.getProto
import org.oppia.android.util.extensions.putProto
import javax.inject.Inject

/** The presenter for [AudioLanguageFragment]. */
class AudioLanguageFragmentPresenter @Inject constructor(
private val fragment: Fragment,
private val activity: AppCompatActivity,
private val appLanguageResourceHandler: AppLanguageResourceHandler,
private val audioLanguageSelectionViewModel: AudioLanguageSelectionViewModel
private val audioLanguageSelectionViewModel: AudioLanguageSelectionViewModel,
private val translationController: TranslationController,
private val oppiaLogger: OppiaLogger
) {
private lateinit var binding: AudioLanguageSelectionFragmentBinding
private lateinit var selectedLanguage: OppiaLanguage
private lateinit var supportedLanguages: List<OppiaLanguage>

/**
* Returns a newly inflated view to render the fragment with an evaluated audio language as the
* initial selected language, based on current locale.
*/
fun handleCreateView(
inflater: LayoutInflater,
container: ViewGroup?
container: ViewGroup?,
profileId: ProfileId,
outState: Bundle?
): View {

// Hide toolbar as it's not needed in this layout. The toolbar is created by a shared activity
// and is required in OptionsFragment.
activity.findViewById<AppBarLayout>(R.id.reading_list_app_bar_layout).visibility = View.GONE
Expand All @@ -41,33 +60,110 @@ class AudioLanguageFragmentPresenter @Inject constructor(
container,
/* attachToRoot= */ false
)
binding.lifecycleOwner = fragment

val savedSelectedLanguage = outState?.getProto(
FRAGMENT_SAVED_STATE_KEY,
AudioLanguageFragmentStateBundle.getDefaultInstance()
)?.selectedLanguage

binding.apply {
lifecycleOwner = fragment
viewModel = audioLanguageSelectionViewModel
}

audioLanguageSelectionViewModel.updateProfileId(profileId)

savedSelectedLanguage?.let {
if (it != OppiaLanguage.LANGUAGE_UNSPECIFIED) {
setSelectedLanguage(it)
} else {
observePreselectedLanguage()
}
} ?: observePreselectedLanguage()

binding.audioLanguageText.text = appLanguageResourceHandler.getStringInLocaleWithWrapping(
R.string.audio_language_fragment_text,
appLanguageResourceHandler.getStringInLocale(R.string.app_name)
)

binding.onboardingNavigationBack.setOnClickListener {
activity.finish()
}
binding.onboardingNavigationBack.setOnClickListener { activity.finish() }

val adapter = ArrayAdapter(
fragment.requireContext(),
R.layout.onboarding_language_dropdown_item,
R.id.onboarding_language_text_view,
audioLanguageSelectionViewModel.availableAudioLanguages
audioLanguageSelectionViewModel.supportedOppiaLanguagesLiveData.observe(
fragment,
{ languages ->
supportedLanguages = languages
val adapter = ArrayAdapter(
fragment.requireContext(),
R.layout.onboarding_language_dropdown_item,
R.id.onboarding_language_text_view,
languages.map { appLanguageResourceHandler.computeLocalizedDisplayName(it) }
)
binding.audioLanguageDropdownList.setAdapter(adapter)
}
)

binding.audioLanguageDropdownList.apply {
setAdapter(adapter)
setText(
audioLanguageSelectionViewModel.defaultLanguageSelection,
false
)
setRawInputType(EditorInfo.TYPE_NULL)

onItemClickListener =
AdapterView.OnItemClickListener { _, _, position, _ ->
val selectedItem = adapter.getItem(position) as? String
selectedItem?.let {
selectedLanguage = supportedLanguages.associateBy { oppiaLanguage ->
appLanguageResourceHandler.computeLocalizedDisplayName(oppiaLanguage)
}[it] ?: OppiaLanguage.ENGLISH
}
}
}

binding.onboardingNavigationContinue.setOnClickListener {
updateSelectedAudioLanguage(selectedLanguage, profileId).also {
val intent = HomeActivity.createHomeActivity(fragment.requireContext(), profileId)
fragment.startActivity(intent)
// Finish this activity as well as all activities immediately below it in the current
// task so that the user cannot navigate back to the onboarding flow by pressing the
// back button once onboarding is complete
fragment.activity?.finishAffinity()
}
}

return binding.root
}

private fun observePreselectedLanguage() {
audioLanguageSelectionViewModel.languagePreselectionLiveData.observe(
fragment,
{ selectedLanguage -> setSelectedLanguage(selectedLanguage) }
)
}

private fun setSelectedLanguage(selectedLanguage: OppiaLanguage) {
this.selectedLanguage = selectedLanguage
audioLanguageSelectionViewModel.selectedAudioLanguage.set(selectedLanguage)
}

private fun updateSelectedAudioLanguage(selectedLanguage: OppiaLanguage, profileId: ProfileId) {
val audioLanguageSelection =
AudioTranslationLanguageSelection.newBuilder().setSelectedLanguage(selectedLanguage).build()
translationController.updateAudioTranslationContentLanguage(profileId, audioLanguageSelection)
.toLiveData().observe(fragment) {
when (it) {
is AsyncResult.Failure ->
oppiaLogger.e(
"AudioLanguageFragment",
"Failed to set the selected language.",
it.error
)
else -> {} // Do nothing.
}
}
}

/** Save the current dropdown selection to be retrieved on configuration change. */
fun handleSavedState(outState: Bundle) {
outState.putProto(
FRAGMENT_SAVED_STATE_KEY,
AudioLanguageFragmentStateBundle.newBuilder().setSelectedLanguage(selectedLanguage).build()
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import android.content.Intent
import android.os.Bundle
import org.oppia.android.app.activity.ActivityComponentImpl
import org.oppia.android.app.activity.InjectableAutoLocalizedAppCompatActivity
import org.oppia.android.app.model.CreateProfileActivityParams
import org.oppia.android.app.model.ScreenName.CREATE_PROFILE_ACTIVITY
import org.oppia.android.util.extensions.getProtoExtra
import org.oppia.android.util.logging.CurrentAppScreenNameIntentDecorator.decorateWithScreenName
import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId
import javax.inject.Inject

/** Activity for displaying a new learner profile creation flow. */
Expand All @@ -18,7 +21,13 @@ class CreateProfileActivity : InjectableAutoLocalizedAppCompatActivity() {
super.onCreate(savedInstanceState)
(activityComponent as ActivityComponentImpl).inject(this)

learnerProfileActivityPresenter.handleOnCreate()
val profileId = intent.extractCurrentUserProfileId()
val profileType = intent.getProtoExtra(
CREATE_PROFILE_PARAMS_KEY,
CreateProfileActivityParams.getDefaultInstance()
).profileType

learnerProfileActivityPresenter.handleOnCreate(profileId, profileType)
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
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.model.CreateProfileFragmentArguments
import org.oppia.android.app.model.ProfileId
import org.oppia.android.app.model.ProfileType
import org.oppia.android.databinding.CreateProfileActivityBinding
import org.oppia.android.util.extensions.putProto
import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.decorateWithUserProfileId
import javax.inject.Inject

/** Argument key for [CreateProfileFragment] arguments. */
const val CREATE_PROFILE_FRAGMENT_ARGS = "CreateProfileFragment.args"

private const val TAG_CREATE_PROFILE_ACTIVITY_FRAGMENT = "TAG_CREATE_PROFILE_ACTIVITY_FRAGMENT"

/** Presenter for [CreateProfileActivity]. */
Expand All @@ -15,14 +24,24 @@ class CreateProfileActivityPresenter @Inject constructor(
private lateinit var binding: CreateProfileActivityBinding

/** Handle creation and binding of the CreateProfileActivity layout. */
fun handleOnCreate() {
fun handleOnCreate(profileId: ProfileId, profileType: ProfileType) {
binding = DataBindingUtil.setContentView(activity, R.layout.create_profile_activity)
binding.apply {
lifecycleOwner = activity
}

if (getNewLearnerProfileFragment() == null) {
val createLearnerProfileFragment = CreateProfileFragment()

val args = Bundle().apply {
val fragmentArgs =
CreateProfileFragmentArguments.newBuilder().setProfileType(profileType).build()
putProto(CREATE_PROFILE_FRAGMENT_ARGS, fragmentArgs)
decorateWithUserProfileId(profileId)
}

createLearnerProfileFragment.arguments = args

activity.supportFragmentManager.beginTransaction().add(
R.id.profile_fragment_placeholder,
createLearnerProfileFragment,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import org.oppia.android.app.fragment.FragmentComponentImpl
import org.oppia.android.app.fragment.InjectableFragment
import org.oppia.android.app.model.CreateProfileFragmentArguments
import org.oppia.android.util.extensions.getProto
import org.oppia.android.util.profile.CurrentUserProfileIdIntentDecorator.extractCurrentUserProfileId
import javax.inject.Inject

/** Fragment for displaying a new learner profile creation flow. */
Expand All @@ -33,6 +36,23 @@ class CreateProfileFragment : InjectableFragment() {
createProfileFragmentPresenter.handleOnActivityResult(result.data)
}
}
return createProfileFragmentPresenter.handleCreateView(inflater, container)

val profileId = checkNotNull(arguments?.extractCurrentUserProfileId()) {
"Expected CreateProfileFragment to have a profileId argument."
}
val profileType = checkNotNull(
arguments?.getProto(
CREATE_PROFILE_FRAGMENT_ARGS, CreateProfileFragmentArguments.getDefaultInstance()
)?.profileType
) {
"Expected CreateProfileFragment to have a profileType argument."
}

return createProfileFragmentPresenter.handleCreateView(
inflater,
container,
profileId,
profileType
)
}
}
Loading

0 comments on commit 95699f9

Please sign in to comment.