diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/appbar/BitwardenTopAppBar.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/appbar/BitwardenTopAppBar.kt index 8abd9f4bf..401c6efab 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/appbar/BitwardenTopAppBar.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/appbar/BitwardenTopAppBar.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.bottomDivider @@ -88,6 +89,7 @@ fun BitwardenTopAppBar( modifier: Modifier = Modifier, dividerStyle: TopAppBarDividerStyle = TopAppBarDividerStyle.ON_SCROLL, actions: @Composable RowScope.() -> Unit = {}, + minimunHeight: Dp = 48.dp, ) { var titleTextHasOverflow by remember { mutableStateOf(false) @@ -131,7 +133,7 @@ fun BitwardenTopAppBar( colors = bitwardenTopAppBarColors(), scrollBehavior = scrollBehavior, navigationIcon = navigationIconContent, - collapsedHeight = 48.dp, + collapsedHeight = minimunHeight, expandedHeight = 96.dp, title = { // The height of the component is controlled and will only allow for 1 extra row, @@ -151,7 +153,7 @@ fun BitwardenTopAppBar( colors = bitwardenTopAppBarColors(), scrollBehavior = scrollBehavior, navigationIcon = navigationIconContent, - expandedHeight = 48.dp, + expandedHeight = minimunHeight, title = { Text( text = title, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/bottomsheet/BitwardenModalBottomSheet.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/bottomsheet/BitwardenModalBottomSheet.kt new file mode 100644 index 000000000..505b6b445 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/bottomsheet/BitwardenModalBottomSheet.kt @@ -0,0 +1,79 @@ +package com.x8bit.bitwarden.ui.platform.components.bottomsheet + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.R +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.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme + +/** + * A reusable modal bottom sheet that applies provides a bottom sheet layout with the + * standard [BitwardenScaffold] and [BitwardenTopAppBar] and expected scrolling behavior with + * passed in [sheetContent] + * + * @param sheetTitle The title to display in the [BitwardenTopAppBar] + * @param onDismiss The action to perform when the bottom sheet is dismissed will also be performed + * when the "close" icon is clicked, caller must handle any desired animation or hiding of the + * bottom sheet. + * @param showBottomSheet Whether or not to show the bottom sheet, by default this is true assuming + * the showing/hiding will be handled by the caller. + * @param sheetContent Content to display in the bottom sheet. The content is passed the padding + * from the containing [BitwardenScaffold]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BitwardenModalBottomSheet( + sheetTitle: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + showBottomSheet: Boolean = true, + sheetState: SheetState = rememberModalBottomSheetState(), + sheetContent: @Composable (PaddingValues) -> Unit, +) { + if (!showBottomSheet) return + ModalBottomSheet( + onDismissRequest = onDismiss, + modifier = modifier, + dragHandle = null, + sheetState = sheetState, + contentWindowInsets = { + WindowInsets(left = 0, top = 0, right = 0, bottom = 0) + }, + shape = BitwardenTheme.shapes.bottomSheet, + ) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + BitwardenScaffold( + topBar = { + BitwardenTopAppBar( + title = sheetTitle, + navigationIcon = NavigationIcon( + navigationIcon = rememberVectorPainter(R.drawable.ic_close), + onNavigationIconClick = onDismiss, + navigationIconContentDescription = stringResource(R.string.close), + ), + scrollBehavior = scrollBehavior, + minimunHeight = 64.dp, + ) + }, + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .fillMaxSize(), + ) { paddingValues -> + sheetContent(paddingValues) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/content/BitwardenContentBlock.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/content/BitwardenContentBlock.kt index b7424b3e8..c5a3d937d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/content/BitwardenContentBlock.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/content/BitwardenContentBlock.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.platform.components.content +import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -14,33 +15,33 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.ui.platform.components.model.ContentBlockData +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme /** - * An overloaded version [BitwardenContentBlock] which takes a [String] for the header text. + * An overloaded version [BitwardenContentBlock] which takes a [ContentBlockData] for the + * header text. */ @Composable fun BitwardenContentBlock( - headerText: String, + data: ContentBlockData, modifier: Modifier = Modifier, headerTextStyle: TextStyle = BitwardenTheme.typography.titleSmall, - subtitleText: String? = null, subtitleTextStyle: TextStyle = BitwardenTheme.typography.bodyMedium, - iconPainter: Painter? = null, backgroundColor: Color = BitwardenTheme.colorScheme.background.secondary, ) { BitwardenContentBlock( - headerText = AnnotatedString(headerText), + headerText = data.headerText, modifier = modifier, headerTextStyle = headerTextStyle, - subtitleText = subtitleText, + subtitleText = data.subtitleText, subtitleTextStyle = subtitleTextStyle, - iconPainter = iconPainter, + iconVectorResource = data.iconVectorResource, backgroundColor = backgroundColor, ) } @@ -50,13 +51,13 @@ fun BitwardenContentBlock( * Implemented to match design component. */ @Composable -fun BitwardenContentBlock( +private fun BitwardenContentBlock( headerText: AnnotatedString, modifier: Modifier = Modifier, headerTextStyle: TextStyle = BitwardenTheme.typography.titleSmall, subtitleText: String? = null, subtitleTextStyle: TextStyle = BitwardenTheme.typography.bodyMedium, - iconPainter: Painter? = null, + @DrawableRes iconVectorResource: Int? = null, backgroundColor: Color = BitwardenTheme.colorScheme.background.secondary, ) { Row( @@ -65,15 +66,16 @@ fun BitwardenContentBlock( .background(backgroundColor), verticalAlignment = Alignment.CenterVertically, ) { - iconPainter + iconVectorResource ?.let { Spacer(Modifier.width(12.dp)) Icon( - painter = it, + painter = rememberVectorPainter(it), contentDescription = null, tint = BitwardenTheme.colorScheme.icon.secondary, modifier = Modifier.size(24.dp), ) + Spacer(Modifier.width(12.dp)) } ?: Spacer(Modifier.width(16.dp)) @@ -102,9 +104,11 @@ fun BitwardenContentBlock( private fun BitwardenContentBlock_preview() { BitwardenTheme { BitwardenContentBlock( - headerText = "Header", - subtitleText = "Subtitle", - iconPainter = null, + data = ContentBlockData( + headerText = "Header", + subtitleText = "Subtitle", + iconVectorResource = null, + ), ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/ContentBlockData.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/ContentBlockData.kt new file mode 100644 index 000000000..d7644e02e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/ContentBlockData.kt @@ -0,0 +1,31 @@ +package com.x8bit.bitwarden.ui.platform.components.model + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.AnnotatedString +import com.x8bit.bitwarden.ui.platform.components.content.BitwardenContentBlock + +/** + * Wrapper class for data to display in a + * [BitwardenContentBlock] + */ +@Immutable +data class ContentBlockData( + val headerText: AnnotatedString, + val subtitleText: String? = null, + @DrawableRes val iconVectorResource: Int? = null, +) { + /** + * Overloaded constructor for [ContentBlockData] that takes a [String] for the + * header text. + */ + constructor( + headerText: String, + subtitleText: String? = null, + @DrawableRes iconVectorResource: Int? = null, + ) : this( + headerText = AnnotatedString(headerText), + subtitleText = subtitleText, + iconVectorResource = iconVectorResource, + ) +} 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 78100da15..04b58e5dd 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 @@ -221,10 +221,6 @@ fun NavGraphBuilder.vaultUnlockedGraph( ) importLoginsScreenDestination( onNavigateBack = { navController.popBackStack() }, - onNavigateToImportSuccessScreen = { - // TODO: PM-11187 navigate to success screen with popping this screen from stack - navController.popBackStack() - }, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/shape/BitwardenShapes.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/shape/BitwardenShapes.kt index 82fae0385..7edaa014d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/shape/BitwardenShapes.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/shape/BitwardenShapes.kt @@ -18,4 +18,5 @@ data class BitwardenShapes( val menu: CornerBasedShape, val segmentedControl: CornerBasedShape, val snackbar: CornerBasedShape, + val bottomSheet: CornerBasedShape, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/shape/Shapes.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/shape/Shapes.kt index 184630993..5af465e7e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/shape/Shapes.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/shape/Shapes.kt @@ -18,4 +18,5 @@ val bitwardenShapes: BitwardenShapes = BitwardenShapes( menu = RoundedCornerShape(size = 4.dp), segmentedControl = CircleShape, snackbar = RoundedCornerShape(size = 8.dp), + bottomSheet = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), ) 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 index c91e785b2..ba6ed19a5 100644 --- 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 @@ -19,14 +19,12 @@ fun NavController.navigateToImportLoginsScreen(navOptions: NavOptions? = null) { */ fun NavGraphBuilder.importLoginsScreenDestination( onNavigateBack: () -> Unit, - onNavigateToImportSuccessScreen: () -> Unit, ) { composableWithSlideTransitions( route = IMPORT_LOGINS_ROUTE, ) { ImportLoginsScreen( onNavigateBack = onNavigateBack, - onNavigateToImportSuccessScreen = onNavigateToImportSuccessScreen, ) } } 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 index 23b24e053..d5ff4e482 100644 --- 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 @@ -13,14 +13,17 @@ 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.layout.statusBarsPadding 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.rememberModalBottomSheetState import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -43,10 +46,14 @@ import com.x8bit.bitwarden.ui.platform.base.util.createAnnotatedString 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.bottomsheet.BitwardenModalBottomSheet 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.card.BitwardenContentCard +import com.x8bit.bitwarden.ui.platform.components.content.BitwardenContentBlock import com.x8bit.bitwarden.ui.platform.components.content.BitwardenFullScreenLoadingContent import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog +import com.x8bit.bitwarden.ui.platform.components.model.ContentBlockData import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager @@ -57,6 +64,7 @@ import com.x8bit.bitwarden.ui.vault.feature.importlogins.handlers.ImportLoginHan import com.x8bit.bitwarden.ui.vault.feature.importlogins.handlers.rememberImportLoginHandler import com.x8bit.bitwarden.ui.vault.feature.importlogins.model.InstructionStep import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.launch private const val IMPORT_HELP_URL = "https://bitwarden.com/help/import-data/" @@ -68,7 +76,6 @@ private const val IMPORT_HELP_URL = "https://bitwarden.com/help/import-data/" @Composable fun ImportLoginsScreen( onNavigateBack: () -> Unit, - onNavigateToImportSuccessScreen: () -> Unit, viewModel: ImportLoginsViewModel = hiltViewModel(), intentManager: IntentManager = LocalIntentManager.current, ) { @@ -81,8 +88,6 @@ fun ImportLoginsScreen( ImportLoginsEvent.OpenHelpLink -> { intentManager.startCustomTabsActivity(IMPORT_HELP_URL.toUri()) } - - ImportLoginsEvent.NavigateToImportSuccess -> onNavigateToImportSuccessScreen() } } @@ -94,6 +99,31 @@ fun ImportLoginsScreen( } } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + val hideSheetAndExecuteCompleteImportLogins: () -> Unit = { + // This pattern mirrors the onDismissRequest handling in the material ModalBottomSheet + scope + .launch { + sheetState.hide() + } + .invokeOnCompletion { + handler.onSuccessfulSyncAcknowledged() + } + } + BitwardenModalBottomSheet( + showBottomSheet = state.showBottomSheet, + sheetTitle = stringResource(R.string.bitwarden_tools), + onDismiss = hideSheetAndExecuteCompleteImportLogins, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + modifier = Modifier.statusBarsPadding(), + ) { paddingValues -> + ImportLoginsSuccessBottomSheetContent( + onCompleteImportLogins = hideSheetAndExecuteCompleteImportLogins, + modifier = Modifier.padding(paddingValues), + ) + } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) BitwardenFullScreenLoadingContent( modifier = Modifier.fillMaxSize(), @@ -441,6 +471,85 @@ private fun ImportLoginsStepThreeContent( ) } +@Suppress("LongMethod") +@Composable +private fun ImportLoginsSuccessBottomSheetContent( + onCompleteImportLogins: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .then(modifier), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(24.dp)) + Image( + painter = rememberVectorPainter(R.drawable.img_secure_devices), + contentDescription = null, + modifier = Modifier + .standardHorizontalMargin() + .size(124.dp), + ) + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.import_successful), + style = BitwardenTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.standardHorizontalMargin(), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource( + R.string.manage_your_logins_from_anywhere_with_bitwarden_tools, + ), + style = BitwardenTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.standardHorizontalMargin(), + ) + Spacer(Modifier.height(24.dp)) + BitwardenContentCard( + contentItems = persistentListOf( + ContentBlockData( + headerText = stringResource(R.string.download_the_browser_extension), + subtitleText = stringResource( + R.string.go_to_bitwarden_com_download_to_integrate_bitwarden_into_browser, + ), + iconVectorResource = R.drawable.ic_puzzle, + ), + ContentBlockData( + headerText = stringResource(R.string.use_the_web_app), + subtitleText = stringResource( + R.string.log_in_at_bitwarden_com_to_easily_manage_your_account, + ), + iconVectorResource = R.drawable.ic_desktop, + ), + ContentBlockData( + headerText = stringResource(R.string.autofill_passwords), + subtitleText = stringResource(R.string.set_up_autofill_on_all_your_devices), + iconVectorResource = R.drawable.ic_shield, + ), + ), + modifier = Modifier.standardHorizontalMargin(), + ) { contentData -> + BitwardenContentBlock( + data = contentData, + subtitleTextStyle = BitwardenTheme.typography.bodySmall, + ) + } + Spacer(Modifier.height(24.dp)) + BitwardenFilledButton( + label = stringResource(R.string.got_it), + onClick = onCompleteImportLogins, + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth(), + ) + Spacer(Modifier.navigationBarsPadding()) + } +} + @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -488,6 +597,7 @@ private fun ImportLoginsScreenDialog_preview( onMoveToSyncInProgress = {}, onRetrySync = {}, onFailedSyncAcknowledged = {}, + onSuccessfulSyncAcknowledged = {}, ), ) InitialImportLoginsContent( @@ -507,11 +617,13 @@ private class ImportLoginsDialogContentPreviewProvider : dialogState = ImportLoginsState.DialogState.GetStarted, viewState = ImportLoginsState.ViewState.InitialContent, isVaultSyncing = false, + showBottomSheet = false, ), ImportLoginsState( dialogState = ImportLoginsState.DialogState.ImportLater, viewState = ImportLoginsState.ViewState.InitialContent, isVaultSyncing = false, + showBottomSheet = false, ), ) } 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 index 8a54c0222..9d00e9148 100644 --- 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 @@ -25,6 +25,7 @@ class ImportLoginsViewModel @Inject constructor( null, viewState = ImportLoginsState.ViewState.InitialContent, isVaultSyncing = false, + showBottomSheet = false, ), ) { override fun handleAction(action: ImportLoginsAction) { @@ -46,10 +47,21 @@ class ImportLoginsViewModel @Inject constructor( } ImportLoginsAction.RetryVaultSync -> handleRetryVaultSync() - ImportLoginsAction.FailSyncAcknowledged -> handleFailedSyncAcknowledged() + ImportLoginsAction.FailedSyncAcknowledged -> handleFailedSyncAcknowledged() + ImportLoginsAction.SuccessfulSyncAcknowledged -> handleSuccessSyncAcknowledged() } } + private fun handleSuccessSyncAcknowledged() { + mutableStateFlow.update { + it.copy( + isVaultSyncing = false, + showBottomSheet = false, + ) + } + sendEvent(ImportLoginsEvent.NavigateBack) + } + private fun handleFailedSyncAcknowledged() { mutableStateFlow.update { it.copy(dialogState = null) @@ -80,7 +92,14 @@ class ImportLoginsViewModel @Inject constructor( } } - SyncVaultDataResult.Success -> sendEvent(ImportLoginsEvent.NavigateToImportSuccess) + SyncVaultDataResult.Success -> { + mutableStateFlow.update { + it.copy( + showBottomSheet = true, + isVaultSyncing = false, + ) + } + } } } @@ -166,6 +185,7 @@ data class ImportLoginsState( val dialogState: DialogState?, val viewState: ViewState, val isVaultSyncing: Boolean, + val showBottomSheet: Boolean, ) { /** * Dialog states for the [ImportLoginsViewModel]. @@ -249,11 +269,6 @@ sealed class ImportLoginsEvent { */ data object NavigateBack : ImportLoginsEvent() - /** - * Navigate to the import success screen - */ - data object NavigateToImportSuccess : ImportLoginsEvent() - /** * Open the help link in a browser. */ @@ -334,7 +349,12 @@ sealed class ImportLoginsAction { /** * User has acknowledge failed sync and chose not to retry now. */ - data object FailSyncAcknowledged : ImportLoginsAction() + data object FailedSyncAcknowledged : ImportLoginsAction() + + /** + * User has imported logins successfully. + */ + data object SuccessfulSyncAcknowledged : ImportLoginsAction() /** * Internal actions to be handled, not triggered by user actions. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/components/InstructionRowItem.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/components/InstructionRowItem.kt index b49cfaeca..a72ae3e78 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/components/InstructionRowItem.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/importlogins/components/InstructionRowItem.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.tooling.preview.Preview import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.components.card.BitwardenContentCard import com.x8bit.bitwarden.ui.platform.components.content.BitwardenContentBlock +import com.x8bit.bitwarden.ui.platform.components.model.ContentBlockData import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import com.x8bit.bitwarden.ui.vault.feature.importlogins.model.InstructionStep import kotlinx.collections.immutable.persistentListOf @@ -26,10 +27,13 @@ fun InstructionRowItem( modifier: Modifier = Modifier, ) { BitwardenContentBlock( + data = ContentBlockData( + iconVectorResource = instructionStep.imageRes, + headerText = instructionStep.instructionText, + subtitleText = instructionStep.additionalText, + ), modifier = modifier, - headerText = instructionStep.instructionText, headerTextStyle = BitwardenTheme.typography.bodyMedium, - subtitleText = instructionStep.additionalText, subtitleTextStyle = BitwardenTheme.typography.labelSmall, ) } 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 index 24d59b0d4..db93e7f4f 100644 --- 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 @@ -23,6 +23,7 @@ data class ImportLoginHandler( val onMoveToSyncInProgress: () -> Unit, val onRetrySync: () -> Unit, val onFailedSyncAcknowledged: () -> Unit, + val onSuccessfulSyncAcknowledged: () -> Unit, ) { @Suppress("UndocumentedPublicClass") companion object { @@ -50,7 +51,10 @@ data class ImportLoginHandler( }, onRetrySync = { viewModel.trySendAction(ImportLoginsAction.RetryVaultSync) }, onFailedSyncAcknowledged = { - viewModel.trySendAction(ImportLoginsAction.FailSyncAcknowledged) + viewModel.trySendAction(ImportLoginsAction.FailedSyncAcknowledged) + }, + onSuccessfulSyncAcknowledged = { + viewModel.trySendAction(ImportLoginsAction.SuccessfulSyncAcknowledged) }, ) } diff --git a/app/src/main/res/drawable-night/img_secure_devices.xml b/app/src/main/res/drawable-night/img_secure_devices.xml new file mode 100644 index 000000000..45b2b74a4 --- /dev/null +++ b/app/src/main/res/drawable-night/img_secure_devices.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_desktop.xml b/app/src/main/res/drawable/ic_desktop.xml new file mode 100644 index 000000000..412bcaec4 --- /dev/null +++ b/app/src/main/res/drawable/ic_desktop.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_puzzle.xml b/app/src/main/res/drawable/ic_puzzle.xml new file mode 100644 index 000000000..f7790e9d9 --- /dev/null +++ b/app/src/main/res/drawable/ic_puzzle.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_shield.xml b/app/src/main/res/drawable/ic_shield.xml new file mode 100644 index 000000000..7d975facf --- /dev/null +++ b/app/src/main/res/drawable/ic_shield.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/img_secure_devices.xml b/app/src/main/res/drawable/img_secure_devices.xml new file mode 100644 index 000000000..5159d99bd --- /dev/null +++ b/app/src/main/res/drawable/img_secure_devices.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f6e398948..8580c3996 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1062,4 +1062,14 @@ Do you want to switch to this account? This is not a recognized Bitwarden server. You may need to check with your provider or update your server. Syncing logins... SSH Key Cipher Item Types + Download the browser extension + Go to bitwarden.com/download to integrate Bitwarden into your favorite browser for a seamless experience. + Use the web app + Log in at bitwarden.com to easily manage your account and update settings. + Autofill passwords + Set up autofill on all your devices to login with a single tap anywhere. + Import Successful! + Manage your logins from anywhere with Bitwarden tools for web and desktop. + Bitwarden Tools + Got it diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/BaseComposeTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/BaseComposeTest.kt index d21237d0a..0f63c18bc 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/BaseComposeTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/BaseComposeTest.kt @@ -4,9 +4,12 @@ import androidx.activity.OnBackPressedDispatcher import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.runtime.Composable +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.junit4.createComposeRule import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Rule /** @@ -14,8 +17,12 @@ import org.junit.Rule * Testing, and JUnit 4. */ abstract class BaseComposeTest : BaseRobolectricTest() { + @OptIn(ExperimentalCoroutinesApi::class) + protected val dispatcher = UnconfinedTestDispatcher() + + @OptIn(ExperimentalTestApi::class) @get:Rule - val composeTestRule = createComposeRule() + val composeTestRule = createComposeRule(effectContext = dispatcher) /** * instance of [OnBackPressedDispatcher] made available if testing using 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 index 2250e0b22..dca296a32 100644 --- 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 @@ -1,18 +1,24 @@ package com.x8bit.bitwarden.ui.vault.feature.importlogins +import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasAnySibling +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.printToLog import androidx.core.net.toUri import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.util.assertNoDialogExists @@ -28,7 +34,6 @@ import org.junit.Before import org.junit.Test class ImportLoginsScreenTest : BaseComposeTest() { - private var navigateToImportLoginSuccessCalled = false private var navigateBackCalled = false private val mutableImportLoginsStateFlow = MutableStateFlow(DEFAULT_STATE) @@ -47,7 +52,6 @@ class ImportLoginsScreenTest : BaseComposeTest() { setContentWithBackDispatcher { ImportLoginsScreen( onNavigateBack = { navigateBackCalled = true }, - onNavigateToImportSuccessScreen = { navigateToImportLoginSuccessCalled = true }, viewModel = viewModel, intentManager = intentManager, ) @@ -90,28 +94,28 @@ class ImportLoginsScreenTest : BaseComposeTest() { @Test fun `dialog content is shown when state updates and is hidden when null`() { - mutableImportLoginsStateFlow.tryEmit( - DEFAULT_STATE.copy( + mutableImportLoginsStateFlow.update { + it.copy( dialogState = ImportLoginsState.DialogState.GetStarted, - ), - ) + ) + } composeTestRule .onNode(isDialog()) .assertIsDisplayed() - mutableImportLoginsStateFlow.tryEmit( - DEFAULT_STATE.copy( + mutableImportLoginsStateFlow.update { + it.copy( dialogState = null, - ), - ) + ) + } composeTestRule .assertNoDialogExists() - mutableImportLoginsStateFlow.tryEmit( - DEFAULT_STATE.copy( + mutableImportLoginsStateFlow.update { + it.copy( dialogState = ImportLoginsState.DialogState.ImportLater, - ), - ) + ) + } composeTestRule .onNode(isDialog()) @@ -121,11 +125,11 @@ class ImportLoginsScreenTest : BaseComposeTest() { @Suppress("MaxLineLength") @Test fun `when dialog state is GetStarted, GetStarted dialog is shown and sends correct actions when clicked`() { - mutableImportLoginsStateFlow.tryEmit( - DEFAULT_STATE.copy( + mutableImportLoginsStateFlow.update { + it.copy( dialogState = ImportLoginsState.DialogState.GetStarted, - ), - ) + ) + } composeTestRule .onNode(isDialog()) .assertIsDisplayed() @@ -153,11 +157,11 @@ class ImportLoginsScreenTest : BaseComposeTest() { @Suppress("MaxLineLength") @Test fun `when dialog state is ImportLater, ImportLater dialog is shown and sends correct actions when clicked`() { - mutableImportLoginsStateFlow.tryEmit( - DEFAULT_STATE.copy( + mutableImportLoginsStateFlow.update { + it.copy( dialogState = ImportLoginsState.DialogState.ImportLater, - ), - ) + ) + } composeTestRule .onNode(isDialog()) .assertIsDisplayed() @@ -191,22 +195,22 @@ class ImportLoginsScreenTest : BaseComposeTest() { @Test fun `while on initial content system back sends CloseClick action`() { - mutableImportLoginsStateFlow.tryEmit( - DEFAULT_STATE.copy( + mutableImportLoginsStateFlow.update { + it.copy( viewState = ImportLoginsState.ViewState.InitialContent, - ), - ) + ) + } backDispatcher?.onBackPressed() verifyActionSent(ImportLoginsAction.CloseClick) } @Test fun `Step one content is displayed when view state is ImportStepOne`() { - mutableImportLoginsStateFlow.tryEmit( - DEFAULT_STATE.copy( + mutableImportLoginsStateFlow.update { + it.copy( viewState = ImportLoginsState.ViewState.ImportStepOne, - ), - ) + ) + } composeTestRule .onNodeWithText("Step 1 of 3") .assertIsDisplayed() @@ -214,11 +218,11 @@ class ImportLoginsScreenTest : BaseComposeTest() { @Test fun `while on step one correct actions are sent when buttons are clicked`() { - mutableImportLoginsStateFlow.tryEmit( - DEFAULT_STATE.copy( + mutableImportLoginsStateFlow.update { + it.copy( viewState = ImportLoginsState.ViewState.ImportStepOne, - ), - ) + ) + } composeTestRule .onNodeWithText("Back") .performScrollTo() @@ -232,22 +236,22 @@ class ImportLoginsScreenTest : BaseComposeTest() { @Test fun `while on step one system back returns to the previous content`() { - mutableImportLoginsStateFlow.tryEmit( - DEFAULT_STATE.copy( + mutableImportLoginsStateFlow.update { + it.copy( viewState = ImportLoginsState.ViewState.ImportStepOne, - ), - ) + ) + } backDispatcher?.onBackPressed() verifyActionSent(ImportLoginsAction.MoveToInitialContent) } @Test fun `Step two content is displayed when view state is ImportStepTwo`() { - mutableImportLoginsStateFlow.tryEmit( - DEFAULT_STATE.copy( + mutableImportLoginsStateFlow.update { + it.copy( viewState = ImportLoginsState.ViewState.ImportStepTwo, - ), - ) + ) + } composeTestRule .onNodeWithText("Step 2 of 3") .assertIsDisplayed() @@ -255,11 +259,11 @@ class ImportLoginsScreenTest : BaseComposeTest() { @Test fun `while on step two correct actions are sent when buttons are clicked`() { - mutableImportLoginsStateFlow.tryEmit( - DEFAULT_STATE.copy( + mutableImportLoginsStateFlow.update { + it.copy( viewState = ImportLoginsState.ViewState.ImportStepTwo, - ), - ) + ) + } composeTestRule .onNodeWithText("Back") .performScrollTo() @@ -273,22 +277,22 @@ class ImportLoginsScreenTest : BaseComposeTest() { @Test fun `while on step two system back returns to the previous content`() { - mutableImportLoginsStateFlow.tryEmit( - DEFAULT_STATE.copy( + mutableImportLoginsStateFlow.update { + it.copy( viewState = ImportLoginsState.ViewState.ImportStepTwo, - ), - ) + ) + } backDispatcher?.onBackPressed() verifyActionSent(ImportLoginsAction.MoveToStepOne) } @Test fun `Step three content is displayed when view state is ImportStepThree`() { - mutableImportLoginsStateFlow.tryEmit( - DEFAULT_STATE.copy( + mutableImportLoginsStateFlow.update { + it.copy( viewState = ImportLoginsState.ViewState.ImportStepThree, - ), - ) + ) + } composeTestRule .onNodeWithText("Step 3 of 3") .assertIsDisplayed() @@ -296,11 +300,11 @@ class ImportLoginsScreenTest : BaseComposeTest() { @Test fun `while on step three correct actions are sent when buttons are clicked`() { - mutableImportLoginsStateFlow.tryEmit( - DEFAULT_STATE.copy( + mutableImportLoginsStateFlow.update { + it.copy( viewState = ImportLoginsState.ViewState.ImportStepThree, - ), - ) + ) + } composeTestRule .onNodeWithText("Back") .performScrollTo() @@ -314,21 +318,15 @@ class ImportLoginsScreenTest : BaseComposeTest() { @Test fun `while on step three system back returns to the previous content`() { - mutableImportLoginsStateFlow.tryEmit( - DEFAULT_STATE.copy( + mutableImportLoginsStateFlow.update { + it.copy( viewState = ImportLoginsState.ViewState.ImportStepThree, - ), - ) + ) + } backDispatcher?.onBackPressed() verifyActionSent(ImportLoginsAction.MoveToStepTwo) } - @Test - fun `NavigateToImportSuccess event causes correct lambda to invoke`() { - mutableImportLoginsEventFlow.tryEmit(ImportLoginsEvent.NavigateToImportSuccess) - assertTrue(navigateToImportLoginSuccessCalled) - } - @Test fun `Loading content is displayed when isVaultSyncing is true`() { mutableImportLoginsStateFlow.update { @@ -342,11 +340,11 @@ class ImportLoginsScreenTest : BaseComposeTest() { @Test fun `Error dialog is displayed when dialog state is Error`() { - mutableImportLoginsStateFlow.tryEmit( - DEFAULT_STATE.copy( + mutableImportLoginsStateFlow.update { + it.copy( dialogState = ImportLoginsState.DialogState.Error, - ), - ) + ) + } composeTestRule .onNode(isDialog()) .assertIsDisplayed() @@ -370,7 +368,57 @@ class ImportLoginsScreenTest : BaseComposeTest() { .filterToOne(hasAnyAncestor(isDialog())) .assertIsDisplayed() .performClick() - verifyActionSent(ImportLoginsAction.FailSyncAcknowledged) + verifyActionSent(ImportLoginsAction.FailedSyncAcknowledged) + } + + @Test + fun `Success bottom sheet is shown when state is updated`() { + mutableImportLoginsStateFlow.update { + it.copy(showBottomSheet = true) + } + composeTestRule + .onNodeWithText("Import Successful!") + .assertIsDisplayed() + } + + @Test + fun `SuccessfulSyncAcknowledged action is sent when bottom sheet CTA is clicked`() { + mutableImportLoginsStateFlow.update { + it.copy(showBottomSheet = true) + } + composeTestRule + .onNodeWithText("Import Successful!") + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Got it") + .performScrollTo() + .assertIsDisplayed() + .performSemanticsAction(SemanticsActions.OnClick) + + dispatcher.advanceTimeByAndRunCurrent(1000L) + + verifyActionSent(ImportLoginsAction.SuccessfulSyncAcknowledged) + } + + @Test + fun `SuccessfulSyncAcknowledged action is sent when bottom sheet is closed`() { + mutableImportLoginsStateFlow.update { + it.copy(showBottomSheet = true) + } + composeTestRule + .onNodeWithText("Import Successful!") + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithContentDescription("Close") + .filterToOne(hasAnySibling(hasText("Bitwarden Tools"))) + .assertIsDisplayed() + .performSemanticsAction(SemanticsActions.OnClick) + + dispatcher.advanceTimeByAndRunCurrent(1000L) + + verifyActionSent(ImportLoginsAction.SuccessfulSyncAcknowledged) } //region Helper methods @@ -386,4 +434,5 @@ private val DEFAULT_STATE = ImportLoginsState( dialogState = null, viewState = ImportLoginsState.ViewState.InitialContent, isVaultSyncing = false, + showBottomSheet = false, ) 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 index b3901516b..30f885869 100644 --- 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 @@ -38,6 +38,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { dialogState = ImportLoginsState.DialogState.GetStarted, viewState = ImportLoginsState.ViewState.InitialContent, isVaultSyncing = false, + showBottomSheet = false, ), viewModel.stateFlow.value, ) @@ -52,6 +53,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { dialogState = ImportLoginsState.DialogState.ImportLater, viewState = ImportLoginsState.ViewState.InitialContent, isVaultSyncing = false, + showBottomSheet = false, ), viewModel.stateFlow.value, ) @@ -71,6 +73,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { dialogState = ImportLoginsState.DialogState.GetStarted, viewState = ImportLoginsState.ViewState.InitialContent, isVaultSyncing = false, + showBottomSheet = false, ), awaitItem(), ) @@ -80,6 +83,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { dialogState = null, viewState = ImportLoginsState.ViewState.InitialContent, isVaultSyncing = false, + showBottomSheet = false, ), awaitItem(), ) @@ -102,6 +106,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { dialogState = ImportLoginsState.DialogState.ImportLater, viewState = ImportLoginsState.ViewState.InitialContent, isVaultSyncing = false, + showBottomSheet = false, ), stateFlow.awaitItem(), ) @@ -111,6 +116,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { dialogState = null, viewState = ImportLoginsState.ViewState.InitialContent, isVaultSyncing = false, + showBottomSheet = false, ), stateFlow.awaitItem(), ) @@ -135,6 +141,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { dialogState = ImportLoginsState.DialogState.GetStarted, viewState = ImportLoginsState.ViewState.InitialContent, isVaultSyncing = false, + showBottomSheet = false, ), awaitItem(), ) @@ -144,6 +151,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { dialogState = null, viewState = ImportLoginsState.ViewState.ImportStepOne, isVaultSyncing = false, + showBottomSheet = false, ), awaitItem(), ) @@ -183,6 +191,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { dialogState = null, viewState = ImportLoginsState.ViewState.ImportStepOne, isVaultSyncing = false, + showBottomSheet = false, ), viewModel.stateFlow.value, ) @@ -197,6 +206,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { dialogState = null, viewState = ImportLoginsState.ViewState.ImportStepTwo, isVaultSyncing = false, + showBottomSheet = false, ), viewModel.stateFlow.value, ) @@ -211,6 +221,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { dialogState = null, viewState = ImportLoginsState.ViewState.ImportStepThree, isVaultSyncing = false, + showBottomSheet = false, ), viewModel.stateFlow.value, ) @@ -229,23 +240,32 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { dialogState = null, viewState = ImportLoginsState.ViewState.InitialContent, isVaultSyncing = false, + showBottomSheet = false, ), viewModel.stateFlow.value, ) } @Test - fun `MoveToSyncInProgress sets isVaultSyncing to true and calls syncForResult`() { + fun `MoveToSyncInProgress sets isVaultSyncing to true and calls syncForResult`() = runTest { val viewModel = createViewModel() - viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress) - assertEquals( - ImportLoginsState( - dialogState = null, - viewState = ImportLoginsState.ViewState.InitialContent, - isVaultSyncing = true, - ), - viewModel.stateFlow.value, - ) + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress) + assertEquals( + ImportLoginsState( + dialogState = null, + viewState = ImportLoginsState.ViewState.InitialContent, + isVaultSyncing = true, + showBottomSheet = false, + ), + awaitItem(), + ) + cancelAndIgnoreRemainingEvents() + } coVerify { vaultRepository.syncForResult() } } @@ -265,22 +285,29 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { dialogState = null, viewState = ImportLoginsState.ViewState.InitialContent, isVaultSyncing = true, + showBottomSheet = false, ), awaitItem(), ) + cancelAndIgnoreRemainingEvents() } coVerify { vaultRepository.syncForResult() } } @Test - fun `MoveToSyncInProgress should send NavigateToImportSuccess event when sync succeeds`() = - runTest { - val viewModel = createViewModel() - viewModel.eventFlow.test { - viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress) - assertEquals(ImportLoginsEvent.NavigateToImportSuccess, awaitItem()) - } - } + fun `SyncVaultDataResult success should update state to show bottom sheet`() { + val viewModel = createViewModel() + viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress) + assertEquals( + ImportLoginsState( + dialogState = null, + viewState = ImportLoginsState.ViewState.InitialContent, + isVaultSyncing = false, + showBottomSheet = true, + ), + viewModel.stateFlow.value, + ) + } @Test fun `SyncVaultDataResult Error should remove loading state and show error dialog`() = runTest { @@ -292,13 +319,14 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { dialogState = ImportLoginsState.DialogState.Error, viewState = ImportLoginsState.ViewState.InitialContent, isVaultSyncing = false, + showBottomSheet = false, ), viewModel.stateFlow.value, ) } @Test - fun `on FailSyncAcknowledged should remove dialog state and send NavigateBack event`() = + fun `FailSyncAcknowledged should remove dialog state and send NavigateBack event`() = runTest { coEvery { vaultRepository.syncForResult() @@ -307,12 +335,13 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { viewModel.eventFlow.test { viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress) assertNotNull(viewModel.stateFlow.value.dialogState) - viewModel.trySendAction(ImportLoginsAction.FailSyncAcknowledged) + viewModel.trySendAction(ImportLoginsAction.FailedSyncAcknowledged) assertEquals( ImportLoginsState( dialogState = null, viewState = ImportLoginsState.ViewState.InitialContent, isVaultSyncing = false, + showBottomSheet = false, ), viewModel.stateFlow.value, ) @@ -323,6 +352,45 @@ class ImportLoginsViewModelTest : BaseViewModelTest() { } } + @Test + fun `SuccessfulSyncAcknowledged should hide bottom sheet and send NavigateBack`() = runTest { + val viewModel = createViewModel() + viewModel.stateEventFlow(backgroundScope = backgroundScope) { stateFlow, eventFlow -> + // Initial state + assertEquals(DEFAULT_STATE, stateFlow.awaitItem()) + viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress) + assertEquals( + ImportLoginsState( + dialogState = null, + viewState = ImportLoginsState.ViewState.InitialContent, + isVaultSyncing = true, + showBottomSheet = false, + ), + stateFlow.awaitItem(), + ) + assertEquals( + ImportLoginsState( + dialogState = null, + viewState = ImportLoginsState.ViewState.InitialContent, + isVaultSyncing = false, + showBottomSheet = true, + ), + stateFlow.awaitItem(), + ) + viewModel.trySendAction(ImportLoginsAction.SuccessfulSyncAcknowledged) + assertEquals( + ImportLoginsState( + dialogState = null, + viewState = ImportLoginsState.ViewState.InitialContent, + isVaultSyncing = false, + showBottomSheet = false, + ), + stateFlow.awaitItem(), + ) + assertEquals(ImportLoginsEvent.NavigateBack, eventFlow.awaitItem()) + } + } + private fun createViewModel(): ImportLoginsViewModel = ImportLoginsViewModel( vaultRepository = vaultRepository, ) @@ -332,4 +400,5 @@ private val DEFAULT_STATE = ImportLoginsState( dialogState = null, viewState = ImportLoginsState.ViewState.InitialContent, isVaultSyncing = false, + showBottomSheet = false, )