BIT-665: Create Add UI for Login-type item (#179)

This commit is contained in:
joshua-livefront 2023-10-31 11:47:11 -04:00 committed by Álison Fernandes
parent 3c8a0893fd
commit 2e96b8d857
29 changed files with 3050 additions and 225 deletions

View file

@ -0,0 +1,78 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Represents a Bitwarden-styled filled button with an icon.
*
* @param label The label for the button.
* @param icon The icon for the button.
* @param onClick The callback when the button is clicked.
* @param modifier The [Modifier] to be applied to the button.
* @param isEnabled Whether or not the button is enabled.
*/
@Composable
fun BitwardenFilledButtonWithIcon(
label: String,
icon: Painter,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
) {
Button(
onClick = onClick,
modifier = modifier
.semantics(mergeDescendants = true) { },
enabled = isEnabled,
contentPadding = PaddingValues(
vertical = 10.dp,
horizontal = 24.dp,
),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Icon(
painter = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier
.padding(end = 8.dp),
)
Text(
text = label,
color = MaterialTheme.colorScheme.onSecondaryContainer,
style = MaterialTheme.typography.labelLarge,
)
}
}
@Preview(showBackground = true)
@Composable
private fun BitwardenFilledButtonWithIcon_preview() {
BitwardenTheme {
BitwardenFilledButtonWithIcon(
label = "Test Button",
icon = painterResource(id = R.drawable.ic_tooltip),
onClick = {},
isEnabled = true,
)
}
}

View file

@ -0,0 +1,61 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* A filled tonal button for the Bitwarden UI with a customized appearance.
*
* This button uses the `secondaryContainer` color from the current [MaterialTheme.colorScheme]
* for its background and the `onSecondaryContainer` color for its label text.
*
* @param label The text to be displayed on the button.
* @param onClick A lambda which will be invoked when the button is clicked.
* @param modifier A [Modifier] for this composable, allowing for adjustments to its appearance
* or behavior. This can be used to apply padding, layout, and other Modifiers.
*/
@Composable
fun BitwardenFilledTonalButton(
label: String,
onClick: () -> Unit,
modifier: Modifier,
) {
Button(
onClick = onClick,
contentPadding = PaddingValues(
vertical = 10.dp,
horizontal = 24.dp,
),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
),
modifier = modifier,
) {
Text(
text = label,
color = MaterialTheme.colorScheme.onSecondaryContainer,
style = MaterialTheme.typography.labelLarge,
)
}
}
@Preview(showBackground = true)
@Composable
private fun BitwardenFilledTonalButton_preview() {
BitwardenTheme {
BitwardenFilledTonalButton(
label = "Sample Text",
onClick = {},
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}

View file

@ -0,0 +1,52 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.model.IconResource
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* An icon button that displays an icon from the provided [IconResource].
*
* @param onClick Callback for when the icon button is clicked.
*/
@Composable
fun BitwardenIconButtonWithResource(iconRes: IconResource, onClick: () -> Unit) {
FilledIconButton(
modifier = Modifier.semantics(mergeDescendants = true) {},
onClick = onClick,
colors = IconButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer,
disabledContentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Icon(
painter = iconRes.iconPainter,
contentDescription = iconRes.contentDescription,
)
}
}
@Preview(showBackground = true)
@Composable
private fun BitwardenIconButtonWithResource_preview() {
BitwardenTheme {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_tooltip),
contentDescription = "Sample Icon",
),
onClick = {},
)
}
}

View file

@ -0,0 +1,40 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Represents a Bitwarden-styled label text.
*
* @param label The text content for the label.
* @param modifier The [Modifier] to be applied to the label.
*/
@Composable
fun BitwardenListHeaderText(
label: String,
modifier: Modifier,
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier.padding(top = 8.dp),
)
}
@Preview(showBackground = true)
@Composable
private fun BitwardenListHeaderText_preview() {
BitwardenTheme {
BitwardenListHeaderText(
label = "Sample Label",
modifier = Modifier,
)
}
}

View file

@ -0,0 +1,74 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.model.IconResource
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Represents a Bitwarden-styled password field that hoists show/hide password state to the caller
* and provides additional actions.
*
* See overloaded [BitwardenPasswordField] for self managed show/hide state.
*
* @param label Label for the text field.
* @param value Current next on the text field.
* @param onValueChange Callback that is triggered when the password changes.
* @param modifier Modifier for the composable.
* @param actions A lambda containing the set of actions (usually icons or similar) to display
* in the app bar's trailing side. This lambda extends [RowScope], allowing flexibility in
* defining the layout of the actions.
*/
@Composable
fun BitwardenPasswordFieldWithActions(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
actions: @Composable RowScope.() -> Unit = {},
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.semantics(mergeDescendants = true) {},
) {
BitwardenPasswordField(
label = label,
value = value,
onValueChange = onValueChange,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
)
actions()
}
}
@Preview(showBackground = true)
@Composable
private fun BitwardenPasswordFieldWithActions_preview() {
BitwardenTheme {
BitwardenPasswordFieldWithActions(
label = "Password",
value = "samplePassword",
onValueChange = {},
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_check_mark),
contentDescription = "",
),
onClick = {},
)
},
)
}
}

View file

@ -0,0 +1,86 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.text
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
/**
* Represents a Bitwarden-styled text field accompanied by a series of actions.
* This component allows for a more versatile design by accepting
* icons or actions next to the text field. This composable is read-only and because it uses
* the BitwardenTextField we clear the semantics here to prevent talk back from clarifying the
* component is "editable" or "disabled".
*
* @param label Label for the text field.
* @param value Current text in the text field.
* @param modifier [Modifier] applied to this layout composable.
* @param readOnly If `true`, user input is disabled for the text field, making it read-only.
* @param actions A lambda containing the set of actions (usually icons or similar) to display
* next to the text field. This lambda extends [RowScope],
* providing flexibility in the layout definition.
*/
@Composable
fun BitwardenReadOnlyTextFieldWithActions(
label: String,
value: String,
modifier: Modifier = Modifier,
readOnly: Boolean = true,
actions: @Composable RowScope.() -> Unit = {},
) {
Row(
modifier = modifier
.fillMaxWidth()
.semantics(mergeDescendants = true) {},
verticalAlignment = Alignment.CenterVertically,
) {
BitwardenTextField(
modifier = Modifier
.weight(1f)
.clearAndSetSemantics {
contentDescription = "$label, $value"
text = AnnotatedString(label)
},
readOnly = readOnly,
label = label,
value = value,
onValueChange = {},
)
BitwardenRowOfActions(actions)
}
}
@Preview(showBackground = true)
@Composable
private fun BitwardenReadOnlyTextFieldWithActions_preview() {
BitwardenReadOnlyTextFieldWithActions(
label = "Username",
value = "john.doe",
readOnly = true,
actions = {
Icon(
painter = painterResource(id = R.drawable.ic_tooltip),
contentDescription = "",
modifier = Modifier.size(24.dp),
)
Icon(
painter = painterResource(id = R.drawable.ic_tooltip),
contentDescription = "",
modifier = Modifier.size(24.dp),
)
},
)
}

View file

@ -0,0 +1,55 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* A composable function to display a row of actions.
*
* This function takes in a trailing lambda which provides a `RowScope` in order to
* layout individual actions. The actions will be arranged in a horizontal
* sequence, spaced by 8.dp, and are vertically centered.
*
* @param actions The composable actions to execute within the [RowScope]. Typically used to
* layout individual icons or buttons.
*/
@Composable
fun BitwardenRowOfActions(actions: @Composable RowScope.() -> Unit) {
Row(
modifier = Modifier.padding(start = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
content = actions,
)
}
@Preview(showBackground = true)
@Composable
private fun BitwardenRowOfIconButtons_preview() {
BitwardenTheme {
BitwardenRowOfActions {
Icon(
painter = painterResource(id = R.drawable.ic_tooltip),
contentDescription = "Icon 1",
modifier = Modifier.size(24.dp),
)
Icon(
painter = painterResource(id = R.drawable.ic_tooltip),
contentDescription = "Icon 2",
modifier = Modifier.size(24.dp),
)
}
}
}

View file

@ -40,11 +40,17 @@ fun BitwardenSwitch(
horizontalArrangement = Arrangement.Start, horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.clickable( .run {
interactionSource = remember { MutableInteractionSource() }, if (onCheckedChange != null) {
indication = rememberRipple(color = MaterialTheme.colorScheme.primary), this.clickable(
onClick = { onCheckedChange?.invoke(!isChecked) }, interactionSource = remember { MutableInteractionSource() },
) indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = { onCheckedChange.invoke(!isChecked) },
)
} else {
this
}
}
.semantics(mergeDescendants = true) { .semantics(mergeDescendants = true) {
toggleableState = ToggleableState(isChecked) toggleableState = ToggleableState(isChecked)
} }

View file

@ -0,0 +1,91 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.toggleableState
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.tooling.preview.Preview
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Represents a Bitwarden-styled [Switch].
*
* @param label The label for the switch.
* @param isChecked Whether or not the switch is currently checked.
* @param onCheckedChange A callback for when the checked state changes.
* @param modifier The [Modifier] to be applied to the button.
* @param actions A lambda containing the set of actions (usually icons or similar) to display
* in the app bar's trailing side. This lambda extends [RowScope], allowing flexibility in
* defining the layout of the actions.
*/
@Composable
fun BitwardenSwitchWithActions(
label: String,
isChecked: Boolean,
onCheckedChange: ((Boolean) -> Unit)?,
modifier: Modifier = Modifier,
actions: @Composable RowScope.() -> Unit = {},
) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = { onCheckedChange?.invoke(!isChecked) },
)
.semantics(mergeDescendants = true) {
toggleableState = ToggleableState(isChecked)
}
.then(modifier),
) {
BitwardenSwitch(
label = label,
isChecked = isChecked,
onCheckedChange = null,
)
BitwardenRowOfActions(actions)
}
}
@Preview
@Composable
private fun BitwardenSwitchWithActions_preview() {
BitwardenTheme {
BitwardenSwitchWithActions(
label = "Label",
isChecked = true,
onCheckedChange = {},
actions = {
IconButton(onClick = {}) {
Icon(
painter = painterResource(id = R.drawable.ic_tooltip),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = stringResource(
id = R.string.master_password_re_prompt_help,
),
)
}
},
)
}
}

View file

@ -22,6 +22,7 @@ fun BitwardenTextField(
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
readOnly: Boolean = false,
keyboardType: KeyboardType = KeyboardType.Text, keyboardType: KeyboardType = KeyboardType.Text,
) { ) {
OutlinedTextField( OutlinedTextField(
@ -30,6 +31,7 @@ fun BitwardenTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
singleLine = true, singleLine = true,
readOnly = readOnly,
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = keyboardType), keyboardOptions = KeyboardOptions.Default.copy(keyboardType = keyboardType),
) )
} }

View file

@ -0,0 +1,73 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Represents a Bitwarden-styled text field accompanied by a series of actions.
* This component allows for a more versatile design by accepting
* icons or actions next to the text field.
*
* @param label Label for the text field.
* @param value Current text in the text field.
* @param onValueChange Callback that is triggered when the text content changes.
* @param modifier [Modifier] applied to this layout composable.
* @param actions A lambda containing the set of actions (usually icons or similar) to display
* next to the text field. This lambda extends [RowScope],
* providing flexibility in the layout definition.
*/
@Composable
fun BitwardenTextFieldWithActions(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
keyboardType: KeyboardType = KeyboardType.Text,
actions: @Composable RowScope.() -> Unit = {},
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
BitwardenTextField(
modifier = Modifier
.weight(1f),
label = label,
value = value,
onValueChange = onValueChange,
keyboardType = keyboardType,
)
BitwardenRowOfActions(actions)
}
}
@Preview(showBackground = true)
@Composable
private fun BitwardenTextFieldWithActions_preview() {
BitwardenTheme {
BitwardenReadOnlyTextFieldWithActions(
label = "Username",
value = "user@example.com",
actions = {
Icon(
painter = painterResource(id = R.drawable.ic_tooltip),
contentDescription = "Action 1",
)
Icon(
painter = painterResource(id = R.drawable.ic_generator),
contentDescription = "Action 2",
)
},
)
}
}

View file

@ -1,149 +0,0 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.model.IconResource
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* A composable function that displays a customizable item
* with an outlined text field and two icons.
*
* @param label Label for the outlined text field.
* @param value Current value of the outlined text field.
* @param firstIconResource The resource data for the first icon.
* @param onFirstIconClick Callback for when the first icon is clicked.
* @param secondIconResource The resource data for the second icon.
* @param onSecondIconClick Callback for when the second icon is clicked.
* @param modifier Modifier for the outermost Box. Default is [Modifier].
*/
@Composable
fun BitwardenTextFieldWithTwoIcons(
label: String,
value: String,
firstIconResource: IconResource,
onFirstIconClick: () -> Unit,
secondIconResource: IconResource,
onSecondIconClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.semantics(mergeDescendants = true) {}) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
modifier = Modifier
.weight(1f)
.clearAndSetSemantics {
contentDescription = "$label, $value"
},
readOnly = true,
label = {
Text(
text = label,
)
},
value = value,
onValueChange = {
// no-op
},
)
RowOfIconButtons(
firstIconResource = firstIconResource,
onFirstIconClick = onFirstIconClick,
secondIconResource = secondIconResource,
onSecondIconClick = onSecondIconClick,
)
}
}
}
/**
* A row of two customizable icon buttons.
*
* @param firstIconResource The resource data for the first icon button.
* @param onFirstIconClick Callback for when the first icon button is clicked.
* @param secondIconResource The resource data for the second icon button.
* @param onSecondIconClick Callback for when the second icon button is clicked.
*/
@Composable
private fun RowOfIconButtons(
firstIconResource: IconResource,
onFirstIconClick: () -> Unit,
secondIconResource: IconResource,
onSecondIconClick: () -> Unit,
) {
Row(
modifier = Modifier.padding(start = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButtonWithResource(firstIconResource, onClick = onFirstIconClick)
IconButtonWithResource(secondIconResource, onClick = onSecondIconClick)
}
}
/**
* An icon button that displays an icon from the provided [IconResource].
*
* @param onClick Callback for when the icon button is clicked.
*/
@Composable
private fun IconButtonWithResource(iconRes: IconResource, onClick: () -> Unit) {
FilledIconButton(
onClick = onClick,
colors = IconButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
disabledContainerColor = MaterialTheme.colorScheme.secondaryContainer,
disabledContentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Icon(
painter = iconRes.iconPainter,
contentDescription = iconRes.contentDescription,
)
}
}
@Preview
@Composable
private fun BitwardenTextFieldWithTwoIcons_preview() {
BitwardenTheme {
BitwardenTextFieldWithTwoIcons(
label = "Label",
value = "",
firstIconResource = IconResource(
iconPainter = painterResource(R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy),
),
onFirstIconClick = {},
secondIconResource = IconResource(
iconPainter = painterResource(R.drawable.ic_generator),
contentDescription = stringResource(id = R.string.generate_password),
),
onSecondIconClick = {},
)
}
}

View file

@ -47,7 +47,7 @@ fun RootNavScreen(
) { ) {
splashDestination() splashDestination()
authGraph(navController) authGraph(navController)
vaultUnlockedGraph() vaultUnlockedGraph(navController)
} }
val targetRoute = when (state) { val targetRoute = when (state) {

View file

@ -6,6 +6,8 @@ import androidx.navigation.NavOptions
import androidx.navigation.navigation import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination
import com.x8bit.bitwarden.ui.vault.feature.vault.navigateToVaultAddItem
import com.x8bit.bitwarden.ui.vault.feature.vault.vaultAddItemDestination
const val VAULT_UNLOCKED_GRAPH_ROUTE: String = "vault_unlocked_graph" const val VAULT_UNLOCKED_GRAPH_ROUTE: String = "vault_unlocked_graph"
@ -19,11 +21,16 @@ fun NavController.navigateToVaultUnlockedGraph(navOptions: NavOptions? = null) {
/** /**
* Add vault unlocked destinations to the root nav graph. * Add vault unlocked destinations to the root nav graph.
*/ */
fun NavGraphBuilder.vaultUnlockedGraph() { fun NavGraphBuilder.vaultUnlockedGraph(
navController: NavController,
) {
navigation( navigation(
startDestination = VAULT_UNLOCKED_NAV_BAR_ROUTE, startDestination = VAULT_UNLOCKED_NAV_BAR_ROUTE,
route = VAULT_UNLOCKED_GRAPH_ROUTE, route = VAULT_UNLOCKED_GRAPH_ROUTE,
) { ) {
vaultUnlockedNavBarDestination() vaultUnlockedNavBarDestination(
onNavigateToVaultAddItem = { navController.navigateToVaultAddItem() },
)
vaultAddItemDestination(onNavigateBack = { navController.popBackStack() })
} }
} }

View file

@ -20,8 +20,10 @@ fun NavController.navigateToVaultUnlockedNavBar(navOptions: NavOptions? = null)
/** /**
* Add vault unlocked destination to the root nav graph. * Add vault unlocked destination to the root nav graph.
*/ */
fun NavGraphBuilder.vaultUnlockedNavBarDestination() { fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToVaultAddItem: () -> Unit,
) {
composable(VAULT_UNLOCKED_NAV_BAR_ROUTE) { composable(VAULT_UNLOCKED_NAV_BAR_ROUTE) {
VaultUnlockedNavBarScreen() VaultUnlockedNavBarScreen(onNavigateToVaultAddItem = onNavigateToVaultAddItem)
} }
} }

View file

@ -43,6 +43,7 @@ import com.x8bit.bitwarden.ui.tools.feature.generator.generatorDestination
import com.x8bit.bitwarden.ui.tools.feature.generator.navigateToGenerator import com.x8bit.bitwarden.ui.tools.feature.generator.navigateToGenerator
import com.x8bit.bitwarden.ui.vault.feature.vault.VAULT_ROUTE import com.x8bit.bitwarden.ui.vault.feature.vault.VAULT_ROUTE
import com.x8bit.bitwarden.ui.vault.feature.vault.navigateToVault import com.x8bit.bitwarden.ui.vault.feature.vault.navigateToVault
import com.x8bit.bitwarden.ui.vault.feature.vault.navigateToVaultAddItem
import com.x8bit.bitwarden.ui.vault.feature.vault.vaultDestination import com.x8bit.bitwarden.ui.vault.feature.vault.vaultDestination
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -53,6 +54,7 @@ import kotlinx.parcelize.Parcelize
fun VaultUnlockedNavBarScreen( fun VaultUnlockedNavBarScreen(
viewModel: VaultUnlockedNavBarViewModel = hiltViewModel(), viewModel: VaultUnlockedNavBarViewModel = hiltViewModel(),
navController: NavHostController = rememberNavController(), navController: NavHostController = rememberNavController(),
onNavigateToVaultAddItem: () -> Unit,
) { ) {
EventsEffect(viewModel = viewModel) { event -> EventsEffect(viewModel = viewModel) { event ->
navController.apply { navController.apply {
@ -78,6 +80,7 @@ fun VaultUnlockedNavBarScreen(
} }
VaultUnlockedNavBarScaffold( VaultUnlockedNavBarScaffold(
navController = navController, navController = navController,
navigateToVaultAddItem = onNavigateToVaultAddItem,
generatorTabClickedAction = { generatorTabClickedAction = {
viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick) viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick)
}, },
@ -104,6 +107,7 @@ private fun VaultUnlockedNavBarScaffold(
sendTabClickedAction: () -> Unit, sendTabClickedAction: () -> Unit,
generatorTabClickedAction: () -> Unit, generatorTabClickedAction: () -> Unit,
settingsTabClickedAction: () -> Unit, settingsTabClickedAction: () -> Unit,
navigateToVaultAddItem: () -> Unit,
) { ) {
// This scaffold will host screens that contain top bars while not hosting one itself. // This scaffold will host screens that contain top bars while not hosting one itself.
// We need to ignore the status bar insets here and let the content screens handle // We need to ignore the status bar insets here and let the content screens handle
@ -181,7 +185,11 @@ private fun VaultUnlockedNavBarScaffold(
popEnterTransition = RootTransitionProviders.Enter.fadeIn, popEnterTransition = RootTransitionProviders.Enter.fadeIn,
popExitTransition = RootTransitionProviders.Exit.fadeOut, popExitTransition = RootTransitionProviders.Exit.fadeOut,
) { ) {
vaultDestination() vaultDestination(
onNavigateToVaultAddItemScreen = {
navigateToVaultAddItem()
},
)
sendDestination() sendDestination()
generatorDestination() generatorDestination()
settingsGraph(navController) settingsGraph(navController)

View file

@ -47,12 +47,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.toDp import com.x8bit.bitwarden.ui.platform.base.util.toDp
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextFieldWithTwoIcons import com.x8bit.bitwarden.ui.platform.components.BitwardenReadOnlyTextFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch
import com.x8bit.bitwarden.ui.platform.components.BitwardenIconButtonWithResource
import com.x8bit.bitwarden.ui.platform.components.model.IconResource import com.x8bit.bitwarden.ui.platform.components.model.IconResource
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_LENGTH_SLIDER_MAX import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorState.MainType.Passcode.PasscodeType.Password.Companion.PASSWORD_LENGTH_SLIDER_MAX
@ -169,10 +171,8 @@ private fun ScrollContent(
Modifier.height(32.dp), Modifier.height(32.dp),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
) { ) {
Text( BitwardenListHeaderText(
text = stringResource(id = R.string.options), label = stringResource(id = R.string.options),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
) )
} }
@ -200,19 +200,25 @@ private fun GeneratedStringItem(
onCopyClick: () -> Unit, onCopyClick: () -> Unit,
onRegenerateClick: () -> Unit, onRegenerateClick: () -> Unit,
) { ) {
BitwardenTextFieldWithTwoIcons( BitwardenReadOnlyTextFieldWithActions(
label = "", label = "",
value = generatedText, value = generatedText,
firstIconResource = IconResource( actions = {
iconPainter = painterResource(R.drawable.ic_copy), BitwardenIconButtonWithResource(
contentDescription = stringResource(id = R.string.copy), iconRes = IconResource(
), iconPainter = painterResource(id = R.drawable.ic_copy),
onFirstIconClick = onCopyClick, contentDescription = stringResource(id = R.string.copy),
secondIconResource = IconResource( ),
iconPainter = painterResource(R.drawable.ic_generator), onClick = onCopyClick,
contentDescription = stringResource(id = R.string.generate_password), )
), BitwardenIconButtonWithResource(
onSecondIconClick = onRegenerateClick, iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_generator),
contentDescription = stringResource(id = R.string.generate_password),
),
onClick = onRegenerateClick,
)
},
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
) )
} }
@ -465,22 +471,28 @@ private fun PasswordMinNumbersCounterItem(
minNumbers: Int, minNumbers: Int,
onPasswordMinNumbersCounterChange: (Int) -> Unit, onPasswordMinNumbersCounterChange: (Int) -> Unit,
) { ) {
BitwardenTextFieldWithTwoIcons( BitwardenReadOnlyTextFieldWithActions(
label = stringResource(id = R.string.min_numbers), label = stringResource(id = R.string.min_numbers),
value = minNumbers.toString(), value = minNumbers.toString(),
firstIconResource = IconResource( actions = {
iconPainter = painterResource(id = R.drawable.ic_minus), BitwardenIconButtonWithResource(
contentDescription = "\u2212", iconRes = IconResource(
), iconPainter = painterResource(id = R.drawable.ic_minus),
onFirstIconClick = { contentDescription = "\u2212",
onPasswordMinNumbersCounterChange(minNumbers - 1) ),
}, onClick = {
secondIconResource = IconResource( onPasswordMinNumbersCounterChange(minNumbers - 1)
iconPainter = painterResource(id = R.drawable.ic_plus), },
contentDescription = "+", )
), BitwardenIconButtonWithResource(
onSecondIconClick = { iconRes = IconResource(
onPasswordMinNumbersCounterChange(minNumbers + 1) iconPainter = painterResource(id = R.drawable.ic_plus),
contentDescription = "+",
),
onClick = {
onPasswordMinNumbersCounterChange(minNumbers + 1)
},
)
}, },
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
) )
@ -491,22 +503,28 @@ private fun PasswordMinSpecialCharactersCounterItem(
minSpecial: Int, minSpecial: Int,
onPasswordMinSpecialCharactersChange: (Int) -> Unit, onPasswordMinSpecialCharactersChange: (Int) -> Unit,
) { ) {
BitwardenTextFieldWithTwoIcons( BitwardenReadOnlyTextFieldWithActions(
label = stringResource(id = R.string.min_special), label = stringResource(id = R.string.min_special),
value = minSpecial.toString(), value = minSpecial.toString(),
firstIconResource = IconResource( actions = {
iconPainter = painterResource(id = R.drawable.ic_minus), BitwardenIconButtonWithResource(
contentDescription = "\u2212", iconRes = IconResource(
), iconPainter = painterResource(id = R.drawable.ic_minus),
onFirstIconClick = { contentDescription = "\u2212",
onPasswordMinSpecialCharactersChange(minSpecial - 1) ),
}, onClick = {
secondIconResource = IconResource( onPasswordMinSpecialCharactersChange(minSpecial - 1)
iconPainter = painterResource(id = R.drawable.ic_plus), },
contentDescription = "+", )
), BitwardenIconButtonWithResource(
onSecondIconClick = { iconRes = IconResource(
onPasswordMinSpecialCharactersChange(minSpecial + 1) iconPainter = painterResource(id = R.drawable.ic_plus),
contentDescription = "+",
),
onClick = {
onPasswordMinSpecialCharactersChange(minSpecial + 1)
},
)
}, },
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
) )
@ -565,22 +583,28 @@ private fun PassphraseNumWordsCounterItem(
numWords: Int, numWords: Int,
onPassphraseNumWordsCounterChange: (Int) -> Unit, onPassphraseNumWordsCounterChange: (Int) -> Unit,
) { ) {
BitwardenTextFieldWithTwoIcons( BitwardenReadOnlyTextFieldWithActions(
label = stringResource(id = R.string.number_of_words), label = stringResource(id = R.string.number_of_words),
value = numWords.toString(), value = numWords.toString(),
firstIconResource = IconResource( actions = {
iconPainter = painterResource(id = R.drawable.ic_minus), BitwardenIconButtonWithResource(
contentDescription = "\u2212", iconRes = IconResource(
), iconPainter = painterResource(id = R.drawable.ic_minus),
onFirstIconClick = { contentDescription = "\u2212",
onPassphraseNumWordsCounterChange(numWords - 1) ),
}, onClick = {
secondIconResource = IconResource( onPassphraseNumWordsCounterChange(numWords - 1)
iconPainter = painterResource(id = R.drawable.ic_plus), },
contentDescription = "+", )
), BitwardenIconButtonWithResource(
onSecondIconClick = { iconRes = IconResource(
onPassphraseNumWordsCounterChange(numWords + 1) iconPainter = painterResource(id = R.drawable.ic_plus),
contentDescription = "+",
),
onClick = {
onPassphraseNumWordsCounterChange(numWords + 1)
},
)
}, },
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
) )

View file

@ -0,0 +1,33 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import com.x8bit.bitwarden.ui.platform.theme.TransitionProviders
private const val ADD_ITEM_ROUTE = "vault_add_item"
/**
* Add the vault add item screen to the nav graph.
*/
fun NavGraphBuilder.vaultAddItemDestination(
onNavigateBack: () -> Unit,
) {
composable(
ADD_ITEM_ROUTE,
enterTransition = TransitionProviders.Enter.slideUp,
exitTransition = TransitionProviders.Exit.slideDown,
popEnterTransition = TransitionProviders.Enter.slideUp,
popExitTransition = TransitionProviders.Exit.slideDown,
) {
VaultAddItemScreen(onNavigateBack)
}
}
/**
* Navigate to the vault add item screen.
*/
fun NavController.navigateToVaultAddItem(navOptions: NavOptions? = null) {
navigate(ADD_ITEM_ROUTE, navOptions)
}

View file

@ -0,0 +1,386 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButtonWithIcon
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledTonalButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenIconButtonWithResource
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitch
import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitchWithActions
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.model.IconResource
/**
* Top level composable for the vault add item screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun VaultAddItemScreen(
onNavigateBack: () -> Unit,
viewModel: VaultAddItemViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val scrollState = rememberScrollState()
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is VaultAddItemEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
VaultAddItemEvent.NavigateBack -> onNavigateBack.invoke()
}
}
val loginItemTypeHandlers = remember(viewModel) {
VaultAddLoginItemTypeHandlers.create(viewModel = viewModel)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.add_item),
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAddItemAction.CloseClick) }
},
scrollBehavior = scrollBehavior,
actions = {
BitwardenTextButton(
label = stringResource(id = R.string.save),
onClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAddItemAction.SaveClick) }
},
)
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.background(MaterialTheme.colorScheme.surface)
.padding(top = 8.dp)
.fillMaxSize()
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
BitwardenListHeaderText(
label = stringResource(id = R.string.item_information),
modifier = Modifier.padding(horizontal = 16.dp),
)
TypeOptionsItem(
selectedType = state.selectedType,
onTypeOptionClicked = remember(viewModel) {
{ typeOption: VaultAddItemState.ItemTypeOption ->
viewModel.trySendAction(VaultAddItemAction.TypeOptionSelect(typeOption))
}
},
modifier = Modifier.padding(horizontal = 16.dp),
)
when (val selectedType = state.selectedType) {
is VaultAddItemState.ItemType.Login -> {
AddLoginTypeItemContent(
state = selectedType,
loginItemTypeHandlers = loginItemTypeHandlers,
)
}
VaultAddItemState.ItemType.Card -> {
// TODO(BIT-507): Create UI for card-type item creation
}
VaultAddItemState.ItemType.Identity -> {
// TODO(BIT-667): Create UI for identity-type item creation
}
VaultAddItemState.ItemType.SecureNotes -> {
// TODO(BIT-666): Create UI for secure notes type item creation
}
}
}
}
}
@Composable
private fun TypeOptionsItem(
selectedType: VaultAddItemState.ItemType,
onTypeOptionClicked: (VaultAddItemState.ItemTypeOption) -> Unit,
modifier: Modifier,
) {
val possibleMainStates = VaultAddItemState.ItemTypeOption.values().toList()
val optionsWithStrings =
possibleMainStates.associateBy({ it }, { stringResource(id = it.labelRes) })
BitwardenMultiSelectButton(
label = stringResource(id = R.string.type),
options = optionsWithStrings.values.toList(),
selectedOption = stringResource(id = selectedType.displayStringResId),
onOptionSelected = { selectedOption ->
val selectedOptionId =
optionsWithStrings.entries.first { it.value == selectedOption }.key
onTypeOptionClicked(selectedOptionId)
},
modifier = modifier,
)
}
@Suppress("LongMethod")
@Composable
private fun AddLoginTypeItemContent(
state: VaultAddItemState.ItemType.Login,
loginItemTypeHandlers: VaultAddLoginItemTypeHandlers,
) {
BitwardenTextField(
label = stringResource(id = R.string.name),
value = state.name,
onValueChange = loginItemTypeHandlers.onNameTextChange,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
BitwardenTextFieldWithActions(
label = stringResource(id = R.string.username),
value = state.username,
onValueChange = loginItemTypeHandlers.onUsernameTextChange,
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_generator),
contentDescription = stringResource(id = R.string.generate_username),
),
onClick = loginItemTypeHandlers.onOpenUsernameGeneratorClick,
)
},
modifier = Modifier.padding(horizontal = 16.dp),
)
BitwardenPasswordFieldWithActions(
label = stringResource(id = R.string.password),
value = state.password,
onValueChange = loginItemTypeHandlers.onPasswordTextChange,
modifier = Modifier
.padding(horizontal = 16.dp),
) {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_check_mark),
contentDescription = stringResource(id = R.string.check_password),
),
onClick = loginItemTypeHandlers.onPasswordCheckerClick,
)
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_generator),
contentDescription = stringResource(id = R.string.generate_password),
),
onClick = loginItemTypeHandlers.onOpenPasswordGeneratorClick,
)
}
BitwardenListHeaderText(
label = stringResource(id = R.string.authenticator_key),
modifier = Modifier.padding(
top = 8.dp,
start = 16.dp,
end = 16.dp,
),
)
BitwardenFilledButtonWithIcon(
label = stringResource(id = R.string.setup_totp),
icon = painterResource(id = R.drawable.ic_light_bulb),
onClick = loginItemTypeHandlers.onSetupTotpClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
BitwardenListHeaderText(
label = stringResource(id = R.string.ur_is),
modifier = Modifier.padding(
top = 8.dp,
start = 16.dp,
end = 16.dp,
),
)
BitwardenTextFieldWithActions(
label = stringResource(id = R.string.uri),
value = state.uri,
onValueChange = loginItemTypeHandlers.onUriTextChange,
actions = {
BitwardenIconButtonWithResource(
iconRes = IconResource(
iconPainter = painterResource(id = R.drawable.ic_settings),
contentDescription = stringResource(id = R.string.options),
),
onClick = loginItemTypeHandlers.onUriSettingsClick,
)
},
modifier = Modifier.padding(horizontal = 16.dp),
)
BitwardenFilledTonalButton(
label = stringResource(id = R.string.new_uri),
onClick = loginItemTypeHandlers.onAddNewUriClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
BitwardenListHeaderText(
label = stringResource(id = R.string.miscellaneous),
modifier = Modifier.padding(
top = 8.dp,
start = 16.dp,
end = 16.dp,
),
)
BitwardenMultiSelectButton(
label = stringResource(id = R.string.folder),
options = state.availableFolders,
selectedOption = state.folder,
onOptionSelected = loginItemTypeHandlers.onFolderTextChange,
modifier = Modifier.padding(horizontal = 16.dp),
)
BitwardenSwitch(
label = stringResource(
id = R.string.favorite,
),
isChecked = state.favorite,
onCheckedChange = loginItemTypeHandlers.onToggleFavorite,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
BitwardenSwitchWithActions(
label = stringResource(id = R.string.password_prompt),
isChecked = state.masterPasswordReprompt,
onCheckedChange = loginItemTypeHandlers.onToggleMasterPasswordReprompt,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
actions = {
IconButton(onClick = loginItemTypeHandlers.onTooltipClick) {
Icon(
painter = painterResource(id = R.drawable.ic_tooltip),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = stringResource(
id = R.string.master_password_re_prompt_help,
),
)
}
},
)
BitwardenListHeaderText(
label = stringResource(id = R.string.notes),
modifier = Modifier.padding(
top = 8.dp,
start = 16.dp,
end = 16.dp,
),
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
BitwardenTextField(
label = stringResource(id = R.string.notes),
value = state.notes,
onValueChange = loginItemTypeHandlers.onNotesTextChange,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
BitwardenListHeaderText(
label = stringResource(id = R.string.custom_fields),
modifier = Modifier.padding(
top = 8.dp,
start = 16.dp,
end = 16.dp,
),
)
BitwardenFilledTonalButton(
label = stringResource(id = R.string.new_custom_field),
onClick = loginItemTypeHandlers.onAddNewCustomFieldClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
BitwardenListHeaderText(
label = stringResource(id = R.string.ownership),
modifier = Modifier.padding(
top = 8.dp,
start = 16.dp,
end = 16.dp,
),
)
BitwardenMultiSelectButton(
label = stringResource(id = R.string.who_owns_this_item),
options = state.availableOwners,
selectedOption = state.ownership,
onOptionSelected = loginItemTypeHandlers.onOwnershipTextChange,
modifier = Modifier.padding(
bottom = 30.dp,
start = 16.dp,
end = 16.dp,
),
)
}

View file

@ -0,0 +1,672 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultAddItemState.ItemType.Card.displayStringResId
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultAddItemState.ItemType.Identity.displayStringResId
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultAddItemState.ItemType.SecureNotes.displayStringResId
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* ViewModel responsible for handling user interactions in the vault add item screen
*
* This ViewModel processes UI actions, manages the state of the generator screen,
* and provides data for the UI to render. It extends a `BaseViewModel` and works
* with a `SavedStateHandle` for state restoration.
*
* @property savedStateHandle Handles the saved state of this ViewModel.
*/
@HiltViewModel
@Suppress("TooManyFunctions")
class VaultAddItemViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : BaseViewModel<VaultAddItemState, VaultAddItemEvent, VaultAddItemAction>(
initialState = savedStateHandle[KEY_STATE] ?: INITIAL_STATE,
) {
//region Initialization and Overrides
init {
stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope)
}
override fun handleAction(action: VaultAddItemAction) {
when (action) {
is VaultAddItemAction.SaveClick -> {
handleSaveClick()
}
is VaultAddItemAction.CloseClick -> {
handleCloseClick()
}
is VaultAddItemAction.TypeOptionSelect -> {
handleTypeOptionSelect(action)
}
is VaultAddItemAction.ItemType.LoginType -> {
handleAddLoginTypeAction(action)
}
}
}
//endregion Initialization and Overrides
//region Top Level Handlers
private fun handleSaveClick() {
viewModelScope.launch {
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "Save Item",
),
)
}
}
private fun handleCloseClick() {
sendEvent(
event = VaultAddItemEvent.NavigateBack,
)
}
//endregion Top Level Handlers
//region Type Option Handlers
private fun handleTypeOptionSelect(action: VaultAddItemAction.TypeOptionSelect) {
when (action.typeOption) {
VaultAddItemState.ItemTypeOption.LOGIN -> handleSwitchToAddLoginItem()
VaultAddItemState.ItemTypeOption.CARD -> handleSwitchToAddCardItem()
VaultAddItemState.ItemTypeOption.IDENTITY -> handleSwitchToAddIdentityItem()
VaultAddItemState.ItemTypeOption.SECURE_NOTES -> handleSwitchToAddSecureNotesItem()
}
}
private fun handleSwitchToAddLoginItem() {
mutableStateFlow.update { currentState ->
currentState.copy(
selectedType = VaultAddItemState.ItemType.Login(),
)
}
}
//endregion Type Option Handlers
//region Add Login Item Type Handlers
private fun handleAddLoginTypeAction(
action: VaultAddItemAction.ItemType.LoginType,
) {
when (action) {
is VaultAddItemAction.ItemType.LoginType.NameTextChange -> {
handleNameTextInputChange(action)
}
is VaultAddItemAction.ItemType.LoginType.UsernameTextChange -> {
handleUsernameTextInputChange(action)
}
is VaultAddItemAction.ItemType.LoginType.PasswordTextChange -> {
handlePasswordTextInputChange(action)
}
is VaultAddItemAction.ItemType.LoginType.UriTextChange -> {
handleURITextInputChange(action)
}
is VaultAddItemAction.ItemType.LoginType.FolderChange -> {
handleFolderTextInputChange(action)
}
is VaultAddItemAction.ItemType.LoginType.ToggleFavorite -> {
handleToggleFavorite(action)
}
is VaultAddItemAction.ItemType.LoginType.ToggleMasterPasswordReprompt -> {
handleToggleMasterPasswordReprompt(action)
}
is VaultAddItemAction.ItemType.LoginType.NotesTextChange -> {
handleNotesTextInputChange(action)
}
is VaultAddItemAction.ItemType.LoginType.OwnershipChange -> {
handleOwnershipTextInputChange(action)
}
is VaultAddItemAction.ItemType.LoginType.OpenUsernameGeneratorClick -> {
handleOpenUsernameGeneratorClick()
}
is VaultAddItemAction.ItemType.LoginType.PasswordCheckerClick -> {
handlePasswordCheckerClick()
}
is VaultAddItemAction.ItemType.LoginType.OpenPasswordGeneratorClick -> {
handleOpenPasswordGeneratorClick()
}
is VaultAddItemAction.ItemType.LoginType.SetupTotpClick -> {
handleSetupTotpClick()
}
is VaultAddItemAction.ItemType.LoginType.UriSettingsClick -> {
handleUriSettingsClick()
}
is VaultAddItemAction.ItemType.LoginType.AddNewUriClick -> {
handleAddNewUriClick()
}
is VaultAddItemAction.ItemType.LoginType.TooltipClick -> {
handleTooltipClick()
}
is VaultAddItemAction.ItemType.LoginType.AddNewCustomFieldClick -> {
handleAddNewCustomFieldClick()
}
}
}
private fun handleSwitchToAddCardItem() {
mutableStateFlow.update { currentState ->
currentState.copy(
selectedType = VaultAddItemState.ItemType.Card,
)
}
}
private fun handleSwitchToAddIdentityItem() {
mutableStateFlow.update { currentState ->
currentState.copy(
selectedType = VaultAddItemState.ItemType.Identity,
)
}
}
private fun handleSwitchToAddSecureNotesItem() {
mutableStateFlow.update { currentState ->
currentState.copy(
selectedType = VaultAddItemState.ItemType.SecureNotes,
)
}
}
private fun handleNameTextInputChange(
action: VaultAddItemAction.ItemType.LoginType.NameTextChange,
) {
updateLoginType { loginType ->
loginType.copy(name = action.name)
}
}
private fun handleUsernameTextInputChange(
action: VaultAddItemAction.ItemType.LoginType.UsernameTextChange,
) {
updateLoginType { loginType ->
loginType.copy(username = action.username)
}
}
private fun handlePasswordTextInputChange(
action: VaultAddItemAction.ItemType.LoginType.PasswordTextChange,
) {
updateLoginType { loginType ->
loginType.copy(password = action.password)
}
}
private fun handleURITextInputChange(
action: VaultAddItemAction.ItemType.LoginType.UriTextChange,
) {
updateLoginType { loginType ->
loginType.copy(uri = action.uri)
}
}
private fun handleFolderTextInputChange(
action: VaultAddItemAction.ItemType.LoginType.FolderChange,
) {
updateLoginType { loginType ->
loginType.copy(folder = action.folder)
}
}
private fun handleToggleFavorite(
action: VaultAddItemAction.ItemType.LoginType.ToggleFavorite,
) {
updateLoginType { loginType ->
loginType.copy(favorite = action.isFavorite)
}
}
private fun handleToggleMasterPasswordReprompt(
action: VaultAddItemAction.ItemType.LoginType.ToggleMasterPasswordReprompt,
) {
updateLoginType { loginType ->
loginType.copy(masterPasswordReprompt = action.isMasterPasswordReprompt)
}
}
private fun handleNotesTextInputChange(
action: VaultAddItemAction.ItemType.LoginType.NotesTextChange,
) {
updateLoginType { loginType ->
loginType.copy(notes = action.notes)
}
}
private fun handleOwnershipTextInputChange(
action: VaultAddItemAction.ItemType.LoginType.OwnershipChange,
) {
updateLoginType { loginType ->
loginType.copy(ownership = action.ownership)
}
}
private fun handleOpenUsernameGeneratorClick() {
viewModelScope.launch {
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "Open Username Generator",
),
)
}
}
private fun handlePasswordCheckerClick() {
viewModelScope.launch {
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "Password Checker",
),
)
}
}
private fun handleOpenPasswordGeneratorClick() {
viewModelScope.launch {
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "Open Password Generator",
),
)
}
}
private fun handleSetupTotpClick() {
viewModelScope.launch {
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "Setup TOTP",
),
)
}
}
private fun handleUriSettingsClick() {
viewModelScope.launch {
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "URI Settings",
),
)
}
}
private fun handleAddNewUriClick() {
viewModelScope.launch {
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "Add New URI",
),
)
}
}
private fun handleTooltipClick() {
viewModelScope.launch {
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "Tooltip",
),
)
}
}
private fun handleAddNewCustomFieldClick() {
viewModelScope.launch {
sendEvent(
event = VaultAddItemEvent.ShowToast(
message = "Add New Custom Field",
),
)
}
}
//endregion Add Login Item Type Handlers
//region Utility Functions
private inline fun updateLoginType(
crossinline block: (VaultAddItemState.ItemType.Login) -> VaultAddItemState.ItemType.Login,
) {
mutableStateFlow.update { currentState ->
val currentSelectedType = currentState.selectedType
if (currentSelectedType !is VaultAddItemState.ItemType.Login) return@update currentState
val updatedLogin = block(currentSelectedType)
currentState.copy(selectedType = updatedLogin)
}
}
//endregion Utility Functions
companion object {
val INITIAL_STATE: VaultAddItemState = VaultAddItemState(
selectedType = VaultAddItemState.ItemType.Login(),
)
}
}
/**
* Represents the state for adding an item to the vault.
*
* @property selectedType The type of the item (e.g., Card, Identity, SecureNotes)
* that has been selected to be added to the vault.
*/
@Parcelize
data class VaultAddItemState(
val selectedType: ItemType,
) : Parcelable {
/**
* Provides a list of available item types for the vault.
*/
val typeOptions: List<ItemTypeOption>
get() = ItemTypeOption.values().toList()
/**
* Enum representing the main type options for the vault, such as LOGIN, CARD, etc.
*
* @property labelRes The resource ID of the string that represents the label of each type.
*/
enum class ItemTypeOption(val labelRes: Int) {
LOGIN(R.string.type_login),
CARD(R.string.type_card),
IDENTITY(R.string.type_identity),
SECURE_NOTES(R.string.type_secure_note),
}
/**
* A sealed class representing the item types that can be selected in the vault,
* encapsulating the different configurations and properties each item type has.
*/
@Parcelize
sealed class ItemType : Parcelable {
/**
* Represents the resource ID for the display string. This is an abstract property
* that must be overridden by each subclass to provide the appropriate string resource ID
* for display purposes.
*/
abstract val displayStringResId: Int
/**
* Represents the login item information.
*
* @property name The name associated with the login item.
* @property username The username required for the login item.
* @property password The password required for the login item.
* @property uri The URI associated with the login item.
* @property folder The folder used for the login item
* @property favorite Indicates whether this login item is marked as a favorite.
* @property masterPasswordReprompt Indicates if a master password reprompt is required.
* @property notes Any additional notes or comments associated with the login item.
* @property ownership The ownership email associated with the login item.
*/
@Parcelize
data class Login(
val name: String = "",
val username: String = "",
val password: String = "",
val uri: String = "",
val folder: String = DEFAULT_FOLDER,
val favorite: Boolean = false,
val masterPasswordReprompt: Boolean = false,
val notes: String = "",
val ownership: String = DEFAULT_OWNERSHIP,
) : ItemType() {
override val displayStringResId: Int
get() = ItemTypeOption.LOGIN.labelRes
/**
* Retrieves a list of available folders.
*
* TODO(BIT-501): Update this property to pull available folders from the data layer.
* Currently, it returns a hardcoded list of folders.
*/
val availableFolders: List<String>
get() = listOf("Folder 1", "Folder 2", "Folder 3")
/**
* Retrieves a list of available owners.
*
* TODO(BIT-501): Update this property to pull available owners from the data layer.
* Currently, it returns a hardcoded list of email addresses.
*/
val availableOwners: List<String>
get() = listOf("a@b.com", "c@d.com")
companion object {
private const val DEFAULT_FOLDER: String = "No Folder"
private const val DEFAULT_OWNERSHIP: String = "placeholder@email.com"
}
}
/**
* Represents the `Card` item type.
*
* @property displayStringResId Resource ID for the display string of the card type.
*/
@Parcelize
data object Card : ItemType() {
override val displayStringResId: Int
get() = ItemTypeOption.CARD.labelRes
}
/**
* Represents the `Identity` item type.
*
* @property displayStringResId Resource ID for the display string of the identity type.
*/
@Parcelize
data object Identity : ItemType() {
override val displayStringResId: Int
get() = ItemTypeOption.IDENTITY.labelRes
}
/**
* Represents the `SecureNotes` item type.
*
* @property displayStringResId Resource ID for the display string of the secure notes type.
*/
@Parcelize
data object SecureNotes : ItemType() {
override val displayStringResId: Int
get() = ItemTypeOption.SECURE_NOTES.labelRes
}
}
}
/**
* Represents a set of events that can be emitted during the process of adding an item to the vault.
* Each subclass of this sealed class denotes a distinct event that can occur.
*/
sealed class VaultAddItemEvent {
/**
* Shows a toast with the given [message].
*/
data class ShowToast(val message: String) : VaultAddItemEvent()
/**
* Navigate back to previous screen.
*/
data object NavigateBack : VaultAddItemEvent()
}
/**
* Represents a set of actions related to the process of adding an item to the vault.
* Each subclass of this sealed class denotes a distinct action that can be taken.
*/
sealed class VaultAddItemAction {
/**
* Represents the action when the save button is clicked.
*/
data object SaveClick : VaultAddItemAction()
/**
* User clicked close.
*/
data object CloseClick : VaultAddItemAction()
/**
* Represents the action when a type option is selected.
*
* @property typeOption The selected type option.
*/
data class TypeOptionSelect(
val typeOption: VaultAddItemState.ItemTypeOption,
) : VaultAddItemAction()
/**
* Represents actions specific to the item types.
*/
sealed class ItemType : VaultAddItemAction() {
/**
* Represents actions specific to the Login type.
*/
sealed class LoginType : ItemType() {
/**
* Fired when the name text input is changed.
*
* @property name The new name text.
*/
data class NameTextChange(val name: String) : LoginType()
/**
* Fired when the username text input is changed.
*
* @property username The new username text.
*/
data class UsernameTextChange(val username: String) : LoginType()
/**
* Fired when the password text input is changed.
*
* @property password The new password text.
*/
data class PasswordTextChange(val password: String) : LoginType()
/**
* Fired when the URI text input is changed.
*
* @property uri The new URI text.
*/
data class UriTextChange(val uri: String) : LoginType()
/**
* Fired when the folder text input is changed.
*
* @property folder The new folder text.
*/
data class FolderChange(val folder: String) : LoginType()
/**
* Fired when the Favorite toggle is changed.
*
* @property isFavorite The new state of the Favorite toggle.
*/
data class ToggleFavorite(val isFavorite: Boolean) : LoginType()
/**
* Fired when the Master Password Reprompt toggle is changed.
*
* @property isMasterPasswordReprompt The new state of the Master
* Password Re-prompt toggle.
*/
data class ToggleMasterPasswordReprompt(
val isMasterPasswordReprompt: Boolean,
) : LoginType()
/**
* Fired when the notes text input is changed.
*
* @property notes The new notes text.
*/
data class NotesTextChange(val notes: String) : LoginType()
/**
* Fired when the ownership text input is changed.
*
* @property ownership The new ownership text.
*/
data class OwnershipChange(val ownership: String) : LoginType()
/**
* Represents the action to open the username generator.
*/
data object OpenUsernameGeneratorClick : LoginType()
/**
* Represents the action to check the password's strength or integrity.
*/
data object PasswordCheckerClick : LoginType()
/**
* Represents the action to open the password generator.
*/
data object OpenPasswordGeneratorClick : LoginType()
/**
* Represents the action to set up TOTP.
*/
data object SetupTotpClick : LoginType()
/**
* Represents the action of clicking TOTP settings
*/
data object UriSettingsClick : LoginType()
/**
* Represents the action to add a new URI field.
*/
data object AddNewUriClick : LoginType()
/**
* Represents the action to open tooltip
*/
data object TooltipClick : LoginType()
/**
* Represents the action to add a new custom field.
*/
data object AddNewCustomFieldClick : LoginType()
}
}
}

View file

@ -0,0 +1,144 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
/**
* A collection of handler functions specifically tailored for managing actions
* within the context of adding login items to a vault.
*
* @property onNameTextChange Handles the action when the name text is changed.
* @property onUsernameTextChange Handles the action when the username text is changed.
* @property onPasswordTextChange Handles the action when the password text is changed.
* @property onUriTextChange Handles the action when the URI text is changed.
* @property onFolderTextChange Handles the action when the folder text is changed.
* @property onToggleFavorite Handles the action when the favorite toggle is changed.
* @property onToggleMasterPasswordReprompt Handles the action when the master password
* reprompt toggle is changed.
* @property onNotesTextChange Handles the action when the notes text is changed.
* @property onOwnershipTextChange Handles the action when the ownership text is changed.
* @property onOpenUsernameGeneratorClick Handles the action when the username generator
* button is clicked.
* @property onPasswordCheckerClick Handles the action when the password checker
* button is clicked.
* @property onOpenPasswordGeneratorClick Handles the action when the password generator
* button is clicked.
* @property onSetupTotpClick Handles the action when the setup TOTP button is clicked.
* @property onUriSettingsClick Handles the action when the URI settings button is clicked.
* @property onAddNewUriClick Handles the action when the add new URI button is clicked.
* @property onTooltipClick Handles the action when the tooltip button is clicked.
* @property onAddNewCustomFieldClick Handles the action when the add new custom field
* button is clicked.
*/
@Suppress("LongParameterList")
class VaultAddLoginItemTypeHandlers(
val onNameTextChange: (String) -> Unit,
val onUsernameTextChange: (String) -> Unit,
val onPasswordTextChange: (String) -> Unit,
val onUriTextChange: (String) -> Unit,
val onFolderTextChange: (String) -> Unit,
val onToggleFavorite: (Boolean) -> Unit,
val onToggleMasterPasswordReprompt: (Boolean) -> Unit,
val onNotesTextChange: (String) -> Unit,
val onOwnershipTextChange: (String) -> Unit,
val onOpenUsernameGeneratorClick: () -> Unit,
val onPasswordCheckerClick: () -> Unit,
val onOpenPasswordGeneratorClick: () -> Unit,
val onSetupTotpClick: () -> Unit,
val onUriSettingsClick: () -> Unit,
val onAddNewUriClick: () -> Unit,
val onTooltipClick: () -> Unit,
val onAddNewCustomFieldClick: () -> Unit,
) {
companion object {
/**
* Creates an instance of [VaultAddLoginItemTypeHandlers] by binding actions
* to the provided [VaultAddItemViewModel].
*
* @param viewModel The [VaultAddItemViewModel] to which actions will be sent.
* @return A fully initialized [VaultAddLoginItemTypeHandlers] object.
*/
@Suppress("LongMethod")
fun create(viewModel: VaultAddItemViewModel): VaultAddLoginItemTypeHandlers {
return VaultAddLoginItemTypeHandlers(
onNameTextChange = { newName ->
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.NameTextChange(newName),
)
},
onUsernameTextChange = { newUsername ->
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.UsernameTextChange(newUsername),
)
},
onPasswordTextChange = { newPassword ->
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.PasswordTextChange(newPassword),
)
},
onUriTextChange = { newUri ->
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.UriTextChange(newUri),
)
},
onFolderTextChange = { newFolder ->
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.FolderChange(newFolder),
)
},
onToggleFavorite = { isFavorite ->
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.ToggleFavorite(isFavorite),
)
},
onToggleMasterPasswordReprompt = { isMasterPasswordReprompt ->
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.ToggleMasterPasswordReprompt(
isMasterPasswordReprompt,
),
)
},
onNotesTextChange = { newNotes ->
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.NotesTextChange(newNotes),
)
},
onOwnershipTextChange = { newOwnership ->
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.OwnershipChange(newOwnership),
)
},
onOpenUsernameGeneratorClick = {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.OpenUsernameGeneratorClick,
)
},
onPasswordCheckerClick = {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.PasswordCheckerClick,
)
},
onOpenPasswordGeneratorClick = {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.OpenPasswordGeneratorClick,
)
},
onSetupTotpClick = {
viewModel.trySendAction(VaultAddItemAction.ItemType.LoginType.SetupTotpClick)
},
onUriSettingsClick = {
viewModel.trySendAction(VaultAddItemAction.ItemType.LoginType.UriSettingsClick)
},
onAddNewUriClick = {
viewModel.trySendAction(VaultAddItemAction.ItemType.LoginType.AddNewUriClick)
},
onTooltipClick = {
viewModel.trySendAction(VaultAddItemAction.ItemType.LoginType.TooltipClick)
},
onAddNewCustomFieldClick = {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.AddNewCustomFieldClick,
)
},
)
}
}
}

View file

@ -10,9 +10,13 @@ const val VAULT_ROUTE: String = "vault"
/** /**
* Add vault destination to the nav graph. * Add vault destination to the nav graph.
*/ */
fun NavGraphBuilder.vaultDestination() { fun NavGraphBuilder.vaultDestination(
onNavigateToVaultAddItemScreen: () -> Unit,
) {
composable(VAULT_ROUTE) { composable(VAULT_ROUTE) {
VaultScreen() VaultScreen(
onNavigateToVaultAddItemScreen = onNavigateToVaultAddItemScreen,
)
} }
} }

View file

@ -37,15 +37,12 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem
@Composable @Composable
fun VaultScreen( fun VaultScreen(
viewModel: VaultViewModel = hiltViewModel(), viewModel: VaultViewModel = hiltViewModel(),
onNavigateToVaultAddItemScreen: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event -> EventsEffect(viewModel = viewModel) { event ->
when (event) { when (event) {
VaultEvent.NavigateToAddItemScreen -> { VaultEvent.NavigateToAddItemScreen -> onNavigateToVaultAddItemScreen()
// TODO Create add item screen and navigation implementation BIT-207
Toast.makeText(context, "Navigate to the add item screen.", Toast.LENGTH_SHORT)
.show()
}
VaultEvent.NavigateToVaultSearchScreen -> { VaultEvent.NavigateToVaultSearchScreen -> {
// TODO Create vault search screen and navigation implementation BIT-213 // TODO Create vault search screen and navigation implementation BIT-213

View file

@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M2,2h20v20h-20z"/>
<path
android:pathData="M11.871,22C9.918,22 8.01,21.414 6.387,20.315C4.764,19.216 3.498,17.654 2.751,15.827C2.004,13.999 1.809,11.989 2.19,10.049C2.571,8.11 3.511,6.327 4.891,4.929C6.272,3.53 8.031,2.579 9.945,2.192C11.859,1.806 14.104,2.005 15.907,2.761C17.711,3.518 19.252,4.8 20.337,6.444C21.422,8.089 22,10.022 22,12C21.997,14.651 20.956,17.193 19.105,19.067C17.255,20.942 14.488,21.997 11.871,22ZM11.871,3.25C10.162,3.25 8.492,3.763 7.072,4.724C5.652,5.685 4.545,7.052 3.891,8.651C3.237,10.25 3.067,12.009 3.4,13.707C3.733,15.404 4.556,16.963 5.763,18.187C6.971,19.411 8.51,20.244 10.185,20.581C11.861,20.919 13.856,20.746 15.434,20.083C17.012,19.421 18.361,18.299 19.31,16.861C20.259,15.422 20.766,13.73 20.766,11.999C20.763,9.679 19.852,7.455 18.233,5.815C16.614,4.174 14.16,3.252 11.871,3.25Z"
android:fillColor="#151B2C"/>
<path
android:pathData="M16.757,7.604C17.03,7.815 17.081,8.208 16.87,8.481L11.283,15.725C10.825,16.318 9.952,16.378 9.417,15.853L7.187,13.663C6.941,13.421 6.937,13.025 7.179,12.779C7.421,12.533 7.817,12.529 8.063,12.771L10.293,14.961L15.88,7.718C16.091,7.444 16.483,7.394 16.757,7.604Z"
android:fillColor="#151B2C"
android:fillType="evenOdd"/>
</group>
</vector>

View file

@ -0,0 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M2,2h20v20h-20z"/>
<path
android:pathData="M12,20.75C16.833,20.75 20.75,16.833 20.75,12C20.75,7.168 16.833,3.25 12,3.25C7.168,3.25 3.25,7.168 3.25,12C3.25,16.833 7.168,20.75 12,20.75ZM12,22C17.523,22 22,17.523 22,12C22,6.477 17.523,2 12,2C6.477,2 2,6.477 2,12C2,17.523 6.477,22 12,22Z"
android:fillColor="#1B1B1F"
android:fillType="evenOdd"/>
<path
android:pathData="M9.703,7.714C9.214,8.164 8.875,8.867 8.875,9.891C8.875,10.236 8.595,10.516 8.25,10.516C7.905,10.516 7.625,10.236 7.625,9.891C7.625,8.571 8.073,7.516 8.856,6.794C9.631,6.081 10.673,5.75 11.766,5.75C12.859,5.75 13.9,6.081 14.675,6.794C15.458,7.516 15.906,8.571 15.906,9.891C15.906,10.555 15.576,11.093 15.2,11.517C14.847,11.915 14.392,12.282 13.983,12.611C13.96,12.629 13.938,12.647 13.916,12.665C13.468,13.026 13.077,13.348 12.793,13.686C12.514,14.016 12.391,14.3 12.391,14.578V15.75C12.391,16.095 12.111,16.375 11.766,16.375C11.42,16.375 11.141,16.095 11.141,15.75V14.578C11.141,13.892 11.457,13.331 11.837,12.88C12.212,12.435 12.7,12.04 13.131,11.692C13.141,11.684 13.152,11.676 13.162,11.667C13.602,11.313 13.983,11.005 14.264,10.688C14.547,10.368 14.656,10.116 14.656,9.891C14.656,8.867 14.317,8.164 13.828,7.714C13.33,7.255 12.614,7 11.766,7C10.917,7 10.201,7.255 9.703,7.714Z"
android:fillColor="#1B1B1F"
android:fillType="evenOdd"/>
<path
android:pathData="M12.625,18.406C12.625,18.838 12.275,19.188 11.844,19.188C11.412,19.188 11.063,18.838 11.063,18.406C11.063,17.975 11.412,17.625 11.844,17.625C12.275,17.625 12.625,17.975 12.625,18.406Z"
android:fillColor="#1B1B1F"/>
</group>
</vector>

View file

@ -32,6 +32,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
viewModel = viewModel, viewModel = viewModel,
navController = fakeNavHostController, navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
) )
} }
onNodeWithText("My vault").performClick() onNodeWithText("My vault").performClick()
@ -52,6 +53,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
viewModel = viewModel, viewModel = viewModel,
navController = fakeNavHostController, navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
) )
} }
runOnIdle { fakeNavHostController.assertCurrentRoute("vault") } runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }
@ -73,6 +75,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
viewModel = viewModel, viewModel = viewModel,
navController = fakeNavHostController, navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
) )
} }
onNodeWithText("Send").performClick() onNodeWithText("Send").performClick()
@ -93,6 +96,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
viewModel = viewModel, viewModel = viewModel,
navController = fakeNavHostController, navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
) )
} }
runOnIdle { fakeNavHostController.assertCurrentRoute("vault") } runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }
@ -114,6 +118,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
viewModel = viewModel, viewModel = viewModel,
navController = fakeNavHostController, navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
) )
} }
onNodeWithText("Generator").performClick() onNodeWithText("Generator").performClick()
@ -134,6 +139,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
viewModel = viewModel, viewModel = viewModel,
navController = fakeNavHostController, navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
) )
} }
runOnIdle { fakeNavHostController.assertCurrentRoute("vault") } runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }
@ -155,6 +161,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
viewModel = viewModel, viewModel = viewModel,
navController = fakeNavHostController, navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
) )
} }
onNodeWithText("Settings").performClick() onNodeWithText("Settings").performClick()
@ -175,6 +182,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
VaultUnlockedNavBarScreen( VaultUnlockedNavBarScreen(
viewModel = viewModel, viewModel = viewModel,
navController = fakeNavHostController, navController = fakeNavHostController,
onNavigateToVaultAddItem = {},
) )
} }
runOnIdle { fakeNavHostController.assertCurrentRoute("vault") } runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }

View file

@ -0,0 +1,642 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.click
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onSiblings
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTouchInput
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.update
import org.junit.Test
class VaultAddItemScreenTest : BaseComposeTest() {
private val mutableStateFlow = MutableStateFlow(
VaultAddItemState(
selectedType = VaultAddItemState.ItemType.Login(),
),
)
private val viewModel = mockk<VaultAddItemViewModel>(relaxed = true) {
every { eventFlow } returns emptyFlow()
every { stateFlow } returns mutableStateFlow
}
@Test
fun `clicking close button should send CloseClick action`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithContentDescription(label = "Close")
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.CloseClick,
)
}
}
@Test
fun `clicking save button should send SaveClick action`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText(text = "Save")
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.SaveClick,
)
}
}
@Test
fun `clicking a Type Option should send TypeOptionSelect action`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
// Opens the menu
composeTestRule
.onNodeWithContentDescription(label = "Type, Login")
.performClick()
// Choose the option from the menu
composeTestRule
.onAllNodesWithText(text = "Login")
.onLast()
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.TypeOptionSelect(VaultAddItemState.ItemTypeOption.LOGIN),
)
}
}
@Test
fun `the Type Option field should display the text of the selected item type`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithContentDescription(label = "Type, Login")
.assertIsDisplayed()
mutableStateFlow.update { it.copy(selectedType = VaultAddItemState.ItemType.Card) }
composeTestRule
.onNodeWithContentDescription(label = "Type, Card")
.assertIsDisplayed()
}
@Test
fun `in ItemType_Login state changing Name text field should trigger NameTextChange`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText(text = "Name")
.performTextInput(text = "TestName")
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.NameTextChange(name = "TestName"),
)
}
}
@Test
fun `in ItemType_Login the name control should display the text provided by the state`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText(text = "Name")
.assertTextContains("")
mutableStateFlow.update { currentState ->
updateLoginType(currentState) { copy(name = "NewName") }
}
composeTestRule
.onNodeWithText(text = "Name")
.assertTextContains("NewName")
}
@Test
fun `in ItemType_Login state changing Username text field should trigger UsernameTextChange`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText(text = "Username")
.performTextInput(text = "TestUsername")
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.UsernameTextChange(username = "TestUsername"),
)
}
}
@Test
fun `in ItemType_Login the Username control should display the text provided by the state`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText(text = "Username")
.assertTextContains("")
mutableStateFlow.update { currentState ->
updateLoginType(currentState) { copy(username = "NewUsername") }
}
composeTestRule
.onNodeWithText(text = "Username")
.assertTextContains("NewUsername")
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Login state clicking Username generator action should trigger OpenUsernameGeneratorClick`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithContentDescription(label = "Generate username")
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.OpenUsernameGeneratorClick,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Login state clicking Password checker action should trigger PasswordCheckerClick`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText(text = "Password")
.onSiblings()
.onFirst()
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(VaultAddItemAction.ItemType.LoginType.PasswordCheckerClick)
}
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Login state click Password text field generator action should trigger OpenPasswordGeneratorClick`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText(text = "Password")
.onSiblings()
.onLast()
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.OpenPasswordGeneratorClick,
)
}
}
@Test
fun `in ItemType_Login state changing Password text field should trigger PasswordTextChange`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText(text = "Password")
.performTextInput(text = "TestPassword")
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.PasswordTextChange("TestPassword"),
)
}
}
@Test
fun `in ItemType_Login the Password control should display the text provided by the state`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText(text = "Password")
.assertTextContains("")
mutableStateFlow.update { currentState ->
updateLoginType(currentState) { copy(password = "NewPassword") }
}
composeTestRule
.onNodeWithText(text = "Password")
.assertTextContains("•••••••••••")
}
@Test
fun `in ItemType_Login state clicking Set up TOTP button should trigger SetupTotpClick`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText(text = "Set up TOTP")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.SetupTotpClick,
)
}
}
@Test
fun `in ItemType_Login state changing URI text field should trigger UriTextChange`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText("URI")
.performScrollTo()
.performTextInput("TestURI")
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.UriTextChange("TestURI"),
)
}
}
@Test
fun `in ItemType_Login the URI control should display the text provided by the state`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText(text = "URI")
.assertTextContains("")
mutableStateFlow.update { currentState ->
updateLoginType(currentState) { copy(uri = "NewURI") }
}
composeTestRule
.onNodeWithText(text = "URI")
.assertTextContains("NewURI")
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Login state clicking the URI settings action should trigger UriSettingsClick`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText(text = "URI")
.onSiblings()
.filterToOne(hasContentDescription(value = "Options"))
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.UriSettingsClick,
)
}
}
@Test
fun `in ItemType_Login state clicking the New URI button should trigger AddNewUriClick`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText(text = "New URI")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.AddNewUriClick,
)
}
}
@Test
fun `in ItemType_Login state clicking a Folder Option should send FolderChange action`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
// Opens the menu
composeTestRule
.onNodeWithContentDescription(label = "Folder, No Folder")
.performScrollTo()
.performClick()
// Choose the option from the menu
composeTestRule
.onAllNodesWithText(text = "Folder 1")
.onLast()
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.FolderChange("Folder 1"),
)
}
}
@Test
fun `in ItemType_Login the folder control should display the text provided by the state`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithContentDescription(label = "Folder, No Folder")
.performScrollTo()
.assertIsDisplayed()
mutableStateFlow.update { currentState ->
updateLoginType(currentState) { copy(folder = "Folder 2") }
}
composeTestRule
.onNodeWithContentDescription(label = "Folder, Folder 2")
.performScrollTo()
.assertIsDisplayed()
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Login state, toggling the favorite toggle should send ToggleFavorite action`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule.onNodeWithText("Favorite")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.ToggleFavorite(
isFavorite = true,
),
)
}
}
@Test
fun `in ItemType_Login the favorite toggle should be enabled or disabled according to state`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText(text = "Favorite")
.assertIsOff()
mutableStateFlow.update { currentState ->
updateLoginType(currentState) { copy(favorite = true) }
}
composeTestRule
.onNodeWithText(text = "Favorite")
.assertIsOn()
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Login state, toggling the Master password re-prompt toggle should send ToggleMasterPasswordReprompt action`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText("Master password re-prompt")
.performScrollTo()
.performTouchInput {
click(position = Offset(x = 1f, y = center.y))
}
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.ToggleMasterPasswordReprompt(
isMasterPasswordReprompt = true,
),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Login the master password re-prompt toggle should be enabled or disabled according to state`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText("Master password re-prompt")
.assertIsOff()
mutableStateFlow.update { currentState ->
updateLoginType(currentState) { copy(masterPasswordReprompt = true) }
}
composeTestRule
.onNodeWithText("Master password re-prompt")
.assertIsOn()
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Login state, toggling the Master password re-prompt tooltip button should send TooltipClick action`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule.onNodeWithContentDescription(label = "Master password re-prompt help")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.TooltipClick,
)
}
}
@Test
fun `in ItemType_Login state changing Notes text field should trigger NotesTextChange`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNode(hasSetTextAction() and hasText("Notes"))
.performScrollTo()
.performTextInput("TestNotes")
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.NotesTextChange("TestNotes"),
)
}
}
@Test
fun `in ItemType_Login the Notes control should display the text provided by the state`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNode(hasSetTextAction() and hasText("Notes"))
.assertTextContains("")
mutableStateFlow.update { currentState ->
updateLoginType(currentState) { copy(notes = "NewNote") }
}
composeTestRule
.onNode(hasSetTextAction() and hasText("Notes"))
.assertTextContains("NewNote")
}
@Suppress("MaxLineLength")
@Test
fun `in ItemType_Login state clicking New Custom Field button should trigger AddNewCustomFieldClick`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithText(text = "New custom field")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(VaultAddItemAction.ItemType.LoginType.AddNewCustomFieldClick)
}
}
@Test
fun `in ItemType_Login state clicking a Ownership option should send OwnershipChange action`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
// Opens the menu
composeTestRule
.onNodeWithContentDescription(label = "Who owns this item?, placeholder@email.com")
.performScrollTo()
.performClick()
// Choose the option from the menu
composeTestRule
.onAllNodesWithText(text = "a@b.com")
.onLast()
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(
VaultAddItemAction.ItemType.LoginType.OwnershipChange("a@b.com"),
)
}
}
@Test
fun `in ItemType_Login the Ownership control should display the text provided by the state`() {
composeTestRule.setContent {
VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {})
}
composeTestRule
.onNodeWithContentDescription(label = "Who owns this item?, placeholder@email.com")
.performScrollTo()
.assertIsDisplayed()
mutableStateFlow.update { currentState ->
updateLoginType(currentState) { copy(ownership = "Owner 2") }
}
composeTestRule
.onNodeWithContentDescription(label = "Who owns this item?, Owner 2")
.performScrollTo()
.assertIsDisplayed()
}
//region Helper functions
private fun updateLoginType(
currentState: VaultAddItemState,
transform: VaultAddItemState.ItemType.Login.() -> VaultAddItemState.ItemType.Login,
): VaultAddItemState {
val updatedType = when (val currentType = currentState.selectedType) {
is VaultAddItemState.ItemType.Login -> currentType.transform()
else -> currentType
}
return currentState.copy(selectedType = updatedType)
}
//endregion Helper functions
}

View file

@ -0,0 +1,362 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
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.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
class VaultAddItemViewModelTest : BaseViewModelTest() {
private val initialState = createVaultAddLoginItemState()
private val initialSavedStateHandle = createSavedStateHandleWithState(initialState)
@Test
fun `initial state should be correct`() = runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
}
}
@Test
fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultAddItemAction.CloseClick)
assertEquals(VaultAddItemEvent.NavigateBack, awaitItem())
}
}
@Test
fun `SaveClick should emit ShowToast`() = runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultAddItemAction.SaveClick)
assertEquals(VaultAddItemEvent.ShowToast("Save Item"), awaitItem())
}
}
@Test
fun `TypeOptionSelect LOGIN should switch to LoginItem`() = runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
val action = VaultAddItemAction.TypeOptionSelect(VaultAddItemState.ItemTypeOption.LOGIN)
viewModel.actionChannel.trySend(action)
val expectedState = initialState.copy(selectedType = VaultAddItemState.ItemType.Login())
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Nested
inner class VaultAddLoginTypeItemActions {
private lateinit var viewModel: VaultAddItemViewModel
@BeforeEach
fun setup() {
viewModel = VaultAddItemViewModel(initialSavedStateHandle)
}
@Test
fun `NameTextChange should update name in LoginItem`() = runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
val action = VaultAddItemAction.ItemType.LoginType.NameTextChange("newName")
viewModel.actionChannel.trySend(action)
val expectedLoginItem =
(initialState.selectedType as VaultAddItemState.ItemType.Login)
.copy(name = "newName")
val expectedState = initialState.copy(selectedType = expectedLoginItem)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Suppress("MaxLineLength")
@Test
fun `UsernameTextChange should update username in LoginItem`() = runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
val action = VaultAddItemAction.ItemType.LoginType.UsernameTextChange("newUsername")
viewModel.actionChannel.trySend(action)
val expectedLoginItem =
(initialState.selectedType as VaultAddItemState.ItemType.Login)
.copy(username = "newUsername")
val expectedState = initialState.copy(selectedType = expectedLoginItem)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Suppress("MaxLineLength")
@Test
fun `PasswordTextChange should update password in LoginItem`() = runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
val action = VaultAddItemAction.ItemType.LoginType.PasswordTextChange("newPassword")
viewModel.actionChannel.trySend(action)
val expectedLoginItem =
(initialState.selectedType as VaultAddItemState.ItemType.Login)
.copy(password = "newPassword")
val expectedState = initialState.copy(selectedType = expectedLoginItem)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Test
fun `UriTextChange should update uri in LoginItem`() = runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
val action = VaultAddItemAction.ItemType.LoginType.UriTextChange("newUri")
viewModel.actionChannel.trySend(action)
val expectedLoginItem =
(initialState.selectedType as VaultAddItemState.ItemType.Login)
.copy(uri = "newUri")
val expectedState = initialState.copy(selectedType = expectedLoginItem)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Test
fun `FolderChange should update folder in LoginItem`() = runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
val action = VaultAddItemAction.ItemType.LoginType.FolderChange("newFolder")
viewModel.actionChannel.trySend(action)
val expectedLoginItem =
(initialState.selectedType as VaultAddItemState.ItemType.Login)
.copy(folder = "newFolder")
val expectedState = initialState.copy(selectedType = expectedLoginItem)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Test
fun `ToggleFavorite should update favorite in LoginItem`() = runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
val action = VaultAddItemAction.ItemType.LoginType.ToggleFavorite(true)
viewModel.actionChannel.trySend(action)
val expectedLoginItem =
(initialState.selectedType as VaultAddItemState.ItemType.Login)
.copy(favorite = true)
val expectedState = initialState.copy(selectedType = expectedLoginItem)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Suppress("MaxLineLength")
@Test
fun `ToggleMasterPasswordReprompt should update masterPasswordReprompt in LoginItem`() =
runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
val action = VaultAddItemAction.ItemType.LoginType.ToggleMasterPasswordReprompt(
isMasterPasswordReprompt = true,
)
viewModel.actionChannel.trySend(action)
val expectedLoginItem =
(initialState.selectedType as VaultAddItemState.ItemType.Login)
.copy(masterPasswordReprompt = true)
val expectedState = initialState.copy(selectedType = expectedLoginItem)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Test
fun `NotesTextChange should update notes in LoginItem`() = runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
val action = VaultAddItemAction.ItemType.LoginType.NotesTextChange(notes = "newNotes")
viewModel.actionChannel.trySend(action)
val expectedLoginItem =
(initialState.selectedType as VaultAddItemState.ItemType.Login)
.copy(notes = "newNotes")
val expectedState = initialState.copy(selectedType = expectedLoginItem)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Suppress("MaxLineLength")
@Test
fun `OwnershipChange should update ownership in LoginItem`() = runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
val action =
VaultAddItemAction.ItemType.LoginType.OwnershipChange(ownership = "newOwner")
viewModel.actionChannel.trySend(action)
val expectedLoginItem =
(initialState.selectedType as VaultAddItemState.ItemType.Login)
.copy(ownership = "newOwner")
val expectedState = initialState.copy(selectedType = expectedLoginItem)
assertEquals(expectedState, viewModel.stateFlow.value)
}
@Suppress("MaxLineLength")
@Test
fun `OpenUsernameGeneratorClick should emit ShowToast with 'Open Username Generator' message`() =
runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(
VaultAddItemAction.ItemType.LoginType.OpenUsernameGeneratorClick,
)
assertEquals(
VaultAddItemEvent.ShowToast("Open Username Generator"),
awaitItem(),
)
}
}
@Test
fun `PasswordCheckerClick should emit ShowToast with 'Password Checker' message`() =
runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
viewModel.eventFlow.test {
viewModel
.actionChannel
.trySend(VaultAddItemAction.ItemType.LoginType.PasswordCheckerClick)
assertEquals(VaultAddItemEvent.ShowToast("Password Checker"), awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `OpenPasswordGeneratorClick should emit ShowToast with 'Open Password Generator' message`() =
runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
viewModel.eventFlow.test {
viewModel
.actionChannel
.trySend(VaultAddItemAction.ItemType.LoginType.OpenPasswordGeneratorClick)
assertEquals(
VaultAddItemEvent.ShowToast("Open Password Generator"),
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `SetupTotpClick should emit ShowToast with 'Setup TOTP' message`() = runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultAddItemAction.ItemType.LoginType.SetupTotpClick)
assertEquals(VaultAddItemEvent.ShowToast("Setup TOTP"), awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `UriSettingsClick should emit ShowToast with 'URI Settings' message`() = runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(VaultAddItemAction.ItemType.LoginType.UriSettingsClick)
assertEquals(VaultAddItemEvent.ShowToast("URI Settings"), awaitItem())
}
}
@Test
fun `AddNewUriClick should emit ShowToast with 'Add New URI' message`() = runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
viewModel.eventFlow.test {
viewModel
.actionChannel
.trySend(
VaultAddItemAction.ItemType.LoginType.AddNewUriClick,
)
assertEquals(VaultAddItemEvent.ShowToast("Add New URI"), awaitItem())
}
}
@Test
fun `TooltipClick should emit ShowToast with 'Tooltip' message`() = runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
viewModel.eventFlow.test {
viewModel
.actionChannel
.trySend(
VaultAddItemAction.ItemType.LoginType.TooltipClick,
)
assertEquals(VaultAddItemEvent.ShowToast("Tooltip"), awaitItem())
}
}
@Test
fun `AddNewCustomFieldClick should emit ShowToast with 'Add New Custom Field' message`() =
runTest {
val viewModel = VaultAddItemViewModel(initialSavedStateHandle)
viewModel.eventFlow.test {
viewModel
.actionChannel
.trySend(
VaultAddItemAction.ItemType.LoginType.AddNewCustomFieldClick,
)
assertEquals(VaultAddItemEvent.ShowToast("Add New Custom Field"), awaitItem())
}
}
}
@Suppress("LongParameterList")
private fun createVaultAddLoginItemState(
name: String = "",
username: String = "",
password: String = "",
uri: String = "",
folder: String = "No Folder",
favorite: Boolean = false,
masterPasswordReprompt: Boolean = false,
notes: String = "",
ownership: String = "placeholder@email.com",
): VaultAddItemState =
VaultAddItemState(
selectedType = VaultAddItemState.ItemType.Login(
name = name,
username = username,
password = password,
uri = uri,
folder = folder,
favorite = favorite,
masterPasswordReprompt = masterPasswordReprompt,
notes = notes,
ownership = ownership,
),
)
private fun createSavedStateHandleWithState(state: VaultAddItemState) =
SavedStateHandle().apply {
set("state", state)
}
}

View file

@ -10,6 +10,8 @@ import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class VaultScreenTest : BaseComposeTest() { class VaultScreenTest : BaseComposeTest() {
@ -30,6 +32,7 @@ class VaultScreenTest : BaseComposeTest() {
setContent { setContent {
VaultScreen( VaultScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToVaultAddItemScreen = {},
) )
} }
onNodeWithContentDescription("Search vault").performClick() onNodeWithContentDescription("Search vault").performClick()
@ -53,6 +56,7 @@ class VaultScreenTest : BaseComposeTest() {
setContent { setContent {
VaultScreen( VaultScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToVaultAddItemScreen = {},
) )
} }
onNodeWithContentDescription("Add Item").performClick() onNodeWithContentDescription("Add Item").performClick()
@ -77,10 +81,35 @@ class VaultScreenTest : BaseComposeTest() {
setContent { setContent {
VaultScreen( VaultScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToVaultAddItemScreen = {},
) )
} }
onNodeWithText("Add an Item").performClick() onNodeWithText("Add an Item").performClick()
} }
verify { viewModel.trySendAction(VaultAction.AddItemClick) } verify { viewModel.trySendAction(VaultAction.AddItemClick) }
} }
@Test
fun `NavigateToAddItemScreen event should call onNavigateToVaultAddItemScreen`() {
var onNavigateToVaultAddItemScreenCalled = false
val viewModel = mockk<VaultViewModel>(relaxed = true) {
every { eventFlow } returns flowOf(VaultEvent.NavigateToAddItemScreen)
every { stateFlow } returns MutableStateFlow(
VaultState(
avatarColor = Color.Blue,
initials = "BW",
viewState = VaultState.ViewState.NoItems,
),
)
}
composeTestRule.setContent {
VaultScreen(
onNavigateToVaultAddItemScreen = { onNavigateToVaultAddItemScreenCalled = true },
viewModel = viewModel,
)
}
assertTrue(onNavigateToVaultAddItemScreenCalled)
}
} }