mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
BIT-665: Create Add UI for Login-type item (#179)
This commit is contained in:
parent
3c8a0893fd
commit
2e96b8d857
29 changed files with 3050 additions and 225 deletions
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 = {},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -40,11 +40,17 @@ fun BitwardenSwitch(
|
|||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
.run {
|
||||
if (onCheckedChange != null) {
|
||||
this.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
|
||||
onClick = { onCheckedChange?.invoke(!isChecked) },
|
||||
onClick = { onCheckedChange.invoke(!isChecked) },
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
.semantics(mergeDescendants = true) {
|
||||
toggleableState = ToggleableState(isChecked)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -22,6 +22,7 @@ fun BitwardenTextField(
|
|||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
readOnly: Boolean = false,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
|
@ -30,6 +31,7 @@ fun BitwardenTextField(
|
|||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
singleLine = true,
|
||||
readOnly = readOnly,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = keyboardType),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -47,7 +47,7 @@ fun RootNavScreen(
|
|||
) {
|
||||
splashDestination()
|
||||
authGraph(navController)
|
||||
vaultUnlockedGraph()
|
||||
vaultUnlockedGraph(navController)
|
||||
}
|
||||
|
||||
val targetRoute = when (state) {
|
||||
|
|
|
@ -6,6 +6,8 @@ import androidx.navigation.NavOptions
|
|||
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.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"
|
||||
|
||||
|
@ -19,11 +21,16 @@ fun NavController.navigateToVaultUnlockedGraph(navOptions: NavOptions? = null) {
|
|||
/**
|
||||
* Add vault unlocked destinations to the root nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.vaultUnlockedGraph() {
|
||||
fun NavGraphBuilder.vaultUnlockedGraph(
|
||||
navController: NavController,
|
||||
) {
|
||||
navigation(
|
||||
startDestination = VAULT_UNLOCKED_NAV_BAR_ROUTE,
|
||||
route = VAULT_UNLOCKED_GRAPH_ROUTE,
|
||||
) {
|
||||
vaultUnlockedNavBarDestination()
|
||||
vaultUnlockedNavBarDestination(
|
||||
onNavigateToVaultAddItem = { navController.navigateToVaultAddItem() },
|
||||
)
|
||||
vaultAddItemDestination(onNavigateBack = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,8 +20,10 @@ fun NavController.navigateToVaultUnlockedNavBar(navOptions: NavOptions? = null)
|
|||
/**
|
||||
* Add vault unlocked destination to the root nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.vaultUnlockedNavBarDestination() {
|
||||
fun NavGraphBuilder.vaultUnlockedNavBarDestination(
|
||||
onNavigateToVaultAddItem: () -> Unit,
|
||||
) {
|
||||
composable(VAULT_UNLOCKED_NAV_BAR_ROUTE) {
|
||||
VaultUnlockedNavBarScreen()
|
||||
VaultUnlockedNavBarScreen(onNavigateToVaultAddItem = onNavigateToVaultAddItem)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.vault.feature.vault.VAULT_ROUTE
|
||||
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 kotlinx.parcelize.Parcelize
|
||||
|
||||
|
@ -53,6 +54,7 @@ import kotlinx.parcelize.Parcelize
|
|||
fun VaultUnlockedNavBarScreen(
|
||||
viewModel: VaultUnlockedNavBarViewModel = hiltViewModel(),
|
||||
navController: NavHostController = rememberNavController(),
|
||||
onNavigateToVaultAddItem: () -> Unit,
|
||||
) {
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
navController.apply {
|
||||
|
@ -78,6 +80,7 @@ fun VaultUnlockedNavBarScreen(
|
|||
}
|
||||
VaultUnlockedNavBarScaffold(
|
||||
navController = navController,
|
||||
navigateToVaultAddItem = onNavigateToVaultAddItem,
|
||||
generatorTabClickedAction = {
|
||||
viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick)
|
||||
},
|
||||
|
@ -104,6 +107,7 @@ private fun VaultUnlockedNavBarScaffold(
|
|||
sendTabClickedAction: () -> Unit,
|
||||
generatorTabClickedAction: () -> Unit,
|
||||
settingsTabClickedAction: () -> Unit,
|
||||
navigateToVaultAddItem: () -> Unit,
|
||||
) {
|
||||
// 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
|
||||
|
@ -181,7 +185,11 @@ private fun VaultUnlockedNavBarScaffold(
|
|||
popEnterTransition = RootTransitionProviders.Enter.fadeIn,
|
||||
popExitTransition = RootTransitionProviders.Exit.fadeOut,
|
||||
) {
|
||||
vaultDestination()
|
||||
vaultDestination(
|
||||
onNavigateToVaultAddItemScreen = {
|
||||
navigateToVaultAddItem()
|
||||
},
|
||||
)
|
||||
sendDestination()
|
||||
generatorDestination()
|
||||
settingsGraph(navController)
|
||||
|
|
|
@ -47,12 +47,14 @@ 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.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.BitwardenMultiSelectButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
|
||||
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.BitwardenIconButtonWithResource
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconResource
|
||||
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
|
||||
|
@ -169,10 +171,8 @@ private fun ScrollContent(
|
|||
Modifier.height(32.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.options),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
BitwardenListHeaderText(
|
||||
label = stringResource(id = R.string.options),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
|
@ -200,19 +200,25 @@ private fun GeneratedStringItem(
|
|||
onCopyClick: () -> Unit,
|
||||
onRegenerateClick: () -> Unit,
|
||||
) {
|
||||
BitwardenTextFieldWithTwoIcons(
|
||||
BitwardenReadOnlyTextFieldWithActions(
|
||||
label = "",
|
||||
value = generatedText,
|
||||
firstIconResource = IconResource(
|
||||
iconPainter = painterResource(R.drawable.ic_copy),
|
||||
actions = {
|
||||
BitwardenIconButtonWithResource(
|
||||
iconRes = IconResource(
|
||||
iconPainter = painterResource(id = R.drawable.ic_copy),
|
||||
contentDescription = stringResource(id = R.string.copy),
|
||||
),
|
||||
onFirstIconClick = onCopyClick,
|
||||
secondIconResource = IconResource(
|
||||
iconPainter = painterResource(R.drawable.ic_generator),
|
||||
onClick = onCopyClick,
|
||||
)
|
||||
BitwardenIconButtonWithResource(
|
||||
iconRes = IconResource(
|
||||
iconPainter = painterResource(id = R.drawable.ic_generator),
|
||||
contentDescription = stringResource(id = R.string.generate_password),
|
||||
),
|
||||
onSecondIconClick = onRegenerateClick,
|
||||
onClick = onRegenerateClick,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
|
@ -465,23 +471,29 @@ private fun PasswordMinNumbersCounterItem(
|
|||
minNumbers: Int,
|
||||
onPasswordMinNumbersCounterChange: (Int) -> Unit,
|
||||
) {
|
||||
BitwardenTextFieldWithTwoIcons(
|
||||
BitwardenReadOnlyTextFieldWithActions(
|
||||
label = stringResource(id = R.string.min_numbers),
|
||||
value = minNumbers.toString(),
|
||||
firstIconResource = IconResource(
|
||||
actions = {
|
||||
BitwardenIconButtonWithResource(
|
||||
iconRes = IconResource(
|
||||
iconPainter = painterResource(id = R.drawable.ic_minus),
|
||||
contentDescription = "\u2212",
|
||||
),
|
||||
onFirstIconClick = {
|
||||
onClick = {
|
||||
onPasswordMinNumbersCounterChange(minNumbers - 1)
|
||||
},
|
||||
secondIconResource = IconResource(
|
||||
)
|
||||
BitwardenIconButtonWithResource(
|
||||
iconRes = IconResource(
|
||||
iconPainter = painterResource(id = R.drawable.ic_plus),
|
||||
contentDescription = "+",
|
||||
),
|
||||
onSecondIconClick = {
|
||||
onClick = {
|
||||
onPasswordMinNumbersCounterChange(minNumbers + 1)
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
|
@ -491,23 +503,29 @@ private fun PasswordMinSpecialCharactersCounterItem(
|
|||
minSpecial: Int,
|
||||
onPasswordMinSpecialCharactersChange: (Int) -> Unit,
|
||||
) {
|
||||
BitwardenTextFieldWithTwoIcons(
|
||||
BitwardenReadOnlyTextFieldWithActions(
|
||||
label = stringResource(id = R.string.min_special),
|
||||
value = minSpecial.toString(),
|
||||
firstIconResource = IconResource(
|
||||
actions = {
|
||||
BitwardenIconButtonWithResource(
|
||||
iconRes = IconResource(
|
||||
iconPainter = painterResource(id = R.drawable.ic_minus),
|
||||
contentDescription = "\u2212",
|
||||
),
|
||||
onFirstIconClick = {
|
||||
onClick = {
|
||||
onPasswordMinSpecialCharactersChange(minSpecial - 1)
|
||||
},
|
||||
secondIconResource = IconResource(
|
||||
)
|
||||
BitwardenIconButtonWithResource(
|
||||
iconRes = IconResource(
|
||||
iconPainter = painterResource(id = R.drawable.ic_plus),
|
||||
contentDescription = "+",
|
||||
),
|
||||
onSecondIconClick = {
|
||||
onClick = {
|
||||
onPasswordMinSpecialCharactersChange(minSpecial + 1)
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
|
@ -565,23 +583,29 @@ private fun PassphraseNumWordsCounterItem(
|
|||
numWords: Int,
|
||||
onPassphraseNumWordsCounterChange: (Int) -> Unit,
|
||||
) {
|
||||
BitwardenTextFieldWithTwoIcons(
|
||||
BitwardenReadOnlyTextFieldWithActions(
|
||||
label = stringResource(id = R.string.number_of_words),
|
||||
value = numWords.toString(),
|
||||
firstIconResource = IconResource(
|
||||
actions = {
|
||||
BitwardenIconButtonWithResource(
|
||||
iconRes = IconResource(
|
||||
iconPainter = painterResource(id = R.drawable.ic_minus),
|
||||
contentDescription = "\u2212",
|
||||
),
|
||||
onFirstIconClick = {
|
||||
onClick = {
|
||||
onPassphraseNumWordsCounterChange(numWords - 1)
|
||||
},
|
||||
secondIconResource = IconResource(
|
||||
)
|
||||
BitwardenIconButtonWithResource(
|
||||
iconRes = IconResource(
|
||||
iconPainter = painterResource(id = R.drawable.ic_plus),
|
||||
contentDescription = "+",
|
||||
),
|
||||
onSecondIconClick = {
|
||||
onClick = {
|
||||
onPassphraseNumWordsCounterChange(numWords + 1)
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,9 +10,13 @@ const val VAULT_ROUTE: String = "vault"
|
|||
/**
|
||||
* Add vault destination to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.vaultDestination() {
|
||||
fun NavGraphBuilder.vaultDestination(
|
||||
onNavigateToVaultAddItemScreen: () -> Unit,
|
||||
) {
|
||||
composable(VAULT_ROUTE) {
|
||||
VaultScreen()
|
||||
VaultScreen(
|
||||
onNavigateToVaultAddItemScreen = onNavigateToVaultAddItemScreen,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -37,15 +37,12 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenSearchActionItem
|
|||
@Composable
|
||||
fun VaultScreen(
|
||||
viewModel: VaultViewModel = hiltViewModel(),
|
||||
onNavigateToVaultAddItemScreen: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
VaultEvent.NavigateToAddItemScreen -> {
|
||||
// TODO Create add item screen and navigation implementation BIT-207
|
||||
Toast.makeText(context, "Navigate to the add item screen.", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
VaultEvent.NavigateToAddItemScreen -> onNavigateToVaultAddItemScreen()
|
||||
|
||||
VaultEvent.NavigateToVaultSearchScreen -> {
|
||||
// TODO Create vault search screen and navigation implementation BIT-213
|
||||
|
|
17
app/src/main/res/drawable/ic_check_mark.xml
Normal file
17
app/src/main/res/drawable/ic_check_mark.xml
Normal 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>
|
21
app/src/main/res/drawable/ic_tooltip.xml
Normal file
21
app/src/main/res/drawable/ic_tooltip.xml
Normal 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>
|
|
@ -32,6 +32,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
VaultUnlockedNavBarScreen(
|
||||
viewModel = viewModel,
|
||||
navController = fakeNavHostController,
|
||||
onNavigateToVaultAddItem = {},
|
||||
)
|
||||
}
|
||||
onNodeWithText("My vault").performClick()
|
||||
|
@ -52,6 +53,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
VaultUnlockedNavBarScreen(
|
||||
viewModel = viewModel,
|
||||
navController = fakeNavHostController,
|
||||
onNavigateToVaultAddItem = {},
|
||||
)
|
||||
}
|
||||
runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }
|
||||
|
@ -73,6 +75,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
VaultUnlockedNavBarScreen(
|
||||
viewModel = viewModel,
|
||||
navController = fakeNavHostController,
|
||||
onNavigateToVaultAddItem = {},
|
||||
)
|
||||
}
|
||||
onNodeWithText("Send").performClick()
|
||||
|
@ -93,6 +96,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
VaultUnlockedNavBarScreen(
|
||||
viewModel = viewModel,
|
||||
navController = fakeNavHostController,
|
||||
onNavigateToVaultAddItem = {},
|
||||
)
|
||||
}
|
||||
runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }
|
||||
|
@ -114,6 +118,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
VaultUnlockedNavBarScreen(
|
||||
viewModel = viewModel,
|
||||
navController = fakeNavHostController,
|
||||
onNavigateToVaultAddItem = {},
|
||||
)
|
||||
}
|
||||
onNodeWithText("Generator").performClick()
|
||||
|
@ -134,6 +139,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
VaultUnlockedNavBarScreen(
|
||||
viewModel = viewModel,
|
||||
navController = fakeNavHostController,
|
||||
onNavigateToVaultAddItem = {},
|
||||
)
|
||||
}
|
||||
runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }
|
||||
|
@ -155,6 +161,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
VaultUnlockedNavBarScreen(
|
||||
viewModel = viewModel,
|
||||
navController = fakeNavHostController,
|
||||
onNavigateToVaultAddItem = {},
|
||||
)
|
||||
}
|
||||
onNodeWithText("Settings").performClick()
|
||||
|
@ -175,6 +182,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() {
|
|||
VaultUnlockedNavBarScreen(
|
||||
viewModel = viewModel,
|
||||
navController = fakeNavHostController,
|
||||
onNavigateToVaultAddItem = {},
|
||||
)
|
||||
}
|
||||
runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -10,6 +10,8 @@ import io.mockk.mockk
|
|||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class VaultScreenTest : BaseComposeTest() {
|
||||
|
@ -30,6 +32,7 @@ class VaultScreenTest : BaseComposeTest() {
|
|||
setContent {
|
||||
VaultScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToVaultAddItemScreen = {},
|
||||
)
|
||||
}
|
||||
onNodeWithContentDescription("Search vault").performClick()
|
||||
|
@ -53,6 +56,7 @@ class VaultScreenTest : BaseComposeTest() {
|
|||
setContent {
|
||||
VaultScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToVaultAddItemScreen = {},
|
||||
)
|
||||
}
|
||||
onNodeWithContentDescription("Add Item").performClick()
|
||||
|
@ -77,10 +81,35 @@ class VaultScreenTest : BaseComposeTest() {
|
|||
setContent {
|
||||
VaultScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToVaultAddItemScreen = {},
|
||||
)
|
||||
}
|
||||
onNodeWithText("Add an Item").performClick()
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue