From c3161a167ad2493391b28c6216b111a2bb686862 Mon Sep 17 00:00:00 2001 From: Shannon Date: Tue, 30 Jul 2024 10:49:09 -0600 Subject: [PATCH] PM-10242 PM-10243 PM-10244 PM-10245 PM-10246: Welcome carousel --- .../data/auth/repository/AuthRepository.kt | 5 + .../auth/repository/AuthRepositoryImpl.kt | 3 + .../ui/auth/feature/auth/AuthNavigation.kt | 13 +- .../auth/feature/welcome/WelcomeNavigation.kt | 32 +++ .../ui/auth/feature/welcome/WelcomeScreen.kt | 272 ++++++++++++++++++ .../auth/feature/welcome/WelcomeViewModel.kt | 161 +++++++++++ .../platform/feature/rootnav/RootNavScreen.kt | 10 +- .../feature/rootnav/RootNavViewModel.kt | 19 +- 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 | 9 + .../auth/feature/welcome/WelcomeScreenTest.kt | 124 ++++++++ .../feature/welcome/WelcomeViewModelTest.kt | 95 ++++++ .../feature/rootnav/RootNavScreenTest.kt | 5 +- .../feature/rootnav/RootNavViewModelTest.kt | 35 ++- 18 files changed, 1010 insertions(+), 14 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 2fe15b09c6e..d8c33d57b50 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 @@ -80,6 +80,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { */ val webAuthResultFlow: Flow + /** + * Whether or not the user has logged in or created an account before. + */ + val hasUserLoggedInOrCreatedAccount: Boolean + /** * The organization identifier currently associated with this user's SSO flow. */ 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 ce8b93d2a9e..8dde7c4956e 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 @@ -283,6 +283,9 @@ class AuthRepositoryImpl( private val webAuthResultChannel = Channel(capacity = Int.MAX_VALUE) override val webAuthResultFlow: Flow = webAuthResultChannel.receiveAsFlow() + override val hasUserLoggedInOrCreatedAccount + get() = settingsRepository.hasUserLoggedInOrCreatedAccount + private val mutableSsoCallbackResultFlow = bufferedMutableSharedFlow() override val ssoCallbackResultFlow: Flow = mutableSsoCallbackResultFlow.asSharedFlow() 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 060d550d82a..501494cf8a6 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,9 +34,12 @@ 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( + startDestination: String, + navController: NavHostController, +) { navigation( - startDestination = LANDING_ROUTE, + startDestination = startDestination, route = AUTH_GRAPH_ROUTE, ) { createAccountDestination( @@ -72,6 +77,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 00000000000..cac97f0af59 --- /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 + +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 00000000000..3a3fab133f5 --- /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 00000000000..19f494cd1f1 --- /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.welcome_title_1 + 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.welcome_title_2 + 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.welcome_title_3 + 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.welcome_title_4 + 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 22f2d72d72b..2a631e19971 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 @@ -18,6 +18,7 @@ import androidx.navigation.navOptions import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_GRAPH_ROUTE import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph +import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE import com.x8bit.bitwarden.ui.auth.feature.resetpassword.RESET_PASSWORD_ROUTE import com.x8bit.bitwarden.ui.auth.feature.resetpassword.navigateToResetPasswordGraph import com.x8bit.bitwarden.ui.auth.feature.resetpassword.resetPasswordDestination @@ -76,7 +77,10 @@ fun RootNavScreen( popExitTransition = { toExitTransition()(this) }, ) { splashDestination() - authGraph(navController) + authGraph( + startDestination = (state as? RootNavState.Auth)?.startDestination ?: LANDING_ROUTE, + navController = navController, + ) resetPasswordDestination() trustedDeviceGraph(navController) vaultUnlockDestination() @@ -84,7 +88,7 @@ fun RootNavScreen( } val targetRoute = when (state) { - RootNavState.Auth -> AUTH_GRAPH_ROUTE + is RootNavState.Auth -> AUTH_GRAPH_ROUTE RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE RootNavState.SetPassword -> SET_PASSWORD_ROUTE RootNavState.Splash -> SPLASH_ROUTE @@ -132,7 +136,7 @@ fun RootNavScreen( // transition to appear corrupted. LaunchedEffect(state) { when (val currentState = state) { - RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions) + is RootNavState.Auth -> navController.navigateToAuthGraph(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 5e05fab38c5..ca09038c871 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 @@ -11,6 +11,8 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance +import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE +import com.x8bit.bitwarden.ui.auth.feature.welcome.WELCOME_ROUTE import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.combine @@ -25,7 +27,7 @@ import javax.inject.Inject */ @HiltViewModel class RootNavViewModel @Inject constructor( - authRepository: AuthRepository, + private val authRepository: AuthRepository, specialCircumstanceManager: SpecialCircumstanceManager, ) : BaseViewModel( initialState = RootNavState.Splash, @@ -52,7 +54,7 @@ class RootNavViewModel @Inject constructor( } } - @Suppress("CyclomaticComplexMethod", "MaxLineLength") + @Suppress("CyclomaticComplexMethod", "MaxLineLength", "LongMethod") private fun handleUserStateUpdateReceive( action: RootNavAction.Internal.UserStateUpdateReceive, ) { @@ -68,7 +70,14 @@ class RootNavViewModel @Inject constructor( userState == null || !userState.activeAccount.isLoggedIn || - userState.hasPendingAccountAddition -> RootNavState.Auth + userState.hasPendingAccountAddition -> { + val startDestination = if (authRepository.hasUserLoggedInOrCreatedAccount) { + LANDING_ROUTE + } else { + WELCOME_ROUTE + } + RootNavState.Auth(startDestination = startDestination) + } userState.activeAccount.isVaultUnlocked -> { when (val specialCircumstance = action.specialCircumstance) { @@ -133,7 +142,9 @@ sealed class RootNavState : Parcelable { * App should show auth nav graph. */ @Parcelize - data object Auth : RootNavState() + data class Auth( + val startDestination: String, + ) : 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 00000000000..a8050d264f6 --- /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 00000000000..a9607bd74ac --- /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 00000000000..a7486fce91b --- /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 00000000000..ef969822153 --- /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 b2f7fca1d5f..06d3679f60e 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 0ab3ac95c93..a0b82786c7b 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 @@ -4067,6 +4067,15 @@ class AuthRepositoryTest { } } + @Test + fun `hasUserLoggedInOrCreatedAccount should return value from settings repository`() { + every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns true + assertTrue(repository.hasUserLoggedInOrCreatedAccount) + + every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns false + assertFalse(repository.hasUserLoggedInOrCreatedAccount) + } + @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 00000000000..9a4bd0ddbbe --- /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 00000000000..f48e0af891b --- /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 c524de67d84..32ef968432e 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 @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav import androidx.navigation.navOptions import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData +import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.FakeNavHostController import io.mockk.every @@ -64,7 +65,9 @@ class RootNavScreenTest : BaseComposeTest() { assertFalse(isSplashScreenRemoved) // Make sure navigating to Auth works as expected: - rootNavStateFlow.value = RootNavState.Auth + rootNavStateFlow.value = RootNavState.Auth( + startDestination = LANDING_ROUTE, + ) composeTestRule.runOnIdle { fakeNavHostController.assertLastNavigation( route = "auth_graph", 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 0b714557f55..d02e1082594 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 @@ -11,6 +11,8 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE +import com.x8bit.bitwarden.ui.auth.feature.welcome.WELCOME_ROUTE import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import io.mockk.every import io.mockk.mockk @@ -22,6 +24,7 @@ class RootNavViewModelTest : BaseViewModelTest() { private val mutableUserStateFlow = MutableStateFlow(null) private val authRepository = mockk { every { userStateFlow } returns mutableUserStateFlow + every { hasUserLoggedInOrCreatedAccount } returns true } private val specialCircumstanceManager = SpecialCircumstanceManagerImpl() @@ -29,7 +32,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(startDestination = LANDING_ROUTE), + 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 start destination welcome`() { + every { authRepository.hasUserLoggedInOrCreatedAccount } returns false + mutableUserStateFlow.tryEmit(null) + val viewModel = createViewModel() + assertEquals( + RootNavState.Auth(startDestination = WELCOME_ROUTE), + viewModel.stateFlow.value, + ) } @Test @@ -57,7 +75,10 @@ class RootNavViewModelTest : BaseViewModelTest() { ), ) val viewModel = createViewModel() - assertEquals(RootNavState.Auth, viewModel.stateFlow.value) + assertEquals( + RootNavState.Auth(startDestination = LANDING_ROUTE), + viewModel.stateFlow.value, + ) } @Test @@ -220,7 +241,10 @@ class RootNavViewModelTest : BaseViewModelTest() { ), ) val viewModel = createViewModel() - assertEquals(RootNavState.Auth, viewModel.stateFlow.value) + assertEquals( + RootNavState.Auth(startDestination = LANDING_ROUTE), + viewModel.stateFlow.value, + ) } @Suppress("MaxLineLength") @@ -250,7 +274,10 @@ class RootNavViewModelTest : BaseViewModelTest() { ), ) val viewModel = createViewModel() - assertEquals(RootNavState.Auth, viewModel.stateFlow.value) + assertEquals( + RootNavState.Auth(startDestination = LANDING_ROUTE), + viewModel.stateFlow.value, + ) } @Test