diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index 119f9453d..717ee954a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -81,6 +81,9 @@ fun NavGraphBuilder.authGraph( ) checkEmailDestination( onNavigateBack = { navController.popBackStack() }, + onNavigateBackToLanding = { + navController.popBackStack(route = LANDING_ROUTE, inclusive = false) + }, ) completeRegistrationDestination( onNavigateBack = { navController.popBackStack() }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailNavigation.kt index c9c2b72ec..ee8a47ec3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailNavigation.kt @@ -36,6 +36,7 @@ data class CheckEmailArgs( */ fun NavGraphBuilder.checkEmailDestination( onNavigateBack: () -> Unit, + onNavigateBackToLanding: () -> Unit, ) { composableWithSlideTransitions( route = CHECK_EMAIL_ROUTE, @@ -45,6 +46,7 @@ fun NavGraphBuilder.checkEmailDestination( ) { CheckEmailScreen( onNavigateBack = onNavigateBack, + onNavigateBackToLanding = onNavigateBackToLanding, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreen.kt index ac08a8851..e2d48c204 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -20,13 +21,17 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -35,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.auth.feature.checkemail.handlers.rememberCheckEmailHandler import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.createAnnotatedString import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin @@ -47,6 +53,8 @@ import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme +private const val TAG_URL = "URL" + /** * Top level composable for the check email screen. */ @@ -55,19 +63,23 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme @Composable fun CheckEmailScreen( onNavigateBack: () -> Unit, + onNavigateBackToLanding: () -> Unit, intentManager: IntentManager = LocalIntentManager.current, viewModel: CheckEmailViewModel = hiltViewModel(), ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val handler = rememberCheckEmailHandler(viewModel = viewModel) EventsEffect(viewModel) { event -> when (event) { is CheckEmailEvent.NavigateBack -> { - onNavigateBack.invoke() + onNavigateBack() } is CheckEmailEvent.NavigateToEmailApp -> { intentManager.startDefaultEmailApplication() } + + CheckEmailEvent.NavigateBackToLanding -> onNavigateBackToLanding() } } @@ -82,9 +94,7 @@ fun CheckEmailScreen( scrollBehavior = scrollBehavior, navigationIcon = rememberVectorPainter(id = R.drawable.ic_back), navigationIconContentDescription = stringResource(id = R.string.back), - onNavigationIconClick = remember(viewModel) { - { viewModel.trySendAction(CheckEmailAction.BackClick) } - }, + onNavigationIconClick = handler.onBackClick, ) }, ) { innerPadding -> @@ -95,20 +105,21 @@ fun CheckEmailScreen( .fillMaxSize() .verticalScroll(rememberScrollState()), ) { - CheckEmailContent( - email = state.email, - onOpenEmailAppClick = remember(viewModel) { - { - viewModel.trySendAction(CheckEmailAction.OpenEmailClick) - } - }, - onChangeEmailClick = remember(viewModel) { - { - viewModel.trySendAction(CheckEmailAction.ChangeEmailClick) - } - }, - modifier = Modifier.standardHorizontalMargin(), - ) + if (state.showNewOnboardingUi) { + CheckEmailContent( + email = state.email, + onOpenEmailAppClick = handler.onOpenEmailAppClick, + onChangeEmailClick = handler.onChangeEmailClick, + modifier = Modifier.standardHorizontalMargin(), + ) + } else { + CheckEmailLegacyContent( + email = state.email, + onOpenEmailAppClick = handler.onOpenEmailAppClick, + onChangeEmailClick = handler.onChangeEmailClick, + onLoginClick = handler.onLoginClick, + ) + } Spacer(modifier = Modifier.navigationBarsPadding()) } } @@ -198,9 +209,136 @@ private fun CheckEmailContent( } } +@Suppress("LongMethod") +@Composable +private fun CheckEmailLegacyContent( + email: String, + onOpenEmailAppClick: () -> Unit, + onChangeEmailClick: () -> Unit, + onLoginClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(32.dp)) + Image( + painter = rememberVectorPainter(id = R.drawable.email_check), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + contentDescription = null, + contentScale = ContentScale.FillHeight, + modifier = Modifier + .padding(horizontal = 16.dp) + .height(112.dp) + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(32.dp)) + Text( + text = stringResource(id = R.string.check_your_email), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(horizontal = 24.dp) + .wrapContentHeight() + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + + @Suppress("MaxLineLength") + val descriptionAnnotatedString = createAnnotatedString( + mainString = stringResource( + id = R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account, + email, + ), + highlights = listOf(email), + highlightStyle = SpanStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.bodyMedium.fontSize, + fontWeight = FontWeight.Bold, + ), + tag = "EMAIL", + ) + Text( + text = descriptionAnnotatedString, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth() + .wrapContentHeight(), + ) + Spacer(modifier = Modifier.height(32.dp)) + BitwardenFilledButton( + label = stringResource(id = R.string.open_email_app), + onClick = onOpenEmailAppClick, + modifier = Modifier + .testTag("OpenEmailApp") + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(32.dp)) + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val goBackAnnotatedString = createAnnotatedString( + mainString = stringResource( + id = R.string.no_email_go_back_to_edit_your_email_address, + ), + highlights = listOf(stringResource(id = R.string.go_back)), + tag = TAG_URL, + ) + ClickableText( + text = goBackAnnotatedString, + onClick = { + goBackAnnotatedString + .getStringAnnotations(TAG_URL, it, it) + .firstOrNull()?.let { + onChangeEmailClick() + } + }, + modifier = Modifier.semantics { + role = Role.Button + onClick { + onChangeEmailClick() + true + } + }, + ) + Spacer(modifier = Modifier.height(32.dp)) + val logInAnnotatedString = createAnnotatedString( + mainString = stringResource( + id = R.string.or_log_in_you_may_already_have_an_account, + ), + highlights = listOf(stringResource(id = R.string.log_in)), + tag = TAG_URL, + ) + ClickableText( + text = logInAnnotatedString, + onClick = { + logInAnnotatedString + .getStringAnnotations(TAG_URL, it, it) + .firstOrNull()?.let { + onLoginClick() + } + }, + modifier = Modifier.semantics { + role = Role.Button + onClick { + onLoginClick() + true + } + }, + ) + } + } +} + @Preview(showBackground = true) @Composable -private fun CheckEmailScreenPreview() { +private fun CheckEmailScreenNewUi_preview() { BitwardenTheme { CheckEmailContent( email = "email@fake.com", @@ -210,3 +348,16 @@ private fun CheckEmailScreenPreview() { ) } } + +@Preview(showBackground = true) +@Composable +private fun CheckEmailScreenLegacy_preview() { + BitwardenTheme { + CheckEmailLegacyContent( + email = "email@fake.com", + onOpenEmailAppClick = { }, + onChangeEmailClick = { }, + onLoginClick = {}, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModel.kt index 14d3372d1..c65b92d84 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModel.kt @@ -3,10 +3,14 @@ package com.x8bit.bitwarden.ui.auth.feature.checkemail import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -17,11 +21,13 @@ private const val KEY_STATE = "state" */ @HiltViewModel class CheckEmailViewModel @Inject constructor( + featureFlagManager: FeatureFlagManager, savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: CheckEmailState( email = CheckEmailArgs(savedStateHandle).emailAddress, + showNewOnboardingUi = featureFlagManager.getFeatureFlag(FlagKey.OnboardingFlow), ), ) { init { @@ -29,6 +35,14 @@ class CheckEmailViewModel @Inject constructor( stateFlow .onEach { savedStateHandle[KEY_STATE] = it } .launchIn(viewModelScope) + // Listen for changes on the onboarding feature flag. + featureFlagManager + .getFeatureFlagFlow(FlagKey.OnboardingFlow) + .map { + CheckEmailAction.Internal.OnboardingFeatureFlagUpdated(it) + } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: CheckEmailAction) { @@ -36,6 +50,23 @@ class CheckEmailViewModel @Inject constructor( CheckEmailAction.BackClick -> handleBackClick() CheckEmailAction.OpenEmailClick -> handleOpenEmailClick() CheckEmailAction.ChangeEmailClick -> handleChangeEmailClick() + is CheckEmailAction.Internal.OnboardingFeatureFlagUpdated -> { + handleOnboardingFeatureFlagUpdated(action) + } + + CheckEmailAction.LoginClick -> handleLoginClick() + } + } + + private fun handleLoginClick() { + sendEvent(CheckEmailEvent.NavigateBackToLanding) + } + + private fun handleOnboardingFeatureFlagUpdated( + action: CheckEmailAction.Internal.OnboardingFeatureFlagUpdated, + ) { + mutableStateFlow.update { + it.copy(showNewOnboardingUi = action.newValue) } } @@ -52,6 +83,7 @@ class CheckEmailViewModel @Inject constructor( @Parcelize data class CheckEmailState( val email: String, + val showNewOnboardingUi: Boolean, ) : Parcelable /** @@ -68,6 +100,11 @@ sealed class CheckEmailEvent { * Navigate to email app. */ data object NavigateToEmailApp : CheckEmailEvent() + + /** + * Navigate back to Landing + */ + data object NavigateBackToLanding : CheckEmailEvent() } /** @@ -88,4 +125,19 @@ sealed class CheckEmailAction { * User clicked open email. */ data object OpenEmailClick : CheckEmailAction() + + /** + * User clicked log in. + */ + data object LoginClick : CheckEmailAction() + + /** + * Denotes an internal action. + */ + sealed class Internal : CheckEmailAction() { + /** + * Indicates updated value for onboarding feature flag. + */ + data class OnboardingFeatureFlagUpdated(val newValue: Boolean) : Internal() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/handlers/CheckEmailHandler.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/handlers/CheckEmailHandler.kt new file mode 100644 index 000000000..464537c19 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/handlers/CheckEmailHandler.kt @@ -0,0 +1,37 @@ +package com.x8bit.bitwarden.ui.auth.feature.checkemail.handlers + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.x8bit.bitwarden.ui.auth.feature.checkemail.CheckEmailAction +import com.x8bit.bitwarden.ui.auth.feature.checkemail.CheckEmailViewModel + +/** + * Handler for [CheckEmailScreen] actions. + */ +class CheckEmailHandler( + val onOpenEmailAppClick: () -> Unit, + val onChangeEmailClick: () -> Unit, + val onBackClick: () -> Unit, + val onLoginClick: () -> Unit, +) { + companion object { + /** + * Create [CheckEmailHandler] with the given [viewModel] to send actions to. + */ + fun create(viewModel: CheckEmailViewModel) = CheckEmailHandler( + onChangeEmailClick = { viewModel.trySendAction(CheckEmailAction.ChangeEmailClick) }, + onOpenEmailAppClick = { viewModel.trySendAction(CheckEmailAction.OpenEmailClick) }, + onLoginClick = { viewModel.trySendAction(CheckEmailAction.LoginClick) }, + onBackClick = { viewModel.trySendAction(CheckEmailAction.BackClick) }, + ) + } +} + +/** + * Remember [CheckEmailHandler] with the given [viewModel] within a [Composable] scope. + */ +@Composable +fun rememberCheckEmailHandler(viewModel: CheckEmailViewModel) = + remember(viewModel) { + CheckEmailHandler.create(viewModel) + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreen.kt index 7c42b3719..7e448613c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreen.kt @@ -194,6 +194,7 @@ fun StartRegistrationScreen( nameInput = state.nameInput, isReceiveMarketingEmailsToggled = state.isReceiveMarketingEmailsToggled, isContinueButtonEnabled = state.isContinueButtonEnabled, + isNewOnboardingUiEnabled = state.showNewOnboardingUi, handler = handler, ) Spacer(modifier = Modifier.navigationBarsPadding()) @@ -210,18 +211,21 @@ private fun StartRegistrationContent( isReceiveMarketingEmailsToggled: Boolean, isContinueButtonEnabled: Boolean, handler: StartRegistrationHandler, + isNewOnboardingUiEnabled: Boolean, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { Spacer(modifier = Modifier.height(16.dp)) - Image( - painter = rememberVectorPainter(id = R.drawable.vault), - contentDescription = null, - modifier = Modifier - .size(132.dp) - .align(Alignment.CenterHorizontally), - ) - Spacer(modifier = Modifier.height(48.dp)) + if (isNewOnboardingUiEnabled) { + Image( + painter = rememberVectorPainter(id = R.drawable.vault), + contentDescription = null, + modifier = Modifier + .size(132.dp) + .align(Alignment.CenterHorizontally), + ) + Spacer(modifier = Modifier.height(48.dp)) + } BitwardenTextField( label = stringResource(id = R.string.name), value = nameInput, @@ -233,13 +237,9 @@ private fun StartRegistrationContent( ) Spacer(modifier = Modifier.height(16.dp)) BitwardenTextField( - label = if (emailInput.isEmpty()) { - stringResource(R.string.email_address_required) - } else { - stringResource( + label = stringResource( id = R.string.email_address, - ) - }, + ), placeholder = stringResource(R.string.email_address_required), value = emailInput, onValueChange = handler.onEmailInputChange, @@ -263,17 +263,19 @@ private fun StartRegistrationContent( modifier = Modifier .testTag("RegionSelectorDropdown"), ) - IconButton( - onClick = handler.onServerGeologyHelpClick, - // Align with design but keep accessible touch target of IconButton. - modifier = Modifier.offset(y = (-8f).dp, x = 16.dp), - ) { - Icon( - painter = rememberVectorPainter(id = R.drawable.ic_tooltip_small), - contentDescription = stringResource(R.string.help_with_server_geolocations), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) + if (isNewOnboardingUiEnabled) { + IconButton( + onClick = handler.onServerGeologyHelpClick, + // Align with design but keep accessible touch target of IconButton. + modifier = Modifier.offset(y = (-8f).dp, x = 16.dp), + ) { + Icon( + painter = rememberVectorPainter(id = R.drawable.ic_tooltip_small), + contentDescription = stringResource(R.string.help_with_server_geolocations), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + } } } Spacer(modifier = Modifier.height(24.dp)) @@ -474,7 +476,7 @@ private fun ReceiveMarketingEmailsSwitch( @PreviewScreenSizes @Composable -private fun StartRegistrationContentPreview_filledout() { +private fun StartRegistrationContentFilledOut_preview() { BitwardenTheme { StartRegistrationContent( emailInput = "e@mail.com", @@ -482,6 +484,7 @@ private fun StartRegistrationContentPreview_filledout() { nameInput = "Test User", isReceiveMarketingEmailsToggled = true, isContinueButtonEnabled = true, + isNewOnboardingUiEnabled = false, handler = StartRegistrationHandler( onEmailInputChange = {}, onNameInputChange = {}, @@ -500,7 +503,7 @@ private fun StartRegistrationContentPreview_filledout() { @Preview(showBackground = true) @Composable -private fun StartRegistrationContentPreview_empty() { +private fun StartRegistrationContentEmpty_preview() { BitwardenTheme { StartRegistrationContent( emailInput = "", @@ -508,6 +511,34 @@ private fun StartRegistrationContentPreview_empty() { nameInput = "", isReceiveMarketingEmailsToggled = false, isContinueButtonEnabled = false, + isNewOnboardingUiEnabled = false, + handler = StartRegistrationHandler( + onEmailInputChange = {}, + onNameInputChange = {}, + onEnvironmentTypeSelect = {}, + onContinueClick = {}, + onTermsClick = {}, + onPrivacyPolicyClick = {}, + onReceiveMarketingEmailsToggle = {}, + onUnsubscribeMarketingEmailsClick = {}, + onServerGeologyHelpClick = {}, + onBackClick = {}, + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun StartRegistrationContentNewOnboardingUi_preview() { + BitwardenTheme { + StartRegistrationContent( + emailInput = "", + selectedEnvironmentType = Environment.Type.US, + nameInput = "", + isReceiveMarketingEmailsToggled = false, + isContinueButtonEnabled = false, + isNewOnboardingUiEnabled = true, handler = StartRegistrationHandler( onEmailInputChange = {}, onNameInputChange = {}, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModel.kt index 9a25b3e72..9a3480bea 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModel.kt @@ -7,6 +7,8 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.model.Environment.Type @@ -15,6 +17,7 @@ import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAc import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EmailInputChange import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.EnvironmentTypeSelect import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.ErrorDialogDismiss +import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.Internal.OnboardingFeatureFlagUpdated import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.Internal.ReceiveSendVerificationEmailResult import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.Internal.UpdatedEnvironmentReceive import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.NameInputChange @@ -29,6 +32,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.isValidEmail import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -44,6 +48,7 @@ private const val KEY_STATE = "state" @HiltViewModel class StartRegistrationViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + featureFlagManager: FeatureFlagManager, private val authRepository: AuthRepository, private val environmentRepository: EnvironmentRepository, ) : BaseViewModel( @@ -55,6 +60,7 @@ class StartRegistrationViewModel @Inject constructor( isContinueButtonEnabled = false, selectedEnvironmentType = environmentRepository.environment.type, dialog = null, + showNewOnboardingUi = featureFlagManager.getFeatureFlag(FlagKey.OnboardingFlow), ), ) { @@ -73,6 +79,14 @@ class StartRegistrationViewModel @Inject constructor( ) } .launchIn(viewModelScope) + // Listen for changes on the onboarding feature flag. + featureFlagManager + .getFeatureFlagFlow(FlagKey.OnboardingFlow) + .map { + OnboardingFeatureFlagUpdated(it) + } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: StartRegistrationAction) { @@ -99,9 +113,16 @@ class StartRegistrationViewModel @Inject constructor( } ServerGeologyHelpClick -> handleServerGeologyHelpClick() + is OnboardingFeatureFlagUpdated -> handleOnboardingFeatureFlagUpdated(action) } } +private fun handleOnboardingFeatureFlagUpdated(action: OnboardingFeatureFlagUpdated) { + mutableStateFlow.update { + it.copy(showNewOnboardingUi = action.newValue) + } +} + private fun handleServerGeologyHelpClick() { sendEvent(StartRegistrationEvent.NavigateToServerSelectionInfo) } @@ -269,6 +290,7 @@ data class StartRegistrationState( val isContinueButtonEnabled: Boolean, val selectedEnvironmentType: Type, val dialog: StartRegistrationDialog?, + val showNewOnboardingUi: Boolean, ) : Parcelable /** @@ -422,5 +444,10 @@ sealed class StartRegistrationAction { data class UpdatedEnvironmentReceive( val environment: Environment, ) : Internal() + + /** + * Indicates updated value for onboarding feature flag. + */ + data class OnboardingFeatureFlagUpdated(val newValue: Boolean) : Internal() } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreenTest.kt index c3da5dc8b..238b65141 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailScreenTest.kt @@ -1,9 +1,11 @@ package com.x8bit.bitwarden.ui.auth.feature.checkemail +import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performSemanticsAction import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager @@ -12,8 +14,8 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs import io.mockk.verify -import junit.framework.TestCase import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -22,6 +24,7 @@ class CheckEmailScreenTest : BaseComposeTest() { every { startDefaultEmailApplication() } just runs } private var onNavigateBackCalled = false + private var onNavigateToLandingCalled = false private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val mutableEventFlow = bufferedMutableSharedFlow() @@ -35,6 +38,7 @@ class CheckEmailScreenTest : BaseComposeTest() { composeTestRule.setContent { CheckEmailScreen( onNavigateBack = { onNavigateBackCalled = true }, + onNavigateBackToLanding = { onNavigateToLandingCalled = true }, viewModel = viewModel, intentManager = intentManager, ) @@ -63,10 +67,16 @@ class CheckEmailScreenTest : BaseComposeTest() { } } + @Test + fun `login button click should send LoginTap action`() { + mutableEventFlow.tryEmit(CheckEmailEvent.NavigateBackToLanding) + assertTrue(onNavigateToLandingCalled) + } + @Test fun `NavigateBack should call onNavigateBack`() { mutableEventFlow.tryEmit(CheckEmailEvent.NavigateBack) - TestCase.assertTrue(onNavigateBackCalled) + assertTrue(onNavigateBackCalled) } @Test @@ -77,8 +87,31 @@ class CheckEmailScreenTest : BaseComposeTest() { } } + @Test + fun `go back and update email text click should send ChangeEmailClick action`() { + mutableStateFlow.value = DEFAULT_STATE.copy(showNewOnboardingUi = false) + composeTestRule + .onNodeWithText("No email? Go back to edit your email address.") + .performScrollTo() + .performSemanticsAction(SemanticsActions.OnClick) + + verify { viewModel.trySendAction(CheckEmailAction.ChangeEmailClick) } + } + + @Test + fun `already have account text click should send ChangeEmailClick action`() { + mutableStateFlow.value = DEFAULT_STATE.copy(showNewOnboardingUi = false) + composeTestRule + .onNodeWithText("Or log in, you may already have an account.") + .performScrollTo() + .performSemanticsAction(SemanticsActions.OnClick) + + verify { viewModel.trySendAction(CheckEmailAction.LoginClick) } + } + @Test fun `change email button click should send ChangeEmailClick action`() { + mutableStateFlow.value = DEFAULT_STATE.copy(showNewOnboardingUi = true) composeTestRule .onNodeWithText("Change email address") .performScrollTo() @@ -91,6 +124,7 @@ class CheckEmailScreenTest : BaseComposeTest() { private const val EMAIL = "test@gmail.com" private val DEFAULT_STATE = CheckEmailState( email = EMAIL, + showNewOnboardingUi = false, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModelTest.kt index 2b565bf8c..d88bfed08 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/checkemail/CheckEmailViewModelTest.kt @@ -2,12 +2,23 @@ package com.x8bit.bitwarden.ui.auth.feature.checkemail import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class CheckEmailViewModelTest : BaseViewModelTest() { + private val mutableFeatureFlagFlow = MutableStateFlow(false) + private val featureFlagManager = mockk(relaxed = true) { + every { getFeatureFlag(FlagKey.OnboardingFlow) } returns false + every { getFeatureFlagFlow(FlagKey.OnboardingFlow) } returns mutableFeatureFlagFlow + } + @Test fun `initial state should be correct`() = runTest { val viewModel = createViewModel() @@ -63,8 +74,31 @@ class CheckEmailViewModelTest : BaseViewModelTest() { } } + @Test + fun `OnboardingFeatureFlagUpdated should update showNewOnboardingUi in state`() { + val viewModel = createViewModel() + mutableFeatureFlagFlow.value = true + val expectedState = DEFAULT_STATE.copy( + showNewOnboardingUi = true, + ) + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `OnLoginClick action should send NavigateToLanding event`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(CheckEmailAction.LoginClick) + assertEquals( + CheckEmailEvent.NavigateBackToLanding, + awaitItem(), + ) + } + } + private fun createViewModel(state: CheckEmailState? = null): CheckEmailViewModel = CheckEmailViewModel( + featureFlagManager = featureFlagManager, savedStateHandle = SavedStateHandle().also { it["email"] = EMAIL it["state"] = state @@ -75,6 +109,7 @@ class CheckEmailViewModelTest : BaseViewModelTest() { private const val EMAIL = "test@gmail.com" private val DEFAULT_STATE = CheckEmailState( email = EMAIL, + showNewOnboardingUi = false, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreenTest.kt index fcf75ed5d..acf54c325 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationScreenTest.kt @@ -134,7 +134,7 @@ class StartRegistrationScreenTest : BaseComposeTest() { @Test fun `email input change should send EmailInputChange action`() { - composeTestRule.onNodeWithText("Email address (required)").performTextInput(TEST_INPUT) + composeTestRule.onNodeWithText("Email address").performTextInput(TEST_INPUT) verify { viewModel.trySendAction(EmailInputChange(TEST_INPUT)) } } @@ -180,6 +180,7 @@ class StartRegistrationScreenTest : BaseComposeTest() { @Test fun `clicking the server tool tip should send ServerGeologyHelpClickAction`() { + mutableStateFlow.value = DEFAULT_STATE.copy(showNewOnboardingUi = true) composeTestRule .onNodeWithContentDescription("Help with server geolocations.") .performScrollTo() @@ -188,6 +189,14 @@ class StartRegistrationScreenTest : BaseComposeTest() { verify { viewModel.trySendAction(StartRegistrationAction.ServerGeologyHelpClick) } } + @Test + fun `server tool tip should not exist if not in new onboarding ui`() { + mutableStateFlow.value = DEFAULT_STATE.copy(showNewOnboardingUi = false) + composeTestRule + .onNodeWithContentDescription("Help with server geolocations.") + .assertDoesNotExist() + } + @Test fun `when NavigateToServerSelectionInfo is observed event should invoke intent manager`() { mutableEventFlow.tryEmit(StartRegistrationEvent.NavigateToServerSelectionInfo) @@ -279,6 +288,7 @@ class StartRegistrationScreenTest : BaseComposeTest() { isContinueButtonEnabled = false, selectedEnvironmentType = Environment.Type.US, dialog = null, + showNewOnboardingUi = false, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModelTest.kt index bf34853f6..5a430b154 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/startregistration/StartRegistrationViewModelTest.kt @@ -8,6 +8,8 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationAction.BackClick @@ -33,6 +35,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach @@ -42,7 +45,11 @@ import org.junit.jupiter.api.Test @Suppress("LargeClass") class StartRegistrationViewModelTest : BaseViewModelTest() { - + private val mutableFeatureFlagFlow = MutableStateFlow(false) + private val featureFlagManager = mockk(relaxed = true) { + every { getFeatureFlag(FlagKey.OnboardingFlow) } returns false + every { getFeatureFlagFlow(FlagKey.OnboardingFlow) } returns mutableFeatureFlagFlow + } /** * Saved state handle that has valid inputs. Useful for tests that want to test things * after the user has entered all valid inputs. @@ -71,6 +78,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) } @@ -84,12 +92,14 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { isContinueButtonEnabled = false, selectedEnvironmentType = Environment.Type.US, dialog = null, + showNewOnboardingUi = false, ) val handle = SavedStateHandle(mapOf("state" to savedState)) val viewModel = StartRegistrationViewModel( savedStateHandle = handle, authRepository = mockAuthRepository, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) assertEquals(savedState, viewModel.stateFlow.value) } @@ -100,6 +110,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) val input = "a" viewModel.trySendAction(EmailInputChange(input)) @@ -125,6 +136,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) val input = " " viewModel.trySendAction(EmailInputChange(input)) @@ -162,6 +174,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = validInputHandle, authRepository = repo, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) turbineScope { val stateFlow = viewModel.stateFlow.testIn(backgroundScope) @@ -197,6 +210,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = validInputHandle, authRepository = repo, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) viewModel.stateFlow.test { assertEquals(VALID_INPUT_STATE, awaitItem()) @@ -243,6 +257,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = validInputHandle, authRepository = repo, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) viewModel.eventFlow.test { viewModel.trySendAction(ContinueClick) @@ -280,6 +295,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = validInputHandle, authRepository = repo, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) viewModel.eventFlow.test { viewModel.trySendAction(ContinueClick) @@ -298,6 +314,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) viewModel.eventFlow.test { viewModel.trySendAction(BackClick) @@ -311,6 +328,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) viewModel.eventFlow.test { viewModel.trySendAction(PrivacyPolicyClick) @@ -324,6 +342,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) viewModel.eventFlow.test { viewModel.trySendAction(TermsClick) @@ -337,6 +356,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) viewModel.eventFlow.test { viewModel.trySendAction(UnsubscribeMarketingEmailsClick) @@ -351,6 +371,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) viewModel.stateFlow.test { awaitItem() @@ -373,6 +394,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) viewModel.eventFlow.test { viewModel.trySendAction( @@ -393,6 +415,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) viewModel.trySendAction(EmailInputChange("input")) viewModel.stateFlow.test { @@ -412,6 +435,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) viewModel.trySendAction(NameInputChange("input")) viewModel.stateFlow.test { @@ -425,6 +449,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) viewModel.trySendAction(ReceiveMarketingEmailsToggle(false)) viewModel.stateFlow.test { @@ -438,6 +463,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { savedStateHandle = SavedStateHandle(), authRepository = mockAuthRepository, environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, ) viewModel.eventFlow.test { @@ -446,6 +472,21 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { } } + @Test + fun `OnboardingFeatureFlagUpdated should update showNewOnboardingUi in state`() { + val viewModel = StartRegistrationViewModel( + savedStateHandle = SavedStateHandle(), + authRepository = mockAuthRepository, + environmentRepository = fakeEnvironmentRepository, + featureFlagManager = featureFlagManager, + ) + mutableFeatureFlagFlow.value = true + assertEquals( + DEFAULT_STATE.copy(showNewOnboardingUi = true), + viewModel.stateFlow.value, + ) + } + companion object { private const val EMAIL = "test@test.com" private const val NAME = "name" @@ -456,6 +497,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { isContinueButtonEnabled = false, selectedEnvironmentType = Environment.Type.US, dialog = null, + showNewOnboardingUi = false, ) private val VALID_INPUT_STATE = StartRegistrationState( emailInput = EMAIL, @@ -464,6 +506,7 @@ class StartRegistrationViewModelTest : BaseViewModelTest() { isContinueButtonEnabled = true, selectedEnvironmentType = Environment.Type.US, dialog = null, + showNewOnboardingUi = false, ) } }