diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index ca3e2c113e..04b58e5dde 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -39,6 +39,8 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.navigateToVaultAddEdit import com.x8bit.bitwarden.ui.vault.feature.addedit.vaultAddEditDestination import com.x8bit.bitwarden.ui.vault.feature.attachments.attachmentDestination import com.x8bit.bitwarden.ui.vault.feature.attachments.navigateToAttachment +import com.x8bit.bitwarden.ui.vault.feature.importlogins.importLoginsScreenDestination +import com.x8bit.bitwarden.ui.vault.feature.importlogins.navigateToImportLoginsScreen import com.x8bit.bitwarden.ui.vault.feature.item.navigateToVaultItem import com.x8bit.bitwarden.ui.vault.feature.item.vaultItemDestination import com.x8bit.bitwarden.ui.vault.feature.itemlisting.vaultItemListingDestinationAsRoot @@ -104,6 +106,7 @@ fun NavGraphBuilder.vaultUnlockedGraph( }, onNavigateToSetupUnlockScreen = { navController.navigateToSetupUnlockScreen() }, onNavigateToSetupAutoFillScreen = { navController.navigateToSetupAutoFillScreen() }, + onNavigateToImportLogins = { navController.navigateToImportLoginsScreen() }, ) deleteAccountDestination( onNavigateBack = { navController.popBackStack() }, @@ -216,5 +219,8 @@ fun NavGraphBuilder.vaultUnlockedGraph( navController.popBackStack() }, ) + importLoginsScreenDestination( + onNavigateBack = { navController.popBackStack() }, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt index 1b146bf97c..5a028635e4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt @@ -38,6 +38,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination( onNavigateToPasswordHistory: () -> Unit, onNavigateToSetupUnlockScreen: () -> Unit, onNavigateToSetupAutoFillScreen: () -> Unit, + onNavigateToImportLogins: () -> Unit, ) { composableWithStayTransitions( route = VAULT_UNLOCKED_NAV_BAR_ROUTE, @@ -57,6 +58,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination( onNavigateToPasswordHistory = onNavigateToPasswordHistory, onNavigateToSetupUnlockScreen = onNavigateToSetupUnlockScreen, onNavigateToSetupAutoFillScreen = onNavigateToSetupAutoFillScreen, + onNavigateToImportLogins = onNavigateToImportLogins, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index 1ee2d7eeb6..e82b78ec3c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -77,6 +77,7 @@ fun VaultUnlockedNavBarScreen( onNavigateToPasswordHistory: () -> Unit, onNavigateToSetupUnlockScreen: () -> Unit, onNavigateToSetupAutoFillScreen: () -> Unit, + onNavigateToImportLogins: () -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -136,6 +137,7 @@ fun VaultUnlockedNavBarScreen( }, onNavigateToSetupUnlockScreen = onNavigateToSetupUnlockScreen, onNavigateToSetupAutoFillScreen = onNavigateToSetupAutoFillScreen, + onNavigateToImportLogins = onNavigateToImportLogins, ) } @@ -165,6 +167,7 @@ private fun VaultUnlockedNavBarScaffold( navigateToPasswordHistory: () -> Unit, onNavigateToSetupUnlockScreen: () -> Unit, onNavigateToSetupAutoFillScreen: () -> Unit, + onNavigateToImportLogins: () -> Unit, ) { var shouldDimNavBar by remember { mutableStateOf(false) } @@ -224,6 +227,7 @@ private fun VaultUnlockedNavBarScaffold( onDimBottomNavBarRequest = { shouldDim -> shouldDimNavBar = shouldDim }, + onNavigateToImportLogins = onNavigateToImportLogins, ) sendGraph( navController = navController, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsNavigation.kt new file mode 100644 index 0000000000..ba6ed19a57 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsNavigation.kt @@ -0,0 +1,30 @@ +package com.x8bit.bitwarden.ui.vault.feature.importlogins + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions + +private const val IMPORT_LOGINS_ROUTE = "import-logins" + +/** + * Helper function to navigate to the import logins screen. + */ +fun NavController.navigateToImportLoginsScreen(navOptions: NavOptions? = null) { + navigate(route = IMPORT_LOGINS_ROUTE, navOptions = navOptions) +} + +/** + * Adds the import logins screen to the navigation graph. + */ +fun NavGraphBuilder.importLoginsScreenDestination( + onNavigateBack: () -> Unit, +) { + composableWithSlideTransitions( + route = IMPORT_LOGINS_ROUTE, + ) { + ImportLoginsScreen( + onNavigateBack = onNavigateBack, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreen.kt new file mode 100644 index 0000000000..0677787318 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreen.kt @@ -0,0 +1,253 @@ +package com.x8bit.bitwarden.ui.vault.feature.importlogins + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +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.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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +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.data.platform.annotation.OmitFromCoverage +import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin +import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.appbar.NavigationIcon +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog +import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme +import com.x8bit.bitwarden.ui.vault.feature.importlogins.handlers.ImportLoginHandler +import com.x8bit.bitwarden.ui.vault.feature.importlogins.handlers.rememberImportLoginHandler + +/** + * Top level component for the import logins screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ImportLoginsScreen( + onNavigateBack: () -> Unit, + viewModel: ImportLoginsViewModel = hiltViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val handler = rememberImportLoginHandler(viewModel = viewModel) + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + ImportLoginsEvent.NavigateBack -> onNavigateBack() + } + } + + ImportLoginsDialogContent(state = state, handler = handler) + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(R.string.import_logins), + navigationIcon = NavigationIcon( + navigationIcon = rememberVectorPainter(R.drawable.ic_close), + onNavigationIconClick = handler.onCloseClick, + navigationIconContentDescription = stringResource(R.string.close), + ), + scrollBehavior = scrollBehavior, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues = innerPadding), + ) { + ImportLoginsContent( + onGetStartedClick = handler.onGetStartedClick, + onImportLaterClick = handler.onImportLaterClick, + ) + } + } +} + +@Composable +private fun ImportLoginsDialogContent( + state: ImportLoginsState, + handler: ImportLoginHandler, +) { + val confirmButtonText = stringResource(R.string.confirm) + val dismissButtonText = stringResource(R.string.cancel) + when (val dialogState = state.dialogState) { + ImportLoginsState.DialogState.GetStarted -> { + BitwardenTwoButtonDialog( + title = dialogState.title(), + message = dialogState.message(), + onDismissRequest = handler.onDismissDialog, + confirmButtonText = confirmButtonText, + dismissButtonText = dismissButtonText, + onConfirmClick = handler.onConfirmGetStarted, + onDismissClick = handler.onDismissDialog, + ) + } + + ImportLoginsState.DialogState.ImportLater -> { + BitwardenTwoButtonDialog( + title = dialogState.title(), + message = dialogState.message(), + onDismissRequest = handler.onDismissDialog, + confirmButtonText = confirmButtonText, + dismissButtonText = dismissButtonText, + onConfirmClick = handler.onConfirmImportLater, + onDismissClick = handler.onDismissDialog, + ) + } + + null -> Unit + } +} + +@Composable +private fun ImportLoginsContent( + onGetStartedClick: () -> Unit, + onImportLaterClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .fillMaxSize() + .padding(top = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = rememberVectorPainter(R.drawable.img_import_logins), + contentDescription = null, + modifier = Modifier + .standardHorizontalMargin() + .size(124.dp), + ) + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.give_your_vault_a_head_start), + style = BitwardenTheme.typography.titleMedium, + color = BitwardenTheme.colorScheme.text.primary, + textAlign = TextAlign.Center, + modifier = Modifier.standardHorizontalMargin(), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource( + R.string.from_your_computer_follow_these_instructions_to_export_saved_passwords, + ), + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.primary, + textAlign = TextAlign.Center, + modifier = Modifier.standardHorizontalMargin(), + ) + Spacer(Modifier.height(24.dp)) + BitwardenFilledButton( + label = stringResource(R.string.get_started), + onClick = onGetStartedClick, + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + BitwardenOutlinedButton( + label = stringResource(R.string.import_logins_later), + onClick = onImportLaterClick, + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth(), + ) + Spacer(Modifier.navigationBarsPadding()) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ImportLoginsInitialContent_preview() { + BitwardenTheme { + Column( + modifier = Modifier.background( + BitwardenTheme.colorScheme.background.primary, + ), + ) { + ImportLoginsContent( + onGetStartedClick = {}, + onImportLaterClick = {}, + ) + } + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ImportLoginsScreenDialog_preview( + @PreviewParameter(ImportLoginsDialogContentPreviewProvider::class) state: ImportLoginsState, +) { + BitwardenTheme { + Column( + modifier = Modifier.background( + BitwardenTheme.colorScheme.background.primary, + ), + ) { + ImportLoginsDialogContent( + state = state, + handler = ImportLoginHandler( + onDismissDialog = {}, + onConfirmGetStarted = {}, + onConfirmImportLater = {}, + onCloseClick = {}, + onGetStartedClick = {}, + onImportLaterClick = {}, + ), + ) + ImportLoginsContent( + onGetStartedClick = {}, + onImportLaterClick = {}, + ) + } + } +} + +@OmitFromCoverage +private class ImportLoginsDialogContentPreviewProvider : + PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + ImportLoginsState( + dialogState = ImportLoginsState.DialogState.GetStarted, + ), + ImportLoginsState( + dialogState = ImportLoginsState.DialogState.ImportLater, + ), + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModel.kt new file mode 100644 index 0000000000..28b5a54b28 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModel.kt @@ -0,0 +1,146 @@ +package com.x8bit.bitwarden.ui.vault.feature.importlogins + +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +/** + * View model for the [ImportLoginsScreen]. + */ +@HiltViewModel +class ImportLoginsViewModel @Inject constructor() : + BaseViewModel( + initialState = ImportLoginsState( + null, + ), + ) { + override fun handleAction(action: ImportLoginsAction) { + when (action) { + ImportLoginsAction.ConfirmGetStarted -> handleConfirmGetStarted() + ImportLoginsAction.ConfirmImportLater -> handleConfirmImportLater() + ImportLoginsAction.DismissDialog -> handleDismissDialog() + ImportLoginsAction.GetStartedClick -> handleGetStartedClick() + ImportLoginsAction.ImportLaterClick -> handleImportLaterClick() + ImportLoginsAction.CloseClick -> handleCloseClick() + } + } + + private fun handleCloseClick() { + sendEvent(ImportLoginsEvent.NavigateBack) + } + + private fun handleImportLaterClick() { + updateDialogState(ImportLoginsState.DialogState.ImportLater) + } + + private fun handleGetStartedClick() { + updateDialogState(ImportLoginsState.DialogState.GetStarted) + } + + private fun handleDismissDialog() { + dismissDialog() + } + + private fun handleConfirmImportLater() { + dismissDialog() + sendEvent(ImportLoginsEvent.NavigateBack) + } + + private fun handleConfirmGetStarted() { + dismissDialog() + // TODO - PM-11182: Move to first step in instructions. + } + + private fun dismissDialog() { + updateDialogState(null) + } + + private fun updateDialogState(dialogState: ImportLoginsState.DialogState?) { + mutableStateFlow.update { + it.copy(dialogState = dialogState) + } + } +} + +/** + * Model state for the [ImportLoginsViewModel]. + */ +data class ImportLoginsState( + val dialogState: DialogState?, +) { + /** + * Dialog states for the [ImportLoginsViewModel]. + */ + sealed class DialogState { + abstract val message: Text + abstract val title: Text + + /** + * Import logins later dialog state. + */ + data object ImportLater : DialogState() { + override val message: Text = + R.string.you_can_return_to_complete_this_step_anytime_from_settings.asText() + override val title: Text = R.string.import_logins_later_dialog_title.asText() + } + + /** + * Get started dialog state. + */ + data object GetStarted : DialogState() { + override val message: Text = + R.string.the_following_instructions_will_guide_you_through_importing_logins.asText() + override val title: Text = R.string.do_you_have_a_computer_available.asText() + } + } +} + +/** + * Model events that can be sent from the [ImportLoginsViewModel] + */ +sealed class ImportLoginsEvent { + /** + * Navigate back to the previous screen. + */ + data object NavigateBack : ImportLoginsEvent() +} + +/** + * Model actions that can be handled by the [ImportLoginsViewModel]. + */ +sealed class ImportLoginsAction { + + /** + * User has clicked the "Get Started" button. + */ + data object GetStartedClick : ImportLoginsAction() + + /** + * User has clicked the "Import Later" button. + */ + data object ImportLaterClick : ImportLoginsAction() + + /** + * User has clicked the "Close" button on the dialog or outside the dialog. + */ + data object DismissDialog : ImportLoginsAction() + + /** + * User has confirmed the "Import Later" dialog. + */ + data object ConfirmImportLater : ImportLoginsAction() + + /** + * User has confirmed the "Get Started" dialog. + */ + data object ConfirmGetStarted : ImportLoginsAction() + + /** + * User has clicked the "Close" icon button. + */ + data object CloseClick : ImportLoginsAction() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/handlers/ImportLoginHandler.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/handlers/ImportLoginHandler.kt new file mode 100644 index 0000000000..63cbbf98f2 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/handlers/ImportLoginHandler.kt @@ -0,0 +1,44 @@ +package com.x8bit.bitwarden.ui.vault.feature.importlogins.handlers + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.x8bit.bitwarden.ui.vault.feature.importlogins.ImportLoginsAction +import com.x8bit.bitwarden.ui.vault.feature.importlogins.ImportLoginsViewModel + +/** + * Action handlers for the [ImportLoginsScreen]. + */ +data class ImportLoginHandler( + val onGetStartedClick: () -> Unit, + val onImportLaterClick: () -> Unit, + val onDismissDialog: () -> Unit, + val onConfirmGetStarted: () -> Unit, + val onConfirmImportLater: () -> Unit, + val onCloseClick: () -> Unit, +) { + @Suppress("UndocumentedPublicClass") + companion object { + /** + * Creates an instance of [ImportLoginHandler] using the provided [ImportLoginsViewModel]. + */ + fun create(viewModel: ImportLoginsViewModel) = ImportLoginHandler( + onGetStartedClick = { viewModel.trySendAction(ImportLoginsAction.GetStartedClick) }, + onImportLaterClick = { viewModel.trySendAction(ImportLoginsAction.ImportLaterClick) }, + onDismissDialog = { viewModel.trySendAction(ImportLoginsAction.DismissDialog) }, + onConfirmGetStarted = { viewModel.trySendAction(ImportLoginsAction.ConfirmGetStarted) }, + onConfirmImportLater = { + viewModel.trySendAction(ImportLoginsAction.ConfirmImportLater) + }, + onCloseClick = { viewModel.trySendAction(ImportLoginsAction.CloseClick) }, + ) + } +} + +/** + * Helper function to remember a [ImportLoginHandler] instance in a [Composable] scope. + */ +@Composable +fun rememberImportLoginHandler(viewModel: ImportLoginsViewModel): ImportLoginHandler = + remember(viewModel) { + ImportLoginHandler.create(viewModel = viewModel) + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt index 40a83b9628..905ba88a14 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt @@ -24,6 +24,7 @@ fun NavGraphBuilder.vaultGraph( onNavigateToVaultEditItemScreen: (vaultItemId: String) -> Unit, onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit, onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, + onNavigateToImportLogins: () -> Unit, ) { navigation( route = VAULT_GRAPH_ROUTE, @@ -41,6 +42,7 @@ fun NavGraphBuilder.vaultGraph( }, onNavigateToSearchVault = onNavigateToSearchVault, onDimBottomNavBarRequest = onDimBottomNavBarRequest, + onNavigateToImportLogins = onNavigateToImportLogins, ) vaultItemListingDestination( onNavigateBack = { navController.popBackStack() }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt index 94ddeb27d4..201ee264ef 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt @@ -21,6 +21,7 @@ fun NavGraphBuilder.vaultDestination( onNavigateToVaultItemListingScreen: (vaultItemType: VaultItemListingType) -> Unit, onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit, onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, + onNavigateToImportLogins: () -> Unit, ) { composableWithRootPushTransitions( route = VAULT_ROUTE, @@ -33,6 +34,7 @@ fun NavGraphBuilder.vaultDestination( onNavigateToVerificationCodeScreen = onNavigateToVerificationCodeScreen, onNavigateToSearchVault = onNavigateToSearchVault, onDimBottomNavBarRequest = onDimBottomNavBarRequest, + onNavigateToImportLogins = onNavigateToImportLogins, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index af5fa042be..d59ca604fb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -83,6 +83,7 @@ fun VaultScreen( onNavigateToVaultItemListingScreen: (vaultItemType: VaultItemListingType) -> Unit, onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit, onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, + onNavigateToImportLogins: () -> Unit, exitManager: ExitManager = LocalExitManager.current, intentManager: IntentManager = LocalIntentManager.current, permissionsManager: PermissionsManager = LocalPermissionsManager.current, @@ -122,6 +123,8 @@ fun VaultScreen( .makeText(context, event.message(context.resources), Toast.LENGTH_SHORT) .show() } + + VaultEvent.NavigateToImportLogins -> onNavigateToImportLogins() } } val vaultHandlers = remember(viewModel) { VaultHandlers.create(viewModel) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index aacccf23fc..c03f75d9d0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -181,7 +181,7 @@ class VaultViewModel @Inject constructor( private fun handleImportActionCardClick() { dismissImportLoginCard() - // TODO: PM-11179 - navigate to import logins screen + sendEvent(VaultEvent.NavigateToImportLogins) } private fun handleDismissImportActionCard() { @@ -1004,6 +1004,11 @@ sealed class VaultEvent { */ data object NavigateOutOfApp : VaultEvent() + /** + * Navigate to the import logins screen. + */ + data object NavigateToImportLogins : VaultEvent() + /** * Show a toast with the given [message]. */ diff --git a/app/src/main/res/drawable-night/img_import_logins.xml b/app/src/main/res/drawable-night/img_import_logins.xml new file mode 100644 index 0000000000..64443dc268 --- /dev/null +++ b/app/src/main/res/drawable-night/img_import_logins.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/img_import_logins.xml b/app/src/main/res/drawable/img_import_logins.xml new file mode 100644 index 0000000000..af02d13068 --- /dev/null +++ b/app/src/main/res/drawable/img_import_logins.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ff7cebfc03..ac22e5c01c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1020,4 +1020,12 @@ Do you want to switch to this account? Send sensitive information, safely Import saved logins Use a computer to import logins from an existing password manager + You can return to complete this step anytime in Vault Settings. + Import logins later + Import logins later? + Do you have a computer available? + The following instructions will guide you through importing logins from your desktop or laptop computer + Import Logins + From your computer, follow these instructions to export saved passwords from your browser or other password manager. Then, safely import them to Bitwarden. + Give your vault a head start diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt index 55c10cc2ad..9f4f07db93 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt @@ -55,6 +55,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() { onNavigateToSearchSend = {}, onNavigateToSetupAutoFillScreen = {}, onNavigateToSetupUnlockScreen = {}, + onNavigateToImportLogins = {}, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreenTest.kt new file mode 100644 index 0000000000..a7e3239ac3 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsScreenTest.kt @@ -0,0 +1,178 @@ +package com.x8bit.bitwarden.ui.vault.feature.importlogins + +import androidx.compose.ui.test.assertIsDisplayed +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.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 com.x8bit.bitwarden.ui.util.assertNoDialogExists +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 org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ImportLoginsScreenTest : BaseComposeTest() { + + private var navigateBackCalled = false + + private val mutableImportLoginsStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mutableImportLoginsEventFlow = bufferedMutableSharedFlow() + private val viewModel = mockk { + every { eventFlow } returns mutableImportLoginsEventFlow + every { stateFlow } returns mutableImportLoginsStateFlow + every { trySendAction(any()) } just runs + } + + @Before + fun setup() { + composeTestRule.setContent { + ImportLoginsScreen( + onNavigateBack = { navigateBackCalled = true }, + viewModel = viewModel, + ) + } + } + + @Test + fun `navigate back when event is NavigateBack`() { + mutableImportLoginsEventFlow.tryEmit(ImportLoginsEvent.NavigateBack) + + assertTrue(navigateBackCalled) + } + + @Test + fun `when close icon clicked, CloseClick action is sent`() { + composeTestRule + .onNodeWithContentDescription("Close") + .performClick() + + verifyActionSent(ImportLoginsAction.CloseClick) + } + + @Test + fun `when get started clicked, GetStartedClick action is sent`() { + composeTestRule + .onNodeWithText("Get started") + .performClick() + + verifyActionSent(ImportLoginsAction.GetStartedClick) + } + + @Test + fun `when import later clicked, ImportLaterClick action is sent`() { + composeTestRule + .onNodeWithText("Import logins later") + .performClick() + + verifyActionSent(ImportLoginsAction.ImportLaterClick) + } + + @Test + fun `dialog content is shown when state updates and is hidden when null`() { + mutableImportLoginsStateFlow.tryEmit( + DEFAULT_STATE.copy( + dialogState = ImportLoginsState.DialogState.GetStarted, + ), + ) + composeTestRule + .onNode(isDialog()) + .assertIsDisplayed() + + mutableImportLoginsStateFlow.tryEmit( + DEFAULT_STATE.copy( + dialogState = null, + ), + ) + composeTestRule + .assertNoDialogExists() + + mutableImportLoginsStateFlow.tryEmit( + DEFAULT_STATE.copy( + dialogState = ImportLoginsState.DialogState.ImportLater, + ), + ) + + composeTestRule + .onNode(isDialog()) + .assertIsDisplayed() + } + + @Suppress("MaxLineLength") + @Test + fun `when dialog state is GetStarted, GetStarted dialog is shown and sends correct actions when clicked`() { + mutableImportLoginsStateFlow.tryEmit( + DEFAULT_STATE.copy( + dialogState = ImportLoginsState.DialogState.GetStarted, + ), + ) + composeTestRule + .onNode(isDialog()) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Do you have a computer available?", useUnmergedTree = true) + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Confirm") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + verifyActionSent(ImportLoginsAction.ConfirmGetStarted) + + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + verifyActionSent(ImportLoginsAction.DismissDialog) + } + + @Suppress("MaxLineLength") + @Test + fun `when dialog state is ImportLater, ImportLater dialog is shown and sends correct actions when clicked`() { + mutableImportLoginsStateFlow.tryEmit( + DEFAULT_STATE.copy( + dialogState = ImportLoginsState.DialogState.ImportLater, + ), + ) + composeTestRule + .onNode(isDialog()) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Import logins later?", useUnmergedTree = true) + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Confirm") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + verifyActionSent(ImportLoginsAction.ConfirmImportLater) + + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + .performClick() + verifyActionSent(ImportLoginsAction.DismissDialog) + } + + private fun verifyActionSent(action: ImportLoginsAction) { + verify { viewModel.trySendAction(action) } + } +} + +private val DEFAULT_STATE = ImportLoginsState(dialogState = null) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModelTest.kt new file mode 100644 index 0000000000..da5f426571 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/ImportLoginsViewModelTest.kt @@ -0,0 +1,141 @@ +package com.x8bit.bitwarden.ui.vault.feature.importlogins + +import app.cash.turbine.test +import app.cash.turbine.turbineScope +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 ImportLoginsViewModelTest : BaseViewModelTest() { + + @Test + fun `initial state is correct`() { + val viewModel = createViewModel() + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + } + + @Test + fun `GetStartedClick sets dialog state to GetStarted`() { + val viewModel = createViewModel() + viewModel.trySendAction(ImportLoginsAction.GetStartedClick) + assertEquals( + ImportLoginsState( + dialogState = ImportLoginsState.DialogState.GetStarted, + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `ImportLaterClick sets dialog state to ImportLater`() { + val viewModel = createViewModel() + viewModel.trySendAction(ImportLoginsAction.ImportLaterClick) + assertEquals( + ImportLoginsState( + dialogState = ImportLoginsState.DialogState.ImportLater, + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `DismissDialog sets dialog state to null`() = runTest { + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + viewModel.trySendAction(ImportLoginsAction.GetStartedClick) + assertEquals( + ImportLoginsState( + dialogState = ImportLoginsState.DialogState.GetStarted, + ), + awaitItem(), + ) + viewModel.trySendAction(ImportLoginsAction.DismissDialog) + assertEquals( + ImportLoginsState( + dialogState = null, + ), + awaitItem(), + ) + } + } + + @Test + fun `ConfirmImportLater sets dialog state to null and sends NavigateBack event`() = runTest { + val viewModel = createViewModel() + turbineScope { + val stateFlow = viewModel.stateFlow.testIn(backgroundScope) + val eventFlow = viewModel.eventFlow.testIn(backgroundScope) + // Initial state + assertEquals(DEFAULT_STATE, stateFlow.awaitItem()) + + // Set the dialog state to ImportLater + viewModel.trySendAction(ImportLoginsAction.ImportLaterClick) + assertEquals( + ImportLoginsState( + dialogState = ImportLoginsState.DialogState.ImportLater, + ), + stateFlow.awaitItem(), + ) + viewModel.trySendAction(ImportLoginsAction.ConfirmImportLater) + assertEquals( + ImportLoginsState( + dialogState = null, + ), + stateFlow.awaitItem(), + ) + assertEquals( + ImportLoginsEvent.NavigateBack, + eventFlow.awaitItem(), + ) + } + } + + @Test + fun `ConfirmGetStarted sets dialog state to null`() = runTest { + val viewModel = createViewModel() + viewModel.stateFlow.test { + // Initial state + assertEquals(DEFAULT_STATE, awaitItem()) + + // Set the dialog state to GetStarted + viewModel.trySendAction(ImportLoginsAction.GetStartedClick) + assertEquals( + ImportLoginsState( + dialogState = ImportLoginsState.DialogState.GetStarted, + ), + awaitItem(), + ) + viewModel.trySendAction(ImportLoginsAction.ConfirmGetStarted) + assertEquals( + ImportLoginsState( + dialogState = null, + ), + awaitItem(), + ) + } + } + + @Test + fun `CloseClick sends NavigateBack event`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ImportLoginsAction.CloseClick) + assertEquals( + ImportLoginsEvent.NavigateBack, + awaitItem(), + ) + } + } + + private fun createViewModel(): ImportLoginsViewModel = ImportLoginsViewModel() +} + +private val DEFAULT_STATE = ImportLoginsState(dialogState = null) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index 2ddfda13e0..4565ea4262 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -60,7 +60,7 @@ import org.junit.Test @Suppress("LargeClass") class VaultScreenTest : BaseComposeTest() { - + private var onNavigateToImportLoginsCalled = false private var onNavigateToVaultAddItemScreenCalled = false private var onNavigateToVaultItemId: String? = null private var onNavigateToVaultEditItemId: String? = null @@ -91,6 +91,7 @@ class VaultScreenTest : BaseComposeTest() { onDimBottomNavBarRequest = { onDimBottomNavBarRequestCalled = true }, onNavigateToVerificationCodeScreen = { onNavigateToVerificationCodeScreen = true }, onNavigateToSearchVault = { onNavigateToSearchScreen = true }, + onNavigateToImportLogins = { onNavigateToImportLoginsCalled = true }, exitManager = exitManager, intentManager = intentManager, permissionsManager = permissionsManager, @@ -1194,6 +1195,12 @@ class VaultScreenTest : BaseComposeTest() { .performClick() verify { viewModel.trySendAction(VaultAction.DismissImportActionCard) } } + + @Test + fun `when NavigateToImportLogins is sent, it should call onNavigateToImportLogins`() { + mutableEventFlow.tryEmit(VaultEvent.NavigateToImportLogins) + assertTrue(onNavigateToImportLoginsCalled) + } } private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 86b5ed044d..3d0f8731c2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -1576,14 +1576,19 @@ class VaultViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") @Test - fun `when ImportActionCardClick is sent, repository called to set value to false`() { - val viewModel = createViewModel() - viewModel.trySendAction(VaultAction.ImportActionCardClick) - verify(exactly = 1) { - authRepository.setShowImportLogins(false) + fun `when ImportActionCardClick is sent, repository called to set value to false and NavigateToImportLogins event is sent`() = + runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(VaultAction.ImportActionCardClick) + assertEquals(VaultEvent.NavigateToImportLogins, awaitItem()) + } + verify(exactly = 1) { + authRepository.setShowImportLogins(false) + } } - } @Suppress("MaxLineLength") @Test