PM-15599: Allow for custom TextToolbars (#4440)

This commit is contained in:
David Perez
2024-12-09 13:22:44 -06:00
committed by GitHub
parent 7abb52b42d
commit 5997579330
9 changed files with 298 additions and 97 deletions

View File

@@ -4,11 +4,14 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import com.x8bit.bitwarden.ui.platform.components.field.color.bitwardenTextFieldColors
import com.x8bit.bitwarden.ui.platform.components.field.toolbar.BitwardenEmptyTextToolbar
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
@@ -24,19 +27,21 @@ fun BitwardenHiddenPasswordField(
value: String,
modifier: Modifier = Modifier,
) {
OutlinedTextField(
modifier = modifier,
textStyle = BitwardenTheme.typography.sensitiveInfoSmall,
label = { Text(text = label) },
value = value,
onValueChange = { },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
enabled = false,
readOnly = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
colors = bitwardenTextFieldColors(),
)
CompositionLocalProvider(value = LocalTextToolbar provides BitwardenEmptyTextToolbar) {
OutlinedTextField(
modifier = modifier,
textStyle = BitwardenTheme.typography.sensitiveInfoSmall,
label = { Text(text = label) },
value = value,
onValueChange = { },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
enabled = false,
readOnly = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
colors = bitwardenTextFieldColors(),
)
}
}
@Preview

View File

@@ -5,7 +5,9 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -14,18 +16,25 @@ 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.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.tabNavigation
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.x8bit.bitwarden.ui.platform.components.field.color.bitwardenTextFieldColors
import com.x8bit.bitwarden.ui.platform.components.field.toolbar.BitwardenCutCopyTextToolbar
import com.x8bit.bitwarden.ui.platform.components.field.toolbar.BitwardenEmptyTextToolbar
import com.x8bit.bitwarden.ui.platform.components.model.TextToolbarType
import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
@@ -51,7 +60,9 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
* the password field.
* @param imeAction the preferred IME action for the keyboard to have.
* @param keyboardActions the callbacks of keyboard actions.
* @param textToolbarType The type of [TextToolbar] to use on the text field.
*/
@Suppress("LongMethod")
@Composable
fun BitwardenPasswordField(
label: String,
@@ -68,52 +79,82 @@ fun BitwardenPasswordField(
keyboardType: KeyboardType = KeyboardType.Password,
imeAction: ImeAction = ImeAction.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
textToolbarType: TextToolbarType = TextToolbarType.DEFAULT,
) {
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
modifier = modifier
.tabNavigation()
.focusRequester(focusRequester),
colors = bitwardenTextFieldColors(),
textStyle = BitwardenTheme.typography.sensitiveInfoSmall,
label = { Text(text = label) },
value = value,
onValueChange = onValueChange,
visualTransformation = when {
!showPassword -> PasswordVisualTransformation()
readOnly -> nonLetterColorVisualTransformation()
else -> VisualTransformation.None
},
singleLine = singleLine,
readOnly = readOnly,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = imeAction,
),
keyboardActions = keyboardActions,
supportingText = hint?.let {
{
Text(
text = hint,
style = BitwardenTheme.typography.bodySmall,
var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = value)) }
val textFieldValue = textFieldValueState.copy(text = value)
SideEffect {
if (textFieldValue.selection != textFieldValueState.selection ||
textFieldValue.composition != textFieldValueState.composition
) {
textFieldValueState = textFieldValue
}
}
val textToolbar = when (textToolbarType) {
TextToolbarType.DEFAULT -> BitwardenCutCopyTextToolbar(
value = textFieldValue,
onValueChange = onValueChange,
defaultTextToolbar = LocalTextToolbar.current,
clipboardManager = LocalClipboardManager.current.nativeClipboard,
)
TextToolbarType.NONE -> BitwardenEmptyTextToolbar
}
var lastTextValue by remember(value) { mutableStateOf(value = value) }
CompositionLocalProvider(value = LocalTextToolbar provides textToolbar) {
OutlinedTextField(
modifier = modifier
.tabNavigation()
.focusRequester(focusRequester),
colors = bitwardenTextFieldColors(),
textStyle = BitwardenTheme.typography.sensitiveInfoSmall,
label = { Text(text = label) },
value = textFieldValue,
onValueChange = {
textFieldValueState = it
val stringChangedSinceLastInvocation = lastTextValue != it.text
lastTextValue = it.text
if (stringChangedSinceLastInvocation) {
onValueChange(it.text)
}
},
visualTransformation = when {
!showPassword -> PasswordVisualTransformation()
readOnly -> nonLetterColorVisualTransformation()
else -> VisualTransformation.None
},
singleLine = singleLine,
readOnly = readOnly,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = imeAction,
),
keyboardActions = keyboardActions,
supportingText = hint?.let {
{
Text(
text = hint,
style = BitwardenTheme.typography.bodySmall,
)
}
},
trailingIcon = {
BitwardenStandardIconButton(
modifier = Modifier.semantics { showPasswordTestTag?.let { testTag = it } },
vectorIconRes = if (showPassword) {
R.drawable.ic_eye_slash
} else {
R.drawable.ic_eye
},
contentDescription = stringResource(
id = if (showPassword) R.string.hide else R.string.show,
),
onClick = { showPasswordChange.invoke(!showPassword) },
)
}
},
trailingIcon = {
BitwardenStandardIconButton(
modifier = Modifier.semantics { showPasswordTestTag?.let { testTag = it } },
vectorIconRes = if (showPassword) {
R.drawable.ic_eye_slash
} else {
R.drawable.ic_eye
},
contentDescription = stringResource(
id = if (showPassword) R.string.hide else R.string.show,
),
onClick = { showPasswordChange.invoke(!showPassword) },
)
},
)
},
)
}
if (autoFocus) {
LaunchedEffect(Unit) { focusRequester.requestFocus() }
}

View File

@@ -10,12 +10,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
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.button.BitwardenTonalIconButton
import com.x8bit.bitwarden.ui.platform.components.model.TextToolbarType
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
@@ -33,6 +35,7 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
* @param readOnly `true` if the input should be read-only and not accept user interactions.
* @param singleLine when `true`, this text field becomes a single line that horizontally scrolls
* instead of wrapping onto multiple lines.
* @param textToolbarType The type of [TextToolbar] to use on the text field.
* @param actions A lambda containing the set of actions (usually icons or similar) to display
* in the app bar's trailing side. This lambda extends [RowScope], allowing flexibility in
* defining the layout of the actions.
@@ -49,6 +52,7 @@ fun BitwardenPasswordFieldWithActions(
singleLine: Boolean = false,
showPasswordTestTag: String? = null,
passwordFieldTestTag: String? = null,
textToolbarType: TextToolbarType = TextToolbarType.DEFAULT,
actions: @Composable RowScope.() -> Unit = {},
) {
Row(
@@ -68,6 +72,7 @@ fun BitwardenPasswordFieldWithActions(
.weight(1f)
.padding(end = 8.dp),
showPasswordTestTag = showPasswordTestTag,
textToolbarType = textToolbarType,
)
actions()
}

View File

@@ -5,24 +5,34 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
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.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalTextToolbar
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.ui.platform.base.util.toPx
import com.x8bit.bitwarden.ui.platform.base.util.withLineBreaksAtWidth
import com.x8bit.bitwarden.ui.platform.components.field.color.bitwardenTextFieldColors
import com.x8bit.bitwarden.ui.platform.components.field.toolbar.BitwardenCutCopyTextToolbar
import com.x8bit.bitwarden.ui.platform.components.field.toolbar.BitwardenEmptyTextToolbar
import com.x8bit.bitwarden.ui.platform.components.model.IconResource
import com.x8bit.bitwarden.ui.platform.components.model.TextToolbarType
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
@@ -46,7 +56,9 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
* an entire line before breaking. `false` by default.
* @param visualTransformation Transforms the visual representation of the input [value].
* @param keyboardType the preferred type of keyboard input.
* @param textToolbarType The type of [TextToolbar] to use on the text field.
*/
@Suppress("LongMethod")
@Composable
fun BitwardenTextField(
label: String,
@@ -66,6 +78,7 @@ fun BitwardenTextField(
isError: Boolean = false,
autoFocus: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
textToolbarType: TextToolbarType = TextToolbarType.DEFAULT,
) {
var widthPx by remember { mutableIntStateOf(0) }
val focusRequester = remember { FocusRequester() }
@@ -78,48 +91,76 @@ fun BitwardenTextField(
} else {
value
}
var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = formattedText)) }
val textFieldValue = textFieldValueState.copy(text = value)
SideEffect {
if (textFieldValue.selection != textFieldValueState.selection ||
textFieldValue.composition != textFieldValueState.composition
) {
textFieldValueState = textFieldValue
}
}
val textToolbar = when (textToolbarType) {
TextToolbarType.DEFAULT -> BitwardenCutCopyTextToolbar(
value = textFieldValue,
onValueChange = onValueChange,
defaultTextToolbar = LocalTextToolbar.current,
clipboardManager = LocalClipboardManager.current.nativeClipboard,
)
OutlinedTextField(
colors = bitwardenTextFieldColors(),
modifier = modifier
.onGloballyPositioned { widthPx = it.size.width }
.focusRequester(focusRequester),
enabled = enabled,
label = { Text(text = label) },
value = formattedText,
leadingIcon = leadingIconResource?.let { iconResource ->
{
Icon(
painter = iconResource.iconPainter,
contentDescription = iconResource.contentDescription,
)
}
},
trailingIcon = trailingIconContent,
placeholder = placeholder?.let {
{
Text(
text = it,
style = textStyle,
)
}
},
supportingText = hint?.let {
{
Text(
text = hint,
style = BitwardenTheme.typography.bodySmall,
)
}
},
onValueChange = onValueChange,
singleLine = singleLine,
readOnly = readOnly,
textStyle = textStyle,
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = keyboardType),
isError = isError,
visualTransformation = visualTransformation,
)
TextToolbarType.NONE -> BitwardenEmptyTextToolbar
}
var lastTextValue by remember(value) { mutableStateOf(value = value) }
CompositionLocalProvider(value = LocalTextToolbar provides textToolbar) {
OutlinedTextField(
colors = bitwardenTextFieldColors(),
modifier = modifier
.onGloballyPositioned { widthPx = it.size.width }
.focusRequester(focusRequester),
enabled = enabled,
label = { Text(text = label) },
value = textFieldValue,
leadingIcon = leadingIconResource?.let { iconResource ->
{
Icon(
painter = iconResource.iconPainter,
contentDescription = iconResource.contentDescription,
)
}
},
trailingIcon = trailingIconContent,
placeholder = placeholder?.let {
{
Text(
text = it,
style = textStyle,
)
}
},
supportingText = hint?.let {
{
Text(
text = hint,
style = BitwardenTheme.typography.bodySmall,
)
}
},
onValueChange = {
textFieldValueState = it
val stringChangedSinceLastInvocation = lastTextValue != it.text
lastTextValue = it.text
if (stringChangedSinceLastInvocation) {
onValueChange(it.text)
}
},
singleLine = singleLine,
readOnly = readOnly,
textStyle = textStyle,
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = keyboardType),
isError = isError,
visualTransformation = visualTransformation,
)
}
if (autoFocus) {
LaunchedEffect(Unit) { focusRequester.requestFocus() }
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
@@ -15,6 +16,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.model.TextToolbarType
import com.x8bit.bitwarden.ui.platform.components.row.BitwardenRowOfActions
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
@@ -42,6 +44,7 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
* providing flexibility in the layout definition.
* @param actionsTestTag The test tag to use for the row of actions, or null if there is none.
* @param textFieldTestTag The test tag to be used on the text field.
* @param textToolbarType The type of [TextToolbar] to use on the text field.
*/
@Composable
fun BitwardenTextFieldWithActions(
@@ -59,6 +62,7 @@ fun BitwardenTextFieldWithActions(
actions: @Composable RowScope.() -> Unit = {},
actionsTestTag: String? = null,
textFieldTestTag: String? = null,
textToolbarType: TextToolbarType = TextToolbarType.DEFAULT,
) {
Row(
modifier = modifier
@@ -80,6 +84,7 @@ fun BitwardenTextFieldWithActions(
textStyle = textStyle,
shouldAddCustomLineBreaks = shouldAddCustomLineBreaks,
visualTransformation = visualTransformation,
textToolbarType = textToolbarType,
)
BitwardenRowOfActions(
modifier = Modifier.run { actionsTestTag?.let { testTag(it) } ?: this },

View File

@@ -0,0 +1,67 @@
package com.x8bit.bitwarden.ui.platform.components.field.toolbar
import android.content.ClipData
import android.content.ClipboardManager
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.getSelectedText
import androidx.core.os.persistableBundleOf
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
* A custom [TextToolbar] that is obfuscates the copied or cut text.
*/
@OmitFromCoverage
class BitwardenCutCopyTextToolbar(
private val value: TextFieldValue,
private val onValueChange: (String) -> Unit,
private val defaultTextToolbar: TextToolbar,
private val clipboardManager: ClipboardManager,
) : TextToolbar {
override val status: TextToolbarStatus get() = defaultTextToolbar.status
override fun hide() = defaultTextToolbar.hide()
override fun showMenu(
rect: Rect,
onCopyRequested: (() -> Unit)?,
onPasteRequested: (() -> Unit)?,
onCutRequested: (() -> Unit)?,
onSelectAllRequested: (() -> Unit)?,
) {
defaultTextToolbar.showMenu(
rect = rect,
onCopyRequested = onCopyRequested?.let { _ ->
{
clipboardManager.setPrimaryClip(
ClipData
.newPlainText("", value.getSelectedText())
.apply {
description.extras = persistableBundleOf(
"android.content.extra.IS_SENSITIVE" to true,
)
},
)
}
},
onPasteRequested = onPasteRequested,
onCutRequested = onCutRequested?.let { _ ->
{
clipboardManager.setPrimaryClip(
ClipData
.newPlainText("", value.getSelectedText())
.apply {
description.extras = persistableBundleOf(
"android.content.extra.IS_SENSITIVE" to true,
)
},
)
onValueChange("")
}
},
onSelectAllRequested = onSelectAllRequested,
)
}
}

View File

@@ -0,0 +1,24 @@
package com.x8bit.bitwarden.ui.platform.components.field.toolbar
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
* A custom [TextToolbar] that is completely empty.
*/
@OmitFromCoverage
object BitwardenEmptyTextToolbar : TextToolbar {
override val status: TextToolbarStatus = TextToolbarStatus.Hidden
override fun hide() = Unit
override fun showMenu(
rect: Rect,
onCopyRequested: (() -> Unit)?,
onPasteRequested: (() -> Unit)?,
onCutRequested: (() -> Unit)?,
onSelectAllRequested: (() -> Unit)?,
) = Unit
}

View File

@@ -0,0 +1,11 @@
package com.x8bit.bitwarden.ui.platform.components.model
import androidx.compose.ui.platform.TextToolbar
/**
* Indicated the type of [TextToolbar] that should be displayed.
*/
enum class TextToolbarType {
DEFAULT,
NONE,
}

View File

@@ -47,6 +47,7 @@ import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.model.TextToolbarType
import com.x8bit.bitwarden.ui.platform.components.model.TooltipData
import com.x8bit.bitwarden.ui.platform.components.model.TopAppBarDividerStyle
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
@@ -394,6 +395,7 @@ private fun GeneratedStringItem(
shouldAddCustomLineBreaks = true,
visualTransformation = nonLetterColorVisualTransformation(),
modifier = modifier.padding(horizontal = 16.dp),
textToolbarType = TextToolbarType.NONE,
)
}