diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt index 28e7bf7481..a6382a6851 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/MainActivity.kt @@ -88,6 +88,10 @@ class MainActivity : AppCompatActivity() { mainViewModel.trySendAction(MainAction.CookieAcquisitionResult(it)) } + private val premiumCheckoutLauncher = AuthTabIntent.registerActivityResultLauncher(this) { + mainViewModel.trySendAction(MainAction.PremiumCheckoutResult(it)) + } + override fun onCreate(savedInstanceState: Bundle?) { intent = intent.validate() var shouldShowSplashScreen = true @@ -115,6 +119,7 @@ class MainActivity : AppCompatActivity() { sso = ssoLauncher, webAuthn = webAuthnLauncher, cookie = cookieLauncher, + premiumCheckout = premiumCheckoutLauncher, ), ) { ObserveScreenDataEffect( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt index efde74d6ca..6ebf6b82b8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt @@ -198,6 +198,7 @@ class MainViewModel @Inject constructor( is MainAction.SsoResult -> handleSsoResult(action) is MainAction.WebAuthnResult -> handleWebAuthnResult(action) is MainAction.CookieAcquisitionResult -> handleCookieAcquisitionResult(action) + is MainAction.PremiumCheckoutResult -> handlePremiumCheckoutResult() is MainAction.Internal -> handleInternalAction(action) } } @@ -247,6 +248,11 @@ class MainViewModel @Inject constructor( ) } + private fun handlePremiumCheckoutResult() { + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.PremiumCheckoutResult + } + private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) { when (val data = action.screenResumeData) { null -> appResumeManager.clearResumeScreen() @@ -555,6 +561,13 @@ sealed class MainAction { val cookieCallbackResult: AuthTabIntent.AuthResult, ) : MainAction() + /** + * Receive the result from the premium checkout flow. + */ + data class PremiumCheckoutResult( + val authResult: AuthTabIntent.AuthResult, + ) : MainAction() + /** * Receive first Intent by the application. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanNavigation.kt new file mode 100644 index 0000000000..478571fa24 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanNavigation.kt @@ -0,0 +1,90 @@ +@file:OmitFromCoverage + +package com.x8bit.bitwarden.ui.platform.feature.premium.plan + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.toRoute +import com.bitwarden.annotation.OmitFromCoverage +import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions +import com.bitwarden.ui.platform.util.ParcelableRouteSerializer +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * The type-safe route for the plan screen. + */ +@OmitFromCoverage +@Parcelize +@Serializable(with = PlanRoute.Serializer::class) +sealed class PlanRoute : Parcelable { + + /** + * Custom serializer to support polymorphic routes. + */ + class Serializer : ParcelableRouteSerializer(PlanRoute::class) + + /** + * Standard destination — inside settingsGraph, bottom nav visible, back arrow. + */ + @Parcelize + @Serializable(with = Standard.Serializer::class) + data object Standard : PlanRoute() { + /** + * Custom serializer to support polymorphic routes. + */ + class Serializer : ParcelableRouteSerializer(Standard::class) + } + + /** + * Modal destination — parent vaultUnlockedGraph level, bottom nav hidden, close icon. + */ + @Parcelize + @Serializable(with = Modal.Serializer::class) + data object Modal : PlanRoute() { + /** + * Custom serializer to support polymorphic routes. + */ + class Serializer : ParcelableRouteSerializer(Modal::class) + } +} + +/** + * Class to retrieve plan arguments from the [SavedStateHandle]. + */ +@OmitFromCoverage +data class PlanArgs(val planMode: PlanMode) + +/** + * Constructs [PlanArgs] from the [SavedStateHandle] and internal route data. + */ +fun SavedStateHandle.toPlanArgs(): PlanArgs { + val route = this.toRoute() + return PlanArgs( + planMode = when (route) { + is PlanRoute.Standard -> PlanMode.Standard + is PlanRoute.Modal -> PlanMode.Modal + }, + ) +} + +/** + * Register at parent vaultUnlockedGraph level — bottom nav hidden. + */ +fun NavGraphBuilder.planModalDestination( + onNavigateBack: () -> Unit, +) { + composableWithSlideTransitions { + PlanScreen(onNavigateBack = onNavigateBack) + } +} + +/** + * Navigate to the plan screen (modal, at parent level). + */ +fun NavController.navigateToPlanModal(navOptions: NavOptions? = null) { + navigate(route = PlanRoute.Modal, navOptions = navOptions) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt new file mode 100644 index 0000000000..0082006bc9 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt @@ -0,0 +1,304 @@ +package com.x8bit.bitwarden.ui.platform.feature.premium.plan + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +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.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.annotation.OmitFromCoverage +import com.bitwarden.ui.platform.base.util.EventsEffect +import com.bitwarden.ui.platform.base.util.cardStyle +import com.bitwarden.ui.platform.base.util.standardHorizontalMargin +import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.ui.platform.components.button.BitwardenFilledButton +import com.bitwarden.ui.platform.components.content.BitwardenContentBlock +import com.bitwarden.ui.platform.components.content.model.ContentBlockData +import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog +import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog +import com.bitwarden.ui.platform.components.divider.BitwardenHorizontalDivider +import com.bitwarden.ui.platform.components.model.CardStyle +import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.bitwarden.ui.platform.composition.LocalIntentManager +import com.bitwarden.ui.platform.manager.IntentManager +import com.bitwarden.ui.platform.resource.BitwardenDrawable +import com.bitwarden.ui.platform.resource.BitwardenString +import com.bitwarden.ui.platform.theme.BitwardenTheme +import com.x8bit.bitwarden.ui.platform.composition.LocalAuthTabLaunchers +import com.x8bit.bitwarden.ui.platform.feature.premium.plan.handlers.PlanHandlers +import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers + +/** + * The screen for the plan -- shows upgrade flow for free users. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlanScreen( + onNavigateBack: () -> Unit, + viewModel: PlanViewModel = hiltViewModel(), + intentManager: IntentManager = LocalIntentManager.current, + authTabLaunchers: AuthTabLaunchers = LocalAuthTabLaunchers.current, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val handlers = remember(viewModel) { PlanHandlers.create(viewModel) } + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is PlanEvent.LaunchBrowser -> { + intentManager.startAuthTab( + uri = event.url.toUri(), + authTabData = event.authTabData, + launcher = authTabLaunchers.premiumCheckout, + ) + } + + PlanEvent.NavigateBack -> onNavigateBack() + } + } + + FreeDialogs( + dialogState = state.dialogState, + handlers = handlers, + ) + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = BitwardenString.upgrade_to_premium), + scrollBehavior = scrollBehavior, + navigationIcon = rememberVectorPainter(id = state.navigationIcon), + navigationIconContentDescription = stringResource( + id = state.navigationIconContentDescription, + ), + onNavigationIconClick = handlers.onBackClick, + ) + }, + ) { + when (val viewState = state.viewState) { + is PlanState.ViewState.Free -> { + FreeContent( + viewState = viewState, + isDialogShowing = state.dialogState != null, + handlers = handlers, + ) + } + } + } +} + +@Composable +private fun FreeDialogs( + dialogState: PlanState.DialogState?, + handlers: PlanHandlers, +) { + when (dialogState) { + is PlanState.DialogState.CheckoutError -> { + BitwardenTwoButtonDialog( + title = stringResource(id = BitwardenString.secure_checkout_didnt_load), + message = stringResource(id = BitwardenString.trouble_opening_payment_page), + confirmButtonText = stringResource(id = BitwardenString.try_again), + dismissButtonText = stringResource(id = BitwardenString.close), + onConfirmClick = handlers.onRetryClick, + onDismissClick = handlers.onDismissError, + onDismissRequest = handlers.onDismissError, + ) + } + + is PlanState.DialogState.WaitingForPayment -> { + BitwardenTwoButtonDialog( + title = stringResource(id = BitwardenString.payment_not_received_yet), + message = stringResource(id = BitwardenString.return_to_stripe_to_finish), + confirmButtonText = stringResource(id = BitwardenString.go_back), + dismissButtonText = stringResource(id = BitwardenString.close), + onConfirmClick = handlers.onGoBackClick, + onDismissClick = handlers.onCancelWaiting, + onDismissRequest = handlers.onCancelWaiting, + ) + } + + is PlanState.DialogState.Loading -> { + BitwardenLoadingDialog(text = dialogState.message()) + } + + null -> Unit + } +} + +@Composable +private fun FreeContent( + viewState: PlanState.ViewState.Free, + isDialogShowing: Boolean, + handlers: PlanHandlers, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Spacer(Modifier.height(12.dp)) + PremiumDetailsCard( + rate = viewState.rate, + frequency = stringResource(id = BitwardenString.per_month), + modifier = Modifier.standardHorizontalMargin(), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + BitwardenFilledButton( + label = stringResource(id = BitwardenString.upgrade_now), + onClick = handlers.onUpgradeNowClick, + isEnabled = !isDialogShowing, + icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link), + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth() + .testTag("UpgradeNowButton"), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(id = BitwardenString.stripe_checkout_footer), + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .testTag("StripeFooterText"), + ) + + Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} + +@Composable +private fun PremiumDetailsCard( + rate: String, + frequency: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .cardStyle(cardStyle = CardStyle.Full), + ) { + Column( + modifier = Modifier + .padding( + top = 16.dp, + bottom = 12.dp, + ) + .standardHorizontalMargin(), + ) { + PriceRow( + rate = rate, + frequency = frequency, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource( + id = BitwardenString.unlock_premium_features, + ), + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.secondary, + ) + } + + BitwardenHorizontalDivider( + modifier = Modifier.padding(start = 16.dp), + ) + + val features = listOf( + BitwardenString.built_in_authenticator, + BitwardenString.emergency_access, + BitwardenString.secure_file_storage, + BitwardenString.breach_monitoring, + ) + features.forEachIndexed { index, featureStringRes -> + BitwardenContentBlock( + data = ContentBlockData( + headerText = stringResource(id = featureStringRes), + iconVectorResource = BitwardenDrawable.ic_check_mark, + ), + headerTextStyle = BitwardenTheme.typography.titleMedium, + showDivider = index != features.lastIndex, + modifier = Modifier.padding(vertical = 6.dp), + ) + } + } +} + +@Composable +private fun PriceRow( + rate: String, + frequency: String, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier) { + Text( + text = rate, + style = BitwardenTheme.typography.headlineMedium, + color = BitwardenTheme.colorScheme.text.primary, + modifier = Modifier.alignByBaseline(), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = frequency, + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.secondary, + modifier = Modifier.alignByBaseline(), + ) + } +} + +@Preview +@OmitFromCoverage +@Composable +private fun PlanScreenFreeAccount_preview() { + BitwardenTheme { + BitwardenScaffold { + FreeContent( + viewState = PlanState.ViewState.Free(rate = "$1.65"), + isDialogShowing = false, + handlers = PlanHandlers( + onBackClick = {}, + onUpgradeNowClick = {}, + onDismissError = {}, + onRetryClick = {}, + onCancelWaiting = {}, + onGoBackClick = {}, + ), + ) + } + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt new file mode 100644 index 0000000000..d21a6f8673 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt @@ -0,0 +1,376 @@ +package com.x8bit.bitwarden.ui.platform.feature.premium.plan + +import android.os.Parcelable +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.bitwarden.ui.platform.base.BaseViewModel +import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData +import com.bitwarden.ui.platform.manager.intent.model.AuthTabData +import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager +import com.bitwarden.ui.platform.resource.BitwardenDrawable +import com.bitwarden.ui.platform.resource.BitwardenString +import com.bitwarden.ui.util.Text +import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.billing.repository.BillingRepository +import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult +import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager +import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance +import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay +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 +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * The callback URL for the premium checkout custom tab. + */ +const val PREMIUM_CHECKOUT_CALLBACK_URL = "bitwarden://premium-upgrade-callback" + +/** + * Placeholder rate until dynamic pricing is available. + * TODO: [PM-33946] Replace with dynamic pricing from GET /api/plans/premium. + */ +private const val PLACEHOLDER_RATE = "$1.65" + +/** + * View model for the plan screen, handling the free-user upgrade flow. + */ +@HiltViewModel +class PlanViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val billingRepository: BillingRepository, + private val snackbarRelayManager: SnackbarRelayManager, + authRepository: AuthRepository, + specialCircumstanceManager: SpecialCircumstanceManager, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] ?: run { + val planMode = savedStateHandle.toPlanArgs().planMode + val dialogState = when (specialCircumstanceManager.specialCircumstance) { + is SpecialCircumstance.PremiumCheckoutResult -> { + specialCircumstanceManager.specialCircumstance = null + PlanState.DialogState.WaitingForPayment + } + + else -> null + } + PlanState( + planMode = planMode, + viewState = PlanState.ViewState.Free(rate = PLACEHOLDER_RATE), + dialogState = dialogState, + ) + }, +) { + init { + stateFlow + .onEach { savedStateHandle[KEY_STATE] = it } + .launchIn(viewModelScope) + + authRepository + .userStateFlow + .map { PlanAction.Internal.UserStateUpdateReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + } + + override fun handleAction(action: PlanAction) { + when (action) { + is PlanAction.BackClick -> handleBackClick() + is PlanAction.UpgradeNowClick -> { + handleUpgradeNowClick() + } + + is PlanAction.DismissError -> handleDismissError() + is PlanAction.RetryClick -> handleRetryClick() + is PlanAction.CancelWaiting -> handleCancelWaiting() + is PlanAction.GoBackClick -> handleGoBackClick() + is PlanAction.Internal.CheckoutUrlReceive -> { + handleCheckoutUrlReceive(action) + } + + is PlanAction.Internal.UserStateUpdateReceive -> { + handleUserStateUpdateReceive(action) + } + } + } + + private fun handleBackClick() { + sendEvent(PlanEvent.NavigateBack) + } + + private fun handleUpgradeNowClick() { + mutableStateFlow.update { + it.copy( + dialogState = PlanState.DialogState.Loading( + message = BitwardenString.opening_checkout.asText(), + ), + ) + } + viewModelScope.launch { + val result = billingRepository.getCheckoutSessionUrl() + sendAction( + PlanAction.Internal.CheckoutUrlReceive(result), + ) + } + } + + private fun handleRetryClick() { + handleUpgradeNowClick() + } + + private fun handleDismissError() { + mutableStateFlow.update { it.copy(dialogState = null) } + } + + private fun handleCancelWaiting() { + mutableStateFlow.update { it.copy(dialogState = null) } + } + + private fun handleGoBackClick() { + onFreeContent { freeState -> + freeState.checkoutUrl?.let { url -> + sendEvent( + PlanEvent.LaunchBrowser( + url = url, + authTabData = AuthTabData.CustomScheme( + callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL, + ), + ), + ) + } + } + } + + private fun handleCheckoutUrlReceive( + action: PlanAction.Internal.CheckoutUrlReceive, + ) { + when (val result = action.result) { + is CheckoutSessionResult.Success -> { + sendEvent( + PlanEvent.LaunchBrowser( + url = result.url, + authTabData = AuthTabData.CustomScheme( + callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL, + ), + ), + ) + onFreeContent { freeState -> + mutableStateFlow.update { + it.copy( + viewState = freeState.copy( + checkoutUrl = result.url, + ), + dialogState = PlanState.DialogState.WaitingForPayment, + ) + } + } + } + + is CheckoutSessionResult.Error -> { + mutableStateFlow.update { + it.copy(dialogState = PlanState.DialogState.CheckoutError) + } + } + } + } + + private fun handleUserStateUpdateReceive( + action: PlanAction.Internal.UserStateUpdateReceive, + ) { + if (state.dialogState !is PlanState.DialogState.WaitingForPayment) return + + val isPremium = action.userState?.activeAccount?.isPremium == true + if (isPremium) { + snackbarRelayManager.sendSnackbarData( + data = BitwardenSnackbarData( + message = BitwardenString.upgraded_to_premium.asText(), + ), + relay = SnackbarRelay.PREMIUM_UPGRADED, + ) + sendEvent(PlanEvent.NavigateBack) + } + } + + private inline fun onFreeContent( + block: (PlanState.ViewState.Free) -> Unit, + ) { + (state.viewState as? PlanState.ViewState.Free) + ?.let(block) + } +} + +/** + * Determines how the Plan screen was reached. + */ +enum class PlanMode { + /** Back arrow, bottom nav visible (push sub-screen from Settings). */ + Standard, + + /** Close icon, bottom nav hidden (modal overlay from Vault). */ + Modal, +} + +/** + * Models state for the plan screen. + */ +@Parcelize +data class PlanState( + val planMode: PlanMode, + val viewState: ViewState, + val dialogState: DialogState?, +) : Parcelable { + + /** + * The navigation icon drawable resource for the top app bar. + */ + @get:DrawableRes + val navigationIcon: Int + get() = when (planMode) { + PlanMode.Standard -> BitwardenDrawable.ic_back + PlanMode.Modal -> BitwardenDrawable.ic_close + } + + /** + * The navigation icon content description string resource. + */ + @get:StringRes + val navigationIconContentDescription: Int + get() = when (planMode) { + PlanMode.Standard -> BitwardenString.back + PlanMode.Modal -> BitwardenString.close + } + + /** + * Models the content state of the plan screen. + */ + sealed class ViewState : Parcelable { + + /** + * The monthly billing rate for the plan. + */ + abstract val rate: String + + /** + * Free user view — shows upgrade pricing and feature list. + */ + @Parcelize + data class Free( + override val rate: String, + val checkoutUrl: String? = null, + ) : ViewState() + } + + /** + * Represents the dialog/overlay state for the plan screen. + */ + sealed class DialogState : Parcelable { + + /** + * Loading overlay with a configurable message. + */ + @Parcelize + data class Loading( + val message: Text, + ) : DialogState() + + /** + * Error dialog shown when the checkout session could not + * be loaded. + */ + @Parcelize + data object CheckoutError : DialogState() + + /** + * Waiting dialog shown after the browser has been launched + * for checkout. + */ + @Parcelize + data object WaitingForPayment : DialogState() + } +} + +/** + * Models events for the plan screen. + */ +sealed class PlanEvent { + + /** + * Launch the user's browser with the given checkout [url] + * via AuthTab. + */ + data class LaunchBrowser( + val url: String, + val authTabData: AuthTabData, + ) : PlanEvent() + + /** + * Navigate back to the previous screen. + */ + data object NavigateBack : PlanEvent() +} + +/** + * Models actions for the plan screen. + */ +sealed class PlanAction { + + /** + * The user clicked the back/close button. + */ + data object BackClick : PlanAction() + + /** + * The user clicked the upgrade now button. + */ + data object UpgradeNowClick : PlanAction() + + /** + * The user dismissed the checkout error dialog. + */ + data object DismissError : PlanAction() + + /** + * The user clicked retry on the checkout error dialog. + */ + data object RetryClick : PlanAction() + + /** + * The user dismissed the waiting for payment dialog. + */ + data object CancelWaiting : PlanAction() + + /** + * The user clicked go back on the waiting for payment dialog. + */ + data object GoBackClick : PlanAction() + + /** + * Models actions the view model sends itself. + */ + sealed class Internal : PlanAction() { + + /** + * A checkout URL result has been received from the + * repository. + */ + data class CheckoutUrlReceive( + val result: CheckoutSessionResult, + ) : Internal() + + /** + * A user state update has been received. + */ + data class UserStateUpdateReceive( + val userState: UserState?, + ) : Internal() + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/handlers/PlanHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/handlers/PlanHandlers.kt new file mode 100644 index 0000000000..d8d8c77d58 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/handlers/PlanHandlers.kt @@ -0,0 +1,33 @@ +package com.x8bit.bitwarden.ui.platform.feature.premium.plan.handlers + +import com.x8bit.bitwarden.ui.platform.feature.premium.plan.PlanAction +import com.x8bit.bitwarden.ui.platform.feature.premium.plan.PlanViewModel + +/** + * A collection of handler functions for managing actions within the context of + * the plan screen. + */ +data class PlanHandlers( + val onBackClick: () -> Unit, + val onUpgradeNowClick: () -> Unit, + val onDismissError: () -> Unit, + val onRetryClick: () -> Unit, + val onCancelWaiting: () -> Unit, + val onGoBackClick: () -> Unit, +) { + @Suppress("UndocumentedPublicClass") + companion object { + /** + * Creates the [PlanHandlers] using the [PlanViewModel] to send desired + * actions. + */ + fun create(viewModel: PlanViewModel): PlanHandlers = PlanHandlers( + onBackClick = { viewModel.trySendAction(PlanAction.BackClick) }, + onUpgradeNowClick = { viewModel.trySendAction(PlanAction.UpgradeNowClick) }, + onDismissError = { viewModel.trySendAction(PlanAction.DismissError) }, + onRetryClick = { viewModel.trySendAction(PlanAction.RetryClick) }, + onCancelWaiting = { viewModel.trySendAction(PlanAction.CancelWaiting) }, + onGoBackClick = { viewModel.trySendAction(PlanAction.GoBackClick) }, + ) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index 79b4a1923c..fa2ed68994 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -13,6 +13,8 @@ import com.x8bit.bitwarden.ui.auth.feature.accountsetup.navigateToSetupUnlockScr import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupAutoFillDestination import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupBrowserAutofillDestination import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupUnlockDestination +import com.x8bit.bitwarden.ui.platform.feature.premium.plan.navigateToPlanModal +import com.x8bit.bitwarden.ui.platform.feature.premium.plan.planModalDestination import com.x8bit.bitwarden.ui.platform.feature.search.SearchRoute import com.x8bit.bitwarden.ui.platform.feature.search.navigateToSearch import com.x8bit.bitwarden.ui.platform.feature.search.searchDestination @@ -139,6 +141,7 @@ fun NavGraphBuilder.vaultUnlockedGraph( onNavigateToAboutPrivilegedApps = { navController.navigateToAboutPrivilegedAppsScreen() }, + onNavigateToPlan = { navController.navigateToPlanModal() }, ) flightRecorderDestination( isPreAuth = false, @@ -283,6 +286,9 @@ fun NavGraphBuilder.vaultUnlockedGraph( previewAttachmentDestination( onNavigateBack = { navController.popBackStack() }, ) + planModalDestination( + onNavigateBack = { navController.popBackStack() }, + ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt index eb24c800f1..7230b225ca 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt @@ -52,6 +52,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination( onNavigateToImportLogins: () -> Unit, onNavigateToAddFolderScreen: (selectedFolderName: String?) -> Unit, onNavigateToAboutPrivilegedApps: () -> Unit, + onNavigateToPlan: () -> Unit, ) { composableWithStayTransitions { VaultUnlockedNavBarScreen( @@ -75,6 +76,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination( onNavigateToFlightRecorder = onNavigateToFlightRecorder, onNavigateToRecordedLogs = onNavigateToRecordedLogs, onNavigateToAboutPrivilegedApps = onNavigateToAboutPrivilegedApps, + onNavigateToPlan = onNavigateToPlan, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index e1a843c05d..2f30fa8042 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -69,6 +69,7 @@ fun VaultUnlockedNavBarScreen( onNavigateToImportLogins: () -> Unit, onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, onNavigateToAboutPrivilegedApps: () -> Unit, + onNavigateToPlan: () -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -109,6 +110,7 @@ fun VaultUnlockedNavBarScreen( onNavigateToFlightRecorder = onNavigateToFlightRecorder, onNavigateToRecordedLogs = onNavigateToRecordedLogs, onNavigateToAboutPrivilegedApps = onNavigateToAboutPrivilegedApps, + onNavigateToPlan = onNavigateToPlan, ) } @@ -144,6 +146,7 @@ private fun VaultUnlockedNavBarScaffold( onNavigateToImportLogins: () -> Unit, onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, onNavigateToAboutPrivilegedApps: () -> Unit, + onNavigateToPlan: () -> Unit, ) { var shouldDimNavBar by rememberSaveable { mutableStateOf(value = false) } @@ -202,6 +205,7 @@ private fun VaultUnlockedNavBarScaffold( navController.navigateToSettingsGraphRoot() navController.navigateToAutoFill() }, + onNavigateToPlan = onNavigateToPlan, ) sendGraph( navController = navController, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/AuthTabLaunchers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/AuthTabLaunchers.kt index 7aa2f169e0..961d2e1e81 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/AuthTabLaunchers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/AuthTabLaunchers.kt @@ -3,14 +3,17 @@ package com.x8bit.bitwarden.ui.platform.model import android.content.Intent import androidx.activity.result.ActivityResultLauncher import androidx.compose.runtime.Immutable +import com.bitwarden.annotation.OmitFromCoverage /** * Contains all the callbacks for the Auth Tabs. */ +@OmitFromCoverage @Immutable class AuthTabLaunchers( val duo: ActivityResultLauncher, val sso: ActivityResultLauncher, val webAuthn: ActivityResultLauncher, val cookie: ActivityResultLauncher, + val premiumCheckout: ActivityResultLauncher, ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/SnackbarRelay.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/SnackbarRelay.kt index 11659c1a4e..d4d58a729a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/SnackbarRelay.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/SnackbarRelay.kt @@ -20,7 +20,7 @@ enum class SnackbarRelay { /** * A separate instance of the [CIPHER_UNARCHIVED] relay to avoid the View Cipher screen being - * both a producer and consumer of it's own event. + * both a producer and consumer of its own event. */ CIPHER_UNARCHIVED_VIEW, CIPHER_CREATED, @@ -36,6 +36,7 @@ enum class SnackbarRelay { LOGIN_APPROVAL, LOGIN_SUCCESS, LOGINS_IMPORTED, + PREMIUM_UPGRADED, SEND_DELETED, SEND_UPDATED, LEFT_ORGANIZATION, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt index 7775efb8a0..793f83ed69 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt @@ -34,6 +34,7 @@ fun NavGraphBuilder.vaultGraph( onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, onNavigateToAboutScreen: () -> Unit, onNavigateToAutofillScreen: () -> Unit, + onNavigateToPlan: () -> Unit, ) { navigation( startDestination = VaultRoute, @@ -52,6 +53,7 @@ fun NavGraphBuilder.vaultGraph( onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, onNavigateToAboutScreen = onNavigateToAboutScreen, onNavigateToAutofillScreen = onNavigateToAutofillScreen, + onNavigateToPlan = onNavigateToPlan, ) vaultItemListingDestination( onNavigateBack = { navController.popBackStack() }, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt index 6989e53989..a7255e7ef3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt @@ -1,8 +1,11 @@ +@file:OmitFromCoverage + package com.x8bit.bitwarden.ui.vault.feature.vault import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import com.bitwarden.annotation.OmitFromCoverage import com.bitwarden.ui.platform.base.util.composableWithRootPushTransitions import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs @@ -13,6 +16,7 @@ import kotlinx.serialization.Serializable /** * The type-safe route for the vault screen. */ +@OmitFromCoverage @Serializable data object VaultRoute @@ -32,6 +36,7 @@ fun NavGraphBuilder.vaultDestination( onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, onNavigateToAboutScreen: () -> Unit, onNavigateToAutofillScreen: () -> Unit, + onNavigateToPlan: () -> Unit, ) { composableWithRootPushTransitions { VaultScreen( @@ -46,6 +51,7 @@ fun NavGraphBuilder.vaultDestination( onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, onNavigateToAboutScreen = onNavigateToAboutScreen, onNavigateToAutofillScreen = onNavigateToAutofillScreen, + onNavigateToPlan = onNavigateToPlan, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index d1217e5f6c..258286f0f8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -97,6 +97,7 @@ fun VaultScreen( onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, onNavigateToAboutScreen: () -> Unit, onNavigateToAutofillScreen: () -> Unit, + onNavigateToPlan: () -> Unit, intentManager: IntentManager = LocalIntentManager.current, appReviewManager: AppReviewManager = LocalAppReviewManager.current, ) { @@ -177,6 +178,8 @@ fun VaultScreen( } VaultEvent.NavigateToAutofillSettings -> onNavigateToAutofillScreen() + + VaultEvent.NavigateToUpgradePremium -> onNavigateToPlan() } } val vaultHandlers = remember(viewModel) { VaultHandlers.create(viewModel) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 43a73336b0..b417e2e68e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -223,6 +223,7 @@ class VaultViewModel @Inject constructor( SnackbarRelay.CIPHER_UPDATED, SnackbarRelay.FOLDER_CREATED, SnackbarRelay.LOGINS_IMPORTED, + SnackbarRelay.PREMIUM_UPGRADED, SnackbarRelay.LEFT_ORGANIZATION, SnackbarRelay.VAULT_MIGRATED_TO_MY_ITEMS, ), @@ -410,7 +411,7 @@ class VaultViewModel @Inject constructor( private fun handleActionCardClick(action: VaultAction.ActionCardClick) { when (action.actionCard) { VaultState.ActionCardState.UpgradePremium -> { - // Navigation to Plan screen wired in PM-33515/PM-33516. + sendEvent(VaultEvent.NavigateToUpgradePremium) } VaultState.ActionCardState.IntroducingArchive -> { @@ -2037,6 +2038,11 @@ sealed class VaultEvent { * Navigate to Autofill settings screen. */ data object NavigateToAutofillSettings : VaultEvent() + + /** + * Navigate to the premium upgrade plan screen. + */ + data object NavigateToUpgradePremium : VaultEvent() } /** diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt index c6372ddcd1..3f60a69d34 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt @@ -1189,6 +1189,21 @@ class MainViewModelTest : BaseViewModelTest() { } } + @Test + fun `on PremiumCheckoutResult should set PremiumCheckoutResult special circumstance`() { + val authResult = mockk() + val viewModel = createViewModel() + + viewModel.trySendAction( + MainAction.PremiumCheckoutResult(authResult = authResult), + ) + + assertEquals( + SpecialCircumstance.PremiumCheckoutResult, + specialCircumstanceManager.specialCircumstance, + ) + } + @Suppress("MaxLineLength") @Test fun `cookie acquisition should emit NavigateToCookieAcquisition when vault unlocked with matching hostname`() = diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt index 0166bcac5d..992d7bdc2a 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/enterprisesignon/EnterpriseSignOnScreenTest.kt @@ -57,6 +57,7 @@ class EnterpriseSignOnScreenTest : BitwardenComposeTest() { sso = ssoLauncher, webAuthn = mockk(), cookie = mockk(), + premiumCheckout = mockk(), ), intentManager = intentManager, ) { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt index 4a1a639495..3a8d9f3230 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/twofactorlogin/TwoFactorLoginScreenTest.kt @@ -62,6 +62,7 @@ class TwoFactorLoginScreenTest : BitwardenComposeTest() { sso = mockk(), webAuthn = webAuthnLauncher, cookie = mockk(), + premiumCheckout = mockk(), ), intentManager = intentManager, nfcManager = nfcManager, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionScreenTest.kt index 9527e200b1..f92e40e81b 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionScreenTest.kt @@ -52,6 +52,7 @@ class CookieAcquisitionScreenTest : BitwardenComposeTest() { sso = mockk(), webAuthn = mockk(), cookie = cookieLauncher, + premiumCheckout = mockk(), ), intentManager = intentManager, ) { @@ -111,7 +112,6 @@ class CookieAcquisitionScreenTest : BitwardenComposeTest() { } } - @Suppress("MaxLineLength") @Test fun `continue without syncing button click should send ContinueWithoutSyncingClick action`() { composeTestRule diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt new file mode 100644 index 0000000000..44d3cf8412 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt @@ -0,0 +1,326 @@ +package com.x8bit.bitwarden.ui.platform.feature.premium.plan + +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.core.net.toUri +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import com.bitwarden.ui.platform.manager.IntentManager +import com.bitwarden.ui.platform.manager.intent.model.AuthTabData +import com.bitwarden.ui.platform.resource.BitwardenString +import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest +import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class PlanScreenTest : BitwardenComposeTest() { + + private var onNavigateBackCalled = false + private val premiumCheckoutLauncher: ActivityResultLauncher = mockk() + + private val mutableEventFlow = bufferedMutableSharedFlow() + private val mutableStateFlow = MutableStateFlow(DEFAULT_FREE_STATE) + private val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + } + private val intentManager = mockk { + every { + startAuthTab(uri = any(), authTabData = any(), launcher = any()) + } just runs + } + + @Before + fun setUp() { + setContent( + authTabLaunchers = AuthTabLaunchers( + duo = mockk(), + sso = mockk(), + webAuthn = mockk(), + cookie = mockk(), + premiumCheckout = premiumCheckoutLauncher, + ), + intentManager = intentManager, + ) { + PlanScreen( + onNavigateBack = { onNavigateBackCalled = true }, + viewModel = viewModel, + ) + } + } + + // region Events + + @Test + fun `NavigateBack event should call onNavigateBack`() { + mutableEventFlow.tryEmit(PlanEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `LaunchBrowser event should call startAuthTab`() { + val url = "https://checkout.stripe.com/session123" + val authTabData = AuthTabData.CustomScheme( + callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL, + ) + mutableEventFlow.tryEmit( + PlanEvent.LaunchBrowser( + url = url, + authTabData = authTabData, + ), + ) + verify { + intentManager.startAuthTab( + uri = url.toUri(), + authTabData = authTabData, + launcher = premiumCheckoutLauncher, + ) + } + } + + // endregion Events + + // region Free content tests + + @Test + fun `close button click should send BackClick action for Modal mode`() { + composeTestRule + .onNodeWithContentDescription("Close") + .performClick() + verify { viewModel.trySendAction(PlanAction.BackClick) } + } + + @Test + fun `back button click should send BackClick action for Standard mode`() { + mutableStateFlow.update { + it.copy(planMode = PlanMode.Standard) + } + composeTestRule + .onNodeWithContentDescription("Back") + .performClick() + verify { viewModel.trySendAction(PlanAction.BackClick) } + } + + @Test + fun `upgrade now button click should send UpgradeNowClick action`() { + composeTestRule + .onNodeWithTag("UpgradeNowButton") + .performScrollTo() + .performClick() + verify { + viewModel.trySendAction(PlanAction.UpgradeNowClick) + } + } + + @Test + fun `price and subtitle should render from state`() { + composeTestRule + .onNodeWithText("\$1.65") + .assertExists() + composeTestRule + .onNodeWithText("/ month") + .assertExists() + composeTestRule + .onNodeWithText( + "Unlock more advanced features with a Premium plan.", + ) + .assertExists() + } + + @Test + fun `feature list items should render`() { + composeTestRule + .onNodeWithText("Built-in authenticator") + .assertExists() + composeTestRule + .onNodeWithText("Emergency access") + .assertExists() + composeTestRule + .onNodeWithText("Secure file storage") + .assertExists() + composeTestRule + .onNodeWithText("Breach monitoring") + .assertExists() + } + + @Test + fun `stripe footer should render`() { + composeTestRule + .onNodeWithTag("StripeFooterText") + .assertExists() + } + + @Test + fun `upgrade now button should be enabled when dialogState is null`() { + composeTestRule + .onNodeWithTag("UpgradeNowButton") + .assertIsEnabled() + } + + @Test + fun `upgrade now button should be disabled when dialogState is Loading`() { + mutableStateFlow.update { + it.copy( + dialogState = PlanState.DialogState.Loading( + message = BitwardenString.opening_checkout.asText(), + ), + ) + } + composeTestRule + .onNodeWithTag("UpgradeNowButton") + .assertIsNotEnabled() + } + + @Test + fun `loading dialog should render when dialogState is Loading`() { + composeTestRule + .onAllNodesWithText("Opening checkout\u2026") + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = PlanState.DialogState.Loading( + message = BitwardenString.opening_checkout.asText(), + ), + ) + } + + composeTestRule + .onAllNodesWithText("Opening checkout\u2026") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + } + + @Test + fun `error dialog should render when dialogState is Error`() { + composeTestRule + .onAllNodesWithText("Secure checkout didn\u2019t load") + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = PlanState.DialogState.CheckoutError, + ) + } + + composeTestRule + .onAllNodesWithText("Secure checkout didn\u2019t load") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + } + + @Test + fun `error dialog try again click should send RetryClick action`() { + mutableStateFlow.update { + it.copy( + dialogState = PlanState.DialogState.CheckoutError, + ) + } + composeTestRule + .onAllNodesWithText("Try again") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(PlanAction.RetryClick) } + } + + @Test + fun `error dialog close click should send DismissError action`() { + mutableStateFlow.update { + it.copy( + dialogState = PlanState.DialogState.CheckoutError, + ) + } + composeTestRule + .onAllNodesWithText("Close") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { + viewModel.trySendAction(PlanAction.DismissError) + } + } + + @Test + fun `waiting for payment dialog should render when dialogState is WaitingForPayment`() { + composeTestRule + .onAllNodesWithText("Payment not received yet") + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = + PlanState.DialogState.WaitingForPayment, + ) + } + + composeTestRule + .onAllNodesWithText("Payment not received yet") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + } + + @Test + fun `waiting for payment dialog go back click should send GoBackClick action`() { + mutableStateFlow.update { + it.copy( + dialogState = + PlanState.DialogState.WaitingForPayment, + ) + } + composeTestRule + .onAllNodesWithText("Go back") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { + viewModel.trySendAction(PlanAction.GoBackClick) + } + } + + @Test + fun `waiting for payment dialog close click should send CancelWaiting action`() { + mutableStateFlow.update { + it.copy( + dialogState = + PlanState.DialogState.WaitingForPayment, + ) + } + composeTestRule + .onAllNodesWithText("Close") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { + viewModel.trySendAction(PlanAction.CancelWaiting) + } + } + + // endregion Free content tests +} + +private val DEFAULT_FREE_STATE = PlanState( + planMode = PlanMode.Modal, + viewState = PlanState.ViewState.Free( + rate = "$1.65", + ), + dialogState = null, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt new file mode 100644 index 0000000000..6c02dcb2b9 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt @@ -0,0 +1,528 @@ +package com.x8bit.bitwarden.ui.platform.feature.premium.plan + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.bitwarden.ui.platform.base.BaseViewModelTest +import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData +import com.bitwarden.ui.platform.manager.intent.model.AuthTabData +import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager +import com.bitwarden.ui.platform.resource.BitwardenString +import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.billing.repository.BillingRepository +import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult +import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager +import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance +import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class PlanViewModelTest : BaseViewModelTest() { + + private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) + private val mockAuthRepository: AuthRepository = mockk { + every { userStateFlow } returns mutableUserStateFlow + } + private val mockBillingRepository: BillingRepository = mockk() + private val mockSnackbarRelayManager: SnackbarRelayManager = + mockk { + every { sendSnackbarData(any(), any()) } just runs + } + private val mockSpecialCircumstanceManager: SpecialCircumstanceManager = + mockk(relaxed = true) { + every { specialCircumstance } returns null + } + + @BeforeEach + fun setup() { + mockkStatic(SavedStateHandle::toPlanArgs) + } + + @AfterEach + fun tearDown() { + unmockkStatic(SavedStateHandle::toPlanArgs) + } + + // region PlanMode propagation + + @Test + fun `initial state should use Standard PlanMode for Standard route`() = + runTest { + val viewModel = createViewModel(planMode = PlanMode.Standard) + + viewModel.stateFlow.test { + assertEquals(PlanMode.Standard, awaitItem().planMode) + } + } + + @Test + fun `initial state should use Modal PlanMode for Modal route`() = + runTest { + val viewModel = createViewModel(planMode = PlanMode.Modal) + + viewModel.stateFlow.test { + assertEquals(PlanMode.Modal, awaitItem().planMode) + } + } + + // endregion PlanMode propagation + + // region Free user path + + @Test + fun `initial state should be Free ViewState after pricing fetch`() = + runTest { + val viewModel = createViewModel() + + viewModel.stateFlow.test { + assertEquals(DEFAULT_FREE_STATE, awaitItem()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `initial state should be WaitingForPayment when PremiumCheckoutResult special circumstance present`() = + runTest { + every { + mockSpecialCircumstanceManager.specialCircumstance + } returns SpecialCircumstance.PremiumCheckoutResult + + val viewModel = createViewModel() + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_FREE_STATE.copy( + dialogState = PlanState.DialogState.WaitingForPayment, + ), + awaitItem(), + ) + } + + verify { + mockSpecialCircumstanceManager.specialCircumstance = null + } + } + + @Test + fun `BackClick should emit NavigateBack event`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction(PlanAction.BackClick) + assertEquals(PlanEvent.NavigateBack, awaitItem()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `UpgradeNowClick should transition to Loading then emit LaunchBrowser and WaitingForPayment on success`() = + runTest { + val checkoutUrl = "https://checkout.stripe.com/session123" + coEvery { + mockBillingRepository.getCheckoutSessionUrl() + } returns CheckoutSessionResult.Success(url = checkoutUrl) + + val viewModel = createViewModel() + + viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> + assertEquals(DEFAULT_FREE_STATE, stateFlow.awaitItem()) + + viewModel.trySendAction(PlanAction.UpgradeNowClick) + + assertEquals( + DEFAULT_FREE_STATE.copy( + dialogState = PlanState.DialogState.Loading( + message = BitwardenString.opening_checkout.asText(), + ), + ), + stateFlow.awaitItem(), + ) + assertEquals( + DEFAULT_FREE_STATE.copy( + viewState = PlanState.ViewState.Free( + rate = "$1.65", + checkoutUrl = checkoutUrl, + ), + dialogState = PlanState.DialogState.WaitingForPayment, + ), + stateFlow.awaitItem(), + ) + assertEquals( + PlanEvent.LaunchBrowser( + url = checkoutUrl, + authTabData = AuthTabData.CustomScheme( + callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL, + ), + ), + eventFlow.awaitItem(), + ) + } + } + + @Test + fun `UpgradeNowClick should transition to Loading then Error on failure`() = + runTest { + coEvery { + mockBillingRepository.getCheckoutSessionUrl() + } returns CheckoutSessionResult.Error( + error = RuntimeException("Network error"), + ) + + val viewModel = createViewModel() + + viewModel.stateFlow.test { + assertEquals(DEFAULT_FREE_STATE, awaitItem()) + + viewModel.trySendAction(PlanAction.UpgradeNowClick) + + assertEquals( + DEFAULT_FREE_STATE.copy( + dialogState = PlanState.DialogState.Loading( + message = BitwardenString.opening_checkout.asText(), + ), + ), + awaitItem(), + ) + assertEquals( + DEFAULT_FREE_STATE.copy( + dialogState = PlanState.DialogState.CheckoutError, + ), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `RetryClick should transition to Loading then emit LaunchBrowser and WaitingForPayment on success`() = + runTest { + val checkoutUrl = "https://checkout.stripe.com/session123" + coEvery { + mockBillingRepository.getCheckoutSessionUrl() + } returns CheckoutSessionResult.Success(url = checkoutUrl) + + val viewModel = createViewModel( + initialState = DEFAULT_FREE_STATE.copy( + dialogState = PlanState.DialogState.CheckoutError, + ), + ) + + viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> + assertEquals( + DEFAULT_FREE_STATE.copy( + dialogState = PlanState.DialogState.CheckoutError, + ), + stateFlow.awaitItem(), + ) + + viewModel.trySendAction(PlanAction.RetryClick) + + assertEquals( + DEFAULT_FREE_STATE.copy( + dialogState = PlanState.DialogState.Loading( + message = BitwardenString.opening_checkout.asText(), + ), + ), + stateFlow.awaitItem(), + ) + assertEquals( + DEFAULT_FREE_STATE.copy( + viewState = PlanState.ViewState.Free( + rate = "$1.65", + checkoutUrl = checkoutUrl, + ), + dialogState = PlanState.DialogState.WaitingForPayment, + ), + stateFlow.awaitItem(), + ) + assertEquals( + PlanEvent.LaunchBrowser( + url = checkoutUrl, + authTabData = AuthTabData.CustomScheme( + callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL, + ), + ), + eventFlow.awaitItem(), + ) + } + } + + @Test + fun `DismissError should transition from Error to Content`() = runTest { + val viewModel = createViewModel( + initialState = DEFAULT_FREE_STATE.copy( + dialogState = PlanState.DialogState.CheckoutError, + ), + ) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_FREE_STATE.copy( + dialogState = PlanState.DialogState.CheckoutError, + ), + awaitItem(), + ) + + viewModel.trySendAction(PlanAction.DismissError) + + assertEquals(DEFAULT_FREE_STATE, awaitItem()) + } + } + + @Test + fun `CancelWaiting should transition from WaitingForPayment to Content`() = + runTest { + val viewModel = createViewModel( + initialState = DEFAULT_FREE_STATE.copy( + dialogState = PlanState.DialogState.WaitingForPayment, + ), + ) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_FREE_STATE.copy( + dialogState = PlanState.DialogState.WaitingForPayment, + ), + awaitItem(), + ) + + viewModel.trySendAction(PlanAction.CancelWaiting) + + assertEquals(DEFAULT_FREE_STATE, awaitItem()) + } + } + + @Test + fun `GoBackClick should emit LaunchBrowser with checkout URL when URL is available`() = + runTest { + val checkoutUrl = "https://checkout.stripe.com/session123" + val freeState = PlanState.ViewState.Free( + rate = "$1.65", + checkoutUrl = checkoutUrl, + ) + val viewModel = createViewModel( + initialState = DEFAULT_FREE_STATE.copy( + viewState = freeState, + dialogState = PlanState.DialogState.WaitingForPayment, + ), + ) + + viewModel.eventFlow.test { + viewModel.trySendAction(PlanAction.GoBackClick) + assertEquals( + PlanEvent.LaunchBrowser( + url = checkoutUrl, + authTabData = AuthTabData.CustomScheme( + callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL, + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `GoBackClick should not emit event when checkout URL is null`() = + runTest { + val viewModel = createViewModel( + initialState = DEFAULT_FREE_STATE.copy( + dialogState = PlanState.DialogState + .WaitingForPayment, + ), + ) + + viewModel.eventFlow.test { + viewModel.trySendAction(PlanAction.GoBackClick) + expectNoEvents() + } + } + + @Suppress("MaxLineLength") + @Test + fun `premium status flip should send snackbar and emit NavigateBack when in WaitingForPayment`() = + runTest { + val checkoutUrl = "https://checkout.stripe.com/session123" + coEvery { + mockBillingRepository.getCheckoutSessionUrl() + } returns CheckoutSessionResult.Success(url = checkoutUrl) + + val viewModel = createViewModel() + + viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> + assertEquals(DEFAULT_FREE_STATE, stateFlow.awaitItem()) + + viewModel.trySendAction(PlanAction.UpgradeNowClick) + + // Consume Loading and WaitingForPayment states. + stateFlow.awaitItem() + stateFlow.awaitItem() + assertEquals( + PlanEvent.LaunchBrowser( + url = checkoutUrl, + authTabData = AuthTabData.CustomScheme( + callbackUrl = PREMIUM_CHECKOUT_CALLBACK_URL, + ), + ), + eventFlow.awaitItem(), + ) + + // Simulate premium status flip. + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf( + DEFAULT_ACCOUNT.copy(isPremium = true), + ), + ) + + assertEquals( + PlanEvent.NavigateBack, + eventFlow.awaitItem(), + ) + } + + verify { + mockSnackbarRelayManager.sendSnackbarData( + data = BitwardenSnackbarData( + message = BitwardenString.upgraded_to_premium.asText(), + ), + relay = SnackbarRelay.PREMIUM_UPGRADED, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `premium status flip should send snackbar and emit NavigateBack via special circumstance`() = + runTest { + every { + mockSpecialCircumstanceManager.specialCircumstance + } returns SpecialCircumstance.PremiumCheckoutResult + + val viewModel = createViewModel() + + viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> + assertEquals( + DEFAULT_FREE_STATE.copy( + dialogState = PlanState.DialogState.WaitingForPayment, + ), + stateFlow.awaitItem(), + ) + + // Simulate premium status flip. + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf( + DEFAULT_ACCOUNT.copy(isPremium = true), + ), + ) + + assertEquals( + PlanEvent.NavigateBack, + eventFlow.awaitItem(), + ) + } + + verify { + mockSnackbarRelayManager.sendSnackbarData( + data = BitwardenSnackbarData( + message = BitwardenString.upgraded_to_premium.asText(), + ), + relay = SnackbarRelay.PREMIUM_UPGRADED, + ) + } + } + + @Test + fun `premium status flip should not emit event when not in WaitingForPayment`() = + runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + // Flip premium while in Content state. + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf( + DEFAULT_ACCOUNT.copy(isPremium = true), + ), + ) + expectNoEvents() + } + } + + @Test + fun `saved state should be restored`() = runTest { + val savedState = DEFAULT_FREE_STATE.copy( + dialogState = PlanState.DialogState.CheckoutError, + ) + val viewModel = createViewModel(initialState = savedState) + + viewModel.stateFlow.test { + assertEquals(savedState, awaitItem()) + } + } + + // endregion Free user path + + private fun createViewModel( + initialState: PlanState? = null, + planMode: PlanMode = PlanMode.Modal, + ): PlanViewModel { + val savedStateHandle = SavedStateHandle().apply { + set("state", initialState) + every { toPlanArgs() } returns PlanArgs(planMode = planMode) + } + return PlanViewModel( + savedStateHandle = savedStateHandle, + authRepository = mockAuthRepository, + billingRepository = mockBillingRepository, + snackbarRelayManager = mockSnackbarRelayManager, + specialCircumstanceManager = mockSpecialCircumstanceManager, + ) + } +} + +private val DEFAULT_ACCOUNT = UserState.Account( + userId = "user-id-1", + name = "Test User", + email = "test@bitwarden.com", + avatarColorHex = "#000000", + environment = mockk(), + isPremium = false, + isLoggedIn = true, + isVaultUnlocked = true, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = emptyList(), + needsMasterPassword = false, + trustedDevice = null, + hasMasterPassword = true, + isUsingKeyConnector = false, + onboardingStatus = mockk(), + firstTimeState = mockk(), + isExportable = false, + creationDate = null, +) + +private val DEFAULT_USER_STATE = UserState( + activeUserId = "user-id-1", + accounts = listOf(DEFAULT_ACCOUNT), + hasPendingAccountAddition = false, +) + +private val DEFAULT_FREE_STATE = PlanState( + planMode = PlanMode.Modal, + viewState = PlanState.ViewState.Free( + rate = "$1.65", + ), + dialogState = null, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt index a5b15bc50e..bc5645844f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt @@ -64,6 +64,7 @@ class VaultUnlockedNavBarScreenTest : BitwardenComposeTest() { onNavigateToFlightRecorder = {}, onNavigateToRecordedLogs = {}, onNavigateToAboutPrivilegedApps = {}, + onNavigateToPlan = {}, ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index eaf5129889..e8ae9bdb90 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -78,6 +78,7 @@ import org.junit.Test class VaultScreenTest : BitwardenComposeTest() { private var onNavigateToAboutCalled = false private var onNavigateToAutofillCalled = false + private var onNavigateToPlanCalled = false private var onNavigateToImportLoginsCalled = false private var onNavigateToVaultAddItemScreenCalled = false private var onNavigateToVaultItemArgs: VaultItemArgs? = null @@ -121,6 +122,7 @@ class VaultScreenTest : BitwardenComposeTest() { }, onNavigateToAboutScreen = { onNavigateToAboutCalled = true }, onNavigateToAutofillScreen = { onNavigateToAutofillCalled = true }, + onNavigateToPlan = { onNavigateToPlanCalled = true }, ) } } @@ -2295,6 +2297,12 @@ class VaultScreenTest : BitwardenComposeTest() { assertTrue(onNavigateToAutofillCalled) } + @Test + fun `when NavigateToUpgradePremium is sent, it should call onNavigateToPlan`() { + mutableEventFlow.tryEmit(VaultEvent.NavigateToUpgradePremium) + assertTrue(onNavigateToPlanCalled) + } + @Test fun `when ShowSnackbar is sent snackbar should be displayed`() { val data = BitwardenSnackbarData("message".asText()) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 5ed5a2ea0d..c912ab933b 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -357,6 +357,24 @@ class VaultViewModelTest : BaseViewModelTest() { } } + @Test + fun `ActionCardClick with UpgradePremium should emit NavigateToUpgradePremium`() = + runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction( + VaultAction.ActionCardClick( + VaultState.ActionCardState.UpgradePremium, + ), + ) + assertEquals( + VaultEvent.NavigateToUpgradePremium, + awaitItem(), + ) + } + } + @Test fun `actionCard should return UpgradePremium when eligible and content is showing`() { val contentViewState = DEFAULT_CONTENT_VIEW_STATE @@ -3354,6 +3372,22 @@ class VaultViewModelTest : BaseViewModelTest() { } } + @Test + fun `when PREMIUM_UPGRADED relay emits, snackbar is shown`() = + runTest { + val viewModel = createViewModel() + val expectedSnackbarData = BitwardenSnackbarData( + message = BitwardenString.upgraded_to_premium.asText(), + ) + viewModel.eventFlow.test { + mutableSnackbarDataFlow.tryEmit(expectedSnackbarData) + assertEquals( + VaultEvent.ShowSnackbar(expectedSnackbarData), + awaitItem(), + ) + } + } + @Test fun `when account switch action is handled, clear snackbar relay buffer should be called`() = runTest { diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index e0d138e743..2494cbcd3d 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1233,4 +1233,19 @@ Do you want to switch to this account? Bitwarden could not decrypt this file, so the preview cannot be displayed. Preview unavailable for this file. You can still download it to view on your device. Preview unavailable + Upgraded to premium + Unlock more advanced features with a Premium plan. + / month + Built-in authenticator + Emergency access + Secure file storage + Breach monitoring + Upgrade now + You’ll go to Stripe’s secure checkout to complete your purchase. + Opening checkout… + Secure checkout didn’t load + We had trouble opening the payment page, so try again. + Payment not received yet + Return to Stripe in your browser to finish your upgrade. + Go back