mirror of
https://github.com/bitwarden/android.git
synced 2024-11-24 02:15:53 +03:00
BIT-635: Adding updated UI for Passphrase Generator screen (#102)
This commit is contained in:
parent
594c466467
commit
3d925a7804
13 changed files with 796 additions and 384 deletions
|
@ -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(),
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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<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() {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
17
app/src/main/res/drawable/ic_copy.xml
Normal file
17
app/src/main/res/drawable/ic_copy.xml
Normal file
|
@ -0,0 +1,17 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M2,2h20v20h-20z"/>
|
||||
<path
|
||||
android:pathData="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>
|
10
app/src/main/res/drawable/ic_minus.xml
Normal file
10
app/src/main/res/drawable/ic_minus.xml
Normal 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>
|
9
app/src/main/res/drawable/ic_plus.xml
Normal file
9
app/src/main/res/drawable/ic_plus.xml
Normal 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>
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue