diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenFilledButtonWithIcon.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenFilledButtonWithIcon.kt new file mode 100644 index 000000000..0ab24a4c3 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenFilledButtonWithIcon.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenFilledTonalButton.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenFilledTonalButton.kt new file mode 100644 index 000000000..014525a27 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenFilledTonalButton.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenIconButtonWithResource.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenIconButtonWithResource.kt new file mode 100644 index 000000000..2fb0bacac --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenIconButtonWithResource.kt @@ -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 = {}, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenListHeaderText.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenListHeaderText.kt new file mode 100644 index 000000000..571bb6a97 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenListHeaderText.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPasswordFieldWithActions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPasswordFieldWithActions.kt new file mode 100644 index 000000000..b766aa152 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenPasswordFieldWithActions.kt @@ -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 = {}, + ) + }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenReadOnlyTextFieldWithActions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenReadOnlyTextFieldWithActions.kt new file mode 100644 index 000000000..782b6e11a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenReadOnlyTextFieldWithActions.kt @@ -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), + ) + }, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenRowOfActions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenRowOfActions.kt new file mode 100644 index 000000000..89b12b8df --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenRowOfActions.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenSwitch.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenSwitch.kt index 5d71b630c..c8f2c6696 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenSwitch.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenSwitch.kt @@ -40,11 +40,17 @@ fun BitwardenSwitch( horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(color = MaterialTheme.colorScheme.primary), - onClick = { onCheckedChange?.invoke(!isChecked) }, - ) + .run { + if (onCheckedChange != null) { + this.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(color = MaterialTheme.colorScheme.primary), + onClick = { onCheckedChange.invoke(!isChecked) }, + ) + } else { + this + } + } .semantics(mergeDescendants = true) { toggleableState = ToggleableState(isChecked) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenSwitchWithActions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenSwitchWithActions.kt new file mode 100644 index 000000000..bd7c8a84b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenSwitchWithActions.kt @@ -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, + ), + ) + } + }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt index ad583b5e6..312af719f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt @@ -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), ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextFieldWithActions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextFieldWithActions.kt new file mode 100644 index 000000000..a45a07176 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextFieldWithActions.kt @@ -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", + ) + }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextFieldWithTwoIcons.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextFieldWithTwoIcons.kt deleted file mode 100644 index 3cc3fb7ef..000000000 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextFieldWithTwoIcons.kt +++ /dev/null @@ -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 = {}, - ) - } -} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index d599cf562..d9f4f042d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -47,7 +47,7 @@ fun RootNavScreen( ) { splashDestination() authGraph(navController) - vaultUnlockedGraph() + vaultUnlockedGraph(navController) } val targetRoute = when (state) { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index 0e24514d9..fe4f093e7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -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() }) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt index 826dc8d6c..8e34dad34 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt @@ -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) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index 208974de7..ca8547d89 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -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) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt index fedfe3490..8a7ef458e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt @@ -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), - contentDescription = stringResource(id = R.string.copy), - ), - onFirstIconClick = onCopyClick, - secondIconResource = IconResource( - iconPainter = painterResource(R.drawable.ic_generator), - contentDescription = stringResource(id = R.string.generate_password), - ), - onSecondIconClick = onRegenerateClick, + actions = { + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = painterResource(id = R.drawable.ic_copy), + contentDescription = stringResource(id = R.string.copy), + ), + onClick = onCopyClick, + ) + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = painterResource(id = R.drawable.ic_generator), + contentDescription = stringResource(id = R.string.generate_password), + ), + onClick = onRegenerateClick, + ) + }, modifier = Modifier.padding(horizontal = 16.dp), ) } @@ -465,22 +471,28 @@ private fun PasswordMinNumbersCounterItem( minNumbers: Int, onPasswordMinNumbersCounterChange: (Int) -> Unit, ) { - BitwardenTextFieldWithTwoIcons( + BitwardenReadOnlyTextFieldWithActions( label = stringResource(id = R.string.min_numbers), value = minNumbers.toString(), - firstIconResource = IconResource( - iconPainter = painterResource(id = R.drawable.ic_minus), - contentDescription = "\u2212", - ), - onFirstIconClick = { - onPasswordMinNumbersCounterChange(minNumbers - 1) - }, - secondIconResource = IconResource( - iconPainter = painterResource(id = R.drawable.ic_plus), - contentDescription = "+", - ), - onSecondIconClick = { - onPasswordMinNumbersCounterChange(minNumbers + 1) + actions = { + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = painterResource(id = R.drawable.ic_minus), + contentDescription = "\u2212", + ), + onClick = { + onPasswordMinNumbersCounterChange(minNumbers - 1) + }, + ) + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = painterResource(id = R.drawable.ic_plus), + contentDescription = "+", + ), + onClick = { + onPasswordMinNumbersCounterChange(minNumbers + 1) + }, + ) }, modifier = Modifier.padding(horizontal = 16.dp), ) @@ -491,22 +503,28 @@ private fun PasswordMinSpecialCharactersCounterItem( minSpecial: Int, onPasswordMinSpecialCharactersChange: (Int) -> Unit, ) { - BitwardenTextFieldWithTwoIcons( + BitwardenReadOnlyTextFieldWithActions( label = stringResource(id = R.string.min_special), value = minSpecial.toString(), - firstIconResource = IconResource( - iconPainter = painterResource(id = R.drawable.ic_minus), - contentDescription = "\u2212", - ), - onFirstIconClick = { - onPasswordMinSpecialCharactersChange(minSpecial - 1) - }, - secondIconResource = IconResource( - iconPainter = painterResource(id = R.drawable.ic_plus), - contentDescription = "+", - ), - onSecondIconClick = { - onPasswordMinSpecialCharactersChange(minSpecial + 1) + actions = { + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = painterResource(id = R.drawable.ic_minus), + contentDescription = "\u2212", + ), + onClick = { + onPasswordMinSpecialCharactersChange(minSpecial - 1) + }, + ) + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = painterResource(id = R.drawable.ic_plus), + contentDescription = "+", + ), + onClick = { + onPasswordMinSpecialCharactersChange(minSpecial + 1) + }, + ) }, modifier = Modifier.padding(horizontal = 16.dp), ) @@ -565,22 +583,28 @@ private fun PassphraseNumWordsCounterItem( numWords: Int, onPassphraseNumWordsCounterChange: (Int) -> Unit, ) { - BitwardenTextFieldWithTwoIcons( + BitwardenReadOnlyTextFieldWithActions( label = stringResource(id = R.string.number_of_words), value = numWords.toString(), - firstIconResource = IconResource( - iconPainter = painterResource(id = R.drawable.ic_minus), - contentDescription = "\u2212", - ), - onFirstIconClick = { - onPassphraseNumWordsCounterChange(numWords - 1) - }, - secondIconResource = IconResource( - iconPainter = painterResource(id = R.drawable.ic_plus), - contentDescription = "+", - ), - onSecondIconClick = { - onPassphraseNumWordsCounterChange(numWords + 1) + actions = { + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = painterResource(id = R.drawable.ic_minus), + contentDescription = "\u2212", + ), + onClick = { + onPassphraseNumWordsCounterChange(numWords - 1) + }, + ) + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = painterResource(id = R.drawable.ic_plus), + contentDescription = "+", + ), + onClick = { + onPassphraseNumWordsCounterChange(numWords + 1) + }, + ) }, modifier = Modifier.padding(horizontal = 16.dp), ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddItemNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddItemNavigation.kt new file mode 100644 index 000000000..4e8894542 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddItemNavigation.kt @@ -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) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddItemScreen.kt new file mode 100644 index 000000000..79d438693 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddItemScreen.kt @@ -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, + ), + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddItemViewModel.kt new file mode 100644 index 000000000..82edace6a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddItemViewModel.kt @@ -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( + 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 + 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 + 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 + 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() + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddLoginItemTypeHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddLoginItemTypeHandlers.kt new file mode 100644 index 000000000..241f9997d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddLoginItemTypeHandlers.kt @@ -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, + ) + }, + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt index 519cfc47e..aa7963177 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultNavigation.kt @@ -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, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index 0038cc1e3..963271d92 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -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 diff --git a/app/src/main/res/drawable/ic_check_mark.xml b/app/src/main/res/drawable/ic_check_mark.xml new file mode 100644 index 000000000..ea0953d41 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_mark.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_tooltip.xml b/app/src/main/res/drawable/ic_tooltip.xml new file mode 100644 index 000000000..70557ae41 --- /dev/null +++ b/app/src/main/res/drawable/ic_tooltip.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt index 831055c70..c5af1ae95 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt @@ -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") } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddItemScreenTest.kt new file mode 100644 index 000000000..91e6b5563 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddItemScreenTest.kt @@ -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(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 +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddItemViewModelTest.kt new file mode 100644 index 000000000..f2d110bd4 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultAddItemViewModelTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index ee74fac70..ac1460717 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -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(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) + } }