From 1c10a941090ba9b9f410bfcccdd4892118000a4e Mon Sep 17 00:00:00 2001
From: Dave Severns <149429124+dseverns-livefront@users.noreply.github.com>
Date: Thu, 24 Oct 2024 10:44:14 -0400
Subject: [PATCH] PM-11187 show import success bottom sheet after success
import sync (#4125)
---
.../components/appbar/BitwardenTopAppBar.kt | 6 +-
.../bottomsheet/BitwardenModalBottomSheet.kt | 79 +++++++
.../content/BitwardenContentBlock.kt | 34 +--
.../components/model/ContentBlockData.kt | 31 +++
.../vaultunlocked/VaultUnlockedNavigation.kt | 4 -
.../platform/theme/shape/BitwardenShapes.kt | 1 +
.../ui/platform/theme/shape/Shapes.kt | 1 +
.../importlogins/ImportLoginsNavigation.kt | 2 -
.../importlogins/ImportLoginsScreen.kt | 118 ++++++++++-
.../importlogins/ImportLoginsViewModel.kt | 36 +++-
.../components/InstructionRowItem.kt | 8 +-
.../handlers/ImportLoginHandler.kt | 6 +-
.../res/drawable-night/img_secure_devices.xml | 73 +++++++
app/src/main/res/drawable/ic_desktop.xml | 10 +
app/src/main/res/drawable/ic_puzzle.xml | 10 +
app/src/main/res/drawable/ic_shield.xml | 13 ++
.../main/res/drawable/img_secure_devices.xml | 77 +++++++
app/src/main/res/values/strings.xml | 10 +
.../ui/platform/base/BaseComposeTest.kt | 9 +-
.../importlogins/ImportLoginsScreenTest.kt | 195 +++++++++++-------
.../importlogins/ImportLoginsViewModelTest.kt | 109 ++++++++--
21 files changed, 701 insertions(+), 131 deletions(-)
create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/bottomsheet/BitwardenModalBottomSheet.kt
create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/ContentBlockData.kt
create mode 100644 app/src/main/res/drawable-night/img_secure_devices.xml
create mode 100644 app/src/main/res/drawable/ic_desktop.xml
create mode 100644 app/src/main/res/drawable/ic_puzzle.xml
create mode 100644 app/src/main/res/drawable/ic_shield.xml
create mode 100644 app/src/main/res/drawable/img_secure_devices.xml
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,
)