From e6876be9034cfc9eec79f9fc967b82248dc8601e Mon Sep 17 00:00:00 2001 From: Kirill Izmaylov Date: Mon, 12 Feb 2024 11:22:00 +0300 Subject: [PATCH] Social signup (#208) * feat: social auth buttons on sign up * refactor: decrease subscriptions count in signup * feat: social sign up * refactor: after merge * fix: tests * fix: tests * fix: addressed PR comments * fix: added different resources for sign up --- .../main/java/org/openedx/app/MainFragment.kt | 6 +- .../main/java/org/openedx/app/di/AppModule.kt | 2 + .../java/org/openedx/app/di/ScreenModule.kt | 4 +- .../auth/domain/model/SocialAuthResponse.kt | 10 + .../presentation/signin/SignInFragment.kt | 17 +- .../presentation/signin/SignInViewModel.kt | 52 +- .../presentation/signin/compose/SignInView.kt | 100 +--- .../presentation/signup/SignUpFragment.kt | 511 +----------------- .../auth/presentation/signup/SignUpUIState.kt | 22 +- .../presentation/signup/SignUpViewModel.kt | 230 +++++--- .../presentation/signup/compose/SignUpView.kt | 498 +++++++++++++++++ .../signup/compose/SocialSignedView.kt | 61 +++ .../presentation/sso/FacebookAuthHelper.kt | 75 ++- .../auth/presentation/sso/GoogleAuthHelper.kt | 46 +- .../presentation/sso/MicrosoftAuthHelper.kt | 28 +- .../auth/presentation/sso/OAuthHelper.kt | 30 + .../openedx/auth/presentation/ui/AuthUI.kt | 41 +- .../auth/presentation/ui/SocialAuthView.kt | 144 +++++ auth/src/main/res/values/strings.xml | 6 + .../signin/SignInViewModelTest.kt | 40 +- .../signup/SignUpViewModelTest.kt | 189 ++++--- .../java/org/openedx/core/ApiConstants.kt | 5 + .../openedx/core/extension/ContinuationExt.kt | 12 + .../java/org/openedx/core/ui/ComposeCommon.kt | 2 +- 24 files changed, 1245 insertions(+), 886 deletions(-) create mode 100644 auth/src/main/java/org/openedx/auth/domain/model/SocialAuthResponse.kt create mode 100644 auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt create mode 100644 auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt create mode 100644 auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt create mode 100644 auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt create mode 100644 core/src/main/java/org/openedx/core/extension/ContinuationExt.kt diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index c42c73857..2021e038f 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -82,8 +82,10 @@ class MainFragment : Fragment(R.layout.fragment_main) { } requireArguments().apply { - this.getString(ARG_COURSE_ID, null)?.apply { - router.navigateToCourseDetail(parentFragmentManager, this) + this.getString(ARG_COURSE_ID, null)?.let { + if (it.isNotBlank()) { + router.navigateToCourseDetail(parentFragmentManager, it) + } } this.putString(ARG_COURSE_ID, null) } diff --git a/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index 9b326da18..c5a267ece 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -23,6 +23,7 @@ import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.FacebookAuthHelper import org.openedx.auth.presentation.sso.GoogleAuthHelper import org.openedx.auth.presentation.sso.MicrosoftAuthHelper +import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences @@ -157,6 +158,7 @@ val appModule = module { factory { FacebookAuthHelper() } factory { GoogleAuthHelper(get()) } factory { MicrosoftAuthHelper() } + factory { OAuthHelper(get(), get(), get()) } factory { CourseInteractor(get()) } } diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 64365ea52..9ccac7be7 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -74,13 +74,11 @@ val screenModule = module { get(), get(), get(), - get(), - get(), courseId, ) } viewModel { (courseId: String?) -> - SignUpViewModel(get(), get(), get(), get(), get(), courseId) + SignUpViewModel(get(), get(), get(), get(), get(), get(), get(), courseId) } viewModel { RestorePasswordViewModel(get(), get(), get(), get()) } diff --git a/auth/src/main/java/org/openedx/auth/domain/model/SocialAuthResponse.kt b/auth/src/main/java/org/openedx/auth/domain/model/SocialAuthResponse.kt new file mode 100644 index 000000000..dae98fd39 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/domain/model/SocialAuthResponse.kt @@ -0,0 +1,10 @@ +package org.openedx.auth.domain.model + +import org.openedx.auth.data.model.AuthType + +data class SocialAuthResponse( + var accessToken: String = "", + var name: String = "", + var email: String = "", + var authType: AuthType = AuthType.PASSWORD, +) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt index 12eeedc7d..1adcaa3a1 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt @@ -14,6 +14,7 @@ import androidx.fragment.app.Fragment import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.auth.data.model.AuthType import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.signin.compose.LoginScreen import org.openedx.core.AppUpdateState @@ -51,14 +52,10 @@ class SignInFragment : Fragment() { onEvent = { event -> when (event) { is AuthEvent.SignIn -> viewModel.login(event.login, event.password) - AuthEvent.SignInGoogle -> viewModel.signInGoogle(requireActivity()) - AuthEvent.SignInFacebook -> { - viewModel.signInFacebook(this@SignInFragment) - } - - AuthEvent.SignInMicrosoft -> { - viewModel.signInMicrosoft(requireActivity()) - } + is AuthEvent.SocialSignIn -> viewModel.socialAuth( + this@SignInFragment, + event.authType + ) AuthEvent.ForgotPasswordClick -> { viewModel.forgotPasswordClickedEvent() @@ -114,9 +111,7 @@ class SignInFragment : Fragment() { internal sealed interface AuthEvent { data class SignIn(val login: String, val password: String) : AuthEvent - object SignInGoogle : AuthEvent - object SignInFacebook : AuthEvent - object SignInMicrosoft : AuthEvent + data class SocialSignIn(val authType: AuthType) : AuthEvent object RegisterClick : AuthEvent object ForgotPasswordClick : AuthEvent object BackClick : AuthEvent diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt index 172e1fc41..e5532429b 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt @@ -1,6 +1,5 @@ package org.openedx.auth.presentation.signin -import android.app.Activity import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -14,10 +13,9 @@ import kotlinx.coroutines.withContext import org.openedx.auth.R import org.openedx.auth.data.model.AuthType import org.openedx.auth.domain.interactor.AuthInteractor +import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.auth.presentation.AuthAnalytics -import org.openedx.auth.presentation.sso.FacebookAuthHelper -import org.openedx.auth.presentation.sso.GoogleAuthHelper -import org.openedx.auth.presentation.sso.MicrosoftAuthHelper +import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.BaseViewModel import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage @@ -39,10 +37,8 @@ class SignInViewModel( private val validator: Validator, private val appUpgradeNotifier: AppUpgradeNotifier, private val analytics: AuthAnalytics, - private val facebookAuthHelper: FacebookAuthHelper, - private val googleAuthHelper: GoogleAuthHelper, - private val microsoftAuthHelper: MicrosoftAuthHelper, - val config: Config, + private val oAuthHelper: OAuthHelper, + config: Config, val courseId: String?, ) : BaseViewModel() { @@ -114,44 +110,16 @@ class SignInViewModel( } } - fun signInGoogle(activityContext: Activity) { + fun socialAuth(fragment: Fragment, authType: AuthType) { _uiState.update { it.copy(showProgress = true) } viewModelScope.launch { withContext(Dispatchers.IO) { runCatching { - googleAuthHelper.signIn(activityContext) + oAuthHelper.socialAuth(fragment, authType) } } .getOrNull() - .checkToken(AuthType.GOOGLE) - } - } - - fun signInFacebook(fragment: Fragment) { - _uiState.update { it.copy(showProgress = true) } - viewModelScope.launch { - runCatching { - facebookAuthHelper.signIn(fragment) - }.onFailure { - logger.e { "Facebook auth error: $it" } - } - .getOrNull() - .checkToken(AuthType.FACEBOOK) - } - } - - fun signInMicrosoft(activityContext: Activity) { - _uiState.update { it.copy(showProgress = true) } - viewModelScope.launch { - withContext(Dispatchers.IO) { - runCatching { - microsoftAuthHelper.signIn(activityContext) - } - }.onFailure { - logger.e { "Microsoft auth error: $it" } - } - .getOrNull() - .checkToken(AuthType.MICROSOFT) + .checkToken() } } @@ -165,7 +133,7 @@ class SignInViewModel( override fun onCleared() { super.onCleared() - facebookAuthHelper.clear() + oAuthHelper.clear() } private suspend fun exchangeToken(token: String, authType: AuthType) { @@ -199,8 +167,8 @@ class SignInViewModel( } } - private suspend fun String?.checkToken(authType: AuthType) { - this?.let { token -> + private suspend fun SocialAuthResponse?.checkToken() { + this?.accessToken?.let { token -> if (token.isNotEmpty()) { exchangeToken(token, authType) } else { diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index 49ebc20f5..0abcf0bf9 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Scaffold @@ -58,11 +57,11 @@ import org.openedx.auth.R import org.openedx.auth.presentation.signin.AuthEvent import org.openedx.auth.presentation.signin.SignInUIState import org.openedx.auth.presentation.ui.LoginTextField +import org.openedx.auth.presentation.ui.SocialAuthView import org.openedx.core.UIMessage import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OpenEdXButton -import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape @@ -266,95 +265,14 @@ private fun AuthForm( ) } if (state.isSocialAuthEnabled) { - SocialLoginView(state = state, buttonWidth = buttonWidth, onEvent = onEvent) - } - } -} - -@Composable -private fun SocialLoginView( - buttonWidth: Modifier, - state: SignInUIState, - onEvent: (AuthEvent) -> Unit, -) { - if (state.isGoogleAuthEnabled) { - OpenEdXOutlinedButton( - modifier = buttonWidth - .testTag("btn_google_auth") - .padding(top = 24.dp), - backgroundColor = MaterialTheme.appColors.background, - borderColor = MaterialTheme.appColors.primary, - textColor = Color.Unspecified, - onClick = { - onEvent(AuthEvent.SignInGoogle) - } - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(id = R.drawable.ic_auth_google), - contentDescription = null, - tint = Color.Unspecified, - ) - Text( - modifier = Modifier - .testTag("txt_google_auth") - .padding(start = 10.dp), - text = stringResource(id = R.string.auth_google) - ) - } - } - } - if (state.isFacebookAuthEnabled) { - OpenEdXButton( - width = buttonWidth - .testTag("btn_facebook_auth") - .padding(top = 12.dp), - text = stringResource(id = R.string.auth_facebook), - backgroundColor = MaterialTheme.appColors.authFacebookButtonBackground, - onClick = { - onEvent(AuthEvent.SignInFacebook) - } - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(id = R.drawable.ic_auth_facebook), - contentDescription = null, - tint = MaterialTheme.appColors.buttonText, - ) - Text( - modifier = Modifier - .testTag("txt_facebook_auth") - .padding(start = 10.dp), - color = MaterialTheme.appColors.buttonText, - text = stringResource(id = R.string.auth_facebook) - ) - } - } - } - if (state.isMicrosoftAuthEnabled) { - OpenEdXButton( - width = buttonWidth - .testTag("btn_microsoft_auth") - .padding(top = 12.dp), - text = stringResource(id = R.string.auth_microsoft), - backgroundColor = MaterialTheme.appColors.authMicrosoftButtonBackground, - onClick = { - onEvent(AuthEvent.SignInMicrosoft) - } - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(id = R.drawable.ic_auth_microsoft), - contentDescription = null, - tint = Color.Unspecified, - ) - Text( - modifier = Modifier - .testTag("txt_microsoft_auth") - .padding(start = 10.dp), - color = MaterialTheme.appColors.buttonText, - text = stringResource(id = R.string.auth_microsoft) - ) + SocialAuthView( + modifier = buttonWidth, + isGoogleAuthEnabled = state.isGoogleAuthEnabled, + isFacebookAuthEnabled = state.isFacebookAuthEnabled, + isMicrosoftAuthEnabled = state.isMicrosoftAuthEnabled, + isSignIn = true, + ) { + onEvent(AuthEvent.SocialSignIn(it)) } } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt index a30e25451..97bfe45d9 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt @@ -1,105 +1,25 @@ -@file:OptIn(ExperimentalComposeUiApi::class, ExperimentalComposeUiApi::class) - package org.openedx.auth.presentation.signup -import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.MaterialTheme -import androidx.compose.material.ModalBottomSheetLayout -import androidx.compose.material.ModalBottomSheetValue -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.rememberModalBottomSheetState -import androidx.compose.material.rememberScaffoldState -import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.auth.data.model.AuthType import org.openedx.auth.presentation.AuthRouter -import org.openedx.auth.presentation.ui.ExpandableText -import org.openedx.auth.presentation.ui.OptionalFields -import org.openedx.auth.presentation.ui.RequiredFields +import org.openedx.auth.presentation.signup.compose.SignUpView import org.openedx.core.AppUpdateState -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.domain.model.RegistrationField -import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRequiredScreen -import org.openedx.core.ui.BackBtn -import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.OpenEdXButton -import org.openedx.core.ui.SheetContent -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType -import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.isImeVisibleState -import org.openedx.core.ui.noRippleClickable -import org.openedx.core.ui.rememberSaveableMap import org.openedx.core.ui.rememberWindowSize -import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme -import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appShapes -import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue class SignUpFragment : Fragment() { @@ -123,30 +43,35 @@ class SignUpFragment : Fragment() { OpenEdXTheme { val windowSize = rememberWindowSize() - val uiState by viewModel.uiState.observeAsState() - val uiMessage by viewModel.uiMessage.observeAsState() - val isButtonClicked by viewModel.isButtonLoading.observeAsState(false) - val successLogin by viewModel.successLogin.observeAsState() - val validationError by viewModel.validationError.observeAsState(false) - val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState(null) + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) - if (appUpgradeEvent == null) { - RegistrationScreen( + if (uiState.appUpgradeEvent == null) { + SignUpView( windowSize = windowSize, - uiState = uiState!!, + uiState = uiState, uiMessage = uiMessage, - isButtonClicked = isButtonClicked, - validationError, onBackClick = { requireActivity().supportFragmentManager.popBackStackImmediate() }, - onRegisterClick = { map -> - viewModel.register(map.mapValues { it.value ?: "" }) + onRegisterClick = { authType -> + when (authType) { + AuthType.PASSWORD -> viewModel.register() + AuthType.GOOGLE, + AuthType.FACEBOOK, + AuthType.MICROSOFT -> viewModel.socialAuth( + this@SignUpFragment, + authType + ) + } + }, + onFieldUpdated = { key, value -> + viewModel.updateField(key, value) } ) - LaunchedEffect(successLogin) { - if (successLogin == true) { + LaunchedEffect(uiState.successLogin) { + if (uiState.successLogin) { router.clearBackStack(requireActivity().supportFragmentManager) router.navigateToMain(parentFragmentManager, viewModel.courseId) } @@ -173,393 +98,3 @@ class SignUpFragment : Fragment() { } } } - -@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) -@Composable -internal fun RegistrationScreen( - windowSize: WindowSize, - uiState: SignUpUIState, - uiMessage: UIMessage?, - isButtonClicked: Boolean, - validationError: Boolean, - onBackClick: () -> Unit, - onRegisterClick: (Map) -> Unit -) { - val scaffoldState = rememberScaffoldState() - val focusManager = LocalFocusManager.current - val bottomSheetScaffoldState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, - skipHalfExpanded = true - ) - val coroutine = rememberCoroutineScope() - val keyboardController = LocalSoftwareKeyboardController.current - var expandedList by rememberSaveable { - mutableStateOf(emptyList()) - } - val selectableNamesMap = rememberSaveableMap { - mutableStateMapOf() - } - val serverFieldName = rememberSaveable { - mutableStateOf("") - } - var showOptionalFields by rememberSaveable { - mutableStateOf(false) - } - val mapFields = rememberSaveableMap { - mutableStateMapOf() - } - val showErrorMap = rememberSaveableMap { - mutableStateMapOf() - } - val scrollState = rememberScrollState() - - val haptic = LocalHapticFeedback.current - - val listState = rememberLazyListState() - - var bottomDialogTitle by rememberSaveable { - mutableStateOf("") - } - - var searchValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue()) - } - - val isImeVisible by isImeVisibleState() - - LaunchedEffect(validationError) { - if (validationError) { - coroutine.launch { - scrollState.animateScrollTo(0, tween(300)) - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - } - } - } - - LaunchedEffect(bottomSheetScaffoldState.isVisible) { - if (!bottomSheetScaffoldState.isVisible) { - focusManager.clearFocus() - searchValue = TextFieldValue("") - } - } - - Scaffold( - scaffoldState = scaffoldState, - modifier = Modifier - .semantics { - testTagsAsResourceId = true - } - .fillMaxSize() - .navigationBarsPadding(), - backgroundColor = MaterialTheme.appColors.background - ) { - - val topBarPadding by remember { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier - .width(560.dp) - .padding(bottom = 24.dp), - compact = Modifier - .fillMaxWidth() - .padding(bottom = 6.dp) - ) - ) - } - val contentPaddings by remember { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier - .widthIn(Dp.Unspecified, 420.dp) - .padding( - top = 32.dp, - bottom = 40.dp - ), - compact = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 28.dp) - ) - ) - } - val buttonWidth by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.widthIn(232.dp, Dp.Unspecified), - compact = Modifier.fillMaxWidth() - ) - ) - } - - ModalBottomSheetLayout( - modifier = Modifier - .padding(bottom = if (isImeVisible && bottomSheetScaffoldState.isVisible) 120.dp else 0.dp) - .noRippleClickable { - if (bottomSheetScaffoldState.isVisible) { - coroutine.launch { - bottomSheetScaffoldState.hide() - } - } - }, - sheetState = bottomSheetScaffoldState, - sheetShape = MaterialTheme.appShapes.screenBackgroundShape, - scrimColor = Color.Black.copy(alpha = 0.4f), - sheetBackgroundColor = MaterialTheme.appColors.background, - sheetContent = { - SheetContent( - title = bottomDialogTitle, - searchValue = searchValue, - expandedList = expandedList, - listState = listState, - onItemClick = { item -> - mapFields[serverFieldName.value] = item.value - selectableNamesMap[serverFieldName.value] = item.name - coroutine.launch { - bottomSheetScaffoldState.hide() - } - }, - searchValueChanged = { - searchValue = TextFieldValue(it) - } - ) - } - ) { - Image( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.3f), - painter = painterResource(id = R.drawable.core_top_header), - contentScale = ContentScale.FillBounds, - contentDescription = null - ) - HandleUIMessage( - uiMessage = uiMessage, - scaffoldState = scaffoldState - ) - Column( - Modifier - .fillMaxWidth() - .padding(it) - .statusBarsInset(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .then(topBarPadding), - contentAlignment = Alignment.CenterStart - ) { - Text( - modifier = Modifier - .testTag("txt_screen_title") - .fillMaxWidth(), - text = stringResource(id = R.string.core_register), - color = Color.White, - textAlign = TextAlign.Center, - style = MaterialTheme.appTypography.titleMedium - ) - BackBtn( - modifier = Modifier.padding(end = 16.dp), - tint = Color.White - ) { - onBackClick() - } - } - Surface( - color = MaterialTheme.appColors.background, - shape = MaterialTheme.appShapes.screenBackgroundShape, - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxHeight() - .background(MaterialTheme.appColors.background), - verticalArrangement = Arrangement.spacedBy(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - when (uiState) { - is SignUpUIState.Loading -> { - Box( - Modifier - .fillMaxSize(), contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } - - is SignUpUIState.Fields -> { - mapFields.let { - if (it.isEmpty()) { - it.putAll(uiState.fields.associate { it.name to "" }) - it["honor_code"] = true.toString() - } - } - Column( - Modifier - .fillMaxHeight() - .verticalScroll(scrollState) - .displayCutoutForLandscape() - .then(contentPaddings), - verticalArrangement = Arrangement.spacedBy(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Column() { - Text( - modifier = Modifier - .testTag("txt_sign_up_title") - .fillMaxWidth(), - text = stringResource(id = org.openedx.auth.R.string.auth_sign_up), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.displaySmall - ) - Text( - modifier = Modifier - .testTag("txt_sign_up_description") - .fillMaxWidth() - .padding(top = 4.dp), - text = stringResource(id = org.openedx.auth.R.string.auth_create_new_account), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleSmall - ) - } - RequiredFields( - fields = uiState.fields, - mapFields = mapFields, - showErrorMap = showErrorMap, - selectableNamesMap = selectableNamesMap, - onSelectClick = { serverName, field, list -> - keyboardController?.hide() - serverFieldName.value = serverName - expandedList = list - coroutine.launch { - if (bottomSheetScaffoldState.isVisible) { - bottomSheetScaffoldState.hide() - } else { - bottomDialogTitle = field.label - showErrorMap[field.name] = false - bottomSheetScaffoldState.show() - } - } - } - ) - if (uiState.optionalFields.isNotEmpty()) { - ExpandableText(modifier = Modifier.testTag("txt_optional_field"), - isExpanded = showOptionalFields, onClick = { - showOptionalFields = !showOptionalFields - }) - Surface(color = MaterialTheme.appColors.background) { - AnimatedVisibility(visible = showOptionalFields) { - OptionalFields( - fields = uiState.optionalFields, - mapFields = mapFields, - showErrorMap = showErrorMap, - selectableNamesMap = selectableNamesMap, - onSelectClick = { serverName, field, list -> - keyboardController?.hide() - serverFieldName.value = - serverName - expandedList = list - coroutine.launch { - if (bottomSheetScaffoldState.isVisible) { - bottomSheetScaffoldState.hide() - } else { - bottomDialogTitle = field.label - showErrorMap[field.name] = false - bottomSheetScaffoldState.show() - } - } - } - ) - } - } - } - - if (isButtonClicked) { - Box( - Modifier - .fillMaxWidth() - .height(42.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } else { - OpenEdXButton( - width = buttonWidth.testTag("btn_create_account"), - text = stringResource(id = org.openedx.auth.R.string.auth_create_account), - onClick = { - showErrorMap.clear() - onRegisterClick(mapFields.toMap()) - } - ) - } - Spacer(Modifier.height(70.dp)) - } - } - } - } - } - } - } - } -} - - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun RegistrationScreenPreview() { - OpenEdXTheme { - RegistrationScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = SignUpUIState.Fields( - fields = listOf(field, field, field), - optionalFields = listOf( - field - ) - ), - uiMessage = null, - isButtonClicked = false, - validationError = false, - onBackClick = {}, - onRegisterClick = {} - ) - } -} - -@Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "NEXUS_9_Dark", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun RegistrationScreenTabletPreview() { - OpenEdXTheme { - RegistrationScreen( - windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = SignUpUIState.Fields( - fields = listOf(field, field, field), - optionalFields = listOf( - field - ) - ), - uiMessage = null, - isButtonClicked = false, - validationError = false, - onBackClick = {}, - onRegisterClick = {} - ) - } -} - -private val option = RegistrationField.Option("def", "Bachelor", "Android") - -private val field = RegistrationField( - "Fullname", - "Fullname", - RegistrationFieldType.TEXT, - "Fullname", - instructions = "Enter your fullname", - exposed = false, - required = true, - restrictions = RegistrationField.Restrictions(), - options = listOf(option, option), - errorInstructions = "" -) \ No newline at end of file diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt index a6af275f5..23e0458d9 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt @@ -1,11 +1,19 @@ package org.openedx.auth.presentation.signup +import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.core.domain.model.RegistrationField +import org.openedx.core.system.notifier.AppUpgradeEvent -sealed class SignUpUIState { - data class Fields( - val fields: List, - val optionalFields: List - ) : SignUpUIState() - object Loading : SignUpUIState() -} \ No newline at end of file +data class SignUpUIState( + val allFields: List = emptyList(), + val isFacebookAuthEnabled: Boolean = false, + val isGoogleAuthEnabled: Boolean = false, + val isMicrosoftAuthEnabled: Boolean = false, + val isSocialAuthEnabled: Boolean = false, + val isLoading: Boolean = false, + val isButtonLoading: Boolean = false, + val validationError: Boolean = false, + val successLogin: Boolean = false, + val socialAuth: SocialAuthResponse? = null, + val appUpgradeEvent: AppUpgradeEvent? = null, +) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt index 4cac4f400..af1b8e094 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt @@ -1,22 +1,33 @@ package org.openedx.auth.presentation.signup -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import androidx.fragment.app.Fragment import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.openedx.auth.data.model.AuthType import org.openedx.auth.domain.interactor.AuthInteractor +import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.ApiConstants import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage +import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.RegistrationField +import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager -import org.openedx.core.system.notifier.AppUpgradeEvent import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.utils.Logger class SignUpViewModel( private val interactor: AuthInteractor, @@ -24,106 +35,170 @@ class SignUpViewModel( private val analytics: AuthAnalytics, private val preferencesManager: CorePreferences, private val appUpgradeNotifier: AppUpgradeNotifier, + private val oAuthHelper: OAuthHelper, + private val config: Config, val courseId: String?, ) : BaseViewModel() { - private val _uiState = MutableLiveData(SignUpUIState.Loading) - val uiState: LiveData - get() = _uiState + private val logger = Logger("SignUpViewModel") - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - - private val _isButtonLoading = MutableLiveData(false) - val isButtonLoading: LiveData - get() = _isButtonLoading - - private val _successLogin = MutableLiveData(false) - val successLogin: LiveData - get() = _successLogin - - private val _validationError = MutableLiveData(false) - val validationError: LiveData - get() = _validationError - - private val _appUpgradeEvent = MutableLiveData() - val appUpgradeEvent: LiveData - get() = _appUpgradeEvent + private val _uiState = MutableStateFlow( + SignUpUIState( + isFacebookAuthEnabled = config.getFacebookConfig().isEnabled(), + isGoogleAuthEnabled = config.getGoogleConfig().isEnabled(), + isMicrosoftAuthEnabled = config.getMicrosoftConfig().isEnabled(), + isSocialAuthEnabled = config.isSocialAuthEnabled(), + isLoading = true, + ) + ) + val uiState = _uiState.asStateFlow() - private val optionalFields = mutableMapOf() - private val allFields = mutableListOf() + private val _uiMessage = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val uiMessage = _uiMessage.asSharedFlow() init { collectAppUpgradeEvent() } fun getRegistrationFields() { - _uiState.value = SignUpUIState.Loading + _uiState.update { it.copy(isLoading = true) } viewModelScope.launch { try { - val fields = interactor.getRegistrationFields() - _uiState.value = SignUpUIState.Fields( - fields = fields.filter { it.required }, - optionalFields = fields.filter { !it.required } - ) - optionalFields.clear() - allFields.clear() - allFields.addAll(fields) - optionalFields.putAll((fields.filter { !it.required }.associate { it.name to "" })) + val allFields = interactor.getRegistrationFields() + _uiState.update { state -> + state.copy( + allFields = allFields, + isLoading = false, + ) + } } catch (e: Exception) { if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + _uiMessage.emit( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_no_connection) + ) + ) } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + _uiMessage.emit( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_unknown_error) + ) + ) } } } } - fun register(mapFields: Map) { + fun register() { analytics.createAccountClickedEvent("") + val mapFields = uiState.value.allFields.associate { it.name to it.placeholder } + + mapOf(ApiConstants.HONOR_CODE to true.toString()) val resultMap = mapFields.toMutableMap() - optionalFields.forEach { (k, v) -> + uiState.value.allFields.filter { !it.required }.forEach { (k, _) -> if (mapFields[k].isNullOrEmpty()) { resultMap.remove(k) } } - _isButtonLoading.value = true - _validationError.value = false + _uiState.update { it.copy(isButtonLoading = true, validationError = false) } viewModelScope.launch { try { - val validationFields = interactor.validateRegistrationFields(resultMap.toMap()) + setErrorInstructions(emptyMap()) + val validationFields = interactor.validateRegistrationFields(mapFields) setErrorInstructions(validationFields.validationResult) if (validationFields.hasValidationError()) { - _validationError.value = true + _uiState.update { it.copy(validationError = true, isButtonLoading = false) } } else { + val socialAuth = uiState.value.socialAuth + if (socialAuth?.accessToken != null) { + resultMap[ApiConstants.ACCESS_TOKEN] = socialAuth.accessToken + resultMap[ApiConstants.PROVIDER] = socialAuth.authType.postfix + resultMap[ApiConstants.CLIENT_ID] = config.getOAuthClientId() + } interactor.register(resultMap.toMap()) - interactor.login( - resultMap.getValue(ApiConstants.EMAIL), - resultMap.getValue(ApiConstants.PASSWORD) - ) - setUserId() - analytics.registrationSuccessEvent("") - _successLogin.value = true + analytics.registrationSuccessEvent(socialAuth?.authType?.postfix.orEmpty()) + if (socialAuth == null) { + interactor.login( + resultMap.getValue(ApiConstants.EMAIL), + resultMap.getValue(ApiConstants.PASSWORD) + ) + setUserId() + _uiState.update { it.copy(successLogin = true, isButtonLoading = false) } + } else { + exchangeToken(socialAuth) + } } - _isButtonLoading.value = false } catch (e: Exception) { - _isButtonLoading.value = false + _uiState.update { it.copy(isButtonLoading = false) } if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + _uiMessage.emit( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_no_connection) + ) + ) } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + _uiMessage.emit( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_unknown_error) + ) + ) + } + } + } + } + + fun socialAuth(fragment: Fragment, authType: AuthType) { + _uiState.update { it.copy(isLoading = true) } + viewModelScope.launch { + withContext(Dispatchers.IO) { + runCatching { + oAuthHelper.socialAuth(fragment, authType) } } + .getOrNull() + .checkToken() + } + } + + private suspend fun SocialAuthResponse?.checkToken() { + this?.accessToken?.let { token -> + if (token.isNotEmpty()) { + exchangeToken(this) + } else { + _uiState.update { it.copy(isLoading = false) } + } + } ?: _uiState.update { it.copy(isLoading = false) } + } + + private suspend fun exchangeToken(socialAuth: SocialAuthResponse) { + runCatching { + interactor.loginSocial(socialAuth.accessToken, socialAuth.authType) + }.onFailure { + _uiState.update { + val fields = it.allFields.toMutableList() + .filter { field -> field.type != RegistrationFieldType.PASSWORD } + updateField(ApiConstants.NAME, socialAuth.name) + updateField(ApiConstants.EMAIL, socialAuth.email) + setErrorInstructions(emptyMap()) + it.copy( + isLoading = false, + socialAuth = socialAuth, + allFields = fields + ) + } + }.onSuccess { + setUserId() + analytics.userLoginEvent(socialAuth.authType.methodName) + _uiState.update { it.copy(successLogin = true) } + logger.d { "Social login (${socialAuth.authType.methodName}) success" } } } private fun setErrorInstructions(errorMap: Map) { + val allFields = uiState.value.allFields val updatedFields = ArrayList(allFields.size) allFields.forEach { if (errorMap.containsKey(it.name)) { @@ -132,33 +207,38 @@ class SignUpViewModel( updatedFields.add(it.copy(errorInstructions = "")) } } - allFields.clear() - allFields.addAll(updatedFields) - _uiState.value = SignUpUIState.Fields( - updatedFields.filter { it.required }, - updatedFields.filter { !it.required } - ) + _uiState.update { state -> + state.copy( + allFields = updatedFields, + isLoading = false, + ) + } } private fun collectAppUpgradeEvent() { viewModelScope.launch { appUpgradeNotifier.notifier.collect { event -> - _appUpgradeEvent.value = event + _uiState.update { it.copy(appUpgradeEvent = event) } } } } - private fun setUserId() { preferencesManager.user?.let { analytics.setUserIdForSession(it.id) } } + fun updateField(key: String, value: String) { + _uiState.update { + val updatedFields = uiState.value.allFields.toMutableList().map { field -> + if (field.name == key) { + field.copy(placeholder = value) + } else { + field + } + } + it.copy(allFields = updatedFields) + } + } } - -private enum class RegisterProvider(val keyName: String) { - GOOGLE("google-oauth2"), - AZURE("azuread-oauth2"), - FACEBOOK("facebook") -} \ No newline at end of file diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt new file mode 100644 index 000000000..0658396e2 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt @@ -0,0 +1,498 @@ +package org.openedx.auth.presentation.signup.compose + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.openedx.auth.R +import org.openedx.auth.data.model.AuthType +import org.openedx.auth.presentation.signup.SignUpUIState +import org.openedx.auth.presentation.ui.ExpandableText +import org.openedx.auth.presentation.ui.OptionalFields +import org.openedx.auth.presentation.ui.RequiredFields +import org.openedx.auth.presentation.ui.SocialAuthView +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.RegistrationField +import org.openedx.core.domain.model.RegistrationFieldType +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.SheetContent +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.isImeVisibleState +import org.openedx.core.ui.noRippleClickable +import org.openedx.core.ui.rememberSaveableMap +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue +import org.openedx.core.R as coreR + +@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) +@Composable +internal fun SignUpView( + windowSize: WindowSize, + uiState: SignUpUIState, + uiMessage: UIMessage?, + onBackClick: () -> Unit, + onFieldUpdated: (String, String) -> Unit, + onRegisterClick: (authType: AuthType) -> Unit, +) { + val scaffoldState = rememberScaffoldState() + val focusManager = LocalFocusManager.current + val bottomSheetScaffoldState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + skipHalfExpanded = true + ) + val coroutine = rememberCoroutineScope() + val keyboardController = LocalSoftwareKeyboardController.current + var expandedList by rememberSaveable { + mutableStateOf(emptyList()) + } + val selectableNamesMap = rememberSaveableMap { + mutableStateMapOf() + } + val serverFieldName = rememberSaveable { + mutableStateOf("") + } + var showOptionalFields by rememberSaveable { + mutableStateOf(false) + } + val showErrorMap = rememberSaveableMap { + mutableStateMapOf() + } + val scrollState = rememberScrollState() + + val haptic = LocalHapticFeedback.current + + val listState = rememberLazyListState() + + var bottomDialogTitle by rememberSaveable { + mutableStateOf("") + } + + var searchValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + + val isImeVisible by isImeVisibleState() + + val fields = uiState.allFields.filter { it.required } + val optionalFields = uiState.allFields.filter { !it.required } + + LaunchedEffect(uiState.validationError) { + if (uiState.validationError) { + coroutine.launch { + scrollState.animateScrollTo(0, tween(300)) + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + } + } + + LaunchedEffect(uiState.socialAuth) { + if (uiState.socialAuth != null) { + coroutine.launch { + showErrorMap.clear() + scrollState.animateScrollTo(0, tween(300)) + } + } + } + + LaunchedEffect(bottomSheetScaffoldState.isVisible) { + if (!bottomSheetScaffoldState.isVisible) { + focusManager.clearFocus() + searchValue = TextFieldValue("") + } + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier + .semantics { + testTagsAsResourceId = true + } + .fillMaxSize() + .navigationBarsPadding(), + backgroundColor = MaterialTheme.appColors.background + ) { + + val topBarPadding by remember { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier + .width(560.dp) + .padding(bottom = 24.dp), + compact = Modifier + .fillMaxWidth() + .padding(bottom = 6.dp) + ) + ) + } + val contentPaddings by remember { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier + .widthIn(Dp.Unspecified, 420.dp) + .padding( + top = 32.dp, + bottom = 40.dp + ), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 28.dp) + ) + ) + } + val buttonWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(232.dp, Dp.Unspecified), + compact = Modifier.fillMaxWidth() + ) + ) + } + + ModalBottomSheetLayout( + modifier = Modifier + .padding(bottom = if (isImeVisible && bottomSheetScaffoldState.isVisible) 120.dp else 0.dp) + .noRippleClickable { + if (bottomSheetScaffoldState.isVisible) { + coroutine.launch { + bottomSheetScaffoldState.hide() + } + } + }, + sheetState = bottomSheetScaffoldState, + sheetShape = MaterialTheme.appShapes.screenBackgroundShape, + scrimColor = Color.Black.copy(alpha = 0.4f), + sheetBackgroundColor = MaterialTheme.appColors.background, + sheetContent = { + SheetContent( + title = bottomDialogTitle, + searchValue = searchValue, + expandedList = expandedList, + listState = listState, + onItemClick = { item -> + onFieldUpdated(serverFieldName.value, item.value) + selectableNamesMap[serverFieldName.value] = item.name + coroutine.launch { + bottomSheetScaffoldState.hide() + } + }, + searchValueChanged = { + searchValue = TextFieldValue(it) + } + ) + } + ) { + Image( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.3f), + painter = painterResource(id = coreR.drawable.core_top_header), + contentScale = ContentScale.FillBounds, + contentDescription = null + ) + HandleUIMessage( + uiMessage = uiMessage, + scaffoldState = scaffoldState + ) + Column( + Modifier + .fillMaxWidth() + .padding(it) + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .then(topBarPadding), + contentAlignment = Alignment.CenterStart + ) { + Text( + modifier = Modifier + .testTag("txt_screen_title") + .fillMaxWidth(), + text = stringResource(id = coreR.string.core_register), + color = Color.White, + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.titleMedium + ) + BackBtn( + modifier = Modifier.padding(end = 16.dp), + tint = Color.White + ) { + onBackClick() + } + } + Surface( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.screenBackgroundShape, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .background(MaterialTheme.appColors.background), + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (uiState.isLoading) { + Box( + Modifier + .fillMaxSize(), contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } else { + Column( + Modifier + .fillMaxHeight() + .verticalScroll(scrollState) + .displayCutoutForLandscape() + .then(contentPaddings), + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column { + if (uiState.socialAuth != null) { + SocialSignedView(uiState.socialAuth.authType) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + text = stringResource( + id = R.string.auth_compete_registration + ), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleSmall + ) + } else { + Text( + modifier = Modifier + .testTag("txt_sign_up_title") + .fillMaxWidth(), + text = stringResource(id = R.string.auth_sign_up), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.displaySmall + ) + Text( + modifier = Modifier + .testTag("txt_sign_up_description") + .fillMaxWidth() + .padding(top = 4.dp), + text = stringResource( + id = R.string.auth_create_new_account + ), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleSmall + ) + } + } + RequiredFields( + fields = fields, + showErrorMap = showErrorMap, + selectableNamesMap = selectableNamesMap, + onSelectClick = { serverName, field, list -> + keyboardController?.hide() + serverFieldName.value = serverName + expandedList = list + coroutine.launch { + if (bottomSheetScaffoldState.isVisible) { + bottomSheetScaffoldState.hide() + } else { + bottomDialogTitle = field.label + showErrorMap[field.name] = false + bottomSheetScaffoldState.show() + } + } + }, + onFieldUpdated = onFieldUpdated + ) + if (optionalFields.isNotEmpty()) { + ExpandableText( + modifier = Modifier.testTag("txt_optional_field"), + isExpanded = showOptionalFields, + onClick = { + showOptionalFields = !showOptionalFields + } + ) + Surface(color = MaterialTheme.appColors.background) { + AnimatedVisibility(visible = showOptionalFields) { + OptionalFields( + fields = optionalFields, + showErrorMap = showErrorMap, + selectableNamesMap = selectableNamesMap, + onSelectClick = { serverName, field, list -> + keyboardController?.hide() + serverFieldName.value = + serverName + expandedList = list + coroutine.launch { + if (bottomSheetScaffoldState.isVisible) { + bottomSheetScaffoldState.hide() + } else { + bottomDialogTitle = field.label + showErrorMap[field.name] = false + bottomSheetScaffoldState.show() + } + } + }, + onFieldUpdated = onFieldUpdated, + ) + } + } + } + + if (uiState.isButtonLoading) { + Box( + Modifier + .fillMaxWidth() + .height(42.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } else { + OpenEdXButton( + width = buttonWidth.testTag("btn_create_account"), + text = stringResource(id = R.string.auth_create_account), + onClick = { + showErrorMap.clear() + onRegisterClick(AuthType.PASSWORD) + } + ) + } + if (uiState.isSocialAuthEnabled && uiState.socialAuth == null) { + SocialAuthView( + modifier = buttonWidth, + isGoogleAuthEnabled = uiState.isGoogleAuthEnabled, + isFacebookAuthEnabled = uiState.isFacebookAuthEnabled, + isMicrosoftAuthEnabled = uiState.isMicrosoftAuthEnabled, + isSignIn = false, + ) { + onRegisterClick(it) + } + } + Spacer(Modifier.height(70.dp)) + } + } + } + } + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun RegistrationScreenPreview() { + OpenEdXTheme { + SignUpView( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = SignUpUIState( + allFields = listOf(field, field, field.copy(required = false)), + ), + uiMessage = null, + onBackClick = {}, + onRegisterClick = {}, + onFieldUpdated = { _, _ -> }, + ) + } +} + +@Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_9_Dark", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun RegistrationScreenTabletPreview() { + OpenEdXTheme { + SignUpView( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = SignUpUIState( + allFields = listOf(field, field, field.copy(required = false)), + ), + uiMessage = null, + onBackClick = {}, + onRegisterClick = {}, + onFieldUpdated = { _, _ -> }, + ) + } +} + +private val option = RegistrationField.Option("def", "Bachelor", "Android") + +private val field = RegistrationField( + "Fullname", + "Fullname", + RegistrationFieldType.TEXT, + "Fullname", + instructions = "Enter your fullname", + exposed = false, + required = true, + restrictions = RegistrationField.Restrictions(), + options = listOf(option, option), + errorInstructions = "" +) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt new file mode 100644 index 000000000..25a9434d1 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt @@ -0,0 +1,61 @@ +package org.openedx.auth.presentation.signup.compose + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.openedx.auth.R +import org.openedx.auth.data.model.AuthType +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.R as coreR + +@Composable +internal fun SocialSignedView(authType: AuthType) { + Column( + modifier = Modifier + .background( + color = MaterialTheme.appColors.secondary, + shape = MaterialTheme.appShapes.buttonShape + ) + .padding(20.dp) + ) { + Text( + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + text = stringResource( + id = R.string.auth_social_signed_title, + authType.methodName + ) + ) + Text( + modifier = Modifier.padding(top = 8.dp), + text = stringResource( + id = R.string.auth_social_signed_desc, + stringResource(id = coreR.string.app_name) + ) + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewSocialSignedView() { + OpenEdXTheme { + SocialSignedView(AuthType.GOOGLE) + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt index b138f9459..70f2209ab 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt @@ -1,12 +1,18 @@ package org.openedx.auth.presentation.sso +import android.os.Bundle import androidx.fragment.app.Fragment import com.facebook.CallbackManager import com.facebook.FacebookCallback import com.facebook.FacebookException +import com.facebook.GraphRequest import com.facebook.login.LoginManager import com.facebook.login.LoginResult import kotlinx.coroutines.suspendCancellableCoroutine +import org.openedx.auth.data.model.AuthType +import org.openedx.auth.domain.model.SocialAuthResponse +import org.openedx.core.ApiConstants +import org.openedx.core.extension.safeResume import org.openedx.core.utils.Logger import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -16,33 +22,54 @@ class FacebookAuthHelper { private val logger = Logger(TAG) private val callbackManager = CallbackManager.Factory.create() - suspend fun signIn(fragment: Fragment): String? = suspendCancellableCoroutine { continuation -> - LoginManager.getInstance().registerCallback( - callbackManager, - object : FacebookCallback { - override fun onCancel() { - logger.d { "Facebook login canceled" } - continuation.resume("") - } + suspend fun socialAuth(fragment: Fragment): SocialAuthResponse? = + suspendCancellableCoroutine { continuation -> + LoginManager.getInstance().registerCallback( + callbackManager, + object : FacebookCallback { + override fun onCancel() { + logger.d { "Facebook auth canceled" } + continuation.resume(SocialAuthResponse()) + } - override fun onError(error: FacebookException) { - logger.e { "Facebook login error: $error" } - continuation.resumeWithException(error) - } + override fun onError(error: FacebookException) { + logger.e { "Facebook auth error: $error" } + continuation.resumeWithException(error) + } - override fun onSuccess(result: LoginResult) { - logger.d { "Facebook login success" } - continuation.resume(result.accessToken.token) + override fun onSuccess(result: LoginResult) { + logger.d { "Facebook auth success" } + GraphRequest.newMeRequest(result.accessToken) { obj, response -> + if (response?.error != null) { + continuation.cancel() + } else { + continuation.safeResume( + SocialAuthResponse( + accessToken = result.accessToken.token, + name = obj?.getString(ApiConstants.NAME) ?: "", + email = obj?.getString(ApiConstants.EMAIL) ?: "", + authType = AuthType.FACEBOOK, + ) + ) { + continuation.cancel() + } + } + }.also { + it.parameters = Bundle().apply { + putString("fields", "${ApiConstants.NAME}, ${ApiConstants.EMAIL}") + } + it.executeAsync() + } + } } - } - ) - LoginManager.getInstance().logOut() - LoginManager.getInstance().logInWithReadPermissions( - fragment, - callbackManager, - PERMISSIONS_LIST - ) - } + ) + LoginManager.getInstance().logOut() + LoginManager.getInstance().logInWithReadPermissions( + fragment, + callbackManager, + PERMISSIONS_LIST + ) + } fun clear() { runCatching { diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/GoogleAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/GoogleAuthHelper.kt index c82fa1d1d..99985b882 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/sso/GoogleAuthHelper.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/GoogleAuthHelper.kt @@ -3,15 +3,19 @@ package org.openedx.auth.presentation.sso import android.accounts.Account import android.app.Activity import android.credentials.GetCredentialException +import android.os.Bundle import androidx.annotation.WorkerThread import androidx.credentials.Credential import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential import androidx.credentials.GetCredentialRequest import androidx.credentials.exceptions.GetCredentialCancellationException import com.google.android.gms.auth.GoogleAuthUtil import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException +import org.openedx.auth.data.model.AuthType +import org.openedx.auth.domain.model.SocialAuthResponse import org.openedx.core.config.Config import org.openedx.core.utils.Logger @@ -29,7 +33,7 @@ class GoogleAuthHelper(private val config: Config) { }.getOrNull() } - private suspend fun getCredentials(activityContext: Activity): String? { + private suspend fun getCredentials(activityContext: Activity): GoogleIdTokenCredential? { return runCatching { val credentialManager = CredentialManager.create(activityContext) val googleIdOption = @@ -41,12 +45,12 @@ class GoogleAuthHelper(private val config: Config) { request = request, context = activityContext, ) - getGoogleIdToken(result.credential)?.id + getGoogleIdToken(result.credential) }.onFailure { if (it is GetCredentialCancellationException && it.type == GetCredentialException.TYPE_USER_CANCELED ) { - return "" + return null } logger.e { "GetCredentials error: ${it.message}" } }.getOrNull() @@ -55,10 +59,13 @@ class GoogleAuthHelper(private val config: Config) { private fun getGoogleIdToken(credential: Credential): GoogleIdTokenCredential? { return when (credential) { is GoogleIdTokenCredential -> { - try { - GoogleIdTokenCredential.createFrom(credential.data) - } catch (e: GoogleIdTokenParsingException) { - logger.e { "Token parsing exception: $e" } + parseToken(credential.data) + } + + is CustomCredential -> { + if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { + parseToken(credential.data) + } else { null } } @@ -70,13 +77,28 @@ class GoogleAuthHelper(private val config: Config) { } } + private fun parseToken(data: Bundle): GoogleIdTokenCredential? = + try { + GoogleIdTokenCredential.createFrom(data) + } catch (e: GoogleIdTokenParsingException) { + logger.e { "Token parsing exception: $e" } + null + } + @WorkerThread - suspend fun signIn(activityContext: Activity): String? { - return getCredentials(activityContext)?.let { token -> - if (token.isNotBlank()) { - getAuthToken(activityContext, token) + suspend fun socialAuth(activityContext: Activity): SocialAuthResponse? { + return getCredentials(activityContext)?.let { credentials -> + if (credentials.id.isNotBlank()) { + val token = getAuthToken(activityContext, credentials.id).orEmpty() + logger.d { token } + SocialAuthResponse( + accessToken = token, + name = credentials.displayName.orEmpty(), + email = credentials.id, + authType = AuthType.GOOGLE, + ) } else { - "" + return null } } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt index 264f349cb..7cfcef591 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt @@ -8,7 +8,11 @@ import com.microsoft.identity.client.IAuthenticationResult import com.microsoft.identity.client.PublicClientApplication import com.microsoft.identity.client.exception.MsalException import kotlinx.coroutines.suspendCancellableCoroutine +import org.openedx.auth.data.model.AuthType +import org.openedx.auth.domain.model.SocialAuthResponse +import org.openedx.core.ApiConstants import org.openedx.core.R +import org.openedx.core.extension.safeResume import org.openedx.core.utils.Logger import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -17,7 +21,7 @@ class MicrosoftAuthHelper { private val logger = Logger(TAG) @WorkerThread - suspend fun signIn(activityContext: Activity): String? = + suspend fun socialAuth(activityContext: Activity): SocialAuthResponse? = suspendCancellableCoroutine { continuation -> val clientApplication = PublicClientApplication.createMultipleAccountPublicClientApplication( @@ -29,7 +33,23 @@ class MicrosoftAuthHelper { .withScopes(SCOPES) .withCallback(object : AuthenticationCallback { override fun onSuccess(authenticationResult: IAuthenticationResult?) { - continuation.resume(authenticationResult?.accessToken) + val claims = authenticationResult?.account?.claims + val name = + (claims?.getOrDefault(ApiConstants.NAME, "") as? String) + .orEmpty() + val email = + (claims?.getOrDefault(ApiConstants.EMAIL, "") as? String) + .orEmpty() + continuation.safeResume( + SocialAuthResponse( + accessToken = authenticationResult?.accessToken.orEmpty(), + name = name, + email = email, + authType = AuthType.MICROSOFT, + ) + ) { + continuation.cancel() + } } override fun onError(exception: MsalException) { @@ -39,7 +59,7 @@ class MicrosoftAuthHelper { override fun onCancel() { logger.d { "Microsoft auth canceled" } - continuation.resume("") + continuation.resume(SocialAuthResponse()) } }).build() clientApplication.accounts.forEach { @@ -50,6 +70,6 @@ class MicrosoftAuthHelper { private companion object { const val TAG = "MicrosoftAuthHelper" - val SCOPES = listOf("User.Read") + val SCOPES = listOf("User.Read", ApiConstants.EMAIL) } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt new file mode 100644 index 000000000..776df7c46 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt @@ -0,0 +1,30 @@ +package org.openedx.auth.presentation.sso + +import androidx.fragment.app.Fragment +import org.openedx.auth.data.model.AuthType +import org.openedx.auth.domain.model.SocialAuthResponse + +class OAuthHelper( + private val facebookAuthHelper: FacebookAuthHelper, + private val googleAuthHelper: GoogleAuthHelper, + private val microsoftAuthHelper: MicrosoftAuthHelper, +) { + /** + * SDK integration guides: + * https://developer.android.com/training/sign-in/credential-manager + * https://developers.facebook.com/docs/facebook-login/android/ + * https://github.com/AzureAD/microsoft-authentication-library-for-android + */ + internal suspend fun socialAuth(fragment: Fragment, authType: AuthType): SocialAuthResponse? { + return when (authType) { + AuthType.PASSWORD -> null + AuthType.GOOGLE -> googleAuthHelper.socialAuth(fragment.requireActivity()) + AuthType.FACEBOOK -> facebookAuthHelper.socialAuth(fragment) + AuthType.MICROSOFT -> microsoftAuthHelper.socialAuth(fragment.requireActivity()) + } + } + + fun clear() { + facebookAuthHelper.clear() + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt index 2ee5f1a55..e875a4539 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt @@ -61,9 +61,9 @@ import org.openedx.core.ui.theme.appTypography @Composable fun RequiredFields( fields: List, - mapFields: MutableMap, showErrorMap: MutableMap, selectableNamesMap: MutableMap, + onFieldUpdated: (String, String) -> Unit, onSelectClick: (String, RegistrationField, List) -> Unit ) { fields.forEach { field -> @@ -77,7 +77,7 @@ fun RequiredFields( if (!isErrorShown) { showErrorMap[serverName] = isErrorShown } - mapFields[serverName] = value + onFieldUpdated(serverName, value) } ) } @@ -119,7 +119,7 @@ fun RequiredFields( if (!isErrorShown) { showErrorMap[serverName] = isErrorShown } - mapFields[serverName] = value + onFieldUpdated(serverName, value) } ) } @@ -134,12 +134,12 @@ fun RequiredFields( @Composable fun OptionalFields( fields: List, - mapFields: MutableMap, showErrorMap: MutableMap, selectableNamesMap: MutableMap, - onSelectClick: (String, RegistrationField, List) -> Unit + onSelectClick: (String, RegistrationField, List) -> Unit, + onFieldUpdated: (String, String) -> Unit, ) { - Column() { + Column { fields.forEach { field -> when (field.type) { RegistrationFieldType.TEXT, RegistrationFieldType.EMAIL, RegistrationFieldType.CONFIRM_EMAIL, RegistrationFieldType.PASSWORD -> { @@ -153,8 +153,7 @@ fun OptionalFields( showErrorMap[serverName] = isErrorShown } - mapFields[serverName] = - value + onFieldUpdated(serverName, value) } ) } @@ -197,11 +196,9 @@ fun OptionalFields( registrationField = field, onValueChanged = { serverName, value, isErrorShown -> if (!isErrorShown) { - showErrorMap[serverName] = - isErrorShown + showErrorMap[serverName] = isErrorShown } - mapFields[serverName] = - value + onFieldUpdated(serverName, value) } ) } @@ -277,7 +274,7 @@ fun InputRegistrationField( onValueChanged: (String, String, Boolean) -> Unit ) { var inputRegistrationFieldValue by rememberSaveable { - mutableStateOf("") + mutableStateOf(registrationField.placeholder) } val focusManager = LocalFocusManager.current val visualTransformation = if (registrationField.type == RegistrationFieldType.PASSWORD) { @@ -539,15 +536,13 @@ fun InputRegistrationFieldPreview() { private fun OptionalFieldsPreview() { OpenEdXTheme { Column(Modifier.background(MaterialTheme.appColors.background)) { + val optionalField = field.copy(required = false) OptionalFields( - fields = listOf(field, field, field), - mapFields = SnapshotStateMap(), + fields = List(3) { optionalField }, showErrorMap = SnapshotStateMap(), selectableNamesMap = SnapshotStateMap(), - onSelectClick = { _, _, _ -> - - } - + onSelectClick = { _, _, _ -> }, + onFieldUpdated = { _, _ -> } ) } } @@ -561,12 +556,10 @@ private fun RequiredFieldsPreview() { Column(Modifier.background(MaterialTheme.appColors.background)) { RequiredFields( fields = listOf(field, field, field), - mapFields = SnapshotStateMap(), showErrorMap = SnapshotStateMap(), selectableNamesMap = SnapshotStateMap(), - onSelectClick = { _, _, _ -> - - } + onSelectClick = { _, _, _ -> }, + onFieldUpdated = { _, _ -> } ) } } @@ -602,4 +595,4 @@ private val field = RegistrationField( restrictions = RegistrationField.Restrictions(), options = listOf(option, option), errorInstructions = "" -) \ No newline at end of file +) diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt new file mode 100644 index 000000000..c9d73662b --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt @@ -0,0 +1,144 @@ +package org.openedx.auth.presentation.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.openedx.auth.R +import org.openedx.auth.data.model.AuthType +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors + +@Composable +internal fun SocialAuthView( + modifier: Modifier = Modifier, + isGoogleAuthEnabled: Boolean = true, + isFacebookAuthEnabled: Boolean = true, + isMicrosoftAuthEnabled: Boolean = true, + isSignIn: Boolean = false, + onEvent: (AuthType) -> Unit, +) { + Column(modifier = modifier) { + if (isGoogleAuthEnabled) { + val stringRes = if (isSignIn) { + R.string.auth_google + } else { + R.string.auth_continue_google + } + OpenEdXOutlinedButton( + modifier = Modifier + .testTag("btn_google_auth") + .padding(top = 24.dp) + .fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primary, + textColor = Color.Unspecified, + onClick = { + onEvent(AuthType.GOOGLE) + } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.ic_auth_google), + contentDescription = null, + tint = Color.Unspecified, + ) + Text( + modifier = Modifier + .testTag("txt_google_auth") + .padding(start = 10.dp), + text = stringResource(id = stringRes) + ) + } + } + } + if (isFacebookAuthEnabled) { + val stringRes = if (isSignIn) { + R.string.auth_facebook + } else { + R.string.auth_continue_facebook + } + OpenEdXButton( + width = Modifier + .testTag("btn_facebook_auth") + .padding(top = 12.dp) + .fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.authFacebookButtonBackground, + onClick = { + onEvent(AuthType.FACEBOOK) + } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.ic_auth_facebook), + contentDescription = null, + tint = MaterialTheme.appColors.buttonText, + ) + Text( + modifier = Modifier + .testTag("txt_facebook_auth") + .padding(start = 10.dp), + color = MaterialTheme.appColors.buttonText, + text = stringResource(id = stringRes) + ) + } + } + } + if (isMicrosoftAuthEnabled) { + val stringRes = if (isSignIn) { + R.string.auth_microsoft + } else { + R.string.auth_continue_microsoft + } + OpenEdXButton( + width = Modifier + .testTag("btn_microsoft_auth") + .padding(top = 12.dp) + .fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.authMicrosoftButtonBackground, + onClick = { + onEvent(AuthType.MICROSOFT) + } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.ic_auth_microsoft), + contentDescription = null, + tint = Color.Unspecified, + ) + Text( + modifier = Modifier + .testTag("txt_microsoft_auth") + .padding(start = 10.dp), + color = MaterialTheme.appColors.buttonText, + text = stringResource(id = stringRes) + ) + } + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun SocialAuthViewPreview() { + OpenEdXTheme { + SocialAuthView() {} + } +} diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index cc960f498..85eb3a47f 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -24,7 +24,13 @@ Enter email or username Enter password Create new account. + Complete your registration Sign in with Google Sign in with Facebook Sign in with Microsoft + Continue with Google + Continue with Facebook + Continue with Microsoft + You\'ve successfully signed in with %s. + We just need a little more information before you start learning with %s. diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index 3da90a8fb..16b5032c4 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -24,9 +24,7 @@ import org.junit.rules.TestRule import org.openedx.auth.R import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AuthAnalytics -import org.openedx.auth.presentation.sso.FacebookAuthHelper -import org.openedx.auth.presentation.sso.GoogleAuthHelper -import org.openedx.auth.presentation.sso.MicrosoftAuthHelper +import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.UIMessage import org.openedx.core.Validator import org.openedx.core.config.Config @@ -56,9 +54,7 @@ class SignInViewModelTest { private val interactor = mockk() private val analytics = mockk() private val appUpgradeNotifier = mockk() - private val facebookAuthHelper = mockk() - private val googleAuthHelper = mockk() - private val microsoftAuthHelper = mockk() + private val oAuthHelper = mockk() private val invalidCredential = "Invalid credentials" private val noInternet = "Slow or no internet connection" @@ -101,9 +97,7 @@ class SignInViewModelTest { validator = validator, analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, - facebookAuthHelper = facebookAuthHelper, - googleAuthHelper = googleAuthHelper, - microsoftAuthHelper = microsoftAuthHelper, + oAuthHelper = oAuthHelper, config = config, courseId = "", ) @@ -130,9 +124,7 @@ class SignInViewModelTest { validator = validator, analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, - facebookAuthHelper = facebookAuthHelper, - googleAuthHelper = googleAuthHelper, - microsoftAuthHelper = microsoftAuthHelper, + oAuthHelper = oAuthHelper, config = config, courseId = "", ) @@ -161,9 +153,7 @@ class SignInViewModelTest { validator = validator, analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, - facebookAuthHelper = facebookAuthHelper, - googleAuthHelper = googleAuthHelper, - microsoftAuthHelper = microsoftAuthHelper, + oAuthHelper = oAuthHelper, config = config, courseId = "", ) @@ -191,9 +181,7 @@ class SignInViewModelTest { validator = validator, analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, - facebookAuthHelper = facebookAuthHelper, - googleAuthHelper = googleAuthHelper, - microsoftAuthHelper = microsoftAuthHelper, + oAuthHelper = oAuthHelper, config = config, courseId = "", ) @@ -223,9 +211,7 @@ class SignInViewModelTest { validator = validator, analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, - facebookAuthHelper = facebookAuthHelper, - googleAuthHelper = googleAuthHelper, - microsoftAuthHelper = microsoftAuthHelper, + oAuthHelper = oAuthHelper, config = config, courseId = "", ) @@ -256,9 +242,7 @@ class SignInViewModelTest { validator = validator, analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, - facebookAuthHelper = facebookAuthHelper, - googleAuthHelper = googleAuthHelper, - microsoftAuthHelper = microsoftAuthHelper, + oAuthHelper = oAuthHelper, config = config, courseId = "", ) @@ -290,9 +274,7 @@ class SignInViewModelTest { validator = validator, analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, - facebookAuthHelper = facebookAuthHelper, - googleAuthHelper = googleAuthHelper, - microsoftAuthHelper = microsoftAuthHelper, + oAuthHelper = oAuthHelper, config = config, courseId = "", ) @@ -324,9 +306,7 @@ class SignInViewModelTest { validator = validator, analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, - facebookAuthHelper = facebookAuthHelper, - googleAuthHelper = googleAuthHelper, - microsoftAuthHelper = microsoftAuthHelper, + oAuthHelper = oAuthHelper, config = config, courseId = "", ) diff --git a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt index 0aa4a1201..bd048902c 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt @@ -8,7 +8,9 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -16,6 +18,8 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -23,9 +27,14 @@ import org.junit.rules.TestRule import org.openedx.auth.data.model.ValidationFields import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.ApiConstants import org.openedx.core.R import org.openedx.core.UIMessage +import org.openedx.core.config.Config +import org.openedx.core.config.FacebookConfig +import org.openedx.core.config.GoogleConfig +import org.openedx.core.config.MicrosoftConfig import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.RegistrationField @@ -42,22 +51,25 @@ class SignUpViewModelTest { val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() private val dispatcher = StandardTestDispatcher() + private val config = mockk() private val resourceManager = mockk() private val preferencesManager = mockk() private val interactor = mockk() private val analytics = mockk() private val appUpgradeNotifier = mockk() + private val oAuthHelper = mockk() //region parameters private val parametersMap = mapOf( ApiConstants.EMAIL to "user@gmail.com", - ApiConstants.PASSWORD to "password123" + ApiConstants.PASSWORD to "password123", + "honor_code" to "true", ) private val listOfFields = listOf( RegistrationField( - "", + ApiConstants.EMAIL, "", RegistrationFieldType.TEXT, "", @@ -69,7 +81,7 @@ class SignUpViewModelTest { ), RegistrationField( - "", + ApiConstants.PASSWORD, "", RegistrationFieldType.TEXT, "", @@ -95,6 +107,10 @@ class SignUpViewModelTest { every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { appUpgradeNotifier.notifier } returns emptyFlow() + every { config.isSocialAuthEnabled() } returns false + every { config.getFacebookConfig() } returns FacebookConfig() + every { config.getGoogleConfig() } returns GoogleConfig() + every { config.getMicrosoftConfig() } returns MicrosoftConfig() } @After @@ -105,22 +121,30 @@ class SignUpViewModelTest { @Test fun `register has validation errors`() = runTest { val viewModel = SignUpViewModel( - interactor, - resourceManager, - analytics, - preferencesManager, - appUpgradeNotifier, + interactor = interactor, + resourceManager = resourceManager, + analytics = analytics, + preferencesManager = preferencesManager, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, courseId = "", ) coEvery { interactor.validateRegistrationFields(parametersMap) } returns ValidationFields( parametersMap ) + coEvery { interactor.getRegistrationFields() } returns listOfFields every { analytics.createAccountClickedEvent(any()) } returns Unit coEvery { interactor.register(parametersMap) } returns Unit coEvery { interactor.login("", "") } returns Unit every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - viewModel.register(parametersMap) + viewModel.getRegistrationFields() + advanceUntilIdle() + parametersMap.forEach { + viewModel.updateField(it.key, it.value) + } + viewModel.register() advanceUntilIdle() coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } verify(exactly = 1) { analytics.createAccountClickedEvent(any()) } @@ -129,23 +153,27 @@ class SignUpViewModelTest { verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } - assertEquals(true, viewModel.validationError.value) - assert(viewModel.successLogin.value != true) - assert(viewModel.isButtonLoading.value != true) - assertEquals(null, viewModel.uiMessage.value) + assertEquals(true, viewModel.uiState.value.validationError) + assertFalse(viewModel.uiState.value.successLogin) + assertFalse(viewModel.uiState.value.isButtonLoading) } @Test fun `register no internet error`() = runTest { val viewModel = SignUpViewModel( - interactor, - resourceManager, - analytics, - preferencesManager, - appUpgradeNotifier, + interactor = interactor, + resourceManager = resourceManager, + analytics = analytics, + preferencesManager = preferencesManager, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, courseId = "", ) + val deferred = async { viewModel.uiMessage.first() } + coEvery { interactor.validateRegistrationFields(parametersMap) } throws UnknownHostException() + coEvery { interactor.getRegistrationFields() } returns listOfFields coEvery { interactor.register(parametersMap) } returns Unit coEvery { interactor.login( @@ -156,7 +184,12 @@ class SignUpViewModelTest { every { analytics.createAccountClickedEvent(any()) } returns Unit every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - viewModel.register(parametersMap) + viewModel.getRegistrationFields() + advanceUntilIdle() + parametersMap.forEach { + viewModel.updateField(it.key, it.value) + } + viewModel.register() advanceUntilIdle() verify(exactly = 1) { analytics.createAccountClickedEvent(any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } @@ -165,31 +198,33 @@ class SignUpViewModelTest { coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - - assertEquals(false, viewModel.validationError.value) - assert(viewModel.successLogin.value != true) - assert(viewModel.isButtonLoading.value != true) - assertEquals(noInternet, message?.message) + assertFalse(viewModel.uiState.value.validationError) + assertFalse(viewModel.uiState.value.successLogin) + assertFalse(viewModel.uiState.value.isButtonLoading) + assertEquals(noInternet, (deferred.await() as? UIMessage.SnackBarMessage)?.message) } @Test fun `something went wrong error`() = runTest { val viewModel = SignUpViewModel( - interactor, - resourceManager, - analytics, - preferencesManager, - appUpgradeNotifier, + interactor = interactor, + resourceManager = resourceManager, + analytics = analytics, + preferencesManager = preferencesManager, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, courseId = "", ) + val deferred = async { viewModel.uiMessage.first() } + coEvery { interactor.validateRegistrationFields(parametersMap) } throws Exception() coEvery { interactor.register(parametersMap) } returns Unit coEvery { interactor.login("", "") } returns Unit every { analytics.createAccountClickedEvent(any()) } returns Unit every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - viewModel.register(parametersMap) + viewModel.register() advanceUntilIdle() verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { analytics.createAccountClickedEvent(any()) } @@ -198,23 +233,23 @@ class SignUpViewModelTest { coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - - assertEquals(false, viewModel.validationError.value) - assert(viewModel.successLogin.value != true) - assert(viewModel.isButtonLoading.value != true) - assertEquals(somethingWrong, message?.message) + assertFalse(viewModel.uiState.value.validationError) + assertFalse(viewModel.uiState.value.successLogin) + assertFalse(viewModel.uiState.value.isButtonLoading) + assertEquals(somethingWrong, (deferred.await() as? UIMessage.SnackBarMessage)?.message) } @Test fun `success register`() = runTest { val viewModel = SignUpViewModel( - interactor, - resourceManager, - analytics, - preferencesManager, - appUpgradeNotifier, + interactor = interactor, + resourceManager = resourceManager, + analytics = analytics, + preferencesManager = preferencesManager, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, courseId = "", ) coEvery { interactor.validateRegistrationFields(parametersMap) } returns ValidationFields( @@ -222,6 +257,7 @@ class SignUpViewModelTest { ) every { analytics.createAccountClickedEvent(any()) } returns Unit every { analytics.registrationSuccessEvent(any()) } returns Unit + coEvery { interactor.getRegistrationFields() } returns listOfFields coEvery { interactor.register(parametersMap) } returns Unit coEvery { interactor.login( @@ -231,7 +267,12 @@ class SignUpViewModelTest { } returns Unit every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - viewModel.register(parametersMap) + viewModel.getRegistrationFields() + advanceUntilIdle() + parametersMap.forEach { + viewModel.updateField(it.key, it.value) + } + viewModel.register() advanceUntilIdle() verify(exactly = 1) { analytics.setUserIdForSession(any()) } coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } @@ -241,64 +282,69 @@ class SignUpViewModelTest { verify(exactly = 1) { analytics.registrationSuccessEvent(any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } - assertEquals(false, viewModel.validationError.value) - assertEquals(false, viewModel.isButtonLoading.value) - assertEquals(null, viewModel.uiMessage.value) - assertEquals(true, viewModel.successLogin.value) + assertFalse(viewModel.uiState.value.validationError) + assertFalse(viewModel.uiState.value.isButtonLoading) + assertTrue(viewModel.uiState.value.successLogin) } @Test fun `getRegistrationFields no internet error`() = runTest { val viewModel = SignUpViewModel( - interactor, - resourceManager, - analytics, - preferencesManager, - appUpgradeNotifier, + interactor = interactor, + resourceManager = resourceManager, + analytics = analytics, + preferencesManager = preferencesManager, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, courseId = "", ) + val deferred = async { viewModel.uiMessage.first() } + coEvery { interactor.getRegistrationFields() } throws UnknownHostException() viewModel.getRegistrationFields() advanceUntilIdle() coVerify(exactly = 1) { interactor.getRegistrationFields() } verify(exactly = 1) { appUpgradeNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - - assert(viewModel.uiState.value is SignUpUIState.Loading) - assertEquals(noInternet, message?.message) + assertTrue(viewModel.uiState.value.isLoading) + assertEquals(noInternet, (deferred.await() as? UIMessage.SnackBarMessage)?.message) } @Test fun `getRegistrationFields unknown error`() = runTest { val viewModel = SignUpViewModel( - interactor, - resourceManager, - analytics, - preferencesManager, - appUpgradeNotifier, + interactor = interactor, + resourceManager = resourceManager, + analytics = analytics, + preferencesManager = preferencesManager, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, courseId = "", ) + val deferred = async { viewModel.uiMessage.first() } + coEvery { interactor.getRegistrationFields() } throws Exception() viewModel.getRegistrationFields() advanceUntilIdle() coVerify(exactly = 1) { interactor.getRegistrationFields() } verify(exactly = 1) { appUpgradeNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - - assert(viewModel.uiState.value is SignUpUIState.Loading) - assertEquals(somethingWrong, message?.message) + assertTrue(viewModel.uiState.value.isLoading) + assertEquals(somethingWrong, (deferred.await() as? UIMessage.SnackBarMessage)?.message) } @Test fun `getRegistrationFields success`() = runTest { val viewModel = SignUpViewModel( - interactor, - resourceManager, - analytics, - preferencesManager, - appUpgradeNotifier, + interactor = interactor, + resourceManager = resourceManager, + analytics = analytics, + preferencesManager = preferencesManager, + appUpgradeNotifier = appUpgradeNotifier, + oAuthHelper = oAuthHelper, + config = config, courseId = "", ) coEvery { interactor.getRegistrationFields() } returns listOfFields @@ -309,7 +355,6 @@ class SignUpViewModelTest { //val fields = viewModel.uiState.value as? SignUpUIState.Fields - assert(viewModel.uiState.value is SignUpUIState.Fields) - assertEquals(null, viewModel.uiMessage.value) + assertFalse(viewModel.uiState.value.isLoading) } } diff --git a/core/src/main/java/org/openedx/core/ApiConstants.kt b/core/src/main/java/org/openedx/core/ApiConstants.kt index 04a056153..558df5434 100644 --- a/core/src/main/java/org/openedx/core/ApiConstants.kt +++ b/core/src/main/java/org/openedx/core/ApiConstants.kt @@ -17,8 +17,13 @@ object ApiConstants { const val TOKEN_TYPE_JWT = "jwt" const val TOKEN_TYPE_REFRESH = "refresh_token" + const val ACCESS_TOKEN = "access_token" + const val CLIENT_ID = "client_id" const val EMAIL = "email" + const val HONOR_CODE = "honor_code" + const val NAME = "name" const val PASSWORD = "password" + const val PROVIDER = "provider" const val AUTH_TYPE_GOOGLE = "google-oauth2" const val AUTH_TYPE_FB = "facebook" diff --git a/core/src/main/java/org/openedx/core/extension/ContinuationExt.kt b/core/src/main/java/org/openedx/core/extension/ContinuationExt.kt new file mode 100644 index 000000000..8de4ec05b --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/ContinuationExt.kt @@ -0,0 +1,12 @@ +package org.openedx.core.extension + +import kotlinx.coroutines.CancellableContinuation +import kotlin.coroutines.resume + +inline fun CancellableContinuation.safeResume(value: T, onExceptionCalled: () -> Unit) { + if (isActive) { + resume(value) + } else { + onExceptionCalled() + } +} diff --git a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index 73a38d61d..80f61d75d 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -1036,7 +1036,7 @@ fun OfflineModeDialog( @Composable fun OpenEdXButton( width: Modifier = Modifier.fillMaxWidth(), - text: String, + text: String = "", onClick: () -> Unit, enabled: Boolean = true, backgroundColor: Color = MaterialTheme.appColors.buttonBackground,