BIT-635: Adding updated UI for Passphrase Generator screen (#102)

This commit is contained in:
joshua-livefront 2023-10-09 16:05:36 -04:00 committed by Álison Fernandes
parent 594c466467
commit 3d925a7804
13 changed files with 796 additions and 384 deletions

View file

@ -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(),
)
}

View file

@ -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,
)
}
},
)
}

View file

@ -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<String>,
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 = {},
)
}
}

View file

@ -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 = {},
)
}
}

View file

@ -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 = {},
)
}
}

View file

@ -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,
)

View file

@ -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 ->
val onCopyClick: () -> Unit = remember(viewModel) {
{ viewModel.trySendAction(GeneratorAction.CopyClick) }
}
val onMainStateOptionClicked: (GeneratorState.MainTypeOption) -> Unit = remember(viewModel) {
{ viewModel.trySendAction(GeneratorAction.MainTypeOptionSelect(it)) }
}
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 onPassphraseCapitalizeToggleChange: (Boolean) -> Unit = { shouldCapitalize ->
val onPassphraseCapitalizeToggleChange: (Boolean) -> Unit = remember(viewModel) {
{ shouldCapitalize ->
viewModel.trySendAction(
GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleCapitalizeChange(
capitalize = shouldCapitalize,
),
)
}
}
val onIncludeNumberToggleChange: (Boolean) -> Unit = { shouldIncludeNumber ->
val onIncludeNumberToggleChange: (Boolean) -> Unit = remember(viewModel) {
{ shouldIncludeNumber ->
viewModel.trySendAction(
GeneratorAction.MainType.Passcode.PasscodeType.Passphrase.ToggleIncludeNumberChange(
includeNumber = shouldIncludeNumber,
),
)
}
}
val onWordSeparatorChange: (Char?) -> Unit = { newSeparator ->
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,41 +219,26 @@ 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,
private fun GeneratedStringItem(
generatedText: String,
onCopyClick: () -> Unit,
onRegenerateClick: () -> Unit,
) {
Text(text = generatedText)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = {},
) {
Icon(
imageVector = Icons.Default.Add,
BitwardenTextFieldWithTwoIcons(
label = "",
value = generatedText,
firstIconResource = IconResource(
iconPainter = painterResource(R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(
onClick = {},
) {
Icon(
imageVector = Icons.Default.Refresh,
),
onFirstIconClick = onCopyClick,
secondIconResource = IconResource(
iconPainter = painterResource(R.drawable.ic_generator),
contentDescription = stringResource(id = R.string.generate_password),
tint = MaterialTheme.colorScheme.primary,
),
onSecondIconClick = onRegenerateClick,
)
}
}
}
}
}
@Composable
private fun MainStateOptionsItem(
@ -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,6 +353,9 @@ private fun PassphraseTypeContent(
wordSeparator = passphraseTypeState.wordSeparator,
onWordSeparatorChange = onWordSeparatorChange,
)
Column(
modifier = Modifier.fillMaxWidth(),
) {
PassphraseCapitalizeToggleItem(
capitalize = passphraseTypeState.capitalize,
onPassphraseCapitalizeToggleChange = onCapitalizeToggleChange,
@ -364,16 +365,30 @@ private fun PassphraseTypeContent(
onIncludeNumberToggleChange = onIncludeNumberToggleChange,
)
}
}
@Composable
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<String>,
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() {

View file

@ -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()
}

View file

@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M2,2h20v20h-20z"/>
<path
android:pathData="M7.625,5.75H20.125C21.16,5.75 22,6.589 22,7.625V20.125C22,21.16 21.16,22 20.125,22H7.625C6.589,22 5.75,21.16 5.75,20.125V7.625C5.75,6.589 6.589,5.75 7.625,5.75ZM7.625,7C7.28,7 7,7.28 7,7.625V20.125C7,20.47 7.28,20.75 7.625,20.75H20.125C20.47,20.75 20.75,20.47 20.75,20.125V7.625C20.75,7.28 20.47,7 20.125,7H7.625Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
<path
android:pathData="M16.375,2H3.875C2.839,2 2,2.839 2,3.875V16.375C2,17.41 2.839,18.25 3.875,18.25H5.75V17H3.875C3.53,17 3.25,16.72 3.25,16.375V3.875C3.25,3.53 3.53,3.25 3.875,3.25H16.375C16.72,3.25 17,3.53 17,3.875V5.75H18.25V3.875C18.25,2.839 17.41,2 16.375,2Z"
android:fillColor="#000000"/>
</group>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M4.375,10C4.375,9.655 4.655,9.375 5,9.375H15C15.345,9.375 15.625,9.655 15.625,10C15.625,10.345 15.345,10.625 15,10.625H5C4.655,10.625 4.375,10.345 4.375,10Z"
android:fillColor="#151B2C"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M10,1.875C10,1.53 9.72,1.25 9.375,1.25C9.03,1.25 8.75,1.53 8.75,1.875V10H0.625C0.28,10 0,10.28 0,10.625C0,10.97 0.28,11.25 0.625,11.25H8.75V19.375C8.75,19.72 9.03,20 9.375,20C9.72,20 10,19.72 10,19.375V11.25H18.125C18.47,11.25 18.75,10.97 18.75,10.625C18.75,10.28 18.47,10 18.125,10H10V1.875Z"
android:fillColor="#151B2C"/>
</vector>

View file

@ -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 {

View file

@ -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)