PM-11182 PM-11183 PM-11184 Add the instruction steps to logins import flow (#4089)

This commit is contained in:
Dave Severns 2024-10-16 14:15:13 -04:00 committed by GitHub
parent c382227b6a
commit ab9d57b4f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1183 additions and 41 deletions

View file

@ -6,6 +6,7 @@ import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
@ -135,20 +136,13 @@ fun @receiver:StringRes Int.asText(vararg args: Any): Text = ResArgsText(this, a
fun createAnnotatedString(
mainString: String,
highlights: List<String>,
highlightStyle: SpanStyle = SpanStyle(
color = BitwardenTheme.colorScheme.text.interaction,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
),
tag: String,
highlightStyle: SpanStyle = bitwardenClickableTextSpanStyle,
tag: String? = null,
): AnnotatedString {
return buildAnnotatedString {
append(mainString)
addStyle(
style = SpanStyle(
color = BitwardenTheme.colorScheme.text.primary,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
),
style = bitwardenDefaultSpanStyle,
start = 0,
end = mainString.length,
)
@ -160,12 +154,14 @@ fun createAnnotatedString(
start = startIndex,
end = endIndex,
)
addStringAnnotation(
tag = tag,
annotation = highlightString,
start = startIndex,
end = endIndex,
)
tag?.let {
addStringAnnotation(
tag = it,
annotation = highlightString,
start = startIndex,
end = endIndex,
)
}
}
}
}
@ -182,15 +178,8 @@ fun createAnnotatedString(
fun createClickableAnnotatedString(
mainString: String,
highlights: List<ClickableTextHighlight>,
style: SpanStyle = SpanStyle(
color = BitwardenTheme.colorScheme.text.primary,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
),
highlightStyle: SpanStyle = SpanStyle(
color = BitwardenTheme.colorScheme.text.interaction,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
),
style: SpanStyle = bitwardenDefaultSpanStyle,
highlightStyle: SpanStyle = bitwardenClickableTextSpanStyle,
): AnnotatedString {
return buildAnnotatedString {
append(mainString)
@ -250,3 +239,26 @@ data class ClickableTextHighlight(
LAST,
}
}
val bitwardenDefaultSpanStyle: SpanStyle
@Composable
@ReadOnlyComposable
get() = SpanStyle(
color = BitwardenTheme.colorScheme.text.primary,
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
fontFamily = BitwardenTheme.typography.bodyMedium.fontFamily,
)
val bitwardenBoldSpanStyle: SpanStyle
@Composable
@ReadOnlyComposable
get() = bitwardenDefaultSpanStyle.copy(
fontWeight = FontWeight.Bold,
)
val bitwardenClickableTextSpanStyle: SpanStyle
@Composable
@ReadOnlyComposable
get() = bitwardenBoldSpanStyle.copy(
color = BitwardenTheme.colorScheme.text.interaction,
)

View file

@ -0,0 +1,57 @@
package com.x8bit.bitwarden.ui.platform.components.card
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.ui.platform.base.util.bottomDivider
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import kotlinx.collections.immutable.ImmutableList
/**
* Reusable card for displaying content for a list of items with a generic type [T].
* Items will be displayed in [Column] in the order they are provided with an optional divider
* below them, besides the last item in the list.
*
* @param contentItems list of items to display.
* @param content composable to render each item to the UI.
* @param showBottomDivider whether to show a divider below each item.
* @param bottomDividerPaddingStart padding to apply to the start of the divider.
* @param bottomDividerPaddingEnd padding to apply to the end of the divider.
*/
@Composable
fun <T> BitwardenContentCard(
contentItems: ImmutableList<T>,
modifier: Modifier = Modifier,
showBottomDivider: Boolean = true,
bottomDividerPaddingStart: Dp = 0.dp,
bottomDividerPaddingEnd: Dp = 0.dp,
content: @Composable (T) -> Unit,
) {
Column(
modifier = modifier
.fillMaxWidth()
.background(
color = BitwardenTheme.colorScheme.background.secondary,
shape = BitwardenTheme.shapes.content,
),
) {
contentItems.forEachIndexed { index, item ->
Box(
modifier = Modifier
.fillMaxWidth()
.bottomDivider(
enabled = index != contentItems.lastIndex && showBottomDivider,
paddingStart = bottomDividerPaddingStart,
paddingEnd = bottomDividerPaddingEnd,
),
) {
content(item)
}
}
}
}

View file

@ -0,0 +1,110 @@
package com.x8bit.bitwarden.ui.platform.components.content
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
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.theme.BitwardenTheme
/**
* An overloaded version [BitwardenContentBlock] which takes a [String] for the header text.
*/
@Composable
fun BitwardenContentBlock(
headerText: String,
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),
modifier = modifier,
headerTextStyle = headerTextStyle,
subtitleText = subtitleText,
subtitleTextStyle = subtitleTextStyle,
iconPainter = iconPainter,
backgroundColor = backgroundColor,
)
}
/**
* A default content block which displays a header with an optional subtitle and an icon.
* Implemented to match design component.
*/
@Composable
fun BitwardenContentBlock(
headerText: AnnotatedString,
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,
) {
Row(
modifier = modifier
.fillMaxWidth()
.background(backgroundColor),
verticalAlignment = Alignment.CenterVertically,
) {
iconPainter
?.let {
Spacer(Modifier.width(12.dp))
Icon(
painter = it,
contentDescription = null,
tint = BitwardenTheme.colorScheme.icon.secondary,
modifier = Modifier.size(24.dp),
)
}
?: Spacer(Modifier.width(16.dp))
Column {
Spacer(Modifier.height(12.dp))
Text(
text = headerText,
style = headerTextStyle,
)
subtitleText?.let {
Spacer(Modifier.height(4.dp))
Text(
text = it,
style = subtitleTextStyle,
color = BitwardenTheme.colorScheme.text.secondary,
)
}
Spacer(Modifier.height(12.dp))
}
Spacer(Modifier.width(16.dp))
}
}
@Preview
@Composable
private fun BitwardenContentBlock_preview() {
BitwardenTheme {
BitwardenContentBlock(
headerText = "Header",
subtitleText = "Subtitle",
iconPainter = null,
)
}
}

View file

@ -1,6 +1,8 @@
package com.x8bit.bitwarden.ui.vault.feature.importlogins
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
@ -23,16 +25,21 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.bitwardenBoldSpanStyle
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
@ -41,18 +48,27 @@ import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.importlogins.components.ImportLoginsInstructionStep
import com.x8bit.bitwarden.ui.vault.feature.importlogins.handlers.ImportLoginHandler
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
private const val IMPORT_HELP_URL = "https://bitwarden.com/help/import-data/"
/**
* Top level component for the import logins screen.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ImportLoginsScreen(
onNavigateBack: () -> Unit,
viewModel: ImportLoginsViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val handler = rememberImportLoginHandler(viewModel = viewModel)
@ -60,11 +76,20 @@ fun ImportLoginsScreen(
EventsEffect(viewModel = viewModel) { event ->
when (event) {
ImportLoginsEvent.NavigateBack -> onNavigateBack()
ImportLoginsEvent.OpenHelpLink -> {
intentManager.startCustomTabsActivity(IMPORT_HELP_URL.toUri())
}
}
}
ImportLoginsDialogContent(state = state, handler = handler)
BackHandler(enabled = true) {
state.viewState.backAction?.let {
viewModel.trySendAction(it)
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
@ -82,15 +107,49 @@ fun ImportLoginsScreen(
)
},
) { innerPadding ->
Column(
Crossfade(
targetState = state.viewState,
label = "CrossfadeBetweenViewStates",
modifier = Modifier
.fillMaxSize()
.padding(paddingValues = innerPadding),
) {
ImportLoginsContent(
onGetStartedClick = handler.onGetStartedClick,
onImportLaterClick = handler.onImportLaterClick,
)
) { viewState ->
when (viewState) {
ImportLoginsState.ViewState.InitialContent -> {
InitialImportLoginsContent(
onGetStartedClick = handler.onGetStartedClick,
onImportLaterClick = handler.onImportLaterClick,
)
}
ImportLoginsState.ViewState.ImportStepOne -> {
ImportLoginsStepOneContent(
onBackClick = handler.onMoveToInitialContent,
onContinueClick = handler.onMoveToStepTwo,
onHelpClick = handler.onHelpClick,
)
}
ImportLoginsState.ViewState.ImportStepTwo -> {
ImportLoginsStepTwoContent(
onBackClick = handler.onMoveToStepOne,
onContinueClick = handler.onMoveToStepThree,
onHelpClick = handler.onHelpClick,
)
}
ImportLoginsState.ViewState.ImportStepThree -> {
ImportLoginsStepThreeContent(
onBackClick = handler.onMoveToStepTwo,
onContinueClick = handler.onMoveToSyncInProgress,
onHelpClick = handler.onHelpClick,
)
}
ImportLoginsState.ViewState.SyncInProgress -> {
// TODO PM-11186: Implement sync in progress
}
}
}
}
}
@ -132,7 +191,7 @@ private fun ImportLoginsDialogContent(
}
@Composable
private fun ImportLoginsContent(
private fun InitialImportLoginsContent(
onGetStartedClick: () -> Unit,
onImportLaterClick: () -> Unit,
modifier: Modifier = Modifier,
@ -189,6 +248,181 @@ private fun ImportLoginsContent(
}
}
@Composable
private fun ImportLoginsStepOneContent(
onContinueClick: () -> Unit,
onBackClick: () -> Unit,
onHelpClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val instruction1 = createAnnotatedString(
mainString = stringResource(
R.string.on_your_computer_log_in_to_your_current_browser_or_password_manager,
),
highlights = listOf(
stringResource(R.string.log_in_to_your_current_browser_or_password_manager_highlight),
),
highlightStyle = bitwardenBoldSpanStyle,
)
val instruction2 = createAnnotatedString(
mainString = stringResource(
R.string.export_your_passwords_this_option_is_usually_found_in_your_settings,
),
listOf(stringResource(R.string.export_your_passwords_highlight)),
highlightStyle = bitwardenBoldSpanStyle,
)
val instruction3 = createAnnotatedString(
mainString = stringResource(
R.string.save_the_exported_file_somewhere_on_your_computer_you_can_find_easily,
),
highlights = listOf(stringResource(R.string.save_the_exported_file_highlight)),
highlightStyle = bitwardenBoldSpanStyle,
)
ImportLoginsInstructionStep(
stepText = stringResource(R.string.step_1_of_3),
stepTitle = stringResource(R.string.export_your_saved_logins),
instructions = persistentListOf(
InstructionStep(
stepNumber = 1,
instructionText = instruction1,
additionalText = null,
),
InstructionStep(
stepNumber = 2,
instructionText = instruction2,
additionalText = null,
),
InstructionStep(
stepNumber = 3,
instructionText = instruction3,
additionalText = stringResource(R.string.delete_this_file_after_import_is_complete),
),
),
onBackClick = onBackClick,
onContinueClick = onContinueClick,
onHelpClick = onHelpClick,
modifier = modifier,
)
}
@Composable
private fun ImportLoginsStepTwoContent(
onContinueClick: () -> Unit,
onBackClick: () -> Unit,
onHelpClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val instruction1 = createAnnotatedString(
mainString = stringResource(
R.string.on_your_computer_open_a_new_browser_tab_and_go_to_vault_bitwarden_com,
),
highlights = listOf(stringResource(R.string.go_to_vault_bitwarden_com_highlight)),
highlightStyle = bitwardenBoldSpanStyle,
)
val instruction2Text = stringResource(R.string.log_in_to_the_bitwarden_web_app)
val instruction2 = buildAnnotatedString {
withStyle(bitwardenBoldSpanStyle) {
append(instruction2Text)
}
}
ImportLoginsInstructionStep(
stepText = stringResource(R.string.step_2_of_3),
stepTitle = stringResource(R.string.log_in_to_bitwarden),
instructions = persistentListOf(
InstructionStep(
stepNumber = 1,
instructionText = instruction1,
additionalText = null,
),
InstructionStep(
stepNumber = 2,
instructionText = instruction2,
additionalText = null,
),
),
onBackClick = onBackClick,
onContinueClick = onContinueClick,
onHelpClick = onHelpClick,
modifier = modifier,
)
}
@Suppress("LongMethod")
@Composable
private fun ImportLoginsStepThreeContent(
onContinueClick: () -> Unit,
onBackClick: () -> Unit,
onHelpClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val instruction1 = createAnnotatedString(
mainString = stringResource(
R.string.in_the_bitwarden_navigation_find_the_tools_option_and_select_import_data,
),
highlights = listOf(
stringResource(R.string.find_the_tools_highlight),
stringResource(R.string.select_import_data_step_3_highlight),
),
highlightStyle = bitwardenBoldSpanStyle,
)
val instruction2 = createAnnotatedString(
mainString = stringResource(R.string.fill_out_the_form_and_import_your_saved_password_file),
highlights = listOf(
stringResource(R.string.import_your_saved_password_file_highlight),
),
highlightStyle = bitwardenBoldSpanStyle,
)
val instruction3 = createAnnotatedString(
mainString = stringResource(
R.string.select_import_data_in_the_web_app_then_done_to_finish_syncing,
),
highlights = listOf(
stringResource(R.string.select_import_data_highlight),
stringResource(R.string.then_done_highlight),
),
highlightStyle = bitwardenBoldSpanStyle,
)
val instruction4 = createAnnotatedString(
mainString = stringResource(
R.string.for_your_security_be_sure_to_delete_your_saved_password_file,
),
highlights = listOf(
stringResource(R.string.delete_your_saved_password_file),
),
highlightStyle = bitwardenBoldSpanStyle,
)
ImportLoginsInstructionStep(
stepText = stringResource(R.string.step_3_of_3),
stepTitle = stringResource(R.string.import_logins_to_bitwarden),
instructions = persistentListOf(
InstructionStep(
stepNumber = 1,
instructionText = instruction1,
additionalText = null,
),
InstructionStep(
stepNumber = 2,
instructionText = instruction2,
additionalText = null,
),
InstructionStep(
stepNumber = 3,
instructionText = instruction3,
additionalText = null,
),
InstructionStep(
stepNumber = 4,
instructionText = instruction4,
additionalText = null,
),
),
onBackClick = onBackClick,
onContinueClick = onContinueClick,
onHelpClick = onHelpClick,
modifier = modifier,
)
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@ -199,7 +433,7 @@ private fun ImportLoginsInitialContent_preview() {
BitwardenTheme.colorScheme.background.primary,
),
) {
ImportLoginsContent(
InitialImportLoginsContent(
onGetStartedClick = {},
onImportLaterClick = {},
)
@ -228,9 +462,15 @@ private fun ImportLoginsScreenDialog_preview(
onCloseClick = {},
onGetStartedClick = {},
onImportLaterClick = {},
onHelpClick = {},
onMoveToInitialContent = {},
onMoveToStepOne = {},
onMoveToStepTwo = {},
onMoveToStepThree = {},
onMoveToSyncInProgress = {},
),
)
ImportLoginsContent(
InitialImportLoginsContent(
onGetStartedClick = {},
onImportLaterClick = {},
)
@ -245,9 +485,11 @@ private class ImportLoginsDialogContentPreviewProvider :
get() = sequenceOf(
ImportLoginsState(
dialogState = ImportLoginsState.DialogState.GetStarted,
viewState = ImportLoginsState.ViewState.InitialContent,
),
ImportLoginsState(
dialogState = ImportLoginsState.DialogState.ImportLater,
viewState = ImportLoginsState.ViewState.InitialContent,
),
)
}

View file

@ -11,11 +11,13 @@ import javax.inject.Inject
/**
* View model for the [ImportLoginsScreen].
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class ImportLoginsViewModel @Inject constructor() :
BaseViewModel<ImportLoginsState, ImportLoginsEvent, ImportLoginsAction>(
initialState = ImportLoginsState(
null,
viewState = ImportLoginsState.ViewState.InitialContent,
),
) {
override fun handleAction(action: ImportLoginsAction) {
@ -26,9 +28,39 @@ class ImportLoginsViewModel @Inject constructor() :
ImportLoginsAction.GetStartedClick -> handleGetStartedClick()
ImportLoginsAction.ImportLaterClick -> handleImportLaterClick()
ImportLoginsAction.CloseClick -> handleCloseClick()
ImportLoginsAction.MoveToInitialContent -> handleMoveToInitialContent()
ImportLoginsAction.MoveToStepOne -> handleMoveToStepOne()
ImportLoginsAction.MoveToStepTwo -> handleMoveToStepTwo()
ImportLoginsAction.MoveToStepThree -> handleMoveToStepThree()
ImportLoginsAction.MoveToSyncInProgress -> handleMoveToSyncInProgress()
ImportLoginsAction.HelpClick -> handleHelpClick()
}
}
private fun handleMoveToSyncInProgress() {
// TODO PM-11186: Implement sync in progress
}
private fun handleHelpClick() {
sendEvent(ImportLoginsEvent.OpenHelpLink)
}
private fun handleMoveToStepThree() {
updateViewState(ImportLoginsState.ViewState.ImportStepThree)
}
private fun handleMoveToStepTwo() {
updateViewState(ImportLoginsState.ViewState.ImportStepTwo)
}
private fun handleMoveToStepOne() {
updateViewState(ImportLoginsState.ViewState.ImportStepOne)
}
private fun handleMoveToInitialContent() {
updateViewState(ImportLoginsState.ViewState.InitialContent)
}
private fun handleCloseClick() {
sendEvent(ImportLoginsEvent.NavigateBack)
}
@ -52,7 +84,13 @@ class ImportLoginsViewModel @Inject constructor() :
private fun handleConfirmGetStarted() {
dismissDialog()
// TODO - PM-11182: Move to first step in instructions.
updateViewState(ImportLoginsState.ViewState.ImportStepOne)
}
private fun updateViewState(viewState: ImportLoginsState.ViewState) {
mutableStateFlow.update {
it.copy(viewState = viewState)
}
}
private fun dismissDialog() {
@ -71,6 +109,7 @@ class ImportLoginsViewModel @Inject constructor() :
*/
data class ImportLoginsState(
val dialogState: DialogState?,
val viewState: ViewState,
) {
/**
* Dialog states for the [ImportLoginsViewModel].
@ -97,6 +136,51 @@ data class ImportLoginsState(
override val title: Text = R.string.do_you_have_a_computer_available.asText()
}
}
/**
* View states for the [ImportLoginsViewModel].
*/
sealed class ViewState {
/**
* Back action for each view state.
*/
abstract val backAction: ImportLoginsAction?
/**
* Initial content view state.
*/
data object InitialContent : ViewState() {
override val backAction: ImportLoginsAction = ImportLoginsAction.CloseClick
}
/**
* Import step one view state.
*/
data object ImportStepOne : ViewState() {
override val backAction: ImportLoginsAction = ImportLoginsAction.MoveToInitialContent
}
/**
* Import step two view state.
*/
data object ImportStepTwo : ViewState() {
override val backAction: ImportLoginsAction = ImportLoginsAction.MoveToStepOne
}
/**
* Import step three view state.
*/
data object ImportStepThree : ViewState() {
override val backAction: ImportLoginsAction = ImportLoginsAction.MoveToStepTwo
}
/**
* Sync in progress view state.
*/
data object SyncInProgress : ViewState() {
override val backAction: ImportLoginsAction? = null
}
}
}
/**
@ -107,6 +191,11 @@ sealed class ImportLoginsEvent {
* Navigate back to the previous screen.
*/
data object NavigateBack : ImportLoginsEvent()
/**
* Open the help link in a browser.
*/
data object OpenHelpLink : ImportLoginsEvent()
}
/**
@ -143,4 +232,34 @@ sealed class ImportLoginsAction {
* User has clicked the "Close" icon button.
*/
data object CloseClick : ImportLoginsAction()
/**
* User has clicked the "Help" button.
*/
data object HelpClick : ImportLoginsAction()
/**
* User has performed action which should move to the initial content view state.
*/
data object MoveToInitialContent : ImportLoginsAction()
/**
* User has performed action which should move to the step one view state.
*/
data object MoveToStepOne : ImportLoginsAction()
/**
* User has performed action which should move to the step two view state.
*/
data object MoveToStepTwo : ImportLoginsAction()
/**
* User has performed action which should move to the step three view state.
*/
data object MoveToStepThree : ImportLoginsAction()
/**
* User has performed action which should move to the sync in progress view state.
*/
data object MoveToSyncInProgress : ImportLoginsAction()
}

View file

@ -0,0 +1,153 @@
package com.x8bit.bitwarden.ui.vault.feature.importlogins.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.ClickableTextHighlight
import com.x8bit.bitwarden.ui.platform.base.util.createClickableAnnotatedString
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
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.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.importlogins.model.InstructionStep
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* Reusable component for each step of the import logins flow.
*/
@Suppress("LongMethod")
@Composable
fun ImportLoginsInstructionStep(
stepText: String,
stepTitle: String,
instructions: ImmutableList<InstructionStep>,
onBackClick: () -> Unit,
onContinueClick: () -> Unit,
onHelpClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(24.dp))
Text(
text = stepText,
style = BitwardenTheme.typography.titleSmall,
)
Spacer(Modifier.height(12.dp))
Text(text = stepTitle, style = BitwardenTheme.typography.titleMedium)
Spacer(Modifier.height(24.dp))
BitwardenContentCard(
contentItems = instructions,
bottomDividerPaddingStart = 48.dp,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
) { instructionStep ->
InstructionRowItem(
instructionStep = instructionStep,
modifier = modifier
.padding(all = 12.dp),
)
}
Spacer(Modifier.height(24.dp))
Text(
text = createClickableAnnotatedString(
mainString = stringResource(R.string.need_help_checkout_out_import_help),
highlights = listOf(
ClickableTextHighlight(
textToHighlight = stringResource(R.string.import_help_highlight),
onTextClick = onHelpClick,
),
),
),
style = BitwardenTheme.typography.bodySmall,
color = BitwardenTheme.colorScheme.text.secondary,
modifier = Modifier.standardHorizontalMargin(),
)
Spacer(Modifier.height(24.dp))
BitwardenFilledButton(
label = stringResource(R.string.continue_text),
onClick = onContinueClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(12.dp))
BitwardenOutlinedButton(
label = stringResource(R.string.back),
onClick = onBackClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.navigationBarsPadding())
}
}
@Preview
@Composable
private fun ImportLoginsInstructionStep_preview() {
BitwardenTheme {
Column(modifier = Modifier.background(BitwardenTheme.colorScheme.background.primary)) {
ImportLoginsInstructionStep(
stepText = "Step text",
stepTitle = "Step title",
instructions = persistentListOf(
InstructionStep(
stepNumber = 1,
instructionText = buildAnnotatedString {
append("Step text 1")
withStyle(
SpanStyle(
fontWeight = FontWeight.Bold,
fontFamily = BitwardenTheme.typography.bodyMedium.fontFamily,
),
) {
append(" with bold text")
}
},
additionalText = null,
),
InstructionStep(
stepNumber = 2,
instructionText = buildAnnotatedString {
append("Step text 2")
},
additionalText = "Added deets",
),
InstructionStep(
stepNumber = 3,
instructionText = buildAnnotatedString {
append("Step text 3")
},
),
),
onBackClick = {},
onContinueClick = {},
onHelpClick = {},
)
}
}
}

View file

@ -0,0 +1,92 @@
package com.x8bit.bitwarden.ui.vault.feature.importlogins.components
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
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.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.importlogins.model.InstructionStep
import kotlinx.collections.immutable.persistentListOf
/**
* Row item for the content card of the import logins screen instructions.
*/
@Composable
fun InstructionRowItem(
instructionStep: InstructionStep,
modifier: Modifier = Modifier,
) {
BitwardenContentBlock(
modifier = modifier,
headerText = instructionStep.instructionText,
headerTextStyle = BitwardenTheme.typography.bodyMedium,
subtitleText = instructionStep.additionalText,
subtitleTextStyle = BitwardenTheme.typography.labelSmall,
)
}
@Suppress("MagicNumber")
@get:DrawableRes
private val InstructionStep.imageRes: Int
get() = when (this.stepNumber) {
1 -> R.drawable.ic_number1
2 -> R.drawable.ic_number2
3 -> R.drawable.ic_number3
4 -> R.drawable.ic_number4
else -> error(
"Invalid step number, if new step is required please add drawable asset for it.",
)
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun InstructionCard_preview() {
BitwardenTheme {
Surface {
BitwardenContentCard(
contentItems = persistentListOf(
InstructionStep(
stepNumber = 1,
instructionText = buildAnnotatedString {
append("Step text 1")
withStyle(
SpanStyle(
fontWeight = FontWeight.Bold,
fontFamily = BitwardenTheme.typography.bodyMedium.fontFamily,
),
) {
append(" with bold text")
}
},
additionalText = null,
),
InstructionStep(
stepNumber = 2,
instructionText = buildAnnotatedString {
append("Step text 2")
},
additionalText = "Added deets",
),
InstructionStep(
stepNumber = 3,
instructionText = buildAnnotatedString {
append("Step text 3")
},
),
),
) {
InstructionRowItem(it)
}
}
}
}

View file

@ -6,7 +6,7 @@ import com.x8bit.bitwarden.ui.vault.feature.importlogins.ImportLoginsAction
import com.x8bit.bitwarden.ui.vault.feature.importlogins.ImportLoginsViewModel
/**
* Action handlers for the [ImportLoginsScreen].
* Action handlers for the [com.x8bit.bitwarden.ui.vault.feature.importlogins.ImportLoginsScreen].
*/
data class ImportLoginHandler(
val onGetStartedClick: () -> Unit,
@ -15,6 +15,12 @@ data class ImportLoginHandler(
val onConfirmGetStarted: () -> Unit,
val onConfirmImportLater: () -> Unit,
val onCloseClick: () -> Unit,
val onHelpClick: () -> Unit,
val onMoveToInitialContent: () -> Unit,
val onMoveToStepOne: () -> Unit,
val onMoveToStepTwo: () -> Unit,
val onMoveToStepThree: () -> Unit,
val onMoveToSyncInProgress: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
@ -30,6 +36,16 @@ data class ImportLoginHandler(
viewModel.trySendAction(ImportLoginsAction.ConfirmImportLater)
},
onCloseClick = { viewModel.trySendAction(ImportLoginsAction.CloseClick) },
onHelpClick = { viewModel.trySendAction(ImportLoginsAction.HelpClick) },
onMoveToInitialContent = {
viewModel.trySendAction(ImportLoginsAction.MoveToInitialContent)
},
onMoveToStepOne = { viewModel.trySendAction(ImportLoginsAction.MoveToStepOne) },
onMoveToStepTwo = { viewModel.trySendAction(ImportLoginsAction.MoveToStepTwo) },
onMoveToStepThree = { viewModel.trySendAction(ImportLoginsAction.MoveToStepThree) },
onMoveToSyncInProgress = {
viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress)
},
)
}
}

View file

@ -0,0 +1,14 @@
package com.x8bit.bitwarden.ui.vault.feature.importlogins.model
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
/**
* Models a single instruction step to be displayed in the import login instructions card.
*/
@Immutable
data class InstructionStep(
val stepNumber: Int,
val instructionText: AnnotatedString,
val additionalText: String? = null,
)

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="17dp"
android:viewportWidth="16"
android:viewportHeight="17">
<group>
<clip-path
android:pathData="M0,0.333h16v16h-16z"/>
<path
android:pathData="M7.68,14.333V4.762L5.572,5.306V3.725L8.496,2.433H9.771V14.333H7.68Z"
android:fillColor="#175DDC"/>
</group>
</vector>

View file

@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="17dp"
android:viewportWidth="16"
android:viewportHeight="17">
<group>
<clip-path
android:pathData="M0,0.333h16v16h-16z"/>
<group>
<clip-path
android:pathData="M0,0.333h16v16h-16z"/>
<path
android:pathData="M4.012,14.333V12.854C4.772,12.253 5.497,11.653 6.188,11.052C6.88,10.451 7.503,9.856 8.058,9.267C8.614,8.678 9.05,8.1 9.367,7.533C9.685,6.966 9.843,6.422 9.843,5.901C9.843,5.561 9.781,5.244 9.656,4.949C9.532,4.654 9.333,4.416 9.061,4.235C8.801,4.054 8.449,3.963 8.007,3.963C7.577,3.963 7.208,4.059 6.902,4.252C6.608,4.445 6.387,4.705 6.239,5.034C6.092,5.351 6.018,5.714 6.018,6.122H4.046C4.069,5.261 4.256,4.541 4.607,3.963C4.959,3.385 5.435,2.954 6.035,2.671C6.636,2.376 7.31,2.229 8.058,2.229C8.863,2.229 9.549,2.376 10.115,2.671C10.682,2.966 11.118,3.379 11.424,3.912C11.73,4.445 11.883,5.074 11.883,5.799C11.883,6.332 11.781,6.859 11.577,7.38C11.385,7.89 11.113,8.389 10.761,8.876C10.41,9.352 10.013,9.817 9.571,10.27C9.141,10.712 8.693,11.137 8.228,11.545C7.764,11.942 7.316,12.31 6.885,12.65H12.206V14.333H4.012Z"
android:fillColor="#175DDC"/>
</group>
</group>
</vector>

View file

@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="17dp"
android:viewportWidth="16"
android:viewportHeight="17">
<group>
<clip-path
android:pathData="M0,0.333h16v16h-16z"/>
<group>
<clip-path
android:pathData="M0,0.333h16v16h-16z"/>
<path
android:pathData="M8.478,14.537C7.707,14.537 7.004,14.401 6.37,14.129C5.735,13.846 5.225,13.421 4.84,12.854C4.466,12.276 4.267,11.551 4.245,10.678H6.251C6.262,11.075 6.353,11.437 6.523,11.766C6.704,12.083 6.959,12.338 7.288,12.531C7.616,12.712 8.013,12.803 8.478,12.803C8.92,12.803 9.294,12.718 9.6,12.548C9.917,12.378 10.155,12.146 10.314,11.851C10.472,11.556 10.552,11.228 10.552,10.865C10.552,10.423 10.444,10.06 10.229,9.777C10.013,9.482 9.719,9.261 9.345,9.114C8.971,8.967 8.546,8.893 8.07,8.893H7.186V7.21H8.07C8.693,7.21 9.203,7.068 9.6,6.785C9.996,6.49 10.195,6.065 10.195,5.51C10.195,5.045 10.042,4.671 9.736,4.388C9.441,4.105 9.016,3.963 8.461,3.963C7.883,3.963 7.424,4.133 7.084,4.473C6.755,4.813 6.574,5.232 6.54,5.731H4.534C4.568,5.017 4.749,4.399 5.078,3.878C5.418,3.345 5.877,2.937 6.455,2.654C7.033,2.371 7.707,2.229 8.478,2.229C9.282,2.229 9.962,2.371 10.518,2.654C11.073,2.937 11.492,3.317 11.776,3.793C12.07,4.269 12.218,4.79 12.218,5.357C12.218,5.799 12.133,6.201 11.963,6.564C11.793,6.927 11.56,7.233 11.266,7.482C10.982,7.72 10.648,7.896 10.263,8.009C10.705,8.1 11.096,8.275 11.436,8.536C11.787,8.797 12.059,9.131 12.252,9.539C12.456,9.936 12.558,10.4 12.558,10.933C12.558,11.579 12.399,12.18 12.082,12.735C11.776,13.279 11.317,13.715 10.705,14.044C10.104,14.373 9.362,14.537 8.478,14.537Z"
android:fillColor="#175DDC"/>
</group>
</group>
</vector>

View file

@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="17dp"
android:viewportWidth="16"
android:viewportHeight="17">
<group>
<clip-path
android:pathData="M0,0.333h16v16h-16z"/>
<group>
<clip-path
android:pathData="M0,0.333h16v16h-16z"/>
<path
android:pathData="M9.71,14.333V12.072H3.811V10.44L9.421,2.433H11.733V10.287H13.348V12.072H11.733V14.333H9.71ZM6.004,10.287H9.829V4.677L6.004,10.287Z"
android:fillColor="#175DDC"/>
</group>
</group>
</vector>

View file

@ -1030,4 +1030,32 @@ Do you want to switch to this account?</string>
<string name="give_your_vault_a_head_start">Give your vault a head start</string>
<string name="class_3_biometrics_description">Unlock with biometrics requires strong biometric authentication and may not be compatible with all biometric options on this device.</string>
<string name="class_2_biometrics_description">Unlock with biometrics requires strong biometric authentication and is not compatible with the biometrics options available on this device.</string>
<string name="on_your_computer_log_in_to_your_current_browser_or_password_manager">On your computer, log in to your current browser or password manager.</string>
<string name="log_in_to_your_current_browser_or_password_manager_highlight">log in to your current browser or password manager.</string>
<string name="export_your_passwords_this_option_is_usually_found_in_your_settings">Export your passwords. This option is usually found in your settings.</string>
<string name="export_your_passwords_highlight">Export your passwords.</string>
<string name="select_import_data_in_the_web_app_then_done_to_finish_syncing">Select Import data in the web app, then Done to finish syncing.</string>
<string name="select_import_data_highlight">Select Import data</string>
<string name="step_1_of_3">Step 1 of 3</string>
<string name="export_your_saved_logins">Export your saved logins</string>
<string name="delete_this_file_after_import_is_complete">Youll delete this file after import is complete.</string>
<string name="on_your_computer_open_a_new_browser_tab_and_go_to_vault_bitwarden_com">On your computer, open a new browser tab and go to vault.bitwarden.com</string>
<string name="go_to_vault_bitwarden_com_highlight">go to vault.bitwarden.com</string>
<string name="log_in_to_the_bitwarden_web_app">Log in to the Bitwarden web app.</string>
<string name="step_2_of_3">Step 2 of 3</string>
<string name="log_in_to_bitwarden">Log in to Bitwarden</string>
<string name="step_3_of_3">Step 3 of 3</string>
<string name="import_logins_to_bitwarden">Import logins to Bitwarden</string>
<string name="in_the_bitwarden_navigation_find_the_tools_option_and_select_import_data">In the Bitwarden navigation, find the Tools option and select Import data.</string>
<string name="find_the_tools_highlight">find the Tools</string>
<string name="select_import_data_step_3_highlight">select Import data.</string>
<string name="fill_out_the_form_and_import_your_saved_password_file">Fill out the form and import your saved password file.</string>
<string name="import_your_saved_password_file_highlight">import your saved password file.</string>
<string name="then_done_highlight">then Done</string>
<string name="for_your_security_be_sure_to_delete_your_saved_password_file">For your security, be sure to delete your saved password file.</string>
<string name="delete_your_saved_password_file">delete your saved password file.</string>
<string name="need_help_checkout_out_import_help">Need help? Checkout out import help.</string>
<string name="import_help_highlight">import help</string>
<string name="save_the_exported_file_somewhere_on_your_computer_you_can_find_easily">Save the exported file somewhere on your computer you can find easily.</string>
<string name="save_the_exported_file_highlight">Save the exported file</string>
</resources>

View file

@ -8,8 +8,11 @@ import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.core.net.toUri
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import io.mockk.every
import io.mockk.just
@ -32,13 +35,16 @@ class ImportLoginsScreenTest : BaseComposeTest() {
every { stateFlow } returns mutableImportLoginsStateFlow
every { trySendAction(any()) } just runs
}
private val intentManager = mockk<IntentManager> {
every { startCustomTabsActivity(any()) } just runs
}
@Before
fun setup() {
composeTestRule.setContent {
setContentWithBackDispatcher {
ImportLoginsScreen(
onNavigateBack = { navigateBackCalled = true },
viewModel = viewModel,
intentManager = intentManager,
)
}
}
@ -170,9 +176,158 @@ class ImportLoginsScreenTest : BaseComposeTest() {
verifyActionSent(ImportLoginsAction.DismissDialog)
}
@Test
fun `OpenHelpLink event is used to open URI with intent manager`() {
mutableImportLoginsEventFlow.tryEmit(ImportLoginsEvent.OpenHelpLink)
verify {
intentManager.startCustomTabsActivity("https://bitwarden.com/help/import-data/".toUri())
}
}
@Test
fun `while on initial content system back sends CloseClick action`() {
mutableImportLoginsStateFlow.tryEmit(
DEFAULT_STATE.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(
viewState = ImportLoginsState.ViewState.ImportStepOne,
),
)
composeTestRule
.onNodeWithText("Step 1 of 3")
.assertIsDisplayed()
}
@Test
fun `while on step one correct actions are sent when buttons are clicked`() {
mutableImportLoginsStateFlow.tryEmit(
DEFAULT_STATE.copy(
viewState = ImportLoginsState.ViewState.ImportStepOne,
),
)
composeTestRule
.onNodeWithText("Back")
.performScrollTo()
.performClick()
verifyActionSent(ImportLoginsAction.MoveToInitialContent)
composeTestRule
.onNodeWithText("Continue")
.performClick()
verifyActionSent(ImportLoginsAction.MoveToStepTwo)
}
@Test
fun `while on step one system back returns to the previous content`() {
mutableImportLoginsStateFlow.tryEmit(
DEFAULT_STATE.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(
viewState = ImportLoginsState.ViewState.ImportStepTwo,
),
)
composeTestRule
.onNodeWithText("Step 2 of 3")
.assertIsDisplayed()
}
@Test
fun `while on step two correct actions are sent when buttons are clicked`() {
mutableImportLoginsStateFlow.tryEmit(
DEFAULT_STATE.copy(
viewState = ImportLoginsState.ViewState.ImportStepTwo,
),
)
composeTestRule
.onNodeWithText("Back")
.performScrollTo()
.performClick()
verifyActionSent(ImportLoginsAction.MoveToStepOne)
composeTestRule
.onNodeWithText("Continue")
.performClick()
verifyActionSent(ImportLoginsAction.MoveToStepThree)
}
@Test
fun `while on step two system back returns to the previous content`() {
mutableImportLoginsStateFlow.tryEmit(
DEFAULT_STATE.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(
viewState = ImportLoginsState.ViewState.ImportStepThree,
),
)
composeTestRule
.onNodeWithText("Step 3 of 3")
.assertIsDisplayed()
}
@Test
fun `while on step three correct actions are sent when buttons are clicked`() {
mutableImportLoginsStateFlow.tryEmit(
DEFAULT_STATE.copy(
viewState = ImportLoginsState.ViewState.ImportStepThree,
),
)
composeTestRule
.onNodeWithText("Back")
.performScrollTo()
.performClick()
verifyActionSent(ImportLoginsAction.MoveToStepTwo)
composeTestRule
.onNodeWithText("Continue")
.performClick()
verifyActionSent(ImportLoginsAction.MoveToSyncInProgress)
}
@Test
fun `while on step three system back returns to the previous content`() {
mutableImportLoginsStateFlow.tryEmit(
DEFAULT_STATE.copy(
viewState = ImportLoginsState.ViewState.ImportStepThree,
),
)
backDispatcher?.onBackPressed()
verifyActionSent(ImportLoginsAction.MoveToStepTwo)
}
//region Helper methods
private fun verifyActionSent(action: ImportLoginsAction) {
verify { viewModel.trySendAction(action) }
}
//endregion Helper methods
}
private val DEFAULT_STATE = ImportLoginsState(dialogState = null)
private val DEFAULT_STATE = ImportLoginsState(
dialogState = null,
viewState = ImportLoginsState.ViewState.InitialContent,
)

View file

@ -5,6 +5,7 @@ import app.cash.turbine.turbineScope
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class ImportLoginsViewModelTest : BaseViewModelTest() {
@ -25,6 +26,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
assertEquals(
ImportLoginsState(
dialogState = ImportLoginsState.DialogState.GetStarted,
viewState = ImportLoginsState.ViewState.InitialContent,
),
viewModel.stateFlow.value,
)
@ -37,6 +39,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
assertEquals(
ImportLoginsState(
dialogState = ImportLoginsState.DialogState.ImportLater,
viewState = ImportLoginsState.ViewState.InitialContent,
),
viewModel.stateFlow.value,
)
@ -54,6 +57,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
assertEquals(
ImportLoginsState(
dialogState = ImportLoginsState.DialogState.GetStarted,
viewState = ImportLoginsState.ViewState.InitialContent,
),
awaitItem(),
)
@ -61,6 +65,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
assertEquals(
ImportLoginsState(
dialogState = null,
viewState = ImportLoginsState.ViewState.InitialContent,
),
awaitItem(),
)
@ -81,6 +86,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
assertEquals(
ImportLoginsState(
dialogState = ImportLoginsState.DialogState.ImportLater,
viewState = ImportLoginsState.ViewState.InitialContent,
),
stateFlow.awaitItem(),
)
@ -88,6 +94,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
assertEquals(
ImportLoginsState(
dialogState = null,
viewState = ImportLoginsState.ViewState.InitialContent,
),
stateFlow.awaitItem(),
)
@ -99,7 +106,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
}
@Test
fun `ConfirmGetStarted sets dialog state to null`() = runTest {
fun `ConfirmGetStarted sets dialog state to null and view state to step one`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
// Initial state
@ -110,6 +117,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
assertEquals(
ImportLoginsState(
dialogState = ImportLoginsState.DialogState.GetStarted,
viewState = ImportLoginsState.ViewState.InitialContent,
),
awaitItem(),
)
@ -117,6 +125,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
assertEquals(
ImportLoginsState(
dialogState = null,
viewState = ImportLoginsState.ViewState.ImportStepOne,
),
awaitItem(),
)
@ -135,7 +144,78 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `HelpClick sends OpenHelpLink event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ImportLoginsAction.HelpClick)
assertEquals(
ImportLoginsEvent.OpenHelpLink,
awaitItem(),
)
}
}
@Test
fun `MoveToStepOne sets view state to ImportStepOne`() {
val viewModel = createViewModel()
viewModel.trySendAction(ImportLoginsAction.MoveToStepOne)
assertEquals(
ImportLoginsState(
dialogState = null,
viewState = ImportLoginsState.ViewState.ImportStepOne,
),
viewModel.stateFlow.value,
)
}
@Test
fun `MoveToStepTwo sets view state to ImportStepTwo`() {
val viewModel = createViewModel()
viewModel.trySendAction(ImportLoginsAction.MoveToStepTwo)
assertEquals(
ImportLoginsState(
dialogState = null,
viewState = ImportLoginsState.ViewState.ImportStepTwo,
),
viewModel.stateFlow.value,
)
}
@Test
fun `MoveToStepThree sets view state to ImportStepThree`() {
val viewModel = createViewModel()
viewModel.trySendAction(ImportLoginsAction.MoveToStepThree)
assertEquals(
ImportLoginsState(
dialogState = null,
viewState = ImportLoginsState.ViewState.ImportStepThree,
),
viewModel.stateFlow.value,
)
}
@Test
fun `MoveToInitialContent sets view state to InitialContent`() {
val viewModel = createViewModel()
// first set to step one
viewModel.trySendAction(ImportLoginsAction.MoveToStepOne)
assertTrue(viewModel.stateFlow.value.viewState is ImportLoginsState.ViewState.ImportStepOne)
// now move back to intial
viewModel.trySendAction(ImportLoginsAction.MoveToInitialContent)
assertEquals(
ImportLoginsState(
dialogState = null,
viewState = ImportLoginsState.ViewState.InitialContent,
),
viewModel.stateFlow.value,
)
}
private fun createViewModel(): ImportLoginsViewModel = ImportLoginsViewModel()
}
private val DEFAULT_STATE = ImportLoginsState(dialogState = null)
private val DEFAULT_STATE = ImportLoginsState(
dialogState = null,
viewState = ImportLoginsState.ViewState.InitialContent,
)