PM-11187 show import success bottom sheet after success import sync (#4125)

This commit is contained in:
Dave Severns 2024-10-24 10:44:14 -04:00 committed by GitHub
parent 6f535c0abe
commit 1c10a94109
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 701 additions and 131 deletions

View file

@ -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,

View file

@ -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)
}
}
}

View file

@ -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,
),
)
}
}

View file

@ -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,
)
}

View file

@ -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()
},
)
}
}

View file

@ -18,4 +18,5 @@ data class BitwardenShapes(
val menu: CornerBasedShape,
val segmentedControl: CornerBasedShape,
val snackbar: CornerBasedShape,
val bottomSheet: CornerBasedShape,
)

View file

@ -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),
)

View file

@ -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,
)
}
}

View file

@ -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,
),
)
}

View file

@ -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.

View file

@ -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,
)
}

View file

@ -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)
},
)
}

View file

@ -0,0 +1,73 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="200"
android:viewportHeight="200">
<path
android:pathData="M18.75,38.17C18.75,31.26 24.35,25.67 31.25,25.67H168.75C175.65,25.67 181.25,31.26 181.25,38.17V125.67C181.25,132.57 175.65,138.17 168.75,138.17H31.25C24.35,138.17 18.75,132.57 18.75,125.67V38.17Z"
android:fillColor="#AAC3EF"/>
<path
android:pathData="M168.75,29.83H31.25C26.65,29.83 22.92,33.56 22.92,38.17V125.67C22.92,130.27 26.65,134 31.25,134H168.75C173.35,134 177.08,130.27 177.08,125.67V38.17C177.08,33.56 173.35,29.83 168.75,29.83ZM31.25,25.67C24.35,25.67 18.75,31.26 18.75,38.17V125.67C18.75,132.57 24.35,138.17 31.25,138.17H168.75C175.65,138.17 181.25,132.57 181.25,125.67V38.17C181.25,31.26 175.65,25.67 168.75,25.67H31.25Z"
android:fillColor="#175DDC"
android:fillType="evenOdd"/>
<path
android:pathData="M18.75,38.17H181.25V125.67H18.75V38.17Z"
android:fillColor="#79A1E9"/>
<path
android:pathData="M177.08,42.33H22.92V121.5H177.08V42.33ZM18.75,38.17V125.67H181.25V38.17H18.75Z"
android:fillColor="#175DDC"
android:fillType="evenOdd"/>
<path
android:pathData="M81.25,136.08H118.75V173.58H81.25V136.08Z"
android:fillColor="#AAC3EF"/>
<path
android:pathData="M79.17,134H120.83V171.5H137.5C138.65,171.5 139.58,172.43 139.58,173.58C139.58,174.73 138.65,175.67 137.5,175.67H62.5C61.35,175.67 60.42,174.73 60.42,173.58C60.42,172.43 61.35,171.5 62.5,171.5H79.17V134ZM83.33,171.5H116.67V138.17H83.33V171.5Z"
android:fillColor="#175DDC"
android:fillType="evenOdd"/>
<path
android:pathData="M0,100.67C0,96.06 3.73,92.33 8.33,92.33H45.83C50.44,92.33 54.17,96.06 54.17,100.67V167.33C54.17,171.93 50.44,175.67 45.83,175.67H8.33C3.73,175.67 0,171.93 0,167.33V100.67Z"
android:fillColor="#F3F6F9"/>
<path
android:pathData="M45.83,96.5H8.33C6.03,96.5 4.17,98.37 4.17,100.67V167.33C4.17,169.63 6.03,171.5 8.33,171.5H45.83C48.13,171.5 50,169.63 50,167.33V100.67C50,98.37 48.13,96.5 45.83,96.5ZM8.33,92.33C3.73,92.33 0,96.06 0,100.67V167.33C0,171.93 3.73,175.67 8.33,175.67H45.83C50.44,175.67 54.17,171.93 54.17,167.33V100.67C54.17,96.06 50.44,92.33 45.83,92.33H8.33Z"
android:fillColor="#175DDC"
android:fillType="evenOdd"/>
<path
android:pathData="M20.83,102.75C20.83,101.6 21.77,100.67 22.92,100.67L31.25,100.67C32.4,100.67 33.33,101.6 33.33,102.75C33.33,103.9 32.4,104.83 31.25,104.83L22.92,104.83C21.77,104.83 20.83,103.9 20.83,102.75Z"
android:fillColor="#175DDC"
android:fillType="evenOdd"/>
<path
android:pathData="M100,115.25C118.41,115.25 133.33,100.33 133.33,81.92C133.33,63.51 118.41,48.58 100,48.58C81.59,48.58 66.67,63.51 66.67,81.92C66.67,100.33 81.59,115.25 100,115.25ZM85.42,69.42C81.96,69.42 79.17,72.21 79.17,75.67V96.5C79.17,99.95 81.96,102.75 85.42,102.75H114.58C118.03,102.75 120.83,99.95 120.83,96.5V75.67C120.83,72.21 118.03,69.42 114.58,69.42H85.42Z"
android:fillColor="#AAC3EF"
android:fillType="evenOdd"/>
<path
android:pathData="M79.17,75.67C79.17,72.21 81.96,69.42 85.42,69.42H114.58C118.03,69.42 120.83,72.21 120.83,75.67V96.5C120.83,99.95 118.03,102.75 114.58,102.75H85.42C81.96,102.75 79.17,99.95 79.17,96.5V75.67Z"
android:fillColor="#FFBF00"/>
<path
android:pathData="M114.58,73.58H85.42C84.27,73.58 83.33,74.52 83.33,75.67V96.5C83.33,97.65 84.27,98.58 85.42,98.58H114.58C115.73,98.58 116.67,97.65 116.67,96.5V75.67C116.67,74.52 115.73,73.58 114.58,73.58ZM85.42,69.42C81.96,69.42 79.17,72.21 79.17,75.67V96.5C79.17,99.95 81.96,102.75 85.42,102.75H114.58C118.03,102.75 120.83,99.95 120.83,96.5V75.67C120.83,72.21 118.03,69.42 114.58,69.42H85.42Z"
android:fillColor="#175DDC"
android:fillType="evenOdd"/>
<path
android:pathData="M97.92,81.92C97.92,80.77 98.85,79.83 100,79.83C101.15,79.83 102.08,80.77 102.08,81.92V90.25C102.08,91.4 101.15,92.33 100,92.33C98.85,92.33 97.92,91.4 97.92,90.25V81.92Z"
android:fillColor="#175DDC"/>
<path
android:pathData="M89.58,67.33C89.58,61.58 94.25,56.92 100,56.92C105.75,56.92 110.42,61.58 110.42,67.33V69.42H106.25V67.33C106.25,63.88 103.45,61.08 100,61.08C96.55,61.08 93.75,63.88 93.75,67.33V69.42H89.58V67.33Z"
android:fillColor="#175DDC"
android:fillType="evenOdd"/>
<path
android:pathData="M125,84C125,79.4 128.73,75.67 133.33,75.67H191.67C196.27,75.67 200,79.4 200,84V167.33C200,171.94 196.27,175.67 191.67,175.67H133.33C128.73,175.67 125,171.94 125,167.33V84Z"
android:fillColor="#F3F6F9"/>
<path
android:pathData="M191.67,79.83H133.33C131.03,79.83 129.17,81.7 129.17,84V167.33C129.17,169.63 131.03,171.5 133.33,171.5H191.67C193.97,171.5 195.83,169.63 195.83,167.33V84C195.83,81.7 193.97,79.83 191.67,79.83ZM133.33,75.67C128.73,75.67 125,79.4 125,84V167.33C125,171.94 128.73,175.67 133.33,175.67H191.67C196.27,175.67 200,171.94 200,167.33V84C200,79.4 196.27,75.67 191.67,75.67H133.33Z"
android:fillColor="#175DDC"
android:fillType="evenOdd"/>
<path
android:pathData="M168.75,159C168.75,162.45 165.95,165.25 162.5,165.25C159.05,165.25 156.25,162.45 156.25,159C156.25,155.55 159.05,152.75 162.5,152.75C165.95,152.75 168.75,155.55 168.75,159Z"
android:fillColor="#79A1E9"/>
<path
android:pathData="M162.5,161.08C163.65,161.08 164.58,160.15 164.58,159C164.58,157.85 163.65,156.92 162.5,156.92C161.35,156.92 160.42,157.85 160.42,159C160.42,160.15 161.35,161.08 162.5,161.08ZM162.5,165.25C165.95,165.25 168.75,162.45 168.75,159C168.75,155.55 165.95,152.75 162.5,152.75C159.05,152.75 156.25,155.55 156.25,159C156.25,162.45 159.05,165.25 162.5,165.25Z"
android:fillColor="#175DDC"
android:fillType="evenOdd"/>
<path
android:pathData="M83.33,138.17H116.67V150.67H83.33V138.17Z"
android:fillColor="#79A1E9"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3.2,3.333C1.985,3.333 1,4.34 1,5.583V15.089C1,16.331 1.985,17.338 3.2,17.338H8.769V19.646H7.531C7.076,19.646 6.706,20.024 6.706,20.489C6.706,20.955 7.076,21.333 7.531,21.333H16.469C16.924,21.333 17.294,20.955 17.294,20.489C17.294,20.024 16.924,19.646 16.469,19.646H15.231V17.338H20.8C22.015,17.338 23,16.331 23,15.089V5.583C23,4.34 22.015,3.333 20.8,3.333H3.2ZM20.8,5.02H3.2C2.896,5.02 2.65,5.272 2.65,5.583V15.089C2.65,15.399 2.896,15.651 3.2,15.651H20.8C21.104,15.651 21.35,15.399 21.35,15.089V5.583C21.35,5.272 21.104,5.02 20.8,5.02ZM13.581,19.646V17.338H10.419V19.646L13.581,19.646Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M1.324,5.934C1.324,4.802 2.242,3.884 3.374,3.884H16.375C17.507,3.884 18.425,4.802 18.425,5.934V8.908C18.822,8.642 19.289,8.479 19.8,8.479C20.676,8.479 21.412,8.952 21.904,9.606C22.396,10.26 22.678,11.129 22.678,12.057C22.678,12.985 22.396,13.854 21.904,14.508C21.412,15.162 20.676,15.635 19.8,15.635C19.289,15.635 18.822,15.472 18.425,15.206V18.181C18.425,19.313 17.507,20.231 16.375,20.231H12.08C11.427,20.231 10.897,19.701 10.897,19.048V17.494C10.897,17.066 11.124,16.715 11.411,16.506C11.797,16.226 11.962,15.893 11.962,15.592C11.962,15.296 11.803,14.97 11.432,14.693C11.06,14.415 10.512,14.221 9.875,14.221C9.238,14.221 8.69,14.415 8.319,14.693C7.947,14.97 7.789,15.296 7.789,15.592C7.789,15.892 7.953,16.225 8.338,16.505C8.624,16.714 8.851,17.065 8.851,17.492V19.048C8.851,19.701 8.321,20.231 7.668,20.231H3.374C2.242,20.231 1.324,19.313 1.324,18.181V5.934ZM3.374,5.384C3.07,5.384 2.824,5.63 2.824,5.934V18.181C2.824,18.485 3.07,18.731 3.374,18.731H7.351V17.639C6.732,17.149 6.289,16.437 6.289,15.592C6.289,14.716 6.765,13.981 7.42,13.491C8.076,13.001 8.946,12.721 9.875,12.721C10.805,12.721 11.675,13.001 12.33,13.491C12.985,13.981 13.462,14.716 13.462,15.592C13.462,16.438 13.018,17.151 12.397,17.641V18.731H16.375C16.678,18.731 16.925,18.485 16.925,18.181V14.612C16.925,13.958 17.454,13.428 18.108,13.428H18.193C18.55,13.428 18.852,13.588 19.057,13.793C19.297,14.03 19.554,14.135 19.8,14.135C20.1,14.135 20.427,13.976 20.705,13.606C20.983,13.236 21.178,12.691 21.178,12.057C21.178,11.423 20.983,10.878 20.705,10.508C20.427,10.139 20.1,9.979 19.8,9.979C19.554,9.979 19.296,10.084 19.057,10.322C18.851,10.526 18.55,10.686 18.193,10.686H18.108C17.454,10.686 16.925,10.156 16.925,9.503V5.934C16.925,5.63 16.678,5.384 16.375,5.384H3.374Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<group>
<clip-path
android:pathData="M0,0.333h20v20h-20z"/>
<path
android:pathData="M18.186,0.583C18.109,0.504 18.017,0.441 17.916,0.397C17.814,0.354 17.704,0.332 17.593,0.333H2.408C2.297,0.332 2.187,0.354 2.085,0.397C1.983,0.441 1.892,0.504 1.816,0.583C1.735,0.658 1.671,0.749 1.628,0.85C1.584,0.951 1.562,1.059 1.563,1.168V11.168C1.566,11.929 1.715,12.683 2.003,13.389C2.277,14.086 2.645,14.743 3.098,15.343C3.563,15.943 4.083,16.499 4.653,17.004C5.181,17.483 5.738,17.931 6.319,18.345C6.826,18.7 7.357,19.037 7.914,19.354C8.471,19.672 8.864,19.886 9.093,19.998C9.325,20.11 9.512,20.199 9.651,20.258C9.76,20.31 9.88,20.335 10.001,20.333C10.12,20.335 10.237,20.307 10.344,20.254C10.485,20.193 10.67,20.107 10.904,19.994C11.138,19.882 11.537,19.667 12.084,19.35C12.63,19.034 13.168,18.697 13.678,18.342C14.26,17.927 14.818,17.479 15.347,17C15.917,16.496 16.438,15.94 16.903,15.339C17.354,14.739 17.722,14.082 17.997,13.385C18.286,12.679 18.435,11.925 18.438,11.164V1.164C18.438,1.056 18.416,0.948 18.372,0.848C18.329,0.748 18.265,0.658 18.186,0.583ZM16.228,11.259C16.228,14.884 10.001,17.998 10.001,17.998V2.476H16.228V11.259Z"
android:fillColor="#000000"/>
</group>
</vector>

View file

@ -0,0 +1,77 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="200"
android:viewportHeight="200">
<group>
<clip-path
android:pathData="M0,0.67h200v200h-200z"/>
<path
android:pathData="M18.75,38.17C18.75,31.26 24.35,25.67 31.25,25.67H168.75C175.65,25.67 181.25,31.26 181.25,38.17V125.67C181.25,132.57 175.65,138.17 168.75,138.17H31.25C24.35,138.17 18.75,132.57 18.75,125.67V38.17Z"
android:fillColor="#DBE5F6"/>
<path
android:pathData="M168.75,29.83H31.25C26.65,29.83 22.92,33.56 22.92,38.17V125.67C22.92,130.27 26.65,134 31.25,134H168.75C173.35,134 177.08,130.27 177.08,125.67V38.17C177.08,33.56 173.35,29.83 168.75,29.83ZM31.25,25.67C24.35,25.67 18.75,31.26 18.75,38.17V125.67C18.75,132.57 24.35,138.17 31.25,138.17H168.75C175.65,138.17 181.25,132.57 181.25,125.67V38.17C181.25,31.26 175.65,25.67 168.75,25.67H31.25Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M18.75,38.17H181.25V125.67H18.75V38.17Z"
android:fillColor="#AAC3EF"/>
<path
android:pathData="M177.08,42.33H22.92V121.5H177.08V42.33ZM18.75,38.17V125.67H181.25V38.17H18.75Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M81.25,136.08H118.75V173.58H81.25V136.08Z"
android:fillColor="#DBE5F6"/>
<path
android:pathData="M79.17,134H120.83V171.5H137.5C138.65,171.5 139.58,172.43 139.58,173.58C139.58,174.73 138.65,175.67 137.5,175.67H62.5C61.35,175.67 60.42,174.73 60.42,173.58C60.42,172.43 61.35,171.5 62.5,171.5H79.17V134ZM83.33,171.5H116.67V138.17H83.33V171.5Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M0,100.67C0,96.06 3.73,92.33 8.33,92.33H45.83C50.44,92.33 54.17,96.06 54.17,100.67V167.33C54.17,171.93 50.44,175.67 45.83,175.67H8.33C3.73,175.67 0,171.93 0,167.33V100.67Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M45.83,96.5H8.33C6.03,96.5 4.17,98.37 4.17,100.67V167.33C4.17,169.63 6.03,171.5 8.33,171.5H45.83C48.13,171.5 50,169.63 50,167.33V100.67C50,98.37 48.13,96.5 45.83,96.5ZM8.33,92.33C3.73,92.33 0,96.06 0,100.67V167.33C0,171.93 3.73,175.67 8.33,175.67H45.83C50.44,175.67 54.17,171.93 54.17,167.33V100.67C54.17,96.06 50.44,92.33 45.83,92.33H8.33Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M20.83,102.75C20.83,101.6 21.77,100.67 22.92,100.67L31.25,100.67C32.4,100.67 33.33,101.6 33.33,102.75C33.33,103.9 32.4,104.83 31.25,104.83L22.92,104.83C21.77,104.83 20.83,103.9 20.83,102.75Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M100,115.25C118.41,115.25 133.33,100.33 133.33,81.92C133.33,63.51 118.41,48.58 100,48.58C81.59,48.58 66.67,63.51 66.67,81.92C66.67,100.33 81.59,115.25 100,115.25ZM85.42,69.42C81.96,69.42 79.17,72.21 79.17,75.67V96.5C79.17,99.95 81.96,102.75 85.42,102.75H114.58C118.03,102.75 120.83,99.95 120.83,96.5V75.67C120.83,72.21 118.03,69.42 114.58,69.42H85.42Z"
android:fillColor="#DBE5F6"
android:fillType="evenOdd"/>
<path
android:pathData="M79.17,75.67C79.17,72.21 81.96,69.42 85.42,69.42H114.58C118.03,69.42 120.83,72.21 120.83,75.67V96.5C120.83,99.95 118.03,102.75 114.58,102.75H85.42C81.96,102.75 79.17,99.95 79.17,96.5V75.67Z"
android:fillColor="#FFBF00"/>
<path
android:pathData="M114.58,73.58H85.42C84.27,73.58 83.33,74.52 83.33,75.67V96.5C83.33,97.65 84.27,98.58 85.42,98.58H114.58C115.73,98.58 116.67,97.65 116.67,96.5V75.67C116.67,74.52 115.73,73.58 114.58,73.58ZM85.42,69.42C81.96,69.42 79.17,72.21 79.17,75.67V96.5C79.17,99.95 81.96,102.75 85.42,102.75H114.58C118.03,102.75 120.83,99.95 120.83,96.5V75.67C120.83,72.21 118.03,69.42 114.58,69.42H85.42Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M97.92,81.92C97.92,80.77 98.85,79.83 100,79.83C101.15,79.83 102.08,80.77 102.08,81.92V90.25C102.08,91.4 101.15,92.33 100,92.33C98.85,92.33 97.92,91.4 97.92,90.25V81.92Z"
android:fillColor="#020F66"/>
<path
android:pathData="M89.58,67.33C89.58,61.58 94.25,56.92 100,56.92C105.75,56.92 110.42,61.58 110.42,67.33V69.42H106.25V67.33C106.25,63.88 103.45,61.08 100,61.08C96.55,61.08 93.75,63.88 93.75,67.33V69.42H89.58V67.33Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M125,84C125,79.4 128.73,75.67 133.33,75.67H191.67C196.27,75.67 200,79.4 200,84V167.33C200,171.94 196.27,175.67 191.67,175.67H133.33C128.73,175.67 125,171.94 125,167.33V84Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M191.67,79.83H133.33C131.03,79.83 129.17,81.7 129.17,84V167.33C129.17,169.63 131.03,171.5 133.33,171.5H191.67C193.97,171.5 195.83,169.63 195.83,167.33V84C195.83,81.7 193.97,79.83 191.67,79.83ZM133.33,75.67C128.73,75.67 125,79.4 125,84V167.33C125,171.94 128.73,175.67 133.33,175.67H191.67C196.27,175.67 200,171.94 200,167.33V84C200,79.4 196.27,75.67 191.67,75.67H133.33Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M168.75,159C168.75,162.45 165.95,165.25 162.5,165.25C159.05,165.25 156.25,162.45 156.25,159C156.25,155.55 159.05,152.75 162.5,152.75C165.95,152.75 168.75,155.55 168.75,159Z"
android:fillColor="#AAC3EF"/>
<path
android:pathData="M162.5,161.08C163.65,161.08 164.58,160.15 164.58,159C164.58,157.85 163.65,156.92 162.5,156.92C161.35,156.92 160.42,157.85 160.42,159C160.42,160.15 161.35,161.08 162.5,161.08ZM162.5,165.25C165.95,165.25 168.75,162.45 168.75,159C168.75,155.55 165.95,152.75 162.5,152.75C159.05,152.75 156.25,155.55 156.25,159C156.25,162.45 159.05,165.25 162.5,165.25Z"
android:fillColor="#020F66"
android:fillType="evenOdd"/>
<path
android:pathData="M83.33,138.17H116.67V150.67H83.33V138.17Z"
android:fillColor="#AAC3EF"/>
</group>
</vector>

View file

@ -1062,4 +1062,14 @@ Do you want to switch to this account?</string>
<string name="this_is_not_a_recognized_bitwarden_server_you_may_need_to_check_with_your_provider_or_update_your_server">This is not a recognized Bitwarden server. You may need to check with your provider or update your server.</string>
<string name="syncing_logins_loading_message">Syncing logins...</string>
<string name="ssh_key_cipher_item_types">SSH Key Cipher Item Types</string>
<string name="download_the_browser_extension">Download the browser extension</string>
<string name="go_to_bitwarden_com_download_to_integrate_bitwarden_into_browser">Go to bitwarden.com/download to integrate Bitwarden into your favorite browser for a seamless experience.</string>
<string name="use_the_web_app">Use the web app</string>
<string name="log_in_at_bitwarden_com_to_easily_manage_your_account">Log in at bitwarden.com to easily manage your account and update settings.</string>
<string name="autofill_passwords">Autofill passwords</string>
<string name="set_up_autofill_on_all_your_devices">Set up autofill on all your devices to login with a single tap anywhere.</string>
<string name="import_successful">Import Successful!</string>
<string name="manage_your_logins_from_anywhere_with_bitwarden_tools">Manage your logins from anywhere with Bitwarden tools for web and desktop.</string>
<string name="bitwarden_tools">Bitwarden Tools</string>
<string name="got_it">Got it</string>
</resources>

View file

@ -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

View file

@ -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,
)

View file

@ -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,
)