From f17289a104f25c491ca2b6566fb67e1e84bf7ff3 Mon Sep 17 00:00:00 2001 From: Shannon Draeker <125921730+shannon-livefront@users.noreply.github.com> Date: Wed, 7 Aug 2024 16:12:46 -0400 Subject: [PATCH] PM-10242 PM-10243 PM-10244 PM-10245 PM-10246: Welcome carousel (#3657) --- .../data/auth/repository/AuthRepository.kt | 6 + .../auth/repository/AuthRepositoryImpl.kt | 7 + .../repository/di/AuthRepositoryModule.kt | 3 + .../ui/auth/feature/auth/AuthNavigation.kt | 10 +- .../auth/feature/welcome/WelcomeNavigation.kt | 32 +++ .../ui/auth/feature/welcome/WelcomeScreen.kt | 272 ++++++++++++++++++ .../auth/feature/welcome/WelcomeViewModel.kt | 161 +++++++++++ .../platform/feature/rootnav/RootNavScreen.kt | 3 + .../feature/rootnav/RootNavViewModel.kt | 18 +- app/src/main/res/drawable/welcome_1.xml | 73 +++++ app/src/main/res/drawable/welcome_2.xml | 58 ++++ app/src/main/res/drawable/welcome_3.xml | 46 +++ app/src/main/res/drawable/welcome_4.xml | 56 ++++ app/src/main/res/values/strings.xml | 8 + .../auth/repository/AuthRepositoryTest.kt | 20 ++ .../auth/feature/welcome/WelcomeScreenTest.kt | 124 ++++++++ .../feature/welcome/WelcomeViewModelTest.kt | 95 ++++++ .../feature/rootnav/RootNavScreenTest.kt | 9 + .../feature/rootnav/RootNavViewModelTest.kt | 33 ++- 19 files changed, 1026 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeNavigation.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeScreen.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt create mode 100644 app/src/main/res/drawable/welcome_1.xml create mode 100644 app/src/main/res/drawable/welcome_2.xml create mode 100644 app/src/main/res/drawable/welcome_3.xml create mode 100644 app/src/main/res/drawable/welcome_4.xml create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeScreenTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModelTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 2fe15b09c..ac20b13f7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -130,6 +130,12 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { */ val organizations: List + /** + * Whether or not the welcome carousel should be displayed, based on the feature flag and + * whether the user has ever logged in or created an account before. + */ + val showWelcomeCarousel: Boolean + /** * Clears the pending deletion state that occurs when the an account is successfully deleted. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 18c6eea4c..a020da031 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -77,9 +77,11 @@ import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS import com.x8bit.bitwarden.data.auth.util.YubiKeyResult import com.x8bit.bitwarden.data.auth.util.toSdkParams +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.PushManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -140,6 +142,7 @@ class AuthRepositoryImpl( private val trustedDeviceManager: TrustedDeviceManager, private val userLogoutManager: UserLogoutManager, private val policyManager: PolicyManager, + private val featureFlagManager: FeatureFlagManager, pushManager: PushManager, dispatcherManager: DispatcherManager, ) : AuthRepository, @@ -318,6 +321,10 @@ class AuthRepositoryImpl( override val organizations: List get() = activeUserId?.let { authDiskSource.getOrganizations(it) }.orEmpty() + override val showWelcomeCarousel: Boolean + get() = !settingsRepository.hasUserLoggedInOrCreatedAccount && + featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel) + init { pushManager .syncOrgKeysFlow diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt index 495c57a39..179d47b44 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt @@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.PushManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager @@ -52,6 +53,7 @@ object AuthRepositoryModule { userLogoutManager: UserLogoutManager, pushManager: PushManager, policyManager: PolicyManager, + featureFlagManager: FeatureFlagManager, ): AuthRepository = AuthRepositoryImpl( accountsService = accountsService, devicesService = devicesService, @@ -70,5 +72,6 @@ object AuthRepositoryModule { userLogoutManager = userLogoutManager, pushManager = pushManager, policyManager = policyManager, + featureFlagManager = featureFlagManager, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index 060d550d8..478a59892 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -14,6 +14,7 @@ import com.x8bit.bitwarden.ui.auth.feature.environment.environmentDestination import com.x8bit.bitwarden.ui.auth.feature.environment.navigateToEnvironment import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE import com.x8bit.bitwarden.ui.auth.feature.landing.landingDestination +import com.x8bit.bitwarden.ui.auth.feature.landing.navigateToLanding import com.x8bit.bitwarden.ui.auth.feature.login.loginDestination import com.x8bit.bitwarden.ui.auth.feature.login.navigateToLogin import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.loginWithDeviceDestination @@ -25,6 +26,7 @@ import com.x8bit.bitwarden.ui.auth.feature.setpassword.navigateToSetPassword import com.x8bit.bitwarden.ui.auth.feature.setpassword.setPasswordDestination import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.navigateToTwoFactorLogin import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.twoFactorLoginDestination +import com.x8bit.bitwarden.ui.auth.feature.welcome.welcomeDestination const val AUTH_GRAPH_ROUTE: String = "auth_graph" @@ -32,7 +34,9 @@ const val AUTH_GRAPH_ROUTE: String = "auth_graph" * Add auth destinations to the nav graph. */ @Suppress("LongMethod") -fun NavGraphBuilder.authGraph(navController: NavHostController) { +fun NavGraphBuilder.authGraph( + navController: NavHostController, +) { navigation( startDestination = LANDING_ROUTE, route = AUTH_GRAPH_ROUTE, @@ -72,6 +76,10 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) { navController.navigateToEnvironment() }, ) + welcomeDestination( + onNavigateToCreateAccount = { navController.navigateToCreateAccount() }, + onNavigateToLogin = { navController.navigateToLanding() }, + ) loginDestination( onNavigateBack = { navController.popBackStack() }, onNavigateToMasterPasswordHint = { emailAddress -> diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeNavigation.kt new file mode 100644 index 000000000..ca0fcfb5b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeNavigation.kt @@ -0,0 +1,32 @@ +package com.x8bit.bitwarden.ui.auth.feature.welcome + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.x8bit.bitwarden.ui.platform.base.util.composableWithStayTransitions + +private const val WELCOME_ROUTE: String = "welcome" + +/** + * Navigate to the welcome screen. + */ +fun NavController.navigateToWelcome(navOptions: NavOptions? = null) { + this.navigate(WELCOME_ROUTE, navOptions) +} + +/** + * Add the Welcome screen to the nav graph. + */ +fun NavGraphBuilder.welcomeDestination( + onNavigateToCreateAccount: () -> Unit, + onNavigateToLogin: () -> Unit, +) { + composableWithStayTransitions( + route = WELCOME_ROUTE, + ) { + WelcomeScreen( + onNavigateToCreateAccount = onNavigateToCreateAccount, + onNavigateToLogin = onNavigateToLogin, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeScreen.kt new file mode 100644 index 000000000..3a3fab133 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeScreen.kt @@ -0,0 +1,272 @@ +package com.x8bit.bitwarden.ui.auth.feature.welcome + +import android.content.res.Configuration +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +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.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton +import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter + +/** + * Top level composable for the welcome screen. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun WelcomeScreen( + onNavigateToCreateAccount: () -> Unit, + onNavigateToLogin: () -> Unit, + viewModel: WelcomeViewModel = hiltViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val pagerState = rememberPagerState(pageCount = { state.pages.size }) + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is WelcomeEvent.UpdatePager -> { + pagerState.animateScrollToPage(event.index) + } + + WelcomeEvent.NavigateToCreateAccount -> onNavigateToCreateAccount() + WelcomeEvent.NavigateToLogin -> onNavigateToLogin() + } + } + + BitwardenScaffold( + modifier = Modifier.fillMaxSize(), + ) { innerPadding -> + WelcomeScreenContent( + state = state, + pagerState = pagerState, + onPagerSwipe = remember(viewModel) { + { viewModel.trySendAction(WelcomeAction.PagerSwipe(it)) } + }, + onDotClick = remember(viewModel) { + { viewModel.trySendAction(WelcomeAction.DotClick(it)) } + }, + onCreateAccountClick = remember(viewModel) { + { viewModel.trySendAction(WelcomeAction.CreateAccountClick) } + }, + onLoginClick = remember(viewModel) { + { viewModel.trySendAction(WelcomeAction.LoginClick) } + }, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun WelcomeScreenContent( + state: WelcomeState, + pagerState: PagerState, + onPagerSwipe: (Int) -> Unit, + onDotClick: (Int) -> Unit, + onCreateAccountClick: () -> Unit, + onLoginClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val horizontalPadding = if (isLandscape) 128.dp else 16.dp + + LaunchedEffect(pagerState.currentPage) { + onPagerSwipe(pagerState.currentPage) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.weight(1f)) + + HorizontalPager(state = pagerState) { index -> + if (isLandscape) { + WelcomeCardLandscape( + state = state.pages[index], + modifier = Modifier.padding(horizontal = horizontalPadding), + ) + } else { + WelcomeCardPortrait( + state = state.pages[index], + modifier = Modifier.padding(horizontal = horizontalPadding), + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + IndicatorDots( + selectedIndexProvider = { state.index }, + totalCount = state.pages.size, + onDotClick = onDotClick, + modifier = Modifier + .padding(bottom = 32.dp) + .height(44.dp), + ) + + BitwardenFilledButton( + label = stringResource(id = R.string.create_account), + onClick = onCreateAccountClick, + modifier = Modifier + .padding(horizontal = horizontalPadding) + .fillMaxWidth(), + ) + + BitwardenTextButton( + label = stringResource(id = R.string.log_in), + onClick = onLoginClick, + modifier = Modifier + .padding(horizontal = horizontalPadding) + .padding(bottom = 32.dp), + ) + + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} + +@Composable +private fun WelcomeCardLandscape( + state: WelcomeState.WelcomeCard, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Image( + painter = rememberVectorPainter(id = state.imageRes), + contentDescription = null, + modifier = Modifier.size(132.dp), + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(start = 40.dp), + ) { + Text( + text = stringResource(id = state.titleRes), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Text( + text = stringResource(id = state.messageRes), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +private fun WelcomeCardPortrait( + state: WelcomeState.WelcomeCard, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + Image( + painter = rememberVectorPainter(id = state.imageRes), + contentDescription = null, + modifier = Modifier.size(200.dp), + ) + + Text( + text = stringResource(id = state.titleRes), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding( + top = 48.dp, + bottom = 16.dp, + ), + ) + + Text( + text = stringResource(id = state.messageRes), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun IndicatorDots( + selectedIndexProvider: () -> Int, + totalCount: Int, + onDotClick: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + items(totalCount) { index -> + val color = animateColorAsState( + targetValue = if (index == selectedIndexProvider()) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + }, + label = "dotColor", + ) + + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(color.value) + .clickable { onDotClick(index) }, + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt new file mode 100644 index 000000000..41bd971f8 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt @@ -0,0 +1,161 @@ +package com.x8bit.bitwarden.ui.auth.feature.welcome + +import android.os.Parcelable +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +/** + * Manages application state for the welcome screen. + */ +@HiltViewModel +class WelcomeViewModel @Inject constructor() : + BaseViewModel( + initialState = WelcomeState( + index = 0, + pages = listOf( + WelcomeState.WelcomeCard.CardOne, + WelcomeState.WelcomeCard.CardTwo, + WelcomeState.WelcomeCard.CardThree, + WelcomeState.WelcomeCard.CardFour, + ), + ), + ) { + override fun handleAction(action: WelcomeAction) { + when (action) { + is WelcomeAction.PagerSwipe -> handlePagerSwipe(action) + is WelcomeAction.DotClick -> handleDotClick(action) + WelcomeAction.CreateAccountClick -> handleCreateAccountClick() + WelcomeAction.LoginClick -> handleLoginClick() + } + } + + private fun handlePagerSwipe(action: WelcomeAction.PagerSwipe) { + mutableStateFlow.update { it.copy(index = action.index) } + } + + private fun handleDotClick(action: WelcomeAction.DotClick) { + mutableStateFlow.update { it.copy(index = action.index) } + sendEvent(WelcomeEvent.UpdatePager(index = action.index)) + } + + private fun handleCreateAccountClick() { + sendEvent(WelcomeEvent.NavigateToCreateAccount) + } + + private fun handleLoginClick() { + sendEvent(WelcomeEvent.NavigateToLogin) + } +} + +/** + * Models state of the welcome screen. + */ +@Parcelize +data class WelcomeState( + val index: Int, + val pages: List, +) : Parcelable { + /** + * A sealed class to represent the different cards the user can view on the welcome screen. + */ + sealed class WelcomeCard : Parcelable { + abstract val imageRes: Int + abstract val titleRes: Int + abstract val messageRes: Int + + /** + * Represents the first card the user should see on the welcome screen. + */ + @Parcelize + data object CardOne : WelcomeCard() { + override val imageRes: Int = R.drawable.welcome_1 + override val titleRes: Int = R.string.privacy_prioritized + override val messageRes: Int = R.string.welcome_message_1 + } + + /** + * Represents the second card the user should see on the welcome screen. + */ + @Parcelize + data object CardTwo : WelcomeCard() { + override val imageRes: Int = R.drawable.welcome_2 + override val titleRes: Int = R.string.never_guess_again + override val messageRes: Int = R.string.welcome_message_2 + } + + /** + * Represents the third card the user should see on the welcome screen. + */ + @Parcelize + data object CardThree : WelcomeCard() { + override val imageRes: Int = R.drawable.welcome_3 + override val titleRes: Int = R.string.level_up_your_logins + override val messageRes: Int = R.string.welcome_message_3 + } + + /** + * Represents the fourth card the user should see on the welcome screen. + */ + @Parcelize + data object CardFour : WelcomeCard() { + override val imageRes: Int = R.drawable.welcome_4 + override val titleRes: Int = R.string.your_data_when_and_where_you_need_it + override val messageRes: Int = R.string.welcome_message_4 + } + } +} + +/** + * Models events for the welcome screen. + */ +sealed class WelcomeEvent { + /** + * Updates the current index of the pager. + */ + data class UpdatePager( + val index: Int, + ) : WelcomeEvent() + + /** + * Navigates to the create account screen. + */ + data object NavigateToCreateAccount : WelcomeEvent() + + /** + * Navigates to the login screen. + */ + data object NavigateToLogin : WelcomeEvent() +} + +/** + * Models actions for the welcome screen. + */ +sealed class WelcomeAction { + /** + * Swipe the pager to the given [index]. + */ + data class PagerSwipe( + val index: Int, + ) : WelcomeAction() + + /** + * Click one of the page indicator dots at the given [index]. + */ + data class DotClick( + val index: Int, + ) : WelcomeAction() + + /** + * Click the create account button. + */ + data object CreateAccountClick : WelcomeAction() + + /** + * Click the login button. + */ + data object LoginClick : WelcomeAction() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 22f2d72d7..312d3dfec 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -29,6 +29,7 @@ import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.trustedDeviceGraph import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.VAULT_UNLOCK_ROUTE import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.navigateToVaultUnlock import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.vaultUnlockDestination +import com.x8bit.bitwarden.ui.auth.feature.welcome.navigateToWelcome import com.x8bit.bitwarden.ui.platform.feature.rootnav.util.toVaultItemListingType import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.navigateToLoginApproval import com.x8bit.bitwarden.ui.platform.feature.splash.SPLASH_ROUTE @@ -85,6 +86,7 @@ fun RootNavScreen( val targetRoute = when (state) { RootNavState.Auth -> AUTH_GRAPH_ROUTE + RootNavState.AuthWithWelcome -> AUTH_GRAPH_ROUTE RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE RootNavState.SetPassword -> SET_PASSWORD_ROUTE RootNavState.Splash -> SPLASH_ROUTE @@ -133,6 +135,7 @@ fun RootNavScreen( LaunchedEffect(state) { when (val currentState = state) { RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions) + RootNavState.AuthWithWelcome -> navController.navigateToWelcome(rootNavOptions) RootNavState.ResetPassword -> navController.navigateToResetPasswordGraph(rootNavOptions) RootNavState.SetPassword -> navController.navigateToSetPassword(rootNavOptions) RootNavState.Splash -> navController.navigateToSplash(rootNavOptions) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index 5e05fab38..ca1a6821d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -25,7 +25,7 @@ import javax.inject.Inject */ @HiltViewModel class RootNavViewModel @Inject constructor( - authRepository: AuthRepository, + private val authRepository: AuthRepository, specialCircumstanceManager: SpecialCircumstanceManager, ) : BaseViewModel( initialState = RootNavState.Splash, @@ -52,7 +52,7 @@ class RootNavViewModel @Inject constructor( } } - @Suppress("CyclomaticComplexMethod", "MaxLineLength") + @Suppress("CyclomaticComplexMethod", "MaxLineLength", "LongMethod") private fun handleUserStateUpdateReceive( action: RootNavAction.Internal.UserStateUpdateReceive, ) { @@ -68,7 +68,13 @@ class RootNavViewModel @Inject constructor( userState == null || !userState.activeAccount.isLoggedIn || - userState.hasPendingAccountAddition -> RootNavState.Auth + userState.hasPendingAccountAddition -> { + if (authRepository.showWelcomeCarousel) { + RootNavState.AuthWithWelcome + } else { + RootNavState.Auth + } + } userState.activeAccount.isVaultUnlocked -> { when (val specialCircumstance = action.specialCircumstance) { @@ -135,6 +141,12 @@ sealed class RootNavState : Parcelable { @Parcelize data object Auth : RootNavState() + /** + * App should show auth nav graph starting with the welcome carousel. + */ + @Parcelize + data object AuthWithWelcome : RootNavState() + /** * App should show reset password graph. */ diff --git a/app/src/main/res/drawable/welcome_1.xml b/app/src/main/res/drawable/welcome_1.xml new file mode 100644 index 000000000..a8050d264 --- /dev/null +++ b/app/src/main/res/drawable/welcome_1.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/welcome_2.xml b/app/src/main/res/drawable/welcome_2.xml new file mode 100644 index 000000000..a9607bd74 --- /dev/null +++ b/app/src/main/res/drawable/welcome_2.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/welcome_3.xml b/app/src/main/res/drawable/welcome_3.xml new file mode 100644 index 000000000..a7486fce9 --- /dev/null +++ b/app/src/main/res/drawable/welcome_3.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/welcome_4.xml b/app/src/main/res/drawable/welcome_4.xml new file mode 100644 index 000000000..ef9698221 --- /dev/null +++ b/app/src/main/res/drawable/welcome_4.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b2f7fca1d..492f0b4a2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -927,4 +927,12 @@ Do you want to switch to this account? Self-hosted server URL Passkey operation failed because user could not be verified. User verification + Privacy, prioritized + Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you. + Never guess again + Set up biometric unlock and autofill to log into your accounts without typing a single letter. + Level up your logins + Use the generator to create and save strong, unique passwords for all your accounts. + Your data, when and where you need it + Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps. diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 730802028..92cd83469 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -84,9 +84,11 @@ import com.x8bit.bitwarden.data.auth.repository.util.toUserState import com.x8bit.bitwarden.data.auth.util.YubiKeyResult import com.x8bit.bitwarden.data.auth.util.toSdkParams import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.PushManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.model.NotificationLogoutData import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.Environment @@ -217,6 +219,7 @@ class AuthRepositoryTest { getActivePoliciesFlow(type = PolicyTypeJson.MASTER_PASSWORD) } returns mutableActivePolicyFlow } + private val featureFlagManager: FeatureFlagManager = mockk() private val repository = AuthRepositoryImpl( accountsService = accountsService, @@ -236,6 +239,7 @@ class AuthRepositoryTest { dispatcherManager = dispatcherManager, pushManager = pushManager, policyManager = policyManager, + featureFlagManager = featureFlagManager, ) @BeforeEach @@ -4504,6 +4508,22 @@ class AuthRepositoryTest { } } + @Suppress("MaxLineLength") + @Test + fun `showWelcomeCarousel should return value from settings repository and feature flag manager`() { + every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns false + every { featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel) } returns true + assertTrue(repository.showWelcomeCarousel) + + every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns true + every { featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel) } returns true + assertFalse(repository.showWelcomeCarousel) + + every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns true + every { featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel) } returns false + assertFalse(repository.showWelcomeCarousel) + } + @Test fun `getOrganizationDomainSsoDetails Failure should return Failure `() = runTest { val email = "test@gmail.com" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeScreenTest.kt new file mode 100644 index 000000000..9a4bd0ddb --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeScreenTest.kt @@ -0,0 +1,124 @@ +package com.x8bit.bitwarden.ui.auth.feature.welcome + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertTrue +import org.robolectric.annotation.Config + +class WelcomeScreenTest : BaseComposeTest() { + private var onNavigateToCreateAccountCalled = false + private var onNavigateToLoginCalled = false + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mutableEventFlow = bufferedMutableSharedFlow() + private val viewModel = mockk(relaxed = true) { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns mutableEventFlow + } + + @Before + fun setUp() { + composeTestRule.setContent { + WelcomeScreen( + onNavigateToCreateAccount = { onNavigateToCreateAccountCalled = true }, + onNavigateToLogin = { onNavigateToLoginCalled = true }, + viewModel = viewModel, + ) + } + } + + @Test + fun `pages should display and update according to state`() { + composeTestRule + .onNodeWithText("Privacy, prioritized") + .assertExists() + .assertIsDisplayed() + + mutableEventFlow.tryEmit(WelcomeEvent.UpdatePager(index = 1)) + composeTestRule + .onNodeWithText("Privacy, prioritized") + .assertDoesNotExist() + composeTestRule + .onNodeWithText("Never guess again") + .assertExists() + .assertIsDisplayed() + + mutableStateFlow.update { it.copy(pages = listOf(WelcomeState.WelcomeCard.CardThree)) } + composeTestRule + .onNodeWithText("Level up your logins") + .assertExists() + .assertIsDisplayed() + } + + @Config(qualifiers = "land") + @Test + fun `pages should display and update according to state in landscape mode`() { + composeTestRule + .onNodeWithText("Privacy, prioritized") + .assertExists() + .assertIsDisplayed() + + mutableEventFlow.tryEmit(WelcomeEvent.UpdatePager(index = 1)) + composeTestRule + .onNodeWithText("Privacy, prioritized") + .assertDoesNotExist() + composeTestRule + .onNodeWithText("Never guess again") + .assertExists() + .assertIsDisplayed() + + mutableStateFlow.update { it.copy(pages = listOf(WelcomeState.WelcomeCard.CardThree)) } + composeTestRule + .onNodeWithText("Level up your logins") + .assertExists() + .assertIsDisplayed() + } + + @Test + fun `NavigateToCreateAccount event should call onNavigateToCreateAccount`() { + mutableEventFlow.tryEmit(WelcomeEvent.NavigateToCreateAccount) + assertTrue(onNavigateToCreateAccountCalled) + } + + @Test + fun `NavigateToLogin event should call onNavigateToLogin`() { + mutableEventFlow.tryEmit(WelcomeEvent.NavigateToLogin) + assertTrue(onNavigateToLoginCalled) + } + + @Test + fun `create account button click should send CreateAccountClick action`() { + composeTestRule + .onNodeWithText("Create account") + .performClick() + verify { viewModel.trySendAction(WelcomeAction.CreateAccountClick) } + } + + @Test + fun `login button click should send LoginClick action`() { + // Use an empty list of pages to guarantee that the login button + // will be in view on the UI testing viewport. + mutableStateFlow.update { it.copy(pages = emptyList()) } + composeTestRule + .onNodeWithText("Log In") + .performClick() + verify { viewModel.trySendAction(WelcomeAction.LoginClick) } + } +} + +private val DEFAULT_STATE = WelcomeState( + index = 0, + pages = listOf( + WelcomeState.WelcomeCard.CardOne, + WelcomeState.WelcomeCard.CardTwo, + ), +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModelTest.kt new file mode 100644 index 000000000..f48e0af89 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModelTest.kt @@ -0,0 +1,95 @@ +package com.x8bit.bitwarden.ui.auth.feature.welcome + +import app.cash.turbine.test +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class WelcomeViewModelTest : BaseViewModelTest() { + @Test + fun `initial state should be correct`() = runTest { + val viewModel = WelcomeViewModel() + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + } + } + + @Test + fun `PagerSwipe should update state`() = runTest { + val viewModel = WelcomeViewModel() + val newIndex = 2 + + viewModel.trySendAction(WelcomeAction.PagerSwipe(index = newIndex)) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy(index = newIndex), + awaitItem(), + ) + } + } + + @Test + fun `DotClick should update state and emit UpdatePager`() = runTest { + val viewModel = WelcomeViewModel() + val newIndex = 2 + + viewModel.trySendAction(WelcomeAction.DotClick(index = newIndex)) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy(index = newIndex), + awaitItem(), + ) + } + viewModel.eventFlow.test { + assertEquals( + WelcomeEvent.UpdatePager(index = newIndex), + awaitItem(), + ) + } + } + + @Test + fun `CreateAccountClick should emit NavigateToCreateAccount`() = runTest { + val viewModel = WelcomeViewModel() + + viewModel.trySendAction(WelcomeAction.CreateAccountClick) + + viewModel.eventFlow.test { + assertEquals( + WelcomeEvent.NavigateToCreateAccount, + awaitItem(), + ) + } + } + + @Test + fun `LoginClick should emit NavigateToLogin`() = runTest { + val viewModel = WelcomeViewModel() + + viewModel.trySendAction(WelcomeAction.LoginClick) + + viewModel.eventFlow.test { + assertEquals( + WelcomeEvent.NavigateToLogin, + awaitItem(), + ) + } + } +} + +private val DEFAULT_STATE = WelcomeState( + index = 0, + pages = listOf( + WelcomeState.WelcomeCard.CardOne, + WelcomeState.WelcomeCard.CardTwo, + WelcomeState.WelcomeCard.CardThree, + WelcomeState.WelcomeCard.CardFour, + ), +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt index c524de67d..dc5c89ab9 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -73,6 +73,15 @@ class RootNavScreenTest : BaseComposeTest() { } assertTrue(isSplashScreenRemoved) + // Make sure navigating to Auth with the welcome route works as expected: + rootNavStateFlow.value = RootNavState.AuthWithWelcome + composeTestRule.runOnIdle { + fakeNavHostController.assertLastNavigation( + route = "welcome", + navOptions = expectedNavOptions, + ) + } + // Make sure navigating to vault locked works as expected: rootNavStateFlow.value = RootNavState.VaultLocked composeTestRule.runOnIdle { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index 0b714557f..b9fac81fa 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -22,6 +22,7 @@ class RootNavViewModelTest : BaseViewModelTest() { private val mutableUserStateFlow = MutableStateFlow(null) private val authRepository = mockk { every { userStateFlow } returns mutableUserStateFlow + every { showWelcomeCarousel } returns false } private val specialCircumstanceManager = SpecialCircumstanceManagerImpl() @@ -29,7 +30,22 @@ class RootNavViewModelTest : BaseViewModelTest() { fun `when there are no accounts the nav state should be Auth`() { mutableUserStateFlow.tryEmit(null) val viewModel = createViewModel() - assertEquals(RootNavState.Auth, viewModel.stateFlow.value) + assertEquals( + RootNavState.Auth, + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `when there are no accounts and the user has not logged on before the nav state should be Auth with the welcome route`() { + every { authRepository.showWelcomeCarousel } returns true + mutableUserStateFlow.tryEmit(null) + val viewModel = createViewModel() + assertEquals( + RootNavState.AuthWithWelcome, + viewModel.stateFlow.value, + ) } @Test @@ -57,7 +73,10 @@ class RootNavViewModelTest : BaseViewModelTest() { ), ) val viewModel = createViewModel() - assertEquals(RootNavState.Auth, viewModel.stateFlow.value) + assertEquals( + RootNavState.Auth, + viewModel.stateFlow.value, + ) } @Test @@ -220,7 +239,10 @@ class RootNavViewModelTest : BaseViewModelTest() { ), ) val viewModel = createViewModel() - assertEquals(RootNavState.Auth, viewModel.stateFlow.value) + assertEquals( + RootNavState.Auth, + viewModel.stateFlow.value, + ) } @Suppress("MaxLineLength") @@ -250,7 +272,10 @@ class RootNavViewModelTest : BaseViewModelTest() { ), ) val viewModel = createViewModel() - assertEquals(RootNavState.Auth, viewModel.stateFlow.value) + assertEquals( + RootNavState.Auth, + viewModel.stateFlow.value, + ) } @Test