From 3d925a78040d6222da017940b84afe8036e0352e Mon Sep 17 00:00:00 2001 From: joshua-livefront <139182194+joshua-livefront@users.noreply.github.com> Date: Mon, 9 Oct 2023 16:05:36 -0400 Subject: [PATCH] BIT-635: Adding updated UI for Passphrase Generator screen (#102) --- .../platform/base/util/DensityExtensions.kt | 29 + .../components/BitwardenLargeTopAppBar.kt | 76 +++ .../components/BitwardenMultiSelectButton.kt | 150 +++++ .../BitwardenTextFieldWithTwoIcons.kt | 153 +++++ .../components/BitwardenWideSwitch.kt | 79 +++ .../platform/components/model/IconResource.kt | 14 + .../feature/generator/GeneratorScreen.kt | 549 ++++++------------ .../feature/generator/GeneratorViewModel.kt | 47 +- app/src/main/res/drawable/ic_copy.xml | 17 + app/src/main/res/drawable/ic_minus.xml | 10 + app/src/main/res/drawable/ic_plus.xml | 9 + .../feature/generator/GeneratorScreenTest.kt | 26 +- .../generator/GeneratorViewModelTest.kt | 21 + 13 files changed, 796 insertions(+), 384 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/DensityExtensions.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenLargeTopAppBar.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenMultiSelectButton.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextFieldWithTwoIcons.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenWideSwitch.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/IconResource.kt create mode 100644 app/src/main/res/drawable/ic_copy.xml create mode 100644 app/src/main/res/drawable/ic_minus.xml create mode 100644 app/src/main/res/drawable/ic_plus.xml diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/DensityExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/DensityExtensions.kt new file mode 100644 index 000000000..caebf1090 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/DensityExtensions.kt @@ -0,0 +1,29 @@ +package com.x8bit.bitwarden.ui.platform.base.util + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize + +/** + * A function for converting pixels to [Dp] within a composable function. + */ +@Composable +fun Int.toDp(): Dp = with(LocalDensity.current) { this@toDp.toDp() } + +/** + * A function for converting pixels to [Dp]. + */ +fun Int.toDp(density: Density): Dp = with(density) { this@toDp.toDp() } + +/** + * A function for converting [IntSize] pixels into [DpSize]. + */ +fun IntSize.toDpSize(density: Density): DpSize = with(density) { + DpSize( + width = width.toDp(), + height = height.toDp(), + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenLargeTopAppBar.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenLargeTopAppBar.kt new file mode 100644 index 000000000..33cb9fccf --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenLargeTopAppBar.kt @@ -0,0 +1,76 @@ +package com.x8bit.bitwarden.ui.platform.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.x8bit.bitwarden.R + +/** + * A custom Bitwarden-themed large top app bar with an overflow menu action. + * + * This app bar wraps around the Material 3's [LargeTopAppBar] and customizes its appearance + * and behavior according to the app theme. + * It provides a title and an optional overflow menu, represented by a dropdown containing + * a set of menu items. + * + * @param title The text to be displayed as the title of the app bar. + * @param dropdownMenuItemContent A single overflow menu in the right with contents + * defined by the [dropdownMenuItemContent]. It is strongly recommended that this content + * be a stack of [DropdownMenuItem]. + * @param scrollBehavior Defines the scrolling behavior of the app bar. It controls how the app bar + * behaves in conjunction with scrolling content. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BitwardenLargeTopAppBar( + title: String, + dropdownMenuItemContent: @Composable ColumnScope.() -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior, +) { + var isOverflowMenuVisible by remember { mutableStateOf(false) } + + LargeTopAppBar( + colors = TopAppBarDefaults.largeTopAppBarColors( + scrolledContainerColor = MaterialTheme.colorScheme.surface, + ), + scrollBehavior = scrollBehavior, + title = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + }, + actions = { + Box { + IconButton(onClick = { isOverflowMenuVisible = !isOverflowMenuVisible }) { + Icon( + painter = painterResource(id = R.drawable.ic_more), + contentDescription = stringResource(id = R.string.more), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + DropdownMenu( + expanded = isOverflowMenuVisible, + onDismissRequest = { isOverflowMenuVisible = false }, + content = dropdownMenuItemContent, + ) + } + }, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenMultiSelectButton.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenMultiSelectButton.kt new file mode 100644 index 000000000..5bec201e1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenMultiSelectButton.kt @@ -0,0 +1,150 @@ +package com.x8bit.bitwarden.ui.platform.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.toDp +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme + +/** + * A custom composable representing a multi-select button. + * + * This composable displays an [OutlinedTextField] with a dropdown icon as a trailing icon. + * When the field is clicked, a dropdown menu appears with a list of options to select from. + * + * @param label The descriptive text label for the [OutlinedTextField]. + * @param options A list of strings representing the available options in the dropdown menu. + * @param selectedOption The currently selected option that is displayed in the [OutlinedTextField]. + * @param onOptionSelected A lambda that is invoked when an option + * is selected from the dropdown menu. + * @param modifier A [Modifier] that you can use to apply custom modifications to the composable. + */ +@Suppress("LongMethod") +@Composable +fun BitwardenMultiSelectButton( + label: String, + options: List, + selectedOption: String, + onOptionSelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + var textFieldWidth by remember { mutableIntStateOf(0) } + + // Watch for changes to 'expanded' and request focus if needed + LaunchedEffect(expanded) { + if (expanded) focusRequester.requestFocus() else focusRequester.freeFocus() + } + + Box( + modifier = modifier + .semantics(mergeDescendants = true) {} + .fillMaxWidth()) { + OutlinedTextField( + // TODO: Update with final accessibility reading (BIT-752) + modifier = modifier + .clearAndSetSemantics { + this.role = Role.DropdownList + contentDescription = "$label, $selectedOption" + } + .fillMaxWidth() + .clickable( + // Disable the ripple + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + expanded = !expanded + } + .onGloballyPositioned { coordinates -> + textFieldWidth = coordinates.size.width + } + .focusRequester(focusRequester), + textStyle = MaterialTheme.typography.bodyLarge, + readOnly = true, + label = { + Text( + text = label, + ) + }, + value = selectedOption, + onValueChange = onOptionSelected, + enabled = expanded, + trailingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_region_select_dropdown), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + colors = OutlinedTextFieldDefaults.colors( + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledBorderColor = MaterialTheme.colorScheme.outline, + disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + DropdownMenu( + modifier = Modifier.width(textFieldWidth.toDp()), + expanded = expanded, + onDismissRequest = { + expanded = false + focusRequester.freeFocus() + }, + ) { + options.forEach { optionString -> + DropdownMenuItem( + text = { Text(text = optionString) }, + onClick = { + expanded = false + onOptionSelected(optionString) + focusRequester.freeFocus() + }, + ) + } + } + } +} + +@Preview +@Composable +private fun BitwardenMultiSelectButton_preview() { + BitwardenTheme { + BitwardenMultiSelectButton( + label = "Label", + options = listOf("a", "b"), + selectedOption = "", + onOptionSelected = {}, + ) + } +} 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 new file mode 100644 index 000000000..aa2aafeda --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextFieldWithTwoIcons.kt @@ -0,0 +1,153 @@ +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( + modifier = modifier, + firstIconResource = firstIconResource, + onFirstIconClick = onFirstIconClick, + secondIconResource = secondIconResource, + onSecondIconClick = onSecondIconClick, + ) + } + } +} + +/** + * A row of two customizable icon buttons. + * + * @param modifier Modifier for the Row. + * @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( + modifier: Modifier, + 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, + modifier = Modifier.padding(8.dp), + ) + } +} + +@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/components/BitwardenWideSwitch.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenWideSwitch.kt new file mode 100644 index 000000000..5515f7dfb --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenWideSwitch.kt @@ -0,0 +1,79 @@ +package com.x8bit.bitwarden.ui.platform.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme + +/** + * A wide custom switch composable + * + * @param label The descriptive text label to be displayed adjacent to the switch. + * @param isChecked The current state of the switch (either checked or unchecked). + * @param onCheckedChange A lambda that is invoked when the switch's state changes. + * @param modifier A [Modifier] that you can use to apply custom modifications to the composable. + */ +@Composable +fun BitwardenWideSwitch( + label: String, + isChecked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .semantics(mergeDescendants = true) { } + .fillMaxWidth() + .wrapContentHeight(), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + + Switch( + modifier = Modifier + .height(56.dp), + checked = isChecked, + onCheckedChange = onCheckedChange, + ) + } +} + +@Preview +@Composable +private fun BitwardenWideSwitch_preview_isChecked() { + BitwardenTheme { + BitwardenWideSwitch( + label = "Label", + isChecked = true, + onCheckedChange = {}, + ) + } +} + +@Preview +@Composable +private fun BitwardenWideSwitch_preview_isNotChecked() { + BitwardenTheme { + BitwardenWideSwitch( + label = "Label", + isChecked = false, + onCheckedChange = {}, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/IconResource.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/IconResource.kt new file mode 100644 index 000000000..9f1bbd94f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/IconResource.kt @@ -0,0 +1,14 @@ +package com.x8bit.bitwarden.ui.platform.components.model + +import androidx.compose.ui.graphics.painter.Painter + +/** + * Data class representing the resources required for an icon. + * + * @property iconPainter Painter for the icon. + * @property contentDescription String for the icon's content description. + */ +data class IconResource( + val iconPainter: Painter, + val contentDescription: String, +) 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 680dacbde..867819ada 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 @@ -2,111 +2,144 @@ package com.x8bit.bitwarden.ui.tools.feature.generator -import androidx.compose.foundation.clickable +import android.widget.Toast +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.ArrowForward -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.Divider -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem 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.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue 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.semantics.semantics -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp 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.BitwardenLargeTopAppBar +import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton +import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField +import com.x8bit.bitwarden.ui.platform.components.BitwardenTextFieldWithTwoIcons +import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch +import com.x8bit.bitwarden.ui.platform.components.model.IconResource import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme /** * Top level composable for the generator screen. */ +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("LongMethod") @Composable fun GeneratorScreen(viewModel: GeneratorViewModel = hiltViewModel()) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() - - val onMainStateOptionClicked: (GeneratorState.MainTypeOption) -> Unit = { - viewModel.trySendAction(GeneratorAction.MainTypeOptionSelect(it)) + val context = LocalContext.current + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is GeneratorEvent.ShowToast -> { + Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() + } + } } - val onPasscodeOptionClicked: (GeneratorState.MainType.Passcode.PasscodeTypeOption) -> Unit = { - viewModel.trySendAction(GeneratorAction.MainType.Passcode.PasscodeTypeOptionSelect(it)) + val onRegenerateClick: () -> Unit = remember(viewModel) { + { viewModel.trySendAction(GeneratorAction.RegenerateClick) } } - val onNumWordsCounterChange: (Int) -> Unit = { changeInCounter -> - viewModel.trySendAction( - GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.NumWordsCounterChange( - numWords = changeInCounter, - ), - ) + val onCopyClick: () -> Unit = remember(viewModel) { + { viewModel.trySendAction(GeneratorAction.CopyClick) } } - val onPassphraseCapitalizeToggleChange: (Boolean) -> Unit = { shouldCapitalize -> - viewModel.trySendAction( - GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleCapitalizeChange( - capitalize = shouldCapitalize, - ), - ) + val onMainStateOptionClicked: (GeneratorState.MainTypeOption) -> Unit = remember(viewModel) { + { viewModel.trySendAction(GeneratorAction.MainTypeOptionSelect(it)) } } - val onIncludeNumberToggleChange: (Boolean) -> Unit = { shouldIncludeNumber -> - viewModel.trySendAction( - GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleIncludeNumberChange( - includeNumber = shouldIncludeNumber, - ), - ) + val onPasscodeOptionClicked: (GeneratorState.MainType.Passcode.PasscodeTypeOption) -> Unit = + remember(viewModel) { + { + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeTypeOptionSelect( + it, + ), + ) + } + } + + val onNumWordsCounterChange: (Int) -> Unit = remember(viewModel) { + { changeInCounter -> + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.NumWordsCounterChange( + numWords = changeInCounter, + ), + ) + } } - val onWordSeparatorChange: (Char?) -> Unit = { newSeparator -> - viewModel.trySendAction( - GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.WordSeparatorTextChange( - wordSeparator = newSeparator, - ), - ) + val onPassphraseCapitalizeToggleChange: (Boolean) -> Unit = remember(viewModel) { + { shouldCapitalize -> + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleCapitalizeChange( + capitalize = shouldCapitalize, + ), + ) + } } + val onIncludeNumberToggleChange: (Boolean) -> Unit = remember(viewModel) { + { shouldIncludeNumber -> + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleIncludeNumberChange( + includeNumber = shouldIncludeNumber, + ), + ) + } + } + + val onWordSeparatorChange: (Char?) -> Unit = remember(viewModel) { + { newSeparator -> + viewModel.trySendAction( + GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.WordSeparatorTextChange( + wordSeparator = newSeparator, + ), + ) + } + } + + val scrollBehavior = + TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + Scaffold( - topBar = { TopAppBar() }, + topBar = { + BitwardenLargeTopAppBar( + title = stringResource(id = R.string.generator), + scrollBehavior = scrollBehavior, + ) + }, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { innerPadding -> ScrollContent( state, + onRegenerateClick, + onCopyClick, onMainStateOptionClicked, onPasscodeOptionClicked, onNumWordsCounterChange, @@ -118,52 +151,14 @@ fun GeneratorScreen(viewModel: GeneratorViewModel = hiltViewModel()) { } } -//region TopAppBar Composables - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun TopAppBar() { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primary, - titleContentColor = MaterialTheme.colorScheme.onPrimary, - ), - title = { - Text( - stringResource(id = R.string.generator), - Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - ) - }, - navigationIcon = { - Spacer(Modifier.width(40.dp)) - }, - actions = { - OverflowMenu() - }, - ) -} - -@Composable -private fun OverflowMenu() { - IconButton( - onClick = {}, - ) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(id = R.string.options), - tint = MaterialTheme.colorScheme.onPrimary, - ) - } -} - -//endregion TopAppBar Composables - //region ScrollContent and Static Items +@Suppress("LongMethod") @Composable private fun ScrollContent( state: GeneratorState, + onRegenerateClick: () -> Unit, + onCopyClick: () -> Unit, onMainStateOptionClicked: (GeneratorState.MainTypeOption) -> Unit, onSubStateOptionClicked: (GeneratorState.MainType.Passcode.PasscodeTypeOption) -> Unit, onNumWordsCounterChange: (Int) -> Unit, @@ -173,17 +168,37 @@ private fun ScrollContent( modifier: Modifier = Modifier, ) { Column( + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = modifier - .fillMaxSize() + .fillMaxHeight() + .background(color = MaterialTheme.colorScheme.surface) + .padding(start = 16.dp, end = 16.dp) .verticalScroll(rememberScrollState()), ) { - GeneratedStringItem(state.generatedText) + + GeneratedStringItem( + generatedText = state.generatedText, + onCopyClick = onCopyClick, + onRegenerateClick = onRegenerateClick, + ) + MainStateOptionsItem( selectedType = state.selectedType, possibleMainStates = state.typeOptions, onMainStateOptionClicked = onMainStateOptionClicked, ) + Row( + Modifier.height(32.dp), + verticalAlignment = Alignment.Bottom, + ) { + Text( + text = stringResource(id = R.string.options), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + when (val selectedType = state.selectedType) { is GeneratorState.MainType.Passcode -> { PasscodeTypeItems( @@ -204,40 +219,25 @@ private fun ScrollContent( } @Composable -private fun GeneratedStringItem(generatedText: String) { - Box(modifier = Modifier.padding(horizontal = 16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = generatedText) - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = {}, - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(id = R.string.copy), - tint = MaterialTheme.colorScheme.primary, - ) - } - IconButton( - onClick = {}, - ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = stringResource(id = R.string.generate_password), - tint = MaterialTheme.colorScheme.primary, - ) - } - } - } - } +private fun GeneratedStringItem( + generatedText: String, + onCopyClick: () -> Unit, + onRegenerateClick: () -> Unit, +) { + BitwardenTextFieldWithTwoIcons( + 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, + ) } @Composable @@ -249,9 +249,8 @@ private fun MainStateOptionsItem( val optionsWithStrings = possibleMainStates.associateBy({ it }, { stringResource(id = it.labelRes) }) - OptionsSelectionItem( - title = stringResource(id = R.string.what_would_you_like_to_generate), - showOptionsText = false, + BitwardenMultiSelectButton( + label = stringResource(id = R.string.what_would_you_like_to_generate), options = optionsWithStrings.values.toList(), selectedOption = stringResource(id = selectedType.displayStringResId), onOptionSelected = { selectedOption -> @@ -322,9 +321,8 @@ private fun PasscodeOptionsItem( val optionsWithStrings = possibleSubStates.associateBy({ it }, { stringResource(id = it.labelRes) }) - OptionsSelectionItem( - title = stringResource(id = currentSubState.selectedType.displayStringResId), - showOptionsText = true, + BitwardenMultiSelectButton( + label = stringResource(id = currentSubState.selectedType.displayStringResId), options = optionsWithStrings.values.toList(), selectedOption = stringResource(id = currentSubState.selectedType.displayStringResId), onOptionSelected = { selectedOption -> @@ -355,14 +353,18 @@ private fun PassphraseTypeContent( wordSeparator = passphraseTypeState.wordSeparator, onWordSeparatorChange = onWordSeparatorChange, ) - PassphraseCapitalizeToggleItem( - capitalize = passphraseTypeState.capitalize, - onPassphraseCapitalizeToggleChange = onCapitalizeToggleChange, - ) - PassphraseIncludeNumberToggleItem( - includeNumber = passphraseTypeState.includeNumber, - onIncludeNumberToggleChange = onIncludeNumberToggleChange, - ) + Column( + modifier = Modifier.fillMaxWidth(), + ) { + PassphraseCapitalizeToggleItem( + capitalize = passphraseTypeState.capitalize, + onPassphraseCapitalizeToggleChange = onCapitalizeToggleChange, + ) + PassphraseIncludeNumberToggleItem( + includeNumber = passphraseTypeState.includeNumber, + onIncludeNumberToggleChange = onIncludeNumberToggleChange, + ) + } } @Composable @@ -370,10 +372,23 @@ private fun PassphraseNumWordsCounterItem( numWords: Int, onNumWordsCounterChange: (Int) -> Unit, ) { - CounterItem( + BitwardenTextFieldWithTwoIcons( label = stringResource(id = R.string.number_of_words), - counter = numWords, - counterValueChange = onNumWordsCounterChange, + value = numWords.toString(), + firstIconResource = IconResource( + iconPainter = painterResource(id = R.drawable.ic_minus), + contentDescription = "\u2212", + ), + onFirstIconClick = { + onNumWordsCounterChange(numWords - 1) + }, + secondIconResource = IconResource( + iconPainter = painterResource(id = R.drawable.ic_plus), + contentDescription = "+", + ), + onSecondIconClick = { + onNumWordsCounterChange(numWords + 1) + }, ) } @@ -382,12 +397,13 @@ private fun PassphraseWordSeparatorInputItem( wordSeparator: Char?, onWordSeparatorChange: (wordSeparator: Char?) -> Unit, ) { - TextInputItem( - title = stringResource(id = R.string.word_separator), - defaultText = wordSeparator?.toString() ?: "", - textInputChange = { + BitwardenTextField( + label = stringResource(id = R.string.word_separator), + value = wordSeparator?.toString() ?: "", + onValueChange = { onWordSeparatorChange(it.toCharArray().firstOrNull()) }, + modifier = Modifier.width(267.dp), ) } @@ -396,10 +412,10 @@ private fun PassphraseCapitalizeToggleItem( capitalize: Boolean, onPassphraseCapitalizeToggleChange: (Boolean) -> Unit, ) { - SwitchItem( - title = stringResource(id = R.string.capitalize), - isToggled = capitalize, - onToggleChange = onPassphraseCapitalizeToggleChange, + BitwardenWideSwitch( + label = stringResource(id = R.string.capitalize), + isChecked = capitalize, + onCheckedChange = onPassphraseCapitalizeToggleChange, ) } @@ -408,228 +424,15 @@ private fun PassphraseIncludeNumberToggleItem( includeNumber: Boolean, onIncludeNumberToggleChange: (Boolean) -> Unit, ) { - SwitchItem( - title = stringResource(id = R.string.include_number), - isToggled = includeNumber, - onToggleChange = onIncludeNumberToggleChange, + BitwardenWideSwitch( + label = stringResource(id = R.string.include_number), + isChecked = includeNumber, + onCheckedChange = onIncludeNumberToggleChange, ) } //endregion PassphraseType Composables -//region Generic Control Composables - -/** - * This composable function renders an item for selecting options, with a capability - * to expand and collapse the options. It also optionally displays a text indicating - * that there are multiple options to choose from. - * - * @param title The title of the item. This string will be displayed above the selected option. - * @param showOptionsText A boolean flag that determines whether to show the Options header text. - * @param options A list of strings representing the available options for selection. - * @param selectedOption The currently selected option. This will be displayed on the item. - * @param onOptionSelected A callback invoked when an option is selected, passing the selected - */ -@Composable -private fun OptionsSelectionItem( - title: String, - showOptionsText: Boolean = false, - options: List, - selectedOption: String, - onOptionSelected: (String) -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - - CommonPadding { - Column( - modifier = Modifier - .fillMaxHeight() - .padding(top = 4.dp, bottom = 4.dp), - verticalArrangement = Arrangement.Center, - ) { - if (showOptionsText) { - Text( - stringResource(id = R.string.options), - style = TextStyle(fontSize = 12.sp), - color = MaterialTheme.colorScheme.primary, - ) - } - Text(title, style = TextStyle(fontSize = 10.sp)) - - Box(modifier = Modifier - .fillMaxWidth() - .clickable { expanded = true }) { - Text(selectedOption) - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.fillMaxWidth(), - ) { - options.forEach { optionString -> - DropdownMenuItem( - text = { Text(text = optionString) }, - onClick = { - expanded = false - onOptionSelected(optionString) - }, - ) - } - } - } - } - } -} - -/** - * A composable function to represent a counter item, which consists of a label, - * decrement button, an increment button, and display of the current counter. - * - * @param label The text to be displayed as a label for the counter item. - * @param counter The current value of the counter. - * @param counterValueChange A lambda function invoked when there is a change in the counter value. - * It takes the updated counter value as a parameter. - */ -@Composable -private fun CounterItem( - label: String, - counter: Int, - counterValueChange: (Int) -> Unit, -) { - CommonPadding { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .semantics(mergeDescendants = true) {} - .fillMaxWidth(), - ) { - Text(label) - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = { counterValueChange(counter - 1) }, - ) { - Icon( - Icons.Default.ArrowBack, - // Unicode for "minus" - contentDescription = "\u2212", - tint = MaterialTheme.colorScheme.primary, - ) - } - - Text(counter.toString()) - - IconButton( - onClick = { counterValueChange(counter + 1) }, - ) { - Icon( - Icons.Default.ArrowForward, - contentDescription = "+", - tint = MaterialTheme.colorScheme.primary, - ) - } - } - } - } -} - -/** - * A composable function to represent a text input item, which consists of a title, - * an optional context text above the title, and a text field for input. - * - * @param title The title of the text input item. - * @param defaultText The default text displayed in the text field. - * @param textInputChange A lambda function invoked when there is a change in the text field value. - * It takes the updated text value as a parameter. - * @param contextText The optional context text displayed above the title. - * @param maxLines The maximum number of lines for the text field. - */ -@Composable -private fun TextInputItem( - title: String, - defaultText: String, - textInputChange: (String) -> Unit, - contextText: String? = null, - maxLines: Int = 1, -) { - CommonPadding { - Column( - modifier = Modifier - .semantics(mergeDescendants = true) {} - .fillMaxHeight() - .padding(top = 4.dp, bottom = 4.dp), - verticalArrangement = Arrangement.Center, - ) { - contextText?.let { - Text(it, style = TextStyle(fontSize = 10.sp)) - } - - if (contextText != null) { - Spacer(modifier = Modifier.height(4.dp)) - } - - Text(title, style = TextStyle(fontSize = 10.sp)) - - Box(modifier = Modifier.fillMaxWidth()) { - TextField( - value = defaultText, - onValueChange = { newValue -> - textInputChange(newValue) - }, - maxLines = maxLines, - ) - } - } - } -} - -/** - * A composable function to represent a switch item, which consists of a title and a switch. - * - * @param title The title of the switch item. - * @param isToggled The current state of the switch; whether it's toggled on or off. - * @param onToggleChange A lambda function invoked when there is a change in the switch state. - * It takes the updated switch state as a parameter. - */ -@Composable -private fun SwitchItem( - title: String, - isToggled: Boolean, - onToggleChange: (Boolean) -> Unit, -) { - CommonPadding { - Row( - modifier = Modifier - .semantics(mergeDescendants = true) {} - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = title) - Switch( - checked = isToggled, - onCheckedChange = onToggleChange, - ) - } - } -} - -//endregion Generic Control Composables - -@Composable -private fun CommonPadding(content: @Composable () -> Unit) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - content() - Divider() - } -} - @Preview(showBackground = true) @Composable private fun GeneratorPreview() { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt index 2091d53b9..3b84889b0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModel.kt @@ -51,6 +51,14 @@ class GeneratorViewModel @Inject constructor( override fun handleAction(action: GeneratorAction) { when (action) { + is GeneratorAction.RegenerateClick -> { + handleRegenerationClick() + } + + is GeneratorAction.CopyClick -> { + handleCopyClick() + } + is GeneratorAction.MainTypeOptionSelect -> { handleMainTypeOptionSelect(action) } @@ -67,6 +75,29 @@ class GeneratorViewModel @Inject constructor( //endregion Initialization and Overrides + //region Generated Field Handlers + + private fun handleRegenerationClick() { + mutableStateFlow.update { currentState -> + currentState.copy( + // TODO(BIT-277): Replace placeholder text with function to generate new text + generatedText = currentState.generatedText.reversed(), + ) + } + } + + private fun handleCopyClick() { + viewModelScope.launch { + sendEvent( + event = GeneratorEvent.ShowToast( + message = "Copied", + ), + ) + } + } + + //endregion Generated Field Handlers + //region Main Type Option Handlers private fun handleMainTypeOptionSelect(action: GeneratorAction.MainTypeOptionSelect) { @@ -393,6 +424,16 @@ data class GeneratorState( */ sealed class GeneratorAction { + /** + * Represents the action to regenerate a new passcode or username. + */ + data object RegenerateClick : GeneratorAction() + + /** + * Represents the action to copy the generated field. + */ + data object CopyClick : GeneratorAction() + /** * Represents the action of selecting a main type option. * @@ -484,5 +525,9 @@ sealed class GeneratorAction { * the generator screen. */ sealed class GeneratorEvent { - // TODO(BIT-317): Setup data objects to represent UI events that can be triggered + + /** + * Shows a toast with the given [message]. + */ + data class ShowToast(val message: String) : GeneratorEvent() } diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml new file mode 100644 index 000000000..6de6bd02f --- /dev/null +++ b/app/src/main/res/drawable/ic_copy.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_minus.xml b/app/src/main/res/drawable/ic_minus.xml new file mode 100644 index 000000000..e48c80009 --- /dev/null +++ b/app/src/main/res/drawable/ic_minus.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_plus.xml b/app/src/main/res/drawable/ic_plus.xml new file mode 100644 index 000000000..c0480166d --- /dev/null +++ b/app/src/main/res/drawable/ic_plus.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt index d8b9cfd1c..610ce843b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt @@ -7,11 +7,10 @@ import androidx.compose.ui.semantics.SemanticsProperties.Role import androidx.compose.ui.test.SemanticsMatcher.Companion.expectValue import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasContentDescription -import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onChildren -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.performClick import androidx.compose.ui.test.performScrollTo @@ -44,10 +43,15 @@ class GeneratorScreenTest : BaseComposeTest() { } // Opens the menu - composeTestRule.onAllNodesWithText(text = "Password").onFirst().performClick() + composeTestRule + .onNodeWithContentDescription(label = "What would you like to generate?, Password") + .performClick() // Choose the option from the menu - composeTestRule.onAllNodesWithText(text = "Password").onLast().performClick() + composeTestRule.onAllNodesWithText(text = "Password") + .onLast() + .performScrollTo() + .performClick() verify { viewModel.trySendAction(GeneratorAction.MainTypeOptionSelect(GeneratorState.MainTypeOption.PASSWORD)) @@ -61,7 +65,7 @@ class GeneratorScreenTest : BaseComposeTest() { } // Opens the menu - composeTestRule.onAllNodesWithText(text = "Password").onLast().performClick() + composeTestRule.onNodeWithContentDescription(label = "Password, Password").performClick() // Choose the option from the menu composeTestRule.onAllNodesWithText(text = "Passphrase").onLast().performClick() @@ -91,9 +95,10 @@ class GeneratorScreenTest : BaseComposeTest() { // Unicode for "minus" used for content description composeTestRule - .onNodeWithText("Number of words") + .onNodeWithContentDescription("Number of words, 3") .onChildren() .filterToOne(hasContentDescription("\u2212")) + .performScrollTo() .performClick() verify { @@ -120,9 +125,10 @@ class GeneratorScreenTest : BaseComposeTest() { } composeTestRule - .onNodeWithText("Number of words") + .onNodeWithContentDescription("Number of words, 3") .onChildren() .filterToOne(hasContentDescription("+")) + .performScrollTo() .performClick() verify { @@ -204,9 +210,9 @@ class GeneratorScreenTest : BaseComposeTest() { GeneratorScreen(viewModel = viewModel) } - composeTestRule.onNodeWithText("Word separator") - .onChildren() - .filterToOne(hasSetTextAction()) + composeTestRule + .onNodeWithText("Word separator") + .performScrollTo() .performTextInput("a") verify { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt index c8859ccd0..3398fbeb6 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt @@ -24,6 +24,27 @@ class GeneratorViewModelTest : BaseViewModelTest() { } } + @Test + fun `RegenerateClick refreshes the generated text`() = runTest { + val viewModel = GeneratorViewModel(initialSavedStateHandle) + val initialText = viewModel.stateFlow.value.generatedText + val action = GeneratorAction.RegenerateClick + + viewModel.actionChannel.trySend(action) + + val reversedText = viewModel.stateFlow.value.generatedText + assertEquals(initialText.reversed(), reversedText) + } + + @Test + fun `CopyClick should emit ShowToast`() = runTest { + val viewModel = GeneratorViewModel(initialSavedStateHandle) + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(GeneratorAction.CopyClick) + assertEquals(GeneratorEvent.ShowToast("Copied"), awaitItem()) + } + } + @Test fun `MainTypeOptionSelect PASSWORD should switch to Passcode`() = runTest { val viewModel = GeneratorViewModel(initialSavedStateHandle)