[PM-11270] Hide all new UI behind onboarding flow flag. (#3810)

This commit is contained in:
Dave Severns 2024-08-22 16:06:54 -04:00 committed by GitHub
parent b56a21b6e5
commit 82d3b44712
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 476 additions and 51 deletions

View file

@ -81,6 +81,9 @@ fun NavGraphBuilder.authGraph(
)
checkEmailDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateBackToLanding = {
navController.popBackStack(route = LANDING_ROUTE, inclusive = false)
},
)
completeRegistrationDestination(
onNavigateBack = { navController.popBackStack() },

View file

@ -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,
)
}
}

View file

@ -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 = {},
)
}
}

View file

@ -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<CheckEmailState, CheckEmailEvent, CheckEmailAction>(
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()
}
}

View file

@ -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)
}

View file

@ -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 = {},

View file

@ -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<StartRegistrationState, StartRegistrationEvent, StartRegistrationAction>(
@ -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()
}
}

View file

@ -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<CheckEmailEvent>()
@ -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,
)
}
}

View file

@ -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<FeatureFlagManager>(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,
)
}
}

View file

@ -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,
)
}
}

View file

@ -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<FeatureFlagManager>(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,
)
}
}